344 lines
11 KiB
Python
344 lines
11 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from fastapi.security import OAuth2PasswordRequestForm
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import text
|
|
|
|
from app.core.config import settings
|
|
from app.core.logging import get_logger
|
|
from app.core.security import (
|
|
create_access_token,
|
|
create_refresh_token,
|
|
get_current_user,
|
|
get_password_hash,
|
|
verify_password,
|
|
)
|
|
from app.db.session import get_db
|
|
from app.models.user import User
|
|
from app.schemas.token import Token
|
|
from app.schemas.user import (
|
|
ForgotPasswordRequest,
|
|
ResendCodeRequest,
|
|
ResetPasswordRequest,
|
|
UserRegister,
|
|
UserResponse,
|
|
VerifyEmailRequest,
|
|
)
|
|
from app.services import otp
|
|
from app.services.email import (
|
|
EmailError,
|
|
EmailNotConfiguredError,
|
|
send_verification_email,
|
|
)
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _token_response(user: User) -> dict:
|
|
access_token = create_access_token(data={"sub": user.id})
|
|
refresh = create_refresh_token(data={"sub": user.id})
|
|
expires_in = (
|
|
settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
|
if settings.ACCESS_TOKEN_EXPIRE_MINUTES > 0
|
|
else None
|
|
)
|
|
return {
|
|
"access_token": access_token,
|
|
"token_type": "bearer",
|
|
"expires_in": expires_in,
|
|
"refresh_token": refresh,
|
|
"user": {
|
|
"id": user.id,
|
|
"username": user.username,
|
|
"role": user.role,
|
|
"gatekeeper_groups": user.gatekeeper_groups or [],
|
|
},
|
|
}
|
|
|
|
|
|
async def _load_user_by_email(db: AsyncSession, email: str) -> User | None:
|
|
result = await db.execute(
|
|
text(
|
|
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups, email_verified "
|
|
"FROM users WHERE email = :email"
|
|
),
|
|
{"email": email},
|
|
)
|
|
row = result.fetchone()
|
|
if row is None:
|
|
return None
|
|
user = User()
|
|
user.id = row[0]
|
|
user.username = row[1]
|
|
user.email = row[2]
|
|
user.password_hash = row[3]
|
|
user.role = row[4]
|
|
user.is_active = row[5]
|
|
user.gatekeeper_groups = row[6] or []
|
|
user.email_verified = bool(row[7])
|
|
return user
|
|
|
|
|
|
@router.post("/login", response_model=Token)
|
|
async def login(
|
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
text(
|
|
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups, email_verified "
|
|
"FROM users WHERE username = :username"
|
|
),
|
|
{"username": form_data.username},
|
|
)
|
|
row = result.fetchone()
|
|
if row is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid credentials",
|
|
)
|
|
|
|
user = User()
|
|
user.id = row[0]
|
|
user.username = row[1]
|
|
user.email = row[2]
|
|
user.password_hash = row[3]
|
|
user.role = row[4]
|
|
user.is_active = row[5]
|
|
user.gatekeeper_groups = row[6] or []
|
|
user.email_verified = bool(row[7])
|
|
|
|
if not verify_password(form_data.password, user.password_hash):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid credentials",
|
|
)
|
|
if not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User is inactive",
|
|
)
|
|
if not user.email_verified:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail={"code": "EMAIL_NOT_VERIFIED", "email": user.email},
|
|
)
|
|
|
|
return _token_response(user)
|
|
|
|
|
|
@router.post("/refresh", response_model=Token)
|
|
async def refresh_token(
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
access_token = create_access_token(data={"sub": current_user.id})
|
|
|
|
expires_in = None
|
|
if settings.ACCESS_TOKEN_EXPIRE_MINUTES > 0:
|
|
expires_in = settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
|
|
|
return {
|
|
"access_token": access_token,
|
|
"token_type": "bearer",
|
|
"expires_in": expires_in,
|
|
"user": {
|
|
"id": current_user.id,
|
|
"username": current_user.username,
|
|
"role": current_user.role,
|
|
"gatekeeper_groups": current_user.gatekeeper_groups or [],
|
|
},
|
|
}
|
|
|
|
|
|
@router.post("/logout")
|
|
async def logout():
|
|
return {"message": "Successfully logged out"}
|
|
|
|
|
|
@router.get("/me", response_model=UserResponse)
|
|
async def get_me(current_user: User = Depends(get_current_user)):
|
|
return {
|
|
"id": current_user.id,
|
|
"username": current_user.username,
|
|
"email": current_user.email,
|
|
"role": current_user.role,
|
|
"gatekeeper_groups": current_user.gatekeeper_groups or [],
|
|
"is_active": current_user.is_active,
|
|
"email_verified": getattr(current_user, "email_verified", True),
|
|
"created_at": current_user.created_at,
|
|
}
|
|
|
|
|
|
async def _send_code_or_raise(db: AsyncSession, email: str, code: str, purpose: str) -> None:
|
|
try:
|
|
await send_verification_email(db, to=email, code=code, purpose=purpose)
|
|
except EmailNotConfiguredError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail={"code": exc.code, "message": str(exc)},
|
|
) from exc
|
|
except EmailError as exc:
|
|
logger.warning_event(
|
|
"SMTP send failed",
|
|
event="auth.email.send_failed",
|
|
context={"email": email, "purpose": purpose, "error": str(exc)},
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail={"code": exc.code, "message": str(exc)},
|
|
) from exc
|
|
|
|
|
|
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
|
async def register(payload: UserRegister, db: AsyncSession = Depends(get_db)):
|
|
existing = await db.execute(
|
|
text("SELECT id, email_verified FROM users WHERE username = :u OR email = :e"),
|
|
{"u": payload.username, "e": payload.email},
|
|
)
|
|
row = existing.fetchone()
|
|
if row is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail={"code": "USER_ALREADY_EXISTS", "message": "Username or email already in use"},
|
|
)
|
|
|
|
user = User(
|
|
username=payload.username,
|
|
email=payload.email,
|
|
password_hash=get_password_hash(payload.password),
|
|
role="viewer",
|
|
is_active=True,
|
|
email_verified=False,
|
|
)
|
|
db.add(user)
|
|
await db.commit()
|
|
|
|
try:
|
|
code = otp.issue_code(payload.email, "register")
|
|
except otp.OtpResendRateLimited as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail={"code": exc.code, "retry_after_seconds": exc.retry_after_seconds},
|
|
) from exc
|
|
await _send_code_or_raise(db, payload.email, code, "register")
|
|
return {"status": "pending_verification", "email": payload.email}
|
|
|
|
|
|
@router.post("/verify-email", response_model=Token)
|
|
async def verify_email(payload: VerifyEmailRequest, db: AsyncSession = Depends(get_db)):
|
|
user = await _load_user_by_email(db, payload.email)
|
|
if user is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail={"code": "USER_NOT_FOUND"},
|
|
)
|
|
try:
|
|
otp.verify_code(payload.email, "register", payload.code)
|
|
except otp.OtpExpired as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_410_GONE,
|
|
detail={"code": exc.code, "message": str(exc)},
|
|
) from exc
|
|
except otp.OtpAttemptsExceeded as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail={"code": exc.code, "message": str(exc)},
|
|
) from exc
|
|
except otp.OtpInvalid as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={"code": exc.code, "message": str(exc)},
|
|
) from exc
|
|
|
|
await db.execute(
|
|
text("UPDATE users SET email_verified = TRUE WHERE id = :id"),
|
|
{"id": user.id},
|
|
)
|
|
await db.commit()
|
|
user.email_verified = True
|
|
return _token_response(user)
|
|
|
|
|
|
@router.post("/resend-code")
|
|
async def resend_code(payload: ResendCodeRequest, db: AsyncSession = Depends(get_db)):
|
|
user = await _load_user_by_email(db, payload.email)
|
|
if user is None:
|
|
# Avoid email enumeration; pretend success.
|
|
return {"status": "ok"}
|
|
if payload.purpose == "register" and user.email_verified:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail={"code": "ALREADY_VERIFIED"},
|
|
)
|
|
try:
|
|
code = otp.issue_code(payload.email, payload.purpose)
|
|
except otp.OtpResendRateLimited as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail={"code": exc.code, "retry_after_seconds": exc.retry_after_seconds},
|
|
) from exc
|
|
await _send_code_or_raise(db, payload.email, code, payload.purpose)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.post("/forgot-password")
|
|
async def forgot_password(payload: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
|
|
user = await _load_user_by_email(db, payload.email)
|
|
if user is None:
|
|
# Don't leak whether an email is registered.
|
|
return {"status": "ok"}
|
|
try:
|
|
code = otp.issue_code(payload.email, "reset_password")
|
|
except otp.OtpResendRateLimited:
|
|
# Silently accept; the user can retry after the cooldown.
|
|
return {"status": "ok"}
|
|
try:
|
|
await send_verification_email(db, to=payload.email, code=code, purpose="reset_password")
|
|
except EmailNotConfiguredError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail={"code": exc.code, "message": str(exc)},
|
|
) from exc
|
|
except EmailError as exc:
|
|
logger.warning_event(
|
|
"SMTP send failed",
|
|
event="auth.email.send_failed",
|
|
context={"email": payload.email, "purpose": "reset_password", "error": str(exc)},
|
|
)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.post("/reset-password")
|
|
async def reset_password(payload: ResetPasswordRequest, db: AsyncSession = Depends(get_db)):
|
|
user = await _load_user_by_email(db, payload.email)
|
|
if user is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={"code": "OTP_INVALID"},
|
|
)
|
|
try:
|
|
otp.verify_code(payload.email, "reset_password", payload.code)
|
|
except otp.OtpExpired as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_410_GONE,
|
|
detail={"code": exc.code, "message": str(exc)},
|
|
) from exc
|
|
except otp.OtpAttemptsExceeded as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail={"code": exc.code, "message": str(exc)},
|
|
) from exc
|
|
except otp.OtpInvalid as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={"code": exc.code, "message": str(exc)},
|
|
) from exc
|
|
|
|
await db.execute(
|
|
text("UPDATE users SET password_hash = :p, email_verified = TRUE WHERE id = :id"),
|
|
{"p": get_password_hash(payload.new_password), "id": user.id},
|
|
)
|
|
await db.commit()
|
|
return {"status": "ok"}
|