mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-16 15:45:11 +00:00
bd6a31cb0d
Adds opt-in TOTP-based Multi-Factor Authentication that is fully
backwards compatible with existing logins. Operators choose to enable
MFA per account; nothing changes for users who do not opt in.
Highlights
==========
* RFC 6238 TOTP (6 digits, 30s period, SHA1) with ±30s skew tolerance,
compatible with Microsoft / Google Authenticator, Authy, Duo, 1Password.
* Per-step replay protection (`mfa_last_used_totp_step`) so a captured
code cannot be reused inside the same window.
* Fernet-encrypted TOTP secrets at rest, key resolution via
`MFA_ENCRYPTION_KEY` env (HKDF-derived from `SECRET_KEY` as fallback).
* 10 single-use, bcrypt-hashed backup codes per user, formatted
`XXXX-YYYY` from a confusion-free alphabet (no 0/O/1/I/L).
* Two-step login flow: `POST /api/auth/login` returns `mfa_required`
+ `mfa_token`, then `POST /api/auth/login/mfa-verify` accepts a TOTP
code OR a backup code. JWT is minted only after MFA succeeds.
* Self-service: users enable / disable MFA from their own row in the
Users page; admins reset (single user or bulk) but never enable on
behalf of someone else (matches AWS IAM / GitHub / Google Workspace).
* Bulk emergency reset CLI: `scripts/admin-mfa-reset-all.sh`.
Security hardening
==================
* Atomic transactions with `SELECT … FOR UPDATE` on `mfa_pending_logins`
and `users` rows so concurrent verify / enroll calls cannot race.
* `/api/mfa/enroll/start` refuses re-enrollment when MFA is already on
(prevents silent secret rotation via a stolen JWT).
* Pydantic `ValidationError` messages are sanitized before reaching the
audit log so request bodies (TOTP / backup codes in flight) never
appear in plaintext.
* Slowapi rate limits are per-USER, not per-IP, with a trusted-proxy
XFF strategy so a single ingress address cannot exhaust the bucket
for thousands of operators (`MFA_TRUSTED_PROXY_CIDRS`,
`MFA_RATE_LIMIT_*` env-overridable).
* Login query now scopes to `is_active = TRUE` so a soft-deleted row
with the same username can no longer occlude the active user
(also closes a small account-enumeration side channel).
Database
========
Additive migrations (idempotent `ADD COLUMN IF NOT EXISTS`,
`CREATE TABLE IF NOT EXISTS`):
- users: mfa_enabled, mfa_method, mfa_secret_encrypted,
mfa_enrolled_at, mfa_last_used_at, mfa_last_used_totp_step
- mfa_backup_codes (user_id ON DELETE CASCADE)
- mfa_pending_logins (user_id ON DELETE CASCADE, challenge_token,
attempts, expires_at)
- mfa_pending_enrollments (user_id ON DELETE CASCADE)
Frontend
========
* Login page becomes a 3-phase state machine
(credentials → MFA → submitting); legacy single-step login is
preserved for users who haven't enrolled.
* New MFAEnrollModal (3-step wizard: QR + secret → verify → backup
codes) using `qrcode.react`.
* Users page shows MFA column + per-row enable/disable/reset actions.
Admins viewing other users with MFA off see a non-actionable info
icon explaining that only the user themselves can enable MFA.
Deployment
==========
* `MFA_ENCRYPTION_KEY` is added to `k8s/manifests/03-secrets.yaml` as
a placeholder; `SECRET_KEY` is also placeholder-ized so both are
injected by the existing pipeline pattern (sed-replace + apply).
* No new build-time env vars are required for the frontend. The SPA
uses `window.location.host` for `/api/*` and is routed by the
existing nginx ingress configuration.
* `frontend/.dockerignore` ensures host `.env*` files cannot bleed
into the production bundle.
Tests
=====
* New unit suites:
- `test_mfa_service.py` (TOTP, encryption, backup codes)
- `test_mfa_backwards_compat.py` (regression — non-MFA flow unchanged)
- `test_mfa_rate_limits.py` (env override + dataclass immutability)
- `test_mfa_rate_limit_key.py` (JWT key, trusted-proxy XFF, fallbacks)
* All existing 1000+ unit tests continue to pass.
Documentation
=============
* README MFA section (overview, day-to-day operations, emergency
reset CLI, env variables, rate-limit tuning).
* `scripts/README.md` documents the bulk reset script.
Issue: #18
943 lines
37 KiB
Python
943 lines
37 KiB
Python
from fastapi import APIRouter, HTTPException, Depends, Request, Header
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
from typing import Optional
|
|
import logging
|
|
import hashlib
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
|
|
# Import database and models
|
|
from database.connection import get_database_connection, close_database_connection
|
|
from models.user import LoginRequest, User, UserCreate, UserUpdate, UserPasswordUpdate
|
|
from models.mfa import MfaVerifyRequest
|
|
from utils.activity_log import log_user_activity
|
|
from auth_middleware import get_current_user_from_token
|
|
from services import mfa_service
|
|
|
|
# Rate limiting temporarily disabled
|
|
|
|
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MFA_PENDING_TTL_SECONDS = 300 # 5 minutes — pre-verification challenge lifetime
|
|
MFA_PENDING_MAX_ATTEMPTS = 5 # invalidate token after this many wrong codes
|
|
|
|
|
|
async def _fetch_mfa_state(conn, user_id: int):
|
|
"""Return (mfa_enabled, mfa_secret_encrypted, mfa_last_used_totp_step) or None
|
|
when the MFA columns aren't yet present (pre-migration deploys).
|
|
"""
|
|
try:
|
|
return await conn.fetchrow(
|
|
"""
|
|
SELECT mfa_enabled, mfa_secret_encrypted, mfa_last_used_totp_step
|
|
FROM users
|
|
WHERE id = $1
|
|
""",
|
|
user_id,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(f"MFA columns not available (assuming disabled): {exc}")
|
|
return None
|
|
|
|
|
|
async def _cleanup_expired_pending_logins(conn, user_id: int) -> None:
|
|
"""Lazy cleanup of expired pending MFA challenges for this user."""
|
|
try:
|
|
await conn.execute(
|
|
"DELETE FROM mfa_pending_logins WHERE user_id = $1 AND expires_at < NOW()",
|
|
user_id,
|
|
)
|
|
except Exception as exc:
|
|
logger.debug(f"Pending-login cleanup skipped: {exc}")
|
|
|
|
# Security scheme
|
|
security = HTTPBearer()
|
|
|
|
@router.post("/login", summary="User Login", response_description="JWT access token and user information")
|
|
async def login(login_request: LoginRequest, request: Request):
|
|
"""
|
|
# User Login - Authenticate and Get Access Token
|
|
|
|
Authenticate user with username and password. Returns a JWT access token valid for 24 hours.
|
|
|
|
## Request Body
|
|
- **username**: User's username (required)
|
|
- **password**: User's password (required)
|
|
|
|
## Response
|
|
Returns JWT token, user information, roles, and permissions.
|
|
|
|
## Example Request
|
|
```bash
|
|
curl -X POST "{BASE_URL}/api/auth/login" \\
|
|
-H "Content-Type: application/json" \\
|
|
-d '{
|
|
"username": "admin",
|
|
"password": "admin123"
|
|
}'
|
|
```
|
|
|
|
> Replace `{BASE_URL}` with your deployment URL
|
|
|
|
## Example Response
|
|
```json
|
|
{
|
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
"token_type": "bearer",
|
|
"expires_in": 86400,
|
|
"user": {
|
|
"id": 1,
|
|
"username": "admin",
|
|
"email": "admin@example.com",
|
|
"role": "admin",
|
|
"is_active": true,
|
|
"created_at": "2024-01-01T00:00:00",
|
|
"last_login_at": "2024-01-15T10:30:00"
|
|
},
|
|
"roles": [
|
|
{
|
|
"id": 1,
|
|
"name": "admin",
|
|
"display_name": "Administrator"
|
|
}
|
|
],
|
|
"permissions": {
|
|
"clusters": {"read": true, "write": true, "delete": true},
|
|
"agents": {"read": true, "write": true, "delete": true}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Using the Token
|
|
Include the access token in subsequent requests:
|
|
```bash
|
|
curl -X GET "{BASE_URL}/api/clusters" \\
|
|
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
|
```
|
|
|
|
## Error Responses
|
|
- **401**: Invalid credentials or inactive account
|
|
- **500**: Server error during authentication
|
|
"""
|
|
|
|
try:
|
|
conn = await get_database_connection()
|
|
|
|
# Find user by username (with safe column handling)
|
|
try:
|
|
user = await conn.fetchrow("""
|
|
SELECT id, username, email, password_hash, is_active, role,
|
|
created_at, updated_at, last_login_at
|
|
FROM users
|
|
WHERE username = $1 AND is_active = TRUE
|
|
""", login_request.username)
|
|
except Exception as schema_error:
|
|
logger.warning(f"Schema error, trying fallback query: {schema_error}")
|
|
# Fallback query without role column - try different column names
|
|
try:
|
|
user = await conn.fetchrow("""
|
|
SELECT id, username, email, password_hash, is_active,
|
|
created_at, updated_at, last_login_at
|
|
FROM users
|
|
WHERE username = $1 AND is_active = TRUE
|
|
""", login_request.username)
|
|
except Exception as column_error:
|
|
logger.warning(f"last_login_at column error, trying last_login: {column_error}")
|
|
# Try with last_login instead of last_login_at
|
|
user = await conn.fetchrow("""
|
|
SELECT id, username, email, password_hash, is_active,
|
|
created_at, updated_at, last_login
|
|
FROM users
|
|
WHERE username = $1 AND is_active = TRUE
|
|
""", login_request.username)
|
|
|
|
if not user:
|
|
# Covers both "no such user" and "soft-deleted (is_active=FALSE)".
|
|
# We deliberately return the same generic 401 in either case to
|
|
# avoid leaking whether an account exists (account enumeration
|
|
# prevention). Soft-deleted rows are filtered out by the
|
|
# `AND is_active = TRUE` predicate above.
|
|
await close_database_connection(conn)
|
|
logger.warning(f"Failed login attempt for username: {login_request.username}")
|
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
|
|
|
# Verify password
|
|
import bcrypt
|
|
if not bcrypt.checkpw(login_request.password.encode('utf-8'), user['password_hash'].encode('utf-8')):
|
|
await close_database_connection(conn)
|
|
logger.warning(f"Wrong password for user: {login_request.username}")
|
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
|
|
|
# Issue #18 — MFA branch (v1.6.0): if the user opted in, defer JWT mint and
|
|
# last_login update until /api/auth/login/mfa-verify completes.
|
|
mfa_state = await _fetch_mfa_state(conn, user['id'])
|
|
if mfa_state and mfa_state.get('mfa_enabled'):
|
|
await _cleanup_expired_pending_logins(conn, user['id'])
|
|
challenge_token = mfa_service.generate_challenge_token()
|
|
try:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO mfa_pending_logins (user_id, challenge_token, expires_at, ip_address)
|
|
VALUES ($1, $2, NOW() + ($3 || ' seconds')::interval, $4)
|
|
""",
|
|
user['id'],
|
|
challenge_token,
|
|
str(MFA_PENDING_TTL_SECONDS),
|
|
str(request.client.host) if request.client else None,
|
|
)
|
|
except Exception as exc:
|
|
await close_database_connection(conn)
|
|
logger.error(f"Failed to create MFA pending login: {exc}")
|
|
raise HTTPException(status_code=500, detail="MFA challenge creation failed")
|
|
|
|
await close_database_connection(conn)
|
|
|
|
await log_user_activity(
|
|
user_id=user['id'],
|
|
action='mfa.login.challenge_issued',
|
|
resource_type='mfa',
|
|
resource_id=str(user['id']),
|
|
details={'login_method': 'username_password'},
|
|
ip_address=str(request.client.host) if request.client else None,
|
|
user_agent=request.headers.get('user-agent'),
|
|
)
|
|
|
|
return {
|
|
"mfa_required": True,
|
|
"mfa_token": challenge_token,
|
|
"methods": ["totp", "backup"],
|
|
"expires_in": MFA_PENDING_TTL_SECONDS,
|
|
}
|
|
|
|
# Update last login (try different column names)
|
|
try:
|
|
await conn.execute("""
|
|
UPDATE users SET last_login_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1
|
|
""", user['id'])
|
|
except Exception as update_error:
|
|
logger.warning(f"last_login_at update failed, trying last_login: {update_error}")
|
|
try:
|
|
await conn.execute("""
|
|
UPDATE users SET last_login = CURRENT_TIMESTAMP
|
|
WHERE id = $1
|
|
""", user['id'])
|
|
except Exception as fallback_error:
|
|
logger.warning(f"Could not update last login: {fallback_error}")
|
|
# Continue without updating last_login
|
|
|
|
# Get user roles and permissions before closing connection
|
|
conn_for_roles = conn
|
|
|
|
# Fetch user roles with their permissions
|
|
user_roles = await conn_for_roles.fetch("""
|
|
SELECT r.id, r.name, r.display_name, r.permissions
|
|
FROM user_roles ur
|
|
JOIN roles r ON ur.role_id = r.id
|
|
WHERE ur.user_id = $1 AND ur.is_active = TRUE AND r.is_active = TRUE
|
|
""", user['id'])
|
|
|
|
# Build permissions dictionary
|
|
permissions = {}
|
|
roles_list = []
|
|
|
|
for role_row in user_roles:
|
|
roles_list.append({
|
|
'id': role_row['id'],
|
|
'name': role_row['name'],
|
|
'display_name': role_row['display_name']
|
|
})
|
|
|
|
# Parse permissions from JSON string if needed
|
|
role_permissions = role_row['permissions']
|
|
if isinstance(role_permissions, str):
|
|
import json
|
|
role_permissions = json.loads(role_permissions)
|
|
|
|
# Merge permissions (resource.action format)
|
|
if role_permissions:
|
|
for perm in role_permissions:
|
|
if '.' in perm:
|
|
resource, action = perm.split('.', 1)
|
|
if resource not in permissions:
|
|
permissions[resource] = {}
|
|
permissions[resource][action] = True
|
|
|
|
await close_database_connection(conn)
|
|
|
|
# Create JWT token
|
|
from jose import jwt
|
|
from config import JWT_SECRET_KEY, JWT_ALGORITHM
|
|
|
|
payload = {
|
|
"user_id": user['id'],
|
|
"username": user['username'],
|
|
"email": user.get('email'),
|
|
"role": user.get('role', 'admin'), # Default to admin if role column doesn't exist
|
|
"exp": datetime.utcnow() + timedelta(hours=24) # 24 hour expiry
|
|
}
|
|
|
|
token = jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
|
|
|
# Log login activity
|
|
await log_user_activity(
|
|
user_id=user['id'],
|
|
action='login',
|
|
resource_type='auth',
|
|
resource_id=str(user['id']),
|
|
details={
|
|
'login_method': 'username_password',
|
|
'success': True
|
|
},
|
|
ip_address=str(request.client.host) if request.client else None,
|
|
user_agent=request.headers.get('user-agent')
|
|
)
|
|
|
|
return {
|
|
"access_token": token,
|
|
"token_type": "bearer",
|
|
"expires_in": 86400, # 24 hours in seconds
|
|
"user": {
|
|
"id": user['id'],
|
|
"username": user['username'],
|
|
"email": user.get('email'),
|
|
"role": user.get('role', 'admin'),
|
|
"is_active": user.get('is_active', True),
|
|
"created_at": user['created_at'].isoformat() if user.get('created_at') else None,
|
|
"last_login_at": datetime.utcnow().isoformat()
|
|
},
|
|
"roles": roles_list,
|
|
"permissions": permissions
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Login error: {e}")
|
|
raise HTTPException(status_code=500, detail="Login failed")
|
|
|
|
@router.post(
|
|
"/login/mfa-verify",
|
|
summary="MFA Verification (Step 2 of Login)",
|
|
response_description="JWT access token after successful TOTP/backup verification",
|
|
)
|
|
async def login_mfa_verify(payload: MfaVerifyRequest, request: Request):
|
|
"""
|
|
# MFA Verification — Step 2 of the two-step login flow
|
|
|
|
Submit a 6-digit TOTP code OR an 8-character backup code (with optional dash)
|
|
together with the ``mfa_token`` returned by ``POST /api/auth/login`` for an
|
|
MFA-enabled account. On success, returns the same response shape as a
|
|
non-MFA login (Branch A).
|
|
|
|
## Request Body
|
|
- **mfa_token**: 64-char challenge token from /login response
|
|
- **code**: 6 digits (TOTP) or `XXXX-YYYY` (backup)
|
|
|
|
## Error Responses
|
|
- **401**: Invalid code (attempts counter increments)
|
|
- **410**: Challenge expired or invalidated (too many wrong attempts)
|
|
"""
|
|
ip_address = str(request.client.host) if request.client else None
|
|
user_agent = request.headers.get('user-agent')
|
|
|
|
# Outcome captured from the transactional block so we can do JWT mint /
|
|
# activity logging AFTER commit (no side effects on rollback).
|
|
success_payload: Optional[dict] = None
|
|
failure: Optional[dict] = None # { user_id, attempts, invalidated, reason, http_status, detail }
|
|
|
|
conn = None
|
|
try:
|
|
conn = await get_database_connection()
|
|
|
|
# Round 1 audit fix — wrap the whole verify+update in a single
|
|
# transaction with row-level locks (FOR UPDATE) so two concurrent
|
|
# /mfa-verify calls cannot both consume the same TOTP step or the
|
|
# same pending challenge. We never raise inside the transaction once
|
|
# we've started mutating the pending row (would rollback the mark);
|
|
# instead we capture `failure` and raise after commit.
|
|
async with conn.transaction():
|
|
pending = await conn.fetchrow(
|
|
"""
|
|
SELECT id, user_id, attempts, expires_at, used_at
|
|
FROM mfa_pending_logins
|
|
WHERE challenge_token = $1
|
|
FOR UPDATE
|
|
""",
|
|
payload.mfa_token,
|
|
)
|
|
|
|
if not pending:
|
|
failure = {
|
|
'user_id': None,
|
|
'attempts': 0,
|
|
'invalidated': True,
|
|
'reason': 'challenge_not_found',
|
|
'http_status': 410,
|
|
'detail': 'MFA challenge not found or expired',
|
|
}
|
|
elif pending['used_at'] is not None:
|
|
failure = {
|
|
'user_id': pending['user_id'],
|
|
'attempts': pending['attempts'],
|
|
'invalidated': True,
|
|
'reason': 'challenge_already_used',
|
|
'http_status': 410,
|
|
'detail': 'MFA challenge already used',
|
|
}
|
|
elif pending['expires_at'] and pending['expires_at'] < datetime.utcnow():
|
|
failure = {
|
|
'user_id': pending['user_id'],
|
|
'attempts': pending['attempts'],
|
|
'invalidated': True,
|
|
'reason': 'challenge_expired',
|
|
'http_status': 410,
|
|
'detail': 'MFA challenge expired',
|
|
}
|
|
elif pending['attempts'] >= MFA_PENDING_MAX_ATTEMPTS:
|
|
await conn.execute(
|
|
"UPDATE mfa_pending_logins SET used_at = NOW() WHERE id = $1",
|
|
pending['id'],
|
|
)
|
|
failure = {
|
|
'user_id': pending['user_id'],
|
|
'attempts': pending['attempts'],
|
|
'invalidated': True,
|
|
'reason': 'too_many_attempts_pre_check',
|
|
'http_status': 410,
|
|
'detail': 'MFA challenge invalidated (too many attempts)',
|
|
}
|
|
|
|
if failure is None:
|
|
# Lock the user row so the atomic TOTP-step bump cannot race a
|
|
# parallel verify on a different pending challenge for the
|
|
# same account.
|
|
user_row = await conn.fetchrow(
|
|
"""
|
|
SELECT id, username, email, role, is_active,
|
|
created_at, updated_at, last_login_at,
|
|
mfa_secret_encrypted, mfa_last_used_totp_step
|
|
FROM users
|
|
WHERE id = $1
|
|
FOR UPDATE
|
|
""",
|
|
pending['user_id'],
|
|
)
|
|
if not user_row or not user_row['is_active']:
|
|
failure = {
|
|
'user_id': pending['user_id'],
|
|
'attempts': pending['attempts'],
|
|
'invalidated': False,
|
|
'reason': 'user_unavailable',
|
|
'http_status': 401,
|
|
'detail': 'User not available',
|
|
}
|
|
elif not user_row['mfa_secret_encrypted']:
|
|
failure = {
|
|
'user_id': user_row['id'],
|
|
'attempts': pending['attempts'],
|
|
'invalidated': True,
|
|
'reason': 'mfa_not_configured',
|
|
'http_status': 410,
|
|
'detail': 'MFA not configured for this user',
|
|
}
|
|
else:
|
|
secret_plain = mfa_service.decrypt_secret(user_row['mfa_secret_encrypted'])
|
|
verified_method: Optional[str] = None
|
|
codes_remaining: Optional[int] = None
|
|
|
|
if secret_plain:
|
|
ok, step = mfa_service.verify_totp_with_replay_guard(
|
|
secret_plain, payload.code, user_row['mfa_last_used_totp_step']
|
|
)
|
|
if ok:
|
|
# Atomic step bump — refuse if another request already
|
|
# consumed this (or a newer) TOTP step.
|
|
bumped = await conn.fetchval(
|
|
"""
|
|
UPDATE users
|
|
SET mfa_last_used_totp_step = $1,
|
|
mfa_last_used_at = NOW()
|
|
WHERE id = $2
|
|
AND (mfa_last_used_totp_step IS NULL
|
|
OR mfa_last_used_totp_step < $1)
|
|
RETURNING id
|
|
""",
|
|
step,
|
|
user_row['id'],
|
|
)
|
|
if bumped:
|
|
verified_method = 'totp'
|
|
|
|
if verified_method is None:
|
|
# Backup codes — atomic single-use consumption.
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT id, code_hash FROM mfa_backup_codes
|
|
WHERE user_id = $1 AND used_at IS NULL
|
|
""",
|
|
user_row['id'],
|
|
)
|
|
for row in rows:
|
|
if await mfa_service.check_backup_code(payload.code, row['code_hash']):
|
|
consumed_id = await conn.fetchval(
|
|
"""
|
|
UPDATE mfa_backup_codes
|
|
SET used_at = NOW()
|
|
WHERE id = $1 AND used_at IS NULL
|
|
RETURNING id
|
|
""",
|
|
row['id'],
|
|
)
|
|
if consumed_id:
|
|
verified_method = 'backup'
|
|
codes_remaining = await conn.fetchval(
|
|
"SELECT COUNT(*) FROM mfa_backup_codes WHERE user_id = $1 AND used_at IS NULL",
|
|
user_row['id'],
|
|
)
|
|
break
|
|
|
|
if verified_method is None:
|
|
new_attempts = pending['attempts'] + 1
|
|
invalidated = new_attempts >= MFA_PENDING_MAX_ATTEMPTS
|
|
await conn.execute(
|
|
"""
|
|
UPDATE mfa_pending_logins
|
|
SET attempts = $1,
|
|
used_at = CASE WHEN $2 THEN NOW() ELSE used_at END
|
|
WHERE id = $3
|
|
""",
|
|
new_attempts,
|
|
invalidated,
|
|
pending['id'],
|
|
)
|
|
failure = {
|
|
'user_id': user_row['id'],
|
|
'attempts': new_attempts,
|
|
'invalidated': invalidated,
|
|
'reason': 'invalid_code',
|
|
'http_status': 410 if invalidated else 401,
|
|
'detail': 'MFA challenge invalidated (too many attempts)'
|
|
if invalidated else 'Invalid MFA code',
|
|
}
|
|
else:
|
|
# Verified — finalize state inside the transaction so a
|
|
# concurrent verify sees used_at on retry.
|
|
await conn.execute(
|
|
"UPDATE mfa_pending_logins SET used_at = NOW() WHERE id = $1",
|
|
pending['id'],
|
|
)
|
|
if verified_method != 'totp':
|
|
await conn.execute(
|
|
"UPDATE users SET mfa_last_used_at = NOW() WHERE id = $1",
|
|
user_row['id'],
|
|
)
|
|
try:
|
|
await conn.execute(
|
|
"UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = $1",
|
|
user_row['id'],
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(f"last_login_at update failed (continuing): {exc}")
|
|
|
|
user_roles = await conn.fetch(
|
|
"""
|
|
SELECT r.id, r.name, r.display_name, r.permissions
|
|
FROM user_roles ur
|
|
JOIN roles r ON ur.role_id = r.id
|
|
WHERE ur.user_id = $1 AND ur.is_active = TRUE AND r.is_active = TRUE
|
|
""",
|
|
user_row['id'],
|
|
)
|
|
|
|
permissions: dict = {}
|
|
roles_list: list = []
|
|
for role_row in user_roles:
|
|
roles_list.append({
|
|
'id': role_row['id'],
|
|
'name': role_row['name'],
|
|
'display_name': role_row['display_name'],
|
|
})
|
|
role_permissions = role_row['permissions']
|
|
if isinstance(role_permissions, str):
|
|
import json
|
|
role_permissions = json.loads(role_permissions)
|
|
if role_permissions:
|
|
for perm in role_permissions:
|
|
if '.' in perm:
|
|
resource, action = perm.split('.', 1)
|
|
if resource not in permissions:
|
|
permissions[resource] = {}
|
|
permissions[resource][action] = True
|
|
|
|
success_payload = {
|
|
'user': dict(user_row),
|
|
'roles_list': roles_list,
|
|
'permissions': permissions,
|
|
'method': verified_method,
|
|
'codes_remaining': codes_remaining,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Transaction has committed. Side-effects (JWT mint, audit log) below.
|
|
# ------------------------------------------------------------------
|
|
await close_database_connection(conn)
|
|
conn = None
|
|
|
|
if failure is not None:
|
|
if failure['user_id'] is not None:
|
|
await log_user_activity(
|
|
user_id=failure['user_id'],
|
|
action='mfa.login.failed',
|
|
resource_type='mfa',
|
|
resource_id=str(failure['user_id']),
|
|
details={
|
|
'reason': failure['reason'],
|
|
'attempts': failure['attempts'],
|
|
'invalidated': failure['invalidated'],
|
|
},
|
|
ip_address=ip_address,
|
|
user_agent=user_agent,
|
|
)
|
|
raise HTTPException(status_code=failure['http_status'], detail=failure['detail'])
|
|
|
|
# Success path
|
|
assert success_payload is not None # for type checkers; transaction guarantees this
|
|
user_row = success_payload['user']
|
|
|
|
from jose import jwt
|
|
from config import JWT_SECRET_KEY, JWT_ALGORITHM
|
|
|
|
token_payload = {
|
|
"user_id": user_row['id'],
|
|
"username": user_row['username'],
|
|
"email": user_row['email'],
|
|
"role": user_row['role'] if 'role' in user_row.keys() else 'admin',
|
|
"exp": datetime.utcnow() + timedelta(hours=24),
|
|
}
|
|
token = jwt.encode(token_payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
|
|
|
await log_user_activity(
|
|
user_id=user_row['id'],
|
|
action='mfa.login.success',
|
|
resource_type='mfa',
|
|
resource_id=str(user_row['id']),
|
|
details={
|
|
'method': success_payload['method'],
|
|
'codes_remaining': success_payload['codes_remaining']
|
|
if success_payload['method'] == 'backup' else None,
|
|
},
|
|
ip_address=ip_address,
|
|
user_agent=user_agent,
|
|
)
|
|
|
|
return {
|
|
"access_token": token,
|
|
"token_type": "bearer",
|
|
"expires_in": 86400,
|
|
"user": {
|
|
"id": user_row['id'],
|
|
"username": user_row['username'],
|
|
"email": user_row['email'],
|
|
"role": user_row['role'] if 'role' in user_row.keys() else 'admin',
|
|
"is_active": user_row['is_active'],
|
|
"created_at": user_row['created_at'].isoformat() if user_row.get('created_at') else None,
|
|
"last_login_at": datetime.utcnow().isoformat(),
|
|
},
|
|
"roles": success_payload['roles_list'],
|
|
"permissions": success_payload['permissions'],
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
logger.error(f"MFA verify error: {exc}")
|
|
raise HTTPException(status_code=500, detail="MFA verification failed")
|
|
finally:
|
|
if conn is not None:
|
|
await close_database_connection(conn)
|
|
|
|
|
|
@router.post("/logout", summary="User Logout", response_description="Logout confirmation")
|
|
async def logout(request: Request, authorization: str = Header(None)):
|
|
"""
|
|
# User Logout
|
|
|
|
Logout current user and log activity. Requires valid JWT token.
|
|
|
|
## Headers
|
|
- **Authorization**: Bearer {access_token}
|
|
|
|
## Example Request
|
|
```bash
|
|
curl -X POST "{BASE_URL}/api/auth/logout" \\
|
|
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
|
```
|
|
|
|
## Example Response
|
|
```json
|
|
{
|
|
"message": "Logout successful"
|
|
}
|
|
```
|
|
"""
|
|
try:
|
|
# Get current user for activity logging
|
|
current_user = await get_current_user_from_token(authorization)
|
|
|
|
if current_user and current_user.get('id'):
|
|
# Log logout activity
|
|
await log_user_activity(
|
|
user_id=current_user['id'],
|
|
action='logout',
|
|
resource_type='auth',
|
|
resource_id=str(current_user['id']),
|
|
details={
|
|
'logout_method': 'manual'
|
|
},
|
|
ip_address=str(request.client.host) if request.client else None,
|
|
user_agent=request.headers.get('user-agent')
|
|
)
|
|
|
|
return {"message": "Logout successful"}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Logout error: {e}")
|
|
# Still return success even if logging fails
|
|
return {"message": "Logout successful"}
|
|
|
|
@router.get("/me", summary="Get Current User", response_description="Current user information")
|
|
async def get_current_user(authorization: str = Header(None)):
|
|
"""
|
|
# Get Current User Information
|
|
|
|
Get authenticated user's profile information. Requires valid JWT token.
|
|
|
|
## Headers
|
|
- **Authorization**: Bearer {access_token}
|
|
|
|
## Example Request
|
|
```bash
|
|
curl -X GET "{BASE_URL}/api/auth/me" \\
|
|
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
|
```
|
|
|
|
## Example Response
|
|
```json
|
|
{
|
|
"id": 1,
|
|
"username": "admin",
|
|
"email": "admin@example.com",
|
|
"role": "admin",
|
|
"is_active": true,
|
|
"created_at": "2024-01-01T00:00:00",
|
|
"updated_at": "2024-01-15T10:30:00",
|
|
"last_login_at": "2024-01-15T10:30:00"
|
|
}
|
|
```
|
|
"""
|
|
try:
|
|
current_user = await get_current_user_from_token(authorization)
|
|
|
|
if not current_user:
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
|
|
conn = await get_database_connection()
|
|
|
|
# Get fresh user data (with safe column handling)
|
|
try:
|
|
user = await conn.fetchrow("""
|
|
SELECT id, username, email, role, is_active,
|
|
created_at, updated_at, last_login_at
|
|
FROM users
|
|
WHERE id = $1
|
|
""", current_user['id'])
|
|
except Exception as schema_error:
|
|
logger.warning(f"Schema error in /me endpoint, trying fallback: {schema_error}")
|
|
# Fallback query without role column - try different column names
|
|
try:
|
|
user = await conn.fetchrow("""
|
|
SELECT id, username, email, is_active,
|
|
created_at, updated_at, last_login_at
|
|
FROM users
|
|
WHERE id = $1
|
|
""", current_user['id'])
|
|
except Exception as column_error:
|
|
logger.warning(f"last_login_at column error in /me, trying last_login: {column_error}")
|
|
# Try with last_login instead of last_login_at
|
|
user = await conn.fetchrow("""
|
|
SELECT id, username, email, is_active,
|
|
created_at, updated_at, last_login
|
|
FROM users
|
|
WHERE id = $1
|
|
""", current_user['id'])
|
|
|
|
await close_database_connection(conn)
|
|
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="User not found")
|
|
|
|
return {
|
|
"id": user['id'],
|
|
"username": user['username'],
|
|
"email": user.get('email'),
|
|
"role": user.get('role', 'admin'),
|
|
"is_active": user.get('is_active', True),
|
|
"created_at": user['created_at'].isoformat() if user.get('created_at') else None,
|
|
"updated_at": user['updated_at'].isoformat() if user.get('updated_at') else None,
|
|
"last_login_at": (user.get('last_login_at') or user.get('last_login', None)).isoformat() if (user.get('last_login_at') or user.get('last_login')) else None
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Get current user error: {e}")
|
|
raise HTTPException(status_code=500, detail="Failed to get user info")
|
|
|
|
@router.post("/change-password", summary="Change Password", response_description="Password change confirmation")
|
|
async def change_password(password_update: UserPasswordUpdate, request: Request, authorization: str = Header(None)):
|
|
"""
|
|
# Change User Password
|
|
|
|
Change authenticated user's password. Requires current password for verification.
|
|
|
|
## Headers
|
|
- **Authorization**: Bearer {access_token}
|
|
|
|
## Request Body
|
|
- **current_password**: Current password for verification
|
|
- **new_password**: New password (min 8 characters recommended)
|
|
|
|
## Example Request
|
|
```bash
|
|
curl -X POST "{BASE_URL}/api/auth/change-password" \\
|
|
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \\
|
|
-H "Content-Type: application/json" \\
|
|
-d '{
|
|
"current_password": "admin123",
|
|
"new_password": "newSecurePassword456"
|
|
}'
|
|
```
|
|
|
|
## Example Response
|
|
```json
|
|
{
|
|
"message": "Password changed successfully"
|
|
}
|
|
```
|
|
|
|
## Error Responses
|
|
- **400**: Current password is incorrect
|
|
- **401**: Not authenticated
|
|
- **404**: User not found
|
|
"""
|
|
try:
|
|
current_user = await get_current_user_from_token(authorization)
|
|
|
|
if not current_user:
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
|
|
conn = await get_database_connection()
|
|
|
|
# Get current user data
|
|
user = await conn.fetchrow("""
|
|
SELECT id, password_hash FROM users WHERE id = $1
|
|
""", current_user['id'])
|
|
|
|
if not user:
|
|
await close_database_connection(conn)
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
# Verify current password
|
|
import bcrypt
|
|
if not bcrypt.checkpw(password_update.current_password.encode('utf-8'), user['password_hash'].encode('utf-8')):
|
|
await close_database_connection(conn)
|
|
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
|
|
|
# Hash new password
|
|
new_password_hash = bcrypt.hashpw(password_update.new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
|
|
|
# Update password
|
|
await conn.execute("""
|
|
UPDATE users SET password_hash = $1, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $2
|
|
""", new_password_hash, current_user['id'])
|
|
|
|
await close_database_connection(conn)
|
|
|
|
# Log password change activity
|
|
await log_user_activity(
|
|
user_id=current_user['id'],
|
|
action='update',
|
|
resource_type='user',
|
|
resource_id=str(current_user['id']),
|
|
details={
|
|
'action': 'password_change',
|
|
'success': True
|
|
},
|
|
ip_address=str(request.client.host) if request.client else None,
|
|
user_agent=request.headers.get('user-agent')
|
|
)
|
|
|
|
return {"message": "Password changed successfully"}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Change password error: {e}")
|
|
raise HTTPException(status_code=500, detail="Failed to change password")
|
|
|
|
@router.get("/validate-token", summary="Validate Token", response_description="Token validation result")
|
|
async def validate_token(authorization: str = Header(None)):
|
|
"""
|
|
# Validate JWT Token
|
|
|
|
Check if a JWT token is valid and not expired.
|
|
|
|
## Headers
|
|
- **Authorization**: Bearer {access_token}
|
|
|
|
## Example Request
|
|
```bash
|
|
curl -X GET "{BASE_URL}/api/auth/validate-token" \\
|
|
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
|
```
|
|
|
|
## Example Response
|
|
```json
|
|
{
|
|
"valid": true,
|
|
"user": {
|
|
"id": 1,
|
|
"username": "admin",
|
|
"email": "admin@example.com",
|
|
"role": "admin"
|
|
}
|
|
}
|
|
```
|
|
|
|
## Error Responses
|
|
- **401**: Invalid or expired token
|
|
"""
|
|
try:
|
|
current_user = await get_current_user_from_token(authorization)
|
|
|
|
if not current_user:
|
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
|
|
return {
|
|
"valid": True,
|
|
"user": {
|
|
"id": current_user['id'],
|
|
"username": current_user['username'],
|
|
"email": current_user.get('email'),
|
|
"role": current_user.get('role', 'admin')
|
|
}
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Token validation error: {e}")
|
|
raise HTTPException(status_code=401, detail="Token validation failed") |