142 lines
5.8 KiB
Python
142 lines
5.8 KiB
Python
## Copyright © 2026 Olaf Kolkman
|
|
## SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
from uuid import uuid4
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
|
from pydantic import BaseModel
|
|
|
|
from backend.app.api.dependencies import get_current_user
|
|
from backend.app.database import get_connection, init_db
|
|
from backend.app.core.config import settings
|
|
from backend.app.services.auth_service import authenticate_user, find_user
|
|
from backend.app.services.email_service import send_password_reset_email, smtp_configured
|
|
from backend.app.services.email_verification import verify_email
|
|
from backend.app.services.password_reset import create_reset_token, reset_password
|
|
from backend.app.services.token_service import issue_token, revoke_token, rotate_refresh_token, validate_token
|
|
from backend.app.services.otp_service import verify_code
|
|
from backend.app.services.secret_store import decrypt_secret
|
|
from backend.app.services.email_addresses import verify_user_email_address
|
|
from backend.app.services.login_throttle import check_login_allowed, clear_login_failures, record_login_failure
|
|
|
|
router = APIRouter()
|
|
|
|
init_db()
|
|
|
|
|
|
class LoginRequest(BaseModel):
|
|
email: str
|
|
password: str
|
|
otp: str | None = None
|
|
device_id: str | None = None
|
|
|
|
|
|
class RefreshTokenRequest(BaseModel):
|
|
refresh_token: str
|
|
device_id: str | None = None
|
|
|
|
|
|
class PasswordResetRequest(BaseModel):
|
|
token: str
|
|
password: str
|
|
|
|
|
|
@router.post('/login')
|
|
def login(payload: LoginRequest, request: Request):
|
|
email = payload.email.strip()
|
|
ip_address = request.client.host if request.client else 'unknown'
|
|
retry_after = check_login_allowed(ip_address, email)
|
|
if retry_after is not None:
|
|
raise HTTPException(status_code=429, detail='Too many failed login attempts. Try again later.', headers={'Retry-After': str(retry_after)})
|
|
|
|
user = authenticate_user(email, payload.password)
|
|
if user is None:
|
|
record_login_failure(ip_address, email)
|
|
reset_user = find_user(email)
|
|
if reset_user and reset_user['email_verified'] and smtp_configured():
|
|
try:
|
|
token = create_reset_token(reset_user['id'])
|
|
reset_url = f'{settings.public_url}/reset-password?token={token}'
|
|
send_password_reset_email(reset_user['email'], reset_user['username'], reset_url)
|
|
except Exception:
|
|
pass
|
|
raise HTTPException(status_code=401, detail='Invalid username or password')
|
|
if not user['email_verified']:
|
|
raise HTTPException(status_code=403, detail='Email address is not verified')
|
|
if user['otp_enabled'] and not verify_code(decrypt_secret(user['otp_secret']), payload.otp):
|
|
record_login_failure(ip_address, email)
|
|
raise HTTPException(status_code=401, detail='One-time password required or invalid')
|
|
|
|
clear_login_failures(ip_address, email)
|
|
token_data = issue_token(user['id'], user['username'], payload.device_id)
|
|
return {
|
|
'access_token': token_data['access_token'],
|
|
'token_type': 'bearer',
|
|
'expires_at': token_data['expires_at'],
|
|
'refresh_token': token_data['refresh_token'],
|
|
'device_id': token_data['device_id'],
|
|
'user': {'id': user['id'], 'username': user['username'], 'email': user['email'], 'otp_enabled': bool(user['otp_enabled'])}
|
|
}
|
|
|
|
|
|
@router.post('/refresh')
|
|
def refresh_token_endpoint(payload: RefreshTokenRequest):
|
|
rotated = rotate_refresh_token(payload.refresh_token, payload.device_id)
|
|
if rotated is None:
|
|
raise HTTPException(status_code=401, detail='Refresh token is invalid, expired, or bound to another device')
|
|
return {
|
|
'access_token': rotated['access_token'],
|
|
'token_type': 'bearer',
|
|
'expires_at': rotated['expires_at'],
|
|
'refresh_token': rotated['refresh_token'],
|
|
'device_id': rotated['device_id'],
|
|
'user': {'id': rotated['user_id'], 'username': rotated['username']},
|
|
}
|
|
|
|
|
|
@router.get('/verify-email')
|
|
def verify_email_address(token: str):
|
|
if not verify_email(token):
|
|
raise HTTPException(status_code=400, detail='Verification link is invalid or expired')
|
|
return {'status': 'verified', 'message': 'Email address verified. You can now sign in.'}
|
|
|
|
|
|
@router.get('/verify-additional-email')
|
|
def verify_additional_email_address(token: str):
|
|
if not verify_user_email_address(token):
|
|
raise HTTPException(status_code=400, detail='Email verification link is invalid or expired')
|
|
return {'status': 'verified', 'message': 'Email address verified. You can now sign in.'}
|
|
|
|
|
|
|
|
@router.post('/reset-password')
|
|
def reset_password_endpoint(payload: PasswordResetRequest):
|
|
if len(payload.password) < 8:
|
|
raise HTTPException(status_code=422, detail='Password must contain at least 8 characters')
|
|
if not reset_password(payload.token, payload.password):
|
|
raise HTTPException(status_code=400, detail='Password reset link is invalid or expired')
|
|
return {'status': 'password_reset', 'message': 'Password reset. You can now sign in.'}
|
|
|
|
|
|
@router.post('/logout')
|
|
def logout(authorization: str | None = Header(default=None)):
|
|
if not authorization or not authorization.startswith('Bearer '):
|
|
raise HTTPException(status_code=401, detail='Missing or invalid Authorization header')
|
|
token = authorization.replace('Bearer ', '', 1).strip()
|
|
if not token:
|
|
raise HTTPException(status_code=401, detail='Missing or invalid Authorization header')
|
|
revoked = revoke_token(token)
|
|
if not revoked:
|
|
raise HTTPException(status_code=404, detail='Token not found or already revoked')
|
|
return {'status': 'logged_out'}
|
|
|
|
|
|
@router.get('/me')
|
|
def current_user(user: dict = Depends(get_current_user)):
|
|
return {
|
|
'id': user['id'],
|
|
'username': user['username'],
|
|
'email': user['email'],
|
|
'is_admin': bool(user['is_admin']),
|
|
}
|