Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 59 additions & 5 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,18 +80,72 @@ def create_app(config_path=None, config=None):
# Exempt reviewer routes from main CSRF (reviewer has its own CSRF)
csrf.exempt(reviewer_bp)

# Re-resolve the logged-in user from their immutable id on every request.
# Usernames are mutable (they can be changed in account settings), so the
# name stored in the signed cookie can go stale on other devices. Keying off
# the id lets us refresh the cookie's name in place — otherwise a stale
# session could post content under a username that no longer exists, or lose
# ownership of its own (now-renamed) content. If the account is gone, the
# auth keys are cleared so the request is treated as anonymous.
@app.before_request
def refresh_session_identity():
from bson import ObjectId
from bson.errors import InvalidId
from flask import request, session, g
from app.models.user import user_by_name
from app.services.database import get_collection
from app.models.errors import ErrNoResult

g.current_user = None
if request.endpoint == 'static' or not session.get('name'):
return

try:
user = None
hexid = session.get('hexid')
if hexid:
# Resolve by the primary key (indexed point-read), not the
# unindexed hexid field, so this stays the cheapest query there is.
try:
user = get_collection('user').find_one({'_id': ObjectId(hexid)})
except InvalidId:
user = None
else:
# Legacy session issued before ids were stored; resolve by name
# once and backfill the id so future requests are id-based.
try:
user = user_by_name(session['name'])
except ErrNoResult:
user = None

if user is None:
# Account deleted (or the old name was freed and re-registered by
# someone else): drop our stale auth rather than impersonate.
session.pop('name', None)
session.pop('email', None)
session.pop('hexid', None)
else:
session['name'] = user['name']
session['email'] = user.get('email', session.get('email'))
session['hexid'] = user.get('hexid') or str(user['_id'])
g.current_user = user
except Exception as e:
# A transient DB error shouldn't break the request; leave the
# session untouched and let downstream handlers cope.
print(f"Session identity refresh error: {e}")

# Register template context processors
@app.context_processor
def inject_globals():
from flask import session
from app.models.user import user_get_unread_notifications
from flask import session, g
from app.services.crackme_fields import (
DIFFICULTY_CHOICES, LANG_CHOICES, ARCH_CHOICES, PLATFORM_CHOICES)

username = session.get('name', '')
unread_notifs = 0
if username:
unread_notifs = user_get_unread_notifications(username)
# Reuse the user loaded in refresh_session_identity so this doesn't add a
# second per-request query.
current_user = getattr(g, 'current_user', None)
unread_notifs = current_user.get('unread_notifications', 0) if current_user else 0

return {
'BaseURI': '/',
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def register_blueprints(app):
from app.controllers.rating import rating_bp
from app.controllers.rules import rules_bp
from app.controllers.password import password_bp
from app.controllers.account_settings import account_settings_bp
from app.controllers.password_reset import password_reset_bp
from app.controllers.account_deletion import account_deletion_bp
from app.controllers.static import static_bp
Expand All @@ -38,6 +39,7 @@ def register_blueprints(app):
app.register_blueprint(rating_bp)
app.register_blueprint(rules_bp)
app.register_blueprint(password_bp)
app.register_blueprint(account_settings_bp)
app.register_blueprint(password_reset_bp)
app.register_blueprint(account_deletion_bp)
app.register_blueprint(static_bp)
Expand Down
188 changes: 188 additions & 0 deletions app/controllers/account_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"""
Account settings controller - change username and email.

Usernames and emails are denormalized across many collections, so both changes
route through dedicated model helpers (``user_rename`` / ``user_change_email``)
that cascade the update. A change is only applied after the new value is proven
free (not used by any other account, as either a name or an email) and the
current password is confirmed.
"""

from flask import Blueprint, render_template, request, redirect, flash, session
from app.models.user import (
user_by_name, user_by_mail, user_rename, user_change_email
)
from app.models.errors import ErrNoResult
from app.services.passhash import match_string
from app.services.limiter import limit
from app.services.view import FLASH_ERROR, FLASH_SUCCESS, authorized_chars_only
from app.controllers.decorators import login_required

account_settings_bp = Blueprint('account_settings', __name__)


def _name_available(candidate, current_id):
"""True if ``candidate`` is free to use as a username.

Mirrors registration's cross-checks: it must not be another user's name and
must not be any user's email. A match on the current user's own account
(e.g. a case-only change) is allowed.
"""
try:
existing = user_by_name(candidate)
if existing.get('_id') != current_id:
return False
except ErrNoResult:
pass

try:
user_by_mail(candidate)
return False # Taken as some user's email.
except ErrNoResult:
pass

return True


def _email_available(candidate, current_id):
"""True if ``candidate`` is free to use as an email.

Must not be another user's email and must not be any user's username.
"""
try:
existing = user_by_mail(candidate)
if existing.get('_id') != current_id:
return False
except ErrNoResult:
pass

try:
user_by_name(candidate)
return False # Taken as some user's username.
except ErrNoResult:
pass

return True


@account_settings_bp.route('/settings', methods=['GET'])
@login_required
def settings_get():
"""Display the account settings page."""
username = session.get('name')
try:
user = user_by_name(username)
except ErrNoResult:
# Session references a user that no longer exists; force re-login.
return redirect('/logout')

return render_template('user/settings.html',
current_username=user['name'],
current_email=user['email'])


@account_settings_bp.route('/settings/username', methods=['POST'])
@login_required
@limit("5 per hour", key_func=lambda: session.get('name'))
def change_username_post():
"""Handle a username change, cascading it across all references."""
username = session.get('name')
new_name = request.form.get('name', '').strip()
password = request.form.get('current_password', '')

try:
user = user_by_name(username)
except ErrNoResult:
return redirect('/logout')

if not new_name:
flash('Username cannot be empty.', FLASH_ERROR)
return redirect('/settings')

if not authorized_chars_only(new_name):
flash('Username contains disallowed characters.', FLASH_ERROR)
return redirect('/settings')

if not match_string(user['password'], password):
flash('Current password is incorrect.', FLASH_ERROR)
return redirect('/settings')

if new_name == user['name']:
flash('That is already your username.', FLASH_ERROR)
return redirect('/settings')

try:
available = _name_available(new_name, user.get('_id'))
except Exception as e:
print(f"Username availability check error: {e}")
flash('An error occurred. Please try again later.', FLASH_ERROR)
return redirect('/settings')

if not available:
flash(f'The username "{new_name}" is not available.', FLASH_ERROR)
return redirect('/settings')

try:
user_rename(user['name'], new_name)
except Exception as e:
print(f"Username change error: {e}")
flash('An error occurred. Please try again later.', FLASH_ERROR)
return redirect('/settings')

# Keep the session in step with the renamed account.
session['name'] = new_name
flash('Username updated successfully!', FLASH_SUCCESS)
return redirect('/settings')


@account_settings_bp.route('/settings/email', methods=['POST'])
@login_required
@limit("5 per hour", key_func=lambda: session.get('name'))
def change_email_post():
"""Handle an email change, refreshing denormalized copies."""
username = session.get('name')
new_email = request.form.get('email', '').strip().lower()
password = request.form.get('current_password', '')

try:
user = user_by_name(username)
except ErrNoResult:
return redirect('/logout')

if not new_email:
flash('Email cannot be empty.', FLASH_ERROR)
return redirect('/settings')

if not authorized_chars_only(new_email):
flash('Email contains disallowed characters.', FLASH_ERROR)
return redirect('/settings')

if not match_string(user['password'], password):
flash('Current password is incorrect.', FLASH_ERROR)
return redirect('/settings')

if new_email == (user.get('email') or '').lower():
flash('That is already your email.', FLASH_ERROR)
return redirect('/settings')

try:
available = _email_available(new_email, user.get('_id'))
except Exception as e:
print(f"Email availability check error: {e}")
flash('An error occurred. Please try again later.', FLASH_ERROR)
return redirect('/settings')

if not available:
flash(f'The email "{new_email}" is not available.', FLASH_ERROR)
return redirect('/settings')

try:
user_change_email(user['name'], new_email)
except Exception as e:
print(f"Email change error: {e}")
flash('An error occurred. Please try again later.', FLASH_ERROR)
return redirect('/settings')

session['email'] = new_email
flash('Email updated successfully!', FLASH_SUCCESS)
return redirect('/settings')
28 changes: 24 additions & 4 deletions app/controllers/crackme.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
crackme_by_hexid, last_crackmes, crackme_create_prepare,
crackme_insert, crackme_delete_by_hexid, crackme_by_user_and_name,
crackme_update_difficulty, crackme_update_quality, crackme_increment_downloads,
crackme_update
crackme_update, crackme_name_taken_by_user
)
from app.models.solution import solutions_by_crackme
from app.models.comment import comments_by_crackme
Expand Down Expand Up @@ -158,12 +158,19 @@ def upload_crackme_post():
flash(f'Field missing: {missing}', FLASH_ERROR)
return render_template('crackme/create.html', label_groups=get_label_groups())

name = bleach.clean(request.form.get('name', ''))
# Strip so the stored name matches what the edit form later submits back
# (the edit path also strips); otherwise a later no-op edit would look like
# a rename and fire a spurious cascade.
name = bleach.clean(request.form.get('name', '')).strip()
info = bleach.clean(request.form.get('info', ''))
lang = bleach.clean(request.form.get('lang', ''))
arch = bleach.clean(request.form.get('arch', ''))
platform = request.form.get('platform', '')
difficulty = request.form.get('difficulty', '')

if not name:
flash('Field missing: name', FLASH_ERROR)
return render_template('crackme/create.html', label_groups=get_label_groups())
# Keep only values from the controlled vocabulary. Labels are not mandatory
# (a crackme with no matching technique may legitimately have none).
labels = normalize_labels(request.form.getlist('labels'))
Expand Down Expand Up @@ -336,14 +343,27 @@ def edit_crackme_post(hexid):
flash('You can only edit your own crackmes.', FLASH_ERROR)
return redirect(f'/crackme/{hexid}')

# Get form data (name is not editable)
# Get form data
name = bleach.clean(request.form.get('name', '')).strip()
info = bleach.clean(request.form.get('info', ''))
lang = bleach.clean(request.form.get('lang', ''))
arch = bleach.clean(request.form.get('arch', ''))
platform = request.form.get('platform', '')

if not name:
flash('Crackme name cannot be empty.', FLASH_ERROR)
return redirect(f'/crackme/{hexid}/edit')

# Renaming must not collide with another of the user's own crackmes, so that
# crackme_by_user_and_name lookups stay unambiguous.
if name != crackme.get('name') and \
crackme_name_taken_by_user(username, name, exclude_hexid=hexid):
flash('You already have another crackme with that name.', FLASH_ERROR)
return redirect(f'/crackme/{hexid}/edit')

# Update the crackme
updates = {
'name': name,
'info': info,
'lang': lang,
'arch': arch,
Expand All @@ -362,7 +382,7 @@ def edit_crackme_post(hexid):
if changes:
# Send notification to the author about the edit
try:
notification_add(username, f"Your crackme '<a href=\"/crackme/{hexid}\">{html_escape(crackme.get('name'))}</a>' has been updated.")
notification_add(username, f"Your crackme '<a href=\"/crackme/{hexid}\">{html_escape(name)}</a>' has been updated.")
except Exception as e:
print(f"Notification error: {e}")

Expand Down
4 changes: 4 additions & 0 deletions app/controllers/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def clear_main_auth():
"""Clear main site auth session keys only."""
session.pop('name', None)
session.pop('email', None)
session.pop('hexid', None)
session.pop('login_attempt', None)


Expand Down Expand Up @@ -87,6 +88,9 @@ def login_post():
clear_main_auth()
session['email'] = user['email']
session['name'] = user['name']
# Store the immutable id so the session survives a later username
# change (the name in the cookie is refreshed from this each request).
session['hexid'] = user.get('hexid') or str(user['_id'])
flash('Login successful!', FLASH_SUCCESS)

# Retrieve and clear the redirect from session
Expand Down
Loading
Loading