174 lines
8.2 KiB
Python
174 lines
8.2 KiB
Python
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)}
|