from __future__ import annotations from datetime import timedelta, timezone import hashlib import secrets from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, EmailStr from sqlalchemy.orm import Session from sqlalchemy import select from app.core.db.deps import get_common_db from app.core.settings import get_settings from app.core.security.passwords import verify_password from app.core.security.jwt_tokens import encode_access_token, decode_token, utcnow from app.modules.core.audit.service import write_audit_log from app.modules.core.iam.invite_service import accept_invite, issue_password_reset_token, reset_password_with_token from app.modules.email_integration.services import send_password_reset_link_email, send_password_changed_email from app.modules.core.iam.models import User from app.modules.core.iam.tokens_models import RefreshToken from app.modules.core.rbac.models import Role, UserRole router = APIRouter(prefix="/auth", tags=["auth"]) class TokenRequest(BaseModel): email: EmailStr password: str class TokenResponse(BaseModel): access_token: str token_type: str = "bearer" expires_in: int refresh_token: str class ForgotPasswordRequest(BaseModel): email: EmailStr class ResetPasswordRequest(BaseModel): token: str new_password: str class AcceptInviteRequest(BaseModel): token: str password: str def _hash_refresh(rt: str) -> str: return hashlib.sha256(rt.encode("utf-8")).hexdigest() def _roles(db: Session, user_id: int) -> list[str]: q = select(Role.name).join(UserRole, UserRole.role_id == Role.id).where(UserRole.user_id == user_id) return [r for (r,) in db.execute(q).all()] def _issue_tokens(db: Session, user: User) -> TokenResponse: s = get_settings() roles = _roles(db, user.id) payload = { "sub": str(user.id), "email": user.email, "tenant_id": user.tenant_id, "branch_id": user.branch_id, "roles": roles, } access = encode_access_token(payload, expires_minutes=s.JWT_ACCESS_MINUTES) refresh_plain = secrets.token_urlsafe(48) now = utcnow() exp = now + timedelta(days=s.JWT_REFRESH_DAYS) rt = RefreshToken( user_id=user.id, token_hash=_hash_refresh(refresh_plain), created_at_utc=now, expires_at_utc=exp, revoked=False, rotated_from_id=None, ) db.add(rt) db.commit() return TokenResponse(access_token=access, expires_in=s.JWT_ACCESS_MINUTES * 60, refresh_token=refresh_plain) @router.post("/token", response_model=TokenResponse) def token(req: TokenRequest, db: Session = Depends(get_common_db)): user = db.execute(select(User).where(User.email == req.email.lower().strip())).scalar_one_or_none() if (not user or not user.is_active or not getattr(user, "allow_login", True) or getattr(user, "is_locked", False) or getattr(user, "deleted_at", None) is not None or not verify_password(req.password, user.password_hash)): write_audit_log(db, action="auth.token.failed", entity_type="api_session", actor=user, actor_email=req.email.lower().strip(), status="error", target_tenant_id=(user.tenant_id if user else None), target_branch_id=(user.branch_id if user else None), details={"reason": "invalid credentials"}) raise HTTPException(status_code=401, detail="Invalid credentials") if getattr(user, "must_change_password", False): raise HTTPException(status_code=403, detail="Password setup/change required before API login") token_response = _issue_tokens(db, user) write_audit_log(db, action="auth.token.success", entity_type="api_session", actor=user, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id) return token_response @router.post('/forgot-password') def forgot_password(req: ForgotPasswordRequest, db: Session = Depends(get_common_db)): user = db.execute(select(User).where(User.email == req.email.lower().strip())).scalar_one_or_none() if user and user.is_active and getattr(user, "deleted_at", None) is None: reset_token = issue_password_reset_token(db, user) try: send_password_reset_link_email(db, user=user, reset_token=reset_token) except Exception as exc: write_audit_log(db, action="auth.password_reset.email_failed", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, status="error", target_tenant_id=user.tenant_id, target_branch_id=user.branch_id, details={"error": str(exc)}) write_audit_log(db, action="auth.password_reset.requested", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id) db.commit() return {"status": "ok"} @router.post('/reset-password') def reset_password(req: ResetPasswordRequest, db: Session = Depends(get_common_db)): try: user = reset_password_with_token(db, req.token, req.new_password) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) if not user: raise HTTPException(status_code=400, detail="Invalid or expired reset token") try: send_password_changed_email(db, user=user) except Exception as exc: write_audit_log(db, action="auth.password_changed.email_failed", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, status="error", target_tenant_id=user.tenant_id, target_branch_id=user.branch_id, details={"error": str(exc)}) write_audit_log(db, action="auth.password_reset.completed", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id) db.commit() return {"status": "ok"} @router.post('/invite/accept') def invite_accept(req: AcceptInviteRequest, db: Session = Depends(get_common_db)): try: user = accept_invite(db, req.token, req.password) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) if not user: raise HTTPException(status_code=400, detail="Invalid or expired invite token") write_audit_log(db, action="auth.invite.accepted", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id) return {"status": "ok"} class RefreshRequest(BaseModel): refresh_token: str @router.post("/refresh", response_model=TokenResponse) def refresh(req: RefreshRequest, db: Session = Depends(get_common_db)): h = _hash_refresh(req.refresh_token) rt = db.execute(select(RefreshToken).where(RefreshToken.token_hash == h)).scalar_one_or_none() if not rt or rt.revoked: raise HTTPException(status_code=401, detail="Invalid refresh token") now = utcnow() if rt.expires_at_utc.replace(tzinfo=timezone.utc) < now: raise HTTPException(status_code=401, detail="Refresh token expired") user = db.execute(select(User).where(User.id == rt.user_id)).scalar_one_or_none() if not user or not user.is_active or not getattr(user, "allow_login", True) or getattr(user, "is_locked", False) or getattr(user, "deleted_at", None) is not None: raise HTTPException(status_code=401, detail="User inactive") rt.revoked = True db.commit() token_response = _issue_tokens(db, user) write_audit_log(db, action="auth.token.refresh", entity_type="api_session", actor=user, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id) return token_response class LogoutRequest(BaseModel): refresh_token: str @router.post("/logout") def logout(req: LogoutRequest, db: Session = Depends(get_common_db)): h = _hash_refresh(req.refresh_token) rt = db.execute(select(RefreshToken).where(RefreshToken.token_hash == h)).scalar_one_or_none() if rt: rt.revoked = True db.commit() user = db.execute(select(User).where(User.id == rt.user_id)).scalar_one_or_none() write_audit_log(db, action="auth.token.logout", entity_type="api_session", actor=user, entity_name=(user.email if user else None), target_tenant_id=(user.tenant_id if user else None), target_branch_id=(user.branch_id if user else None)) return {"status": "ok"} @router.get("/me") def me(token: str): return {"token": decode_token(token)}