"""Authentication endpoints.""" from datetime import datetime, timedelta from typing import Any from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi.security import HTTPAuthorizationCredentials from sqlalchemy.orm import Session from slowapi import Limiter from slowapi.util import get_remote_address from app import crud, schemas from app.dependencies import get_db, get_current_user, security from app.core.security import create_access_token from app.config import settings from app.models.user import User router = APIRouter() # Rate limiter for auth endpoints limiter = Limiter(key_func=get_remote_address) def get_client_ip(request: Request) -> str: """Extract client IP from request, considering proxies.""" forwarded = request.headers.get("X-Forwarded-For") if forwarded: return forwarded.split(",")[0].strip() return request.client.host if request.client else "unknown" @router.post("/register", response_model=schemas.User, status_code=status.HTTP_201_CREATED) @limiter.limit("5/minute") # Limit registration attempts def register( request: Request, *, db: Session = Depends(get_db), user_in: schemas.RegisterRequest ) -> Any: """ Register a new user. Creates a new user account with the provided credentials. Registration can be disabled by administrators via settings. """ ip_address = get_client_ip(request) user_agent = request.headers.get("User-Agent", "")[:500] # Check if this is the first user (always allow for initial setup) user_count = db.query(User).count() is_first_user = user_count == 0 # If not the first user, check if registration is enabled if not is_first_user: registration_enabled_raw = crud.settings.get_setting_value( db, key="registration_enabled", default=True # Default to enabled if setting doesn't exist ) if isinstance(registration_enabled_raw, str): registration_enabled = registration_enabled_raw.strip().lower() in ("true", "1") else: registration_enabled = bool(registration_enabled_raw) if not registration_enabled: # Log failed registration attempt crud.audit_log.log_action( db, action="register", resource_type="user", details={"username": user_in.username, "reason": "registration_disabled"}, ip_address=ip_address, user_agent=user_agent, status="failure" ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User registration is currently disabled" ) # Check if username already exists user = crud.user.get_by_username(db, username=user_in.username) if user: crud.audit_log.log_action( db, action="register", resource_type="user", details={"username": user_in.username, "reason": "username_exists"}, ip_address=ip_address, user_agent=user_agent, status="failure" ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Username already registered" ) # Check if email already exists user = crud.user.get_by_email(db, email=user_in.email) if user: crud.audit_log.log_action( db, action="register", resource_type="user", details={"username": user_in.username, "reason": "email_exists"}, ip_address=ip_address, user_agent=user_agent, status="failure" ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered" ) # Validate password against policy from app.core.password_policy import get_password_policy, validate_password policy = get_password_policy(db) password_errors = validate_password( user_in.password, policy=policy, username=user_in.username, email=user_in.email ) if password_errors: crud.audit_log.log_action( db, action="register", resource_type="user", details={"username": user_in.username, "reason": "weak_password"}, ip_address=ip_address, user_agent=user_agent, status="failure" ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"message": "Password does not meet requirements", "errors": password_errors} ) # Create new user (first user becomes superuser) user_create = schemas.UserCreate( username=user_in.username, email=user_in.email, password=user_in.password, is_active=True, is_superuser=is_first_user # First user is superuser ) user = crud.user.create(db, obj_in=user_create) # Log successful registration crud.audit_log.log_action( db, user_id=user.id, username=user.username, action="register", resource_type="user", resource_id=user.id, details={"is_first_user": is_first_user}, ip_address=ip_address, user_agent=user_agent, status="success" ) return user @router.post("/login") @limiter.limit("10/minute") # Stricter limit for login attempts def login( request: Request, *, db: Session = Depends(get_db), credentials: schemas.LoginRequest ) -> Any: """ Login and get access token. Authenticates user and returns a JWT access token. If 2FA is enabled, returns requires_2fa=True with a temp_token. """ ip_address = get_client_ip(request) user_agent = request.headers.get("User-Agent", "")[:500] # Authenticate user user = crud.user.authenticate( db, username=credentials.username, password=credentials.password ) if not user: # Log failed login attempt crud.audit_log.log_action( db, action="login", resource_type="auth", details={"username": credentials.username, "reason": "invalid_credentials"}, ip_address=ip_address, user_agent=user_agent, status="failure" ) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}, ) if not crud.user.is_active(user): # Log inactive user login attempt crud.audit_log.log_action( db, user_id=user.id, username=user.username, action="login", resource_type="auth", details={"reason": "inactive_user"}, ip_address=ip_address, user_agent=user_agent, status="failure" ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" ) # Check if 2FA is enabled if user.totp_enabled: # If TOTP code provided, verify it if credentials.totp_code: from app.api.v1.two_factor import verify_totp_or_backup if not verify_totp_or_backup(user, credentials.totp_code, db): crud.audit_log.log_action( db, user_id=user.id, username=user.username, action="login", resource_type="auth", details={"reason": "invalid_2fa_code"}, ip_address=ip_address, user_agent=user_agent, status="failure" ) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 2FA code" ) else: # No code provided, return temp token for 2FA verification temp_token_expires = timedelta(minutes=5) # 5 minute expiry temp_token = create_access_token( data={"sub": user.id, "temp": True, "purpose": "2fa"}, expires_delta=temp_token_expires ) crud.audit_log.log_action( db, user_id=user.id, username=user.username, action="login_2fa_required", resource_type="auth", ip_address=ip_address, user_agent=user_agent, status="pending" ) return { "access_token": None, "token_type": "bearer", "requires_2fa": True, "temp_token": temp_token } # Update last_login timestamp user.last_login = datetime.utcnow() db.add(user) db.commit() # Create access token access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) access_token = create_access_token( data={"sub": user.id}, expires_delta=access_token_expires ) # Track session for revocation / active sessions session = crud.session.create( db, user_id=user.id, token=access_token, user_agent=user_agent, ip_address=ip_address, expires_at=datetime.utcnow() + access_token_expires, ) crud.session.mark_as_current(db, session_id=session.id, user_id=user.id) # Log successful login crud.audit_log.log_action( db, user_id=user.id, username=user.username, action="login", resource_type="auth", details={"session_id": session.id}, ip_address=ip_address, user_agent=user_agent, status="success" ) return { "access_token": access_token, "token_type": "bearer", "requires_2fa": False, "temp_token": None } @router.post("/verify-2fa", response_model=schemas.Token) def verify_2fa_login( request: Request, *, db: Session = Depends(get_db), verify_request: schemas.Verify2FARequest ) -> Any: """ Complete login by verifying 2FA code. Use the temp_token from the login response along with the TOTP code. """ from jose import JWTError from app.core.security import decode_access_token from app.api.v1.two_factor import verify_totp_or_backup ip_address = get_client_ip(request) user_agent = request.headers.get("User-Agent", "")[:500] try: payload = decode_access_token(verify_request.temp_token) user_id = payload.get("sub") is_temp = payload.get("temp") purpose = payload.get("purpose") if not user_id or not is_temp or purpose != "2fa": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid temporary token" ) except JWTError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired temporary token" ) user = crud.user.get(db, id=user_id) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" ) # Verify TOTP code if not verify_totp_or_backup(user, verify_request.code, db): crud.audit_log.log_action( db, user_id=user.id, username=user.username, action="login_2fa_verify", resource_type="auth", details={"reason": "invalid_code"}, ip_address=ip_address, user_agent=user_agent, status="failure" ) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 2FA code" ) # Update last_login timestamp user.last_login = datetime.utcnow() db.add(user) db.commit() # Create full access token access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) access_token = create_access_token( data={"sub": user.id}, expires_delta=access_token_expires ) # Track session for revocation / active sessions session = crud.session.create( db, user_id=user.id, token=access_token, user_agent=user_agent, ip_address=ip_address, expires_at=datetime.utcnow() + access_token_expires, ) crud.session.mark_as_current(db, session_id=session.id, user_id=user.id) # Log successful login crud.audit_log.log_action( db, user_id=user.id, username=user.username, action="login", resource_type="auth", details={"method": "2fa", "session_id": session.id}, ip_address=ip_address, user_agent=user_agent, status="success" ) return { "access_token": access_token, "token_type": "bearer" } @router.get("/me", response_model=schemas.User) def read_users_me( current_user: User = Depends(get_current_user) ) -> Any: """ Get current user. Returns the currently authenticated user's information. Requires a valid JWT token in the Authorization header. """ return current_user @router.post("/logout") def logout( request: Request, credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> Any: """ Logout by revoking the current session. This invalidates the current access token server-side (session revocation). """ ip_address = get_client_ip(request) user_agent = request.headers.get("User-Agent", "")[:500] token = credentials.credentials session = crud.session.get_by_token(db, token) if session: crud.session.revoke(db, id=session.id, user_id=current_user.id) crud.audit_log.log_action( db, user_id=current_user.id, username=current_user.username, action="logout", resource_type="auth", details={"session_id": session.id if session else None}, ip_address=ip_address, user_agent=user_agent, status="success", ) return {"message": "Logged out"} @router.get("/registration-status") def get_registration_status( db: Session = Depends(get_db) ) -> Any: """ Check if user registration is enabled. This is a public endpoint that doesn't require authentication. Returns the registration_enabled setting value. """ registration_enabled_raw = crud.settings.get_setting_value( db, key="registration_enabled", default=True ) if isinstance(registration_enabled_raw, str): registration_enabled = registration_enabled_raw.strip().lower() in ("true", "1") else: registration_enabled = bool(registration_enabled_raw) return {"registration_enabled": registration_enabled} @router.get("/password-requirements") def get_password_requirements( db: Session = Depends(get_db) ) -> Any: """ Get password requirements/policy. This is a public endpoint that returns the password policy for display during registration. """ from app.core.password_policy import get_password_policy, get_password_requirements as get_reqs policy = get_password_policy(db) return get_reqs(policy) @router.post("/check-password-strength") def check_password_strength( password_data: dict, db: Session = Depends(get_db) ) -> Any: """ Check password strength. This is a public endpoint that returns password strength analysis for real-time feedback during registration. """ from app.core.password_policy import check_password_strength as check_strength password = password_data.get("password", "") if not password: return { "score": 0, "level": "weak", "feedback": ["Password is required"] } return check_strength(password)