Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
+173
View File
@@ -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)}