# myapp/auth/auth_routes.py import datetime import jwt # For encoding JWT tokens import logging from flask import request, jsonify, current_app, has_app_context # Flask utilities from werkzeug.security import generate_password_hash, check_password_hash # For hashing and checking passwords from bson.objectid import ObjectId, InvalidId # For converting string IDs to MongoDB ObjectId from functools import wraps # Import wraps for dummy decorator # --- Local Blueprint Import (Moved to Top) --- # Import the 'bp' instance defined in the local __init__.py FIRST # This often helps resolve circular import issues involving blueprints and utilities/models. from . import bp # --- Shared Utilities Import --- # Import the token_required decorator from the utils module try: # Assumes utils.py is in the parent 'myapp' package from ..utils import token_required except ImportError as e: # Fallback or error handling if the decorator isn't found print("Warning: token_required decorator not found in auth/auth_routes.py. Protected routes will fail.") print(e) # Define a dummy decorator to prevent NameError, but it won't protect routes def token_required(f): @wraps(f) def wrapper(*args, **kwargs): print("ERROR: token_required decorator is not available!") return jsonify({"message": "Server configuration error: Missing authentication utility."}), 500 return wrapper # --- Schema Imports --- try: # Import the relevant schemas defined in schemas.py from ..schemas import UserRegistrationSchema, UserLoginSchema, UserSchema, UserUpdateSchema from marshmallow import ValidationError except ImportError: print("Warning: Could not import User schemas or ValidationError in auth/auth_routes.py.") UserRegistrationSchema = None UserLoginSchema = None UserSchema = None UserUpdateSchema = None ValidationError = None # --- Shared Extensions Import --- # Import mongo for direct use (alternative to current_app.mongo) try: from ..extensions import mongo except ImportError: print("Warning: Could not import mongo extension in auth/auth_routes.py.") mongo = None # --- Helper to get logger safely --- def _get_logger(): if has_app_context(): return current_app.logger return logging.getLogger(__name__) # Note: Routes use paths relative to the '/api/auth' prefix defined in __init__.py. @bp.route('/register', methods=['POST']) def register(): """ Register a new user. Uses UserRegistrationSchema for input validation. Expects 'username', 'email', 'password' in JSON payload. Checks for existing username/email. Hashes password. Stores user. Returns a JWT token and serialized user info (using UserSchema) upon success. """ logger = _get_logger() # Check dependencies if not mongo: return jsonify({"message": "Database connection not available."}), 500 if not UserRegistrationSchema or not UserSchema or not ValidationError: return jsonify({"message": "Server configuration error: Schema unavailable."}), 500 # Get and validate JSON data using the schema json_data = request.get_json() or {} schema = UserRegistrationSchema() try: validated_data = schema.load(json_data) except ValidationError as err: logger.warning(f"Registration validation failed: {err.messages}") return jsonify(err.messages), 422 # Return validation errors # Extract validated data username = validated_data['username'] email = validated_data['email'] password = validated_data['password'] # Raw password (load_only) try: db = mongo.db # Use imported mongo instance's db attribute # Check if username or email already exists if db.users.find_one({"username": username}): return jsonify({"message": "Username already exists."}), 409 # 409 Conflict if db.users.find_one({"email": email}): return jsonify({"message": "Email already registered."}), 409 # 409 Conflict except AttributeError: logger.error("PyMongo extension not initialized or db attribute missing.") return jsonify({"message": "Database configuration error."}), 500 except Exception as e: logger.error(f"Database error checking existing user: {e}", exc_info=True) return jsonify({"message": "Database error during registration check."}), 500 # Hash the password before storing hashed_pw = generate_password_hash(password) # Create the new user document now = datetime.datetime.now(datetime.timezone.utc) # Use timezone-aware UTC time new_user_doc = { "username": username, "email": email, "password": hashed_pw, # Store the hashed password "createdAt": now, "updatedAt": now } # Insert the new user into the database try: result = db.users.insert_one(new_user_doc) user_id = result.inserted_id # This is an ObjectId # Fetch the created user document to serialize it created_user = db.users.find_one({"_id": user_id}) if not created_user: # Should not happen, but check logger.error(f"Failed to retrieve user immediately after insertion: {user_id}") # Don't fail the whole registration, maybe just log and proceed without user data in response created_user = {"_id": user_id, "username": username, "email": email} # Construct manually if needed except Exception as e: logger.error(f"Error inserting new user: {e}", exc_info=True) return jsonify({"message": "An error occurred during registration."}), 500 # Generate JWT token using settings from app config try: secret_key = current_app.config['SECRET_KEY'] algo = current_app.config.get('JWT_ALGORITHM', 'HS256') exp_hours = current_app.config.get('JWT_EXP_DELTA_HOURS', 24) token_payload = { "user_id": str(user_id), # Convert ObjectId to string for JWT payload "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=exp_hours) } token = jwt.encode(token_payload, secret_key, algorithm=algo) except KeyError: logger.error("SECRET_KEY not configured in Flask app for JWT.") return jsonify({"message": "Server configuration error: JWT secret missing."}), 500 except Exception as e: logger.error(f"Error encoding JWT during registration: {e}", exc_info=True) return jsonify({"message": "Could not generate authentication token."}), 500 # Serialize the created user data using UserSchema (excludes password) output_schema = UserSchema() serialized_user = output_schema.dump(created_user) # Return success response with token and serialized user info return jsonify({ "message": "User registered successfully.", "token": token, "user": serialized_user # Return user object instead of just id }), 201 # 201 Created @bp.route('/login', methods=['POST']) def login(): """ Log in an existing user. Uses UserLoginSchema for input validation. Expects 'username' and 'password' in JSON payload. Verifies credentials against the database. Returns a JWT token and serialized user info (using UserSchema) upon success. """ logger = _get_logger() # Check dependencies if not mongo: return jsonify({"message": "Database connection not available."}), 500 if not UserLoginSchema or not UserSchema or not ValidationError: return jsonify({"message": "Server configuration error: Schema unavailable."}), 500 # Get and validate JSON data using the schema json_data = request.get_json() or {} schema = UserLoginSchema() try: validated_data = schema.load(json_data) except ValidationError as err: logger.warning(f"Login validation failed: {err.messages}") return jsonify(err.messages), 422 username = validated_data['username'] password = validated_data['password'] # Raw password (load_only) # Access the database try: db = mongo.db if db is None: raise AttributeError("db attribute is None") # Find user by username user_doc = db.users.find_one({"username": username}) except AttributeError: logger.error("PyMongo extension not initialized or attached correctly during login.") return jsonify({"message": "Database configuration error."}), 500 except Exception as e: logger.error(f"Database error during login for user {username}: {e}", exc_info=True) return jsonify({"message": "An error occurred during login."}), 500 # Check if user exists and if the password hash matches if not user_doc or 'password' not in user_doc or not check_password_hash(user_doc["password"], password): return jsonify({"message": "Invalid credentials."}), 401 # Use 401 for authentication failure # Generate JWT token using settings from app config try: user_id = user_doc["_id"] # Get ObjectId secret_key = current_app.config['SECRET_KEY'] algo = current_app.config.get('JWT_ALGORITHM', 'HS256') exp_hours = current_app.config.get('JWT_EXP_DELTA_HOURS', 24) token_payload = { "user_id": str(user_id), # Convert ObjectId to string "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=exp_hours) } token = jwt.encode(token_payload, secret_key, algorithm=algo) except KeyError: logger.error("SECRET_KEY not configured in Flask app for JWT.") return jsonify({"message": "Server configuration error: JWT secret missing."}), 500 except Exception as e: logger.error(f"Error encoding JWT for user {username}: {e}", exc_info=True) return jsonify({"message": "Could not generate authentication token."}), 500 # Serialize the user data using UserSchema (excludes password) output_schema = UserSchema() serialized_user = output_schema.dump(user_doc) # Return success response with token and serialized user info return jsonify({ "message": "Login successful.", "token": token, "user": serialized_user # Return user object instead of just id }), 200 @bp.route('/delete_account', methods=['DELETE']) @token_required # Apply the decorator to protect the route and inject 'current_user' def delete_account(current_user): """ Delete the account of the currently authenticated user (identified by token). Also handles associated data like projects and URLs. Requires a valid JWT token. (No schema needed for input/output here) """ logger = _get_logger() # Validate user object from token if not current_user or not current_user.get("_id"): return jsonify({"message": "Internal authorization error."}), 500 try: user_id_str = str(current_user.get("user_id") or current_user.get("_id")) if not user_id_str: return jsonify({"message": "Invalid token or user information not found in token."}), 401 user_id = ObjectId(user_id_str) # Convert string ID back to ObjectId except (InvalidId, TypeError) as e: logger.error(f"User ID conversion error in delete_account from token data {current_user}: {e}") return jsonify({"message": "Invalid user ID format in token."}), 400 if not mongo: return jsonify({"message": "Database connection not available."}), 500 try: db = mongo.db # --- Data handling logic (remains the same) --- # [ Deletion logic for user, projects, urls, activity, dialogs ] # 1. Delete the user document itself user_result = db.users.delete_one({"_id": user_id}) # 2. Remove user from collaborator lists in projects they didn't own db.projects.update_many( {"ownerId": {"$ne": user_id}, "collaborators": user_id}, {"$pull": {"collaborators": user_id}} ) # 3. Handle projects owned by the user owned_projects_cursor = db.projects.find({"ownerId": user_id}, {"_id": 1, "collaborators": 1}) project_ids_to_delete = [] projects_to_reassign = [] for project in owned_projects_cursor: project_id = project["_id"] collaborators = [collab_id for collab_id in project.get("collaborators", []) if collab_id != user_id] if collaborators: new_owner = collaborators[0] projects_to_reassign.append({ "filter": {"_id": project_id}, "update": { "$set": {"ownerId": new_owner, "lastActivityBy": new_owner}, "$pull": {"collaborators": new_owner} } }) else: project_ids_to_delete.append(project_id) if projects_to_reassign: for reassignment in projects_to_reassign: db.projects.update_one(reassignment["filter"], reassignment["update"]) logger.info(f"Reassigned ownership for {len(projects_to_reassign)} projects previously owned by {user_id_str}") if project_ids_to_delete: delete_owned_projects_result = db.projects.delete_many({"_id": {"$in": project_ids_to_delete}}) logger.info(f"Deleted {delete_owned_projects_result.deleted_count} projects owned by {user_id_str} with no remaining collaborators.") # Cascade deletes delete_urls_result = db.urls.delete_many({"projectId": {"$in": project_ids_to_delete}}) logger.info(f"Deleted {delete_urls_result.deleted_count} URLs for deleted projects of user {user_id_str}") delete_activity_result = db.project_activity.delete_many({"projectId": {"$in": project_ids_to_delete}}) logger.info(f"Deleted {delete_activity_result.deleted_count} activity logs for deleted projects of user {user_id_str}") delete_dialog_result = db.dialog_activity.delete_many({"projectId": {"$in": project_ids_to_delete}}) logger.info(f"Deleted {delete_dialog_result.deleted_count} dialog sessions for deleted projects of user {user_id_str}") # --- End data handling logic --- if user_result.deleted_count == 1: return jsonify({"message": "Account and associated data handled successfully."}), 200 elif user_result.deleted_count == 0: return jsonify({"message": "User not found or already deleted."}), 404 else: logger.warning(f"Unexpected deleted_count ({user_result.deleted_count}) for user {user_id}") return jsonify({"message": "An issue occurred during account deletion."}), 500 except AttributeError: logger.error("PyMongo extension not initialized or attached correctly.") return jsonify({"message": "Database configuration error."}), 500 except Exception as e: logger.error(f"Error during account deletion for user {user_id_str}: {e}", exc_info=True) return jsonify({"message": "An internal error occurred during account deletion."}), 500 @bp.route('/logout', methods=['POST']) @token_required # Ensures only logged-in users can call logout (though it's stateless) def logout(current_user): """ Logs out a user (stateless JWT). Client is responsible for discarding the token. (No schema needed for input/output here) """ return jsonify({"message": "Logout successful. Please discard your token."}), 200 @bp.route('/account', methods=['PUT']) @token_required # Protect the route and get user info from token def update_account(current_user): """ Update the authenticated user's username, email, and/or password. Uses UserUpdateSchema for input validation. Expects JSON payload with optional 'username', 'email', 'password' fields. (Returns simple message, no schema needed for output) """ logger = _get_logger() # Validate user object from token if not current_user or not current_user.get("_id"): return jsonify({"message": "Internal authorization error."}), 500 try: user_id_str = str(current_user.get("_id") or current_user.get("user_id")) if not user_id_str: return jsonify({"message": "User ID not found in token."}), 401 user_id = ObjectId(user_id_str) except (InvalidId, TypeError) as e: logger.error(f"User ID conversion error from token ({current_user}) in update_account: {e}") return jsonify({"message": "Invalid user ID format in token."}), 400 # Check dependencies if not mongo: return jsonify({"message": "Database connection not available."}), 500 if not UserUpdateSchema or not ValidationError: return jsonify({"message": "Server configuration error: Schema unavailable."}), 500 # Get and validate JSON data using the schema json_data = request.get_json() or {} schema = UserUpdateSchema() try: # Load validates optional fields based on schema rules validated_data = schema.load(json_data) except ValidationError as err: logger.warning(f"Update account validation failed: {err.messages}") return jsonify(err.messages), 422 # If validation passed but no valid fields were provided if not validated_data: return jsonify({"message": "No valid update fields provided (username, email, or password)."}), 400 db = mongo.db update_fields = {} # Dictionary to hold fields to be updated db_validation_errors = {} # Store potential db-level validation errors (like uniqueness) # --- Validate uniqueness and prepare updates based on validated_data --- try: # Check username uniqueness if provided and validated if "username" in validated_data: new_username = validated_data["username"] if db.users.find_one({"username": new_username, "_id": {"$ne": user_id}}): db_validation_errors["username"] = "Username is already taken." else: update_fields["username"] = new_username # Check email uniqueness if provided and validated if "email" in validated_data: new_email = validated_data["email"] if db.users.find_one({"email": new_email, "_id": {"$ne": user_id}}): db_validation_errors["email"] = "Email is already registered by another user." else: update_fields["email"] = new_email # Hash password if provided and validated if "password" in validated_data: update_fields["password"] = generate_password_hash(validated_data["password"]) except AttributeError: logger.error("PyMongo extension not initialized or attached correctly during validation.") return jsonify({"message": "Database configuration error."}), 500 except Exception as e: logger.error(f"Error during database validation for user {user_id}: {e}", exc_info=True) return jsonify({"message": "An error occurred during data validation."}), 500 # If database validation errors occurred (e.g., uniqueness checks) if db_validation_errors: return jsonify({"message": "Validation errors occurred.", "errors": db_validation_errors}), 409 # 409 Conflict # If there are fields to update, add the timestamp and perform the update if update_fields: update_fields["updatedAt"] = datetime.datetime.now(datetime.timezone.utc) try: result = db.users.update_one({"_id": user_id}, {"$set": update_fields}) if result.matched_count == 0: # This case means the user_id from the token doesn't exist in the DB anymore return jsonify({"message": "User not found."}), 404 # modified_count might be 0 if the provided data was the same as existing data # We consider it a success even if no fields were technically modified return jsonify({"message": "Account updated successfully."}), 200 except AttributeError: logger.error("PyMongo extension not initialized or attached correctly during update.") return jsonify({"message": "Database configuration error."}), 500 except Exception as e: logger.error(f"Error updating account for user {user_id}: {e}", exc_info=True) return jsonify({"message": "An error occurred while updating the account."}), 500 else: # This case should ideally not be reached due to the checks at the beginning, # but included for completeness if validation passed with no update fields. return jsonify({"message": "No changes were requested or fields were invalid."}), 400