diff --git a/app/__init__.py b/app/__init__.py index 461c546..c8065da 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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': '/', diff --git a/app/controllers/__init__.py b/app/controllers/__init__.py index 690bfbd..7be45c9 100644 --- a/app/controllers/__init__.py +++ b/app/controllers/__init__.py @@ -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 @@ -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) diff --git a/app/controllers/account_settings.py b/app/controllers/account_settings.py new file mode 100644 index 0000000..b9ec2a9 --- /dev/null +++ b/app/controllers/account_settings.py @@ -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') diff --git a/app/controllers/crackme.py b/app/controllers/crackme.py index 4675007..7cc8e21 100644 --- a/app/controllers/crackme.py +++ b/app/controllers/crackme.py @@ -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 @@ -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')) @@ -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, @@ -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 '{html_escape(crackme.get('name'))}' has been updated.") + notification_add(username, f"Your crackme '{html_escape(name)}' has been updated.") except Exception as e: print(f"Notification error: {e}") diff --git a/app/controllers/login.py b/app/controllers/login.py index 265918f..4b6f207 100644 --- a/app/controllers/login.py +++ b/app/controllers/login.py @@ -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) @@ -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 diff --git a/app/models/crackme.py b/app/models/crackme.py index 3780db0..cd9f3df 100644 --- a/app/models/crackme.py +++ b/app/models/crackme.py @@ -342,6 +342,46 @@ def random_crackmes(count=50): return list(collection.aggregate(pipeline)) +# Collections that keep a denormalized copy of a crackme's name, as +# (collection, hexid-match-field, name-field). Renaming a crackme must refresh +# every one of these so listings (e.g. a user's writeups, the label-request +# review queue) don't show the stale old name. +CRACKME_NAME_REFERENCES = ( + ('comment', 'crackmehexid', 'crackmename'), + ('solution', 'crackmehexid', 'crackmename'), + ('label_request', 'crackme_hexid', 'crackme_name'), +) + + +def cascade_crackme_name(hexid, new_name): + """Update every denormalized copy of a renamed crackme's name. + + Shared by the user-facing edit flow (via :func:`crackme_update`) and the + reviewer edit tool so both keep the copies in sync. + """ + for collection_name, match_field, name_field in CRACKME_NAME_REFERENCES: + get_collection(collection_name).update_many( + {match_field: hexid}, + {'$set': {name_field: new_name}} + ) + + +def crackme_name_taken_by_user(username, name, exclude_hexid=None): + """Return True if the user already has a (different) crackme with this name. + + Used to keep ``crackme_by_user_and_name`` lookups unambiguous when a user + renames one of their crackmes. Matches across all visibility states. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('crackme') + query = {'author': username, 'name': name} + if exclude_hexid: + query['hexid'] = {'$ne': exclude_hexid} + return collection.find_one(query, {'_id': 1}) is not None + + def crackme_update(hexid, updates): """Update a crackme's fields. @@ -351,12 +391,16 @@ def crackme_update(hexid, updates): Returns: Dictionary with old values of changed fields, or None if crackme not found + + When ``name`` changes, the denormalized copies of it on comments, solutions + and label requests are refreshed too (see :func:`_cascade_crackme_name`). """ if not check_connection(): raise ErrUnavailable("Database is unavailable") - # Only allow updating these fields (name is excluded to avoid database issues) - allowed_fields = {'info', 'lang', 'arch', 'platform', 'labels'} + # Fields a user may edit. ``name`` is now editable; any change to it cascades + # to the denormalized copies below. + allowed_fields = {'name', 'info', 'lang', 'arch', 'platform', 'labels'} filtered_updates = {k: v for k, v in updates.items() if k in allowed_fields} if not filtered_updates: @@ -381,6 +425,8 @@ def crackme_update(hexid, updates): {'hexid': hexid}, {'$set': filtered_updates} ) + if 'name' in changes: + cascade_crackme_name(hexid, changes['name']['new']) return changes diff --git a/app/models/user.py b/app/models/user.py index e84350f..5673b03 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -139,6 +139,129 @@ def user_create(name, email, password): collection.insert_one(user) +# Collections (and the field on each) that store a username as a reference to a +# user. The username is denormalized onto all of these, so renaming a user must +# cascade to every one of them or the user would silently lose ownership of their +# crackmes, comments, solutions, ratings, notifications, and pending requests. +# +# Reviewer identities (e.g. ``label_request.reviewed_by`` and +# ``account_deletion_request.reviewed_by``) are deliberately NOT included: +# reviewers authenticate through review/users.json, a namespace separate from the +# user collection, so their names are never main-site usernames. +USERNAME_REFERENCES = ( + ('crackme', 'author'), + ('comment', 'author'), + ('solution', 'author'), + ('rating_difficulty', 'author'), + ('rating_quality', 'author'), + ('notifications', 'user'), + ('label_request', 'requester'), + ('account_deletion_request', 'requester'), +) + + +def user_rename(old_name, new_name): + """Rename a user and update every stored reference to their old username. + + Because the username is denormalized across many collections (see + :data:`USERNAME_REFERENCES`), a rename must touch all of them so nothing + orphans — including content still awaiting review (a pending crackme or + writeup keeps working because the reviewer queue reads ``author`` from the + database at review time). + + Callers must validate that ``new_name`` is available first (see the + availability checks in the account-settings controller). ``old_name`` must + be the user's exact stored name. + + Returns: + The number of user documents renamed (1 on success). + + Raises: + ErrNoResult: If no user matches ``old_name``. + ErrUnavailable: If the database is unavailable. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + if old_name == new_name: + return 0 + + user_collection = get_collection('user') + if user_collection.find_one({'name': old_name}, {'_id': 1}) is None: + raise ErrNoResult("No user found with the provided username") + + # MongoDB has no cheap way to do this atomically without a replica-set + # transaction, so ordering is the safety net: cascade the references FIRST + # and flip the user document LAST. If a reference update fails partway, the + # user document still carries the old name, so the caller's session keeps + # resolving (no lockout) and re-running the rename converges. Renaming the + # user document first would risk exactly that lockout. + for collection_name, field in USERNAME_REFERENCES: + get_collection(collection_name).update_many( + {field: old_name}, + {'$set': {field: new_name}} + ) + + result = user_collection.update_one( + {'name': old_name}, + {'$set': {'name': new_name}} + ) + + return result.modified_count + + +def user_change_email(username, new_email): + """Change a user's email and refresh denormalized copies of it. + + The email also lives on any pending account-deletion request (kept there so + the confirmation email survives the user document being deleted), and it + keys password-reset tokens. Both are handled here: the deletion-request copy + is updated, and any reset tokens for the old address are dropped since the + user no longer controls it. + + Args: + username: The account's exact stored username. + new_email: The new email address (stored lower-cased, matching + registration). + + Returns: + The previous email address, or None if it was unset. + + Raises: + ErrNoResult: If no user matches ``username``. + ErrUnavailable: If the database is unavailable. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + new_email = new_email.lower() + user_collection = get_collection('user') + user = user_collection.find_one({'name': username}, {'email': 1}) + if not user: + raise ErrNoResult("No user found with the provided username") + + old_email = user.get('email') + if old_email == new_email: + return old_email + + user_collection.update_one({'name': username}, {'$set': {'email': new_email}}) + + # Keep the denormalized copy on any pending deletion request fresh. + get_collection('account_deletion_request').update_many( + {'requester': username}, + {'$set': {'email': new_email}} + ) + + # Reset tokens for the old address now point somewhere the user no longer + # owns; discard them so they can't be used against the wrong mailbox. + if old_email: + get_collection('password_reset_tokens').delete_many( + {'email': old_email.lower()} + ) + + return old_email + + def update_user_password(username, hashed_password): """Update user password. diff --git a/app/services/session.py b/app/services/session.py index a4c8c90..a55485f 100644 --- a/app/services/session.py +++ b/app/services/session.py @@ -21,6 +21,7 @@ def clear_session(): """Clear main site auth session keys only.""" flask_session.pop('name', None) flask_session.pop('email', None) + flask_session.pop('hexid', None) flask_session.pop('login_attempt', None) diff --git a/review/routes.py b/review/routes.py index eab0686..ddf6e9b 100644 --- a/review/routes.py +++ b/review/routes.py @@ -45,6 +45,7 @@ from app.services.crypto import get_obfuscation_salt from app.services.view import is_valid_hexid from app.services.labels import get_label_groups, normalize_labels +from app.models.crackme import cascade_crackme_name, crackme_name_taken_by_user from app.models.label_request import ( label_requests_pending, count_pending_label_requests, label_request_by_hexid, label_request_set_status, STATUS_APPROVED, STATUS_REJECTED, @@ -2071,7 +2072,8 @@ def editcrackme(current_user): if request.method == 'POST' and crackme_uuid: validate_csrf_token() - # Get form data (name is not editable) + # Get form data + name = request.form.get('name', '').strip() info = request.form.get('info', '').strip() lang = request.form.get('lang', '') arch = request.form.get('arch', '') @@ -2087,9 +2089,18 @@ def editcrackme(current_user): if not crackme_obj: error = "Crackme not found" + elif not name: + error = "Crackme name cannot be empty" + elif name != crackme_obj.get('name') and crackme_name_taken_by_user( + crackme_obj.get('author'), name, exclude_hexid=crackme_obj['hexid']): + # Keep crackme_by_user_and_name lookups unambiguous. + error = "The author already has another crackme with that name" else: - # Track changes (name is not editable) + # Track changes changes = [] + name_changed = crackme_obj.get('name') != name + if name_changed: + changes.append(f"name: '{crackme_obj.get('name')}' -> '{name}'") if crackme_obj.get('info') != info: changes.append("description updated") if crackme_obj.get('lang') != lang: @@ -2101,10 +2112,11 @@ def editcrackme(current_user): if sorted(crackme_obj.get('labels', [])) != sorted(labels): changes.append(f"labels: {crackme_obj.get('labels', [])} -> {labels}") - # Update the crackme (name excluded) + # Update the crackme g_crackmesone_db.crackme.update_one( {"_id": ObjectId(crackme_uuid)}, {"$set": { + "name": name, "info": info, "lang": lang, "arch": arch, @@ -2113,6 +2125,11 @@ def editcrackme(current_user): }} ) + # A renamed crackme has denormalized name copies on comments, + # solutions and label requests that must be refreshed too. + if name_changed: + cascade_crackme_name(crackme_obj['hexid'], name) + # Handle file replacement file_replaced = False if 'file' in request.files: diff --git a/review/templates/reviewer/editcrackme.html b/review/templates/reviewer/editcrackme.html index 0919b9e..0a603e0 100644 --- a/review/templates/reviewer/editcrackme.html +++ b/review/templates/reviewer/editcrackme.html @@ -42,8 +42,8 @@
Update the details of your crackme. Note: You cannot change the name or uploaded file.
+Update the details of your crackme. Note: You cannot change the uploaded file.