diff --git a/backend/app/config.py b/backend/app/config.py index 246ec5d..5816b54 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -12,6 +12,11 @@ class Config: JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*") + # Auth Security Configuration + MAX_FAILED_LOGIN_ATTEMPTS = int(os.environ.get("MAX_FAILED_LOGIN_ATTEMPTS", "5")) + ACCOUNT_LOCK_DURATION_MINUTES = int(os.environ.get("ACCOUNT_LOCK_DURATION_MINUTES", "30")) + LOCKOUT_STRATEGY = os.environ.get("LOCKOUT_STRATEGY", "timeout") # "timeout" | "permanent" + # Celery Configuration CELERY = { "broker_url": os.environ.get("CELERY_BROKER_URL", "redis://redis:6379/0"), @@ -162,4 +167,4 @@ config_by_name = { "dev": DevelopmentConfig, "test": TestingConfig, "prod": ProductionConfig, -} +} \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 43be910..99f1ced 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -11,6 +11,7 @@ from app.models.epic import Epic from app.models.file_attachment import FileAttachment from app.models.label import Label from app.models.list_model import List +from app.models.login_history import LoginHistory from app.models.user import User from app.models.wiki import Wiki, wiki_entity_links @@ -20,6 +21,7 @@ __all__ = [ "Board", "List", "Card", + "LoginHistory", "Label", "CardLabel", "CardLink", diff --git a/backend/app/models/login_history.py b/backend/app/models/login_history.py new file mode 100644 index 0000000..8ca2985 --- /dev/null +++ b/backend/app/models/login_history.py @@ -0,0 +1,42 @@ +"""Login History model for tracking authentication events""" + +from datetime import UTC, datetime + +from app import db + + +class LoginHistory(db.Model): + """Tracks login attempts (both success and failure)""" + + __tablename__ = "login_history" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column( + db.Integer, db.ForeignKey("users.id"), nullable=False, index=True + ) + ip_address = db.Column(db.String(45), nullable=True) + user_agent = db.Column(db.String(512), nullable=True) + success = db.Column(db.Boolean, default=True) + created_at = db.Column( + db.DateTime, default=lambda: datetime.now(UTC), nullable=False + ) + + # Relationships + user = db.relationship("User", back_populates="login_history") + + def to_dict(self): + """Convert login history to dictionary""" + return { + "id": self.id, + "user_id": self.user_id, + "ip_address": self.ip_address, + "user_agent": self.user_agent, + "success": self.success, + "created_at": self.created_at.isoformat() if self.created_at else None, + } + + def __repr__(self): + return ( + f"" + ) \ No newline at end of file diff --git a/backend/app/models/user.py b/backend/app/models/user.py index cb32c79..b362f9d 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,4 +1,6 @@ -from datetime import UTC, datetime +"""User model""" + +from datetime import UTC, datetime, timezone from werkzeug.security import check_password_hash, generate_password_hash @@ -18,6 +20,9 @@ class User(db.Model): last_name = db.Column(db.String(50)) is_active = db.Column(db.Boolean, default=True) is_admin = db.Column(db.Boolean, default=False) + failed_login_attempts = db.Column(db.Integer, default=0) + locked_until = db.Column(db.DateTime, nullable=True) + last_login_at = db.Column(db.DateTime, nullable=True) created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC)) updated_at = db.Column( db.DateTime, @@ -29,6 +34,21 @@ class User(db.Model): boards = db.relationship( "Board", backref="user", cascade="all, delete-orphan", lazy="dynamic" ) + login_history = db.relationship( + "LoginHistory", + back_populates="user", + lazy="dynamic", + cascade="all, delete-orphan", + ) + + @property + def is_locked(self): + """Check if account is currently locked (timeout or permanent)""" + if not self.is_active: + return True + if self.locked_until and self.locked_until > datetime.now(timezone.utc): + return True + return False def set_password(self, password): """Hash and set password""" @@ -47,10 +67,12 @@ class User(db.Model): "first_name": self.first_name, "last_name": self.last_name, "is_active": self.is_active, - "is_admin": self.is_admin, - "created_at": self.created_at.isoformat() if self.created_at else None, - "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "last_login_at": self.last_login_at + if self.last_login_at + else None, + "created_at": self.created_at if self.created_at else None, + "updated_at": self.updated_at if self.updated_at else None, } def __repr__(self): - return f"" + return f"" \ No newline at end of file diff --git a/backend/app/routes/api.py b/backend/app/routes/api.py index 1ea428a..1eb3904 100644 --- a/backend/app/routes/api.py +++ b/backend/app/routes/api.py @@ -1,9 +1,21 @@ +"""Auth and user management API routes""" + from flask import Blueprint, jsonify, request -from flask_jwt_extended import (create_access_token, create_refresh_token, - get_jwt_identity, jwt_required) +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + jwt_required, +) from app import db from app.models import User +from app.services.auth_service import ( + authenticate_user, + change_password, + get_login_history, + register_user, +) api_bp = Blueprint("api", __name__) @@ -12,63 +24,78 @@ api_bp = Blueprint("api", __name__) @api_bp.route("/auth/register", methods=["POST"]) def register(): """Register a new user""" - data = request.get_json() + try: + data = request.get_json() - if not data or not data.get("email") or not data.get("password"): - return jsonify({"error": "Email and password are required"}), 400 + if not data: + return jsonify({"error": "Request body is required"}), 400 - if User.query.filter_by(email=data["email"]).first(): - return jsonify({"error": "Email already exists"}), 400 + # Server-side validation: require confirm_password for all registrations + confirm = data.get("confirm_password") + if not confirm: + return jsonify({"error": "confirm_password is required"}), 400 - user = User( - email=data["email"], - username=data.get("username", data["email"].split("@")[0]), - first_name=data.get("first_name"), - last_name=data.get("last_name"), - ) - user.set_password(data["password"]) + # Use the auth service for registration with validation + user = register_user(data) - db.session.add(user) - db.session.commit() + # Generate tokens so user is logged in immediately + access_token = create_access_token(identity=str(user.id)) + refresh_token = create_refresh_token(identity=str(user.id)) - return jsonify(user.to_dict()), 201 + return ( + jsonify( + { + "user": user.to_dict(), + "access_token": access_token, + "refresh_token": refresh_token, + } + ), + 201, + ) + + except ValueError as e: + return jsonify({"error": str(e)}), 400 @api_bp.route("/auth/login", methods=["POST"]) def login(): - """Login user""" - data = request.get_json() + """Login user with account lockout and login history tracking""" + try: + data = request.get_json() - if not data or not data.get("email") or not data.get("password"): - return jsonify({"error": "Email and password are required"}), 400 + if not data or not data.get("email") or not data.get("password"): + return jsonify({"error": "Email and password are required"}), 400 - user = User.query.filter_by(email=data["email"]).first() + # Capture IP and user agent for login history + ip_address = request.remote_addr or "unknown" + user_agent = request.headers.get("User-Agent", "unknown") - if not user or not user.check_password(data["password"]): - return jsonify({"error": "Invalid credentials"}), 401 + user, access_token, refresh_token = authenticate_user( + email=data["email"], + password=data["password"], + ip_address=ip_address, + user_agent=user_agent, + ) - if not user.is_active: - return jsonify({"error": "Account is inactive"}), 401 + return ( + jsonify( + { + "user": user.to_dict(), + "access_token": access_token, + "refresh_token": refresh_token, + } + ), + 200, + ) - access_token = create_access_token(identity=str(user.id)) - refresh_token = create_refresh_token(identity=str(user.id)) - - return ( - jsonify( - { - "user": user.to_dict(), - "access_token": access_token, - "refresh_token": refresh_token, - } - ), - 200, - ) + except ValueError as e: + return jsonify({"error": str(e)}), 401 @api_bp.route("/users/me", methods=["GET"]) @jwt_required() def get_current_user(): - """Get current user""" + """Get current user profile""" user_id = int(get_jwt_identity()) user = db.session.get(User, user_id) @@ -76,3 +103,53 @@ def get_current_user(): return jsonify({"error": "User not found"}), 404 return jsonify(user.to_dict()), 200 + + +@api_bp.route("/users/me/password", methods=["PUT"]) +@jwt_required() +def update_password(): + """Change current user's password""" + try: + data = request.get_json() + + if not data: + return jsonify({"error": "Request body is required"}), 400 + + current_password = data.get("current_password") + new_password = data.get("new_password") + confirm_password = data.get("confirm_password") + + if not all([current_password, new_password, confirm_password]): + return jsonify( + { + "error": "current_password, new_password, and confirm_password are required" + } + ), 400 + + user_id = int(get_jwt_identity()) + user = db.session.get(User, user_id) + + if not user: + return jsonify({"error": "User not found"}), 404 + + change_password(user, current_password, new_password, confirm_password) + + return jsonify({"message": "Password updated successfully"}), 200 + + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + +@api_bp.route("/users/me/login-history", methods=["GET"]) +@jwt_required() +def get_user_login_history(): + """Get login history for current user""" + user_id = int(get_jwt_identity()) + + limit = request.args.get("limit", 20, type=int) + # Sanity check on limit + limit = min(max(limit, 1), 100) + + history = get_login_history(user_id, limit=limit) + + return jsonify(history), 200 \ No newline at end of file diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..d1d4351 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,208 @@ +"""Authentication service for user registration, login, password management, and lockout""" + +from datetime import UTC, datetime, timedelta, timezone + +from flask import current_app +from flask_jwt_extended import create_access_token, create_refresh_token + +from app import db +from app.models import LoginHistory, User + + +def register_user(data): + """Register a new user with server-side password confirmation validation. + + Args: + data: dict with email, password, confirm_password, username, etc. + + Returns: + User object on success + + Raises: + ValueError: If validation fails + """ + email = data.get("email", "").strip().lower() + password = data.get("password", "") + confirm_password = data.get("confirm_password", "") + username = data.get("username", email.split("@")[0] if email else "") + + if not email: + raise ValueError("Email is required") + + if not password: + raise ValueError("Password is required") + + if password != confirm_password: + raise ValueError("Passwords do not match") + + if len(password) < 6: + raise ValueError("Password must be at least 6 characters") + + existing = User.query.filter_by(email=email).first() + if existing: + raise ValueError("Email already exists") + + user = User( + email=email, + username=username, + first_name=data.get("first_name"), + last_name=data.get("last_name"), + ) + user.set_password(password) + db.session.add(user) + db.session.commit() + + return user + + +def authenticate_user(email, password, ip_address=None, user_agent=None): + """Authenticate user with account lockout logic. + + Implements two lockout strategies: + - "timeout": locks account for ACCOUNT_LOCK_DURATION_MINUTES + - "permanent": sets is_active to False (requires admin intervention) + + Args: + email: User email + password: Plain text password + ip_address: Request IP address (for logging) + user_agent: Request user agent (for logging) + + Returns: + (user, access_token, refresh_token) on success + + Raises: + ValueError: With appropriate error message on failure + """ + max_attempts = current_app.config["MAX_FAILED_LOGIN_ATTEMPTS"] + lock_duration = current_app.config["ACCOUNT_LOCK_DURATION_MINUTES"] + lockout_strategy = current_app.config["LOCKOUT_STRATEGY"] + + user = User.query.filter_by(email=email.strip().lower()).first() + + if not user: + raise ValueError("Invalid email or password") + + # Check permanent lock (is_active=False) + if not user.is_active: + _record_login_history(user, ip_address, user_agent, success=False) + raise ValueError( + "Account has been locked. Contact an administrator." + ) + + # Check timeout lock + if user.locked_until and user.locked_until > datetime.now(timezone.utc): + remaining_minutes = int( + (user.locked_until - datetime.now(timezone.utc)).total_seconds() / 60 + ) + _record_login_history(user, ip_address, user_agent, success=False) + raise ValueError( + f"Account is locked. Try again in {remaining_minutes} minute(s)." + ) + + if user.check_password(password): + # Success: reset lockout counters + user.failed_login_attempts = 0 + user.locked_until = None + user.last_login_at = datetime.now(timezone.utc) + db.session.commit() + + _record_login_history(user, ip_address, user_agent, success=True) + + access_token = create_access_token(identity=str(user.id)) + refresh_token = create_refresh_token(identity=str(user.id)) + + return user, access_token, refresh_token + + # Failed login + user.failed_login_attempts = (user.failed_login_attempts or 0) + 1 + _record_login_history(user, ip_address, user_agent, success=False) + + if user.failed_login_attempts >= max_attempts: + if lockout_strategy == "permanent": + user.is_active = False + user.locked_until = None + db.session.commit() + raise ValueError( + "Too many failed attempts. Account has been permanently locked. " + "Contact an administrator." + ) + else: + # Default: timeout + user.locked_until = datetime.now(timezone.utc) + timedelta( + minutes=lock_duration + ) + db.session.commit() + raise ValueError( + f"Too many failed attempts. Account locked for " + f"{lock_duration} minute(s)." + ) + + db.session.commit() + raise ValueError("Invalid email or password") + + +def change_password(user, current_password, new_password, confirm_password): + """Change a user's password after verifying current password. + + Args: + user: User object + current_password: Current password for verification + new_password: New password + confirm_password: Confirmation of new password + + Raises: + ValueError: If validation fails + """ + if not user.check_password(current_password): + raise ValueError("Current password is incorrect") + + if new_password != confirm_password: + raise ValueError("New passwords do not match") + + if len(new_password) < 6: + raise ValueError("New password must be at least 6 characters") + + if current_password == new_password: + raise ValueError("New password must be different from current password") + + user.set_password(new_password) + db.session.commit() + + +def get_login_history(user_id, limit=20): + """Get recent login history for a user. + + Args: + user_id: User ID + limit: Maximum number of entries to return + + Returns: + List of LoginHistory dicts, most recent first + """ + entries = ( + LoginHistory.query.filter_by(user_id=user_id) + .order_by(LoginHistory.created_at.desc()) + .limit(limit) + .all() + ) + return [entry.to_dict() for entry in entries] + + +def _record_login_history(user, ip_address, user_agent, success): + """Record a login attempt in the login history. + + Args: + user: User object + ip_address: Request IP address + user_agent: Request user agent + success: Whether the login was successful + """ + entry = LoginHistory( + user_id=user.id, + ip_address=ip_address or "unknown", + user_agent=user_agent or "unknown", + success=success, + ) + db.session.add(entry) + db.session.commit() diff --git a/backend/migrations/versions/9975b2cbb113_add_login_history_model_and_auth_.py b/backend/migrations/versions/9975b2cbb113_add_login_history_model_and_auth_.py new file mode 100644 index 0000000..fbb875d --- /dev/null +++ b/backend/migrations/versions/9975b2cbb113_add_login_history_model_and_auth_.py @@ -0,0 +1,53 @@ +"""add login history model and auth lockout fields + +Revision ID: 9975b2cbb113 +Revises: 7a0cfda486e1 +Create Date: 2026-07-24 19:56:44.795127 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9975b2cbb113' +down_revision = '7a0cfda486e1' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('login_history', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('ip_address', sa.String(length=45), nullable=True), + sa.Column('user_agent', sa.String(length=512), nullable=True), + sa.Column('success', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('login_history', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_login_history_user_id'), ['user_id'], unique=False) + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('failed_login_attempts', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('locked_until', sa.DateTime(), nullable=True)) + batch_op.add_column(sa.Column('last_login_at', sa.DateTime(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('last_login_at') + batch_op.drop_column('locked_until') + batch_op.drop_column('failed_login_attempts') + + with op.batch_alter_table('login_history', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_login_history_user_id')) + + op.drop_table('login_history') + # ### end Alembic commands ### diff --git a/backend/tests/test_routes.py b/backend/tests/test_routes.py index bb89a19..b8e0ab4 100644 --- a/backend/tests/test_routes.py +++ b/backend/tests/test_routes.py @@ -13,6 +13,7 @@ class TestAuthRoutes: json={ "email": "newuser@example.com", "password": "password123", + "confirm_password": "password123", "username": "newuser", "first_name": "New", "last_name": "User", @@ -21,10 +22,38 @@ class TestAuthRoutes: assert response.status_code == 201 data = response.get_json() - assert data["email"] == "newuser@example.com" - assert data["username"] == "newuser" - assert "password" not in data - assert "password_hash" not in data + assert data["user"]["email"] == "newuser@example.com" + assert data["user"]["username"] == "newuser" + assert "password" not in data["user"] + assert "password_hash" not in data["user"] + + @pytest.mark.auth + def test_register_password_mismatch(self, client): + """Test registration with mismatched passwords""" + response = client.post( + "/api/auth/register", + json={ + "email": "mismatch@example.com", + "password": "password123", + "confirm_password": "differentpassword", + "username": "mismatch", + }, + ) + + assert response.status_code == 400 + data = response.get_json() + assert "do not match" in data["error"].lower() + + @pytest.mark.auth + def test_register_missing_confirm_password(self, client): + """Test registration without confirm_password""" + response = client.post( + "/api/auth/register", json={"email": "noconfirm@example.com", "password": "password123"} + ) + + assert response.status_code == 400 + data = response.get_json() + assert "confirm_password" in data["error"].lower() @pytest.mark.auth def test_register_missing_fields(self, client): @@ -42,7 +71,11 @@ class TestAuthRoutes: """Test registration with duplicate email""" response = client.post( "/api/auth/register", - json={"email": regular_user.email, "password": "password123"}, + json={ + "email": regular_user.email, + "password": "password123", + "confirm_password": "password123", + }, ) assert response.status_code == 400 @@ -96,7 +129,7 @@ class TestAuthRoutes: assert response.status_code == 401 data = response.get_json() - assert "inactive" in data["error"].lower() + assert "locked" in data["error"].lower() @pytest.mark.auth def test_get_current_user(self, client, auth_headers, regular_user): @@ -112,3 +145,201 @@ class TestAuthRoutes: """Test getting current user without authentication""" response = client.get("/api/users/me") assert response.status_code == 401 + + @pytest.mark.auth + def test_change_password_success(self, client, auth_headers): + """Test successful password change""" + response = client.put( + "/api/users/me/password", + headers=auth_headers, + json={ + "current_password": "password123", + "new_password": "newpassword456", + "confirm_password": "newpassword456", + }, + ) + + assert response.status_code == 200 + data = response.get_json() + assert "Password updated" in data["message"] + + @pytest.mark.auth + def test_change_password_wrong_current(self, client, auth_headers): + """Test password change with wrong current password""" + response = client.put( + "/api/users/me/password", + headers=auth_headers, + json={ + "current_password": "wrongpassword", + "new_password": "newpassword456", + "confirm_password": "newpassword456", + }, + ) + + assert response.status_code == 400 + data = response.get_json() + assert "incorrect" in data["error"].lower() + + @pytest.mark.auth + def test_change_password_mismatch(self, client, auth_headers): + """Test password change with mismatched new passwords""" + response = client.put( + "/api/users/me/password", + headers=auth_headers, + json={ + "current_password": "password123", + "new_password": "newpassword456", + "confirm_password": "different456", + }, + ) + + assert response.status_code == 400 + data = response.get_json() + assert "do not match" in data["error"].lower() + + @pytest.mark.auth + def test_get_login_history(self, client, auth_headers, regular_user): + """Test getting login history""" + # Login first to create history + client.post( + "/api/auth/login", + json={"email": regular_user.email, "password": "password123"}, + ) + + response = client.get( + "/api/users/me/login-history", headers=auth_headers + ) + + assert response.status_code == 200 + data = response.get_json() + assert isinstance(data, list) + + @pytest.mark.auth + def test_login_lockout_after_five_attempts(self, client, regular_user): + """Test account lockout after 5 failed attempts (default timeout strategy)""" + # First 4 attempts should return generic invalid error + for i in range(4): + response = client.post( + "/api/auth/login", + json={ + "email": regular_user.email, + "password": "wrongpassword", + }, + ) + assert response.status_code == 401 + data = response.get_json() + assert "Invalid" in data["error"] + + # 5th attempt should trigger lockout + response = client.post( + "/api/auth/login", + json={ + "email": regular_user.email, + "password": "wrongpassword", + }, + ) + assert response.status_code == 401 + data = response.get_json() + assert "locked" in data["error"].lower() + + @pytest.mark.auth + def test_login_lockout_increments_counter(self, client, regular_user): + """Test that failed_login_attempts increments correctly""" + from app import db + + # First attempt + client.post( + "/api/auth/login", + json={ + "email": regular_user.email, + "password": "wrongpassword", + }, + ) + db.session.refresh(regular_user) + assert regular_user.failed_login_attempts == 1 + + # Second attempt + client.post( + "/api/auth/login", + json={ + "email": regular_user.email, + "password": "wrongpassword", + }, + ) + db.session.refresh(regular_user) + assert regular_user.failed_login_attempts == 2 + + @pytest.mark.auth + def test_login_resets_counter_on_success(self, client, regular_user): + """Test that successful login resets failed_login_attempts""" + from app import db + + # 3 failed attempts + for _ in range(3): + client.post( + "/api/auth/login", + json={ + "email": regular_user.email, + "password": "wrongpassword", + }, + ) + db.session.refresh(regular_user) + assert regular_user.failed_login_attempts == 3 + + # Successful login resets counter + response = client.post( + "/api/auth/login", + json={ + "email": regular_user.email, + "password": "password123", + }, + ) + assert response.status_code == 200 + db.session.refresh(regular_user) + assert regular_user.failed_login_attempts == 0 + + @pytest.mark.auth + def test_change_password_unauthorized(self, client): + """Test password change without authentication""" + response = client.put( + "/api/users/me/password", + json={ + "current_password": "password123", + "new_password": "newpassword456", + "confirm_password": "newpassword456", + }, + ) + assert response.status_code == 401 + + @pytest.mark.auth + def test_get_login_history_unauthorized(self, client): + """Test login history without authentication""" + response = client.get("/api/users/me/login-history") + assert response.status_code == 401 + + @pytest.mark.auth + def test_get_login_history_success(self, client, auth_headers): + """Test getting login history""" + response = client.get( + "/api/users/me/login-history", headers=auth_headers + ) + + assert response.status_code == 200 + data = response.get_json() + assert isinstance(data, list) + + @pytest.mark.auth + def test_login_history_recorded(self, client, regular_user): + """Test that login history is recorded""" + # Log in + client.post( + "/api/auth/login", + json={"email": regular_user.email, "password": "password123"}, + ) + + # Check history + from app import db + from app.models import LoginHistory + + entries = LoginHistory.query.filter_by(user_id=regular_user.id).all() + assert len(entries) >= 1 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index d668ab5..0c4332f 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,16 +4,29 @@ server { root /usr/share/nginx/html; index index.html; + # Add real IP logging to Nginx access logs + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + location / { try_files $uri $uri/ /index.html; } location /api { proxy_pass http://backend:5000; + + # Forward the real IP proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + # Add these headers too + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; } location /health { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 913824c..097ad2f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { Navbar } from './components/Navbar'; import { Home } from './pages/Home'; import Login from './pages/Login'; import { Register } from './pages/Register'; +import Profile from './pages/Profile'; import { ProtectedRoute } from './components/ProtectedRoute'; import { Boards } from './pages/Boards'; import { BoardCreate } from './pages/BoardCreate'; @@ -59,6 +60,14 @@ const App = () => { } /> } /> } /> + + + + } + /> {/* Protected Routes */}
- {user ? ( - <> - {user.username} - - - ) : ( - <> - - Login - - - Register - - - )} + {user ? ( + <> + + + {user.username} + + + + ) : ( + <> + + Login + + + Register + + + )}
+ + {showPasswordForm && ( +
+
+ + setCurrentPassword(e.target.value)} + required + className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ +
+ + setNewPassword(e.target.value)} + required + className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ + {newPassword !== confirmPassword && confirmPassword && ( +
+ Passwords do not match +
+ )} + + +
+ )} +
+ + {/* Login History Section */} +
+ + + {showHistory && ( +
+ {loginHistory.length === 0 ? ( +

No login history found.

+ ) : ( +
+ {loginHistory.map((entry) => ( +
+
+ +
+

+ {entry.success ? "Successful login" : "Failed login"} +

+

+ {entry.ip_address} — {entry.user_agent?.slice(0, 60)} + {entry.user_agent?.length > 60 ? "..." : ""} +

+
+
+ + {formatRelativeTime(entry.created_at)} + +
+ ))} +
+ )} +
+ )} +
+ + ) +} + +export default Profile \ No newline at end of file diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index ef8c3cb..262f1b1 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -41,11 +41,17 @@ export function Register() { return; } + if (formData.password !== formData.confirmPassword) { + // This shouldn't happen due to disabled button, but handle it gracefully + return; + } + try { await handleRegister({ email: formData.email, username: formData.username, password: formData.password, + confirm_password: formData.confirmPassword, first_name: formData.first_name, last_name: formData.last_name, }); diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index afad8b2..39f361e 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -19,6 +19,7 @@ export interface LoginData { export interface RegisterData { email: string; password: string; + confirm_password: string; username: string; first_name?: string; last_name?: string; @@ -26,5 +27,21 @@ export interface RegisterData { export interface AuthResponse { access_token: string; + refresh_token?: string; user: UserData; } + +export interface LoginHistoryEntry { + id: number; + user_id: number; + ip_address: string; + user_agent: string; + success: boolean; + created_at: string; +} + +export interface ChangePasswordData { + current_password: string; + new_password: string; + confirm_password: string; +}