Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
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)}
|
||||
Reference in New Issue
Block a user