from __future__ import annotations from datetime import datetime, timezone, timedelta from fastapi import APIRouter, Form, Request from fastapi.responses import RedirectResponse from sqlalchemy import select from app.core.db.common import CommonSessionLocal from app.core.security.csrf import get_or_create_csrf_token, validate_csrf from app.core.security.otp import start_otp, verify_otp from app.core.security.passwords import verify_password, hash_password from app.core.security.session_auth import ( SESSION_LOGIN_AT_KEY, SESSION_USER_ID_KEY, get_current_user, ) from app.core.templating import templates from app.core.settings import get_settings from app.modules.core.iam.invite_service import accept_invite, reset_password_with_token from app.modules.core.iam.models import LoginAttempt, User from app.modules.client_identity.models import ClientPortalIdentity from app.modules.client_identity.service import mark_identity_activated, resolve_login_user from app.modules.core.rbac.models import Permission, Role, RolePermission, UserRole from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant from app.modules.core.tenancy.settings_models import BranchSettings from app.modules.email_integration.services import send_auth_otp_email, send_password_changed_email router = APIRouter() PENDING_POST_LOGIN_REDIRECT_KEY = "pending_post_login_redirect" LOGIN_ENTRY_PATH_KEY = "login_entry_path" PROFESSIONAL_LOGIN_PATH = "/login" CLIENT_LOGIN_PATH = "/client/login" PROFESSIONAL_ROLE_NAMES = { "System Admin", "Firm Admin", "Partner", "Manager", "Branch Manager", "Staff", "Consultant", } SAFE_POST_LOGIN_REDIRECTS = { "/mobile/attendance", "/employee/attendance", "/employee/dashboard", } # Verified active audit-firm domains are tenant authentication boundaries. # Marketplace and consultant domain behaviour remains unchanged. TENANT_BOUND_DOMAIN_TYPES = {"audit_firm_domain", "audit_firm_subdomain"} def _bound_domain_tenant_id(request: Request) -> int | None: """Return the trusted tenant id bound to the current audit-firm domain. DomainResolverMiddleware only marks exact, active and verified mappings as resolved. The additional checks here make the authentication boundary explicit and safe if the middleware evolves later. """ if not bool(getattr(request.state, "domain_resolved", False)): return None if not bool(getattr(request.state, "domain_is_verified", False)): return None if (getattr(request.state, "domain_status", None) or "").strip().lower() != "active": return None if (getattr(request.state, "domain_type", None) or "").strip() not in TENANT_BOUND_DOMAIN_TYPES: return None tenant_id = getattr(request.state, "domain_tenant_id", None) try: return int(tenant_id) if tenant_id not in (None, "", 0, "0") else None except (TypeError, ValueError): return None def _clear_login_session(request: Request) -> None: """Remove authentication/context state without disturbing CSRF/session middleware.""" for key in ( SESSION_USER_ID_KEY, SESSION_LOGIN_AT_KEY, "user_email", "tenant_id", "branch_id", "tenant_code", "branch_code", "active_tenant_id", "active_branch_id", "active_tenant_code", "active_branch_code", "active_financial_year", "must_change_password", "post_login_redirect", "otp_verified", LOGIN_ENTRY_PATH_KEY, ): request.session.pop(key, None) def _consume_safe_post_login_redirect(request: Request) -> str | None: value = request.session.pop(PENDING_POST_LOGIN_REDIRECT_KEY, None) value = (value or "").strip() if value in SAFE_POST_LOGIN_REDIRECTS: return value return None def _dev_otp_print_enabled() -> bool: settings = get_settings() return bool(getattr(settings, "DEV_AUTH_OTP_PRINT", False)) and (settings.ENV or "").lower() in {"dev", "local", "development"} def _log_dev_otp(label: str, email: str, code: str) -> None: if _dev_otp_print_enabled(): print(f"[DEV OTP] {label} user={email} code={code}") def _client_ip(request: Request) -> str: return request.client.host if request.client else "unknown" def _attempt_key(email: str, ip: str) -> str: return f"{email.lower().strip()}|{ip}" def _get_branch_security_policy(db, user: User) -> BranchSettings | None: if not getattr(user, "branch_id", None): return None return db.execute( select(BranchSettings).where(BranchSettings.branch_id == user.branch_id) ).scalar_one_or_none() def _user_roles(db, 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 _user_permissions(db, user_id: int) -> set[str]: q = ( select(Permission.code) .join(RolePermission, RolePermission.permission_id == Permission.id) .join(UserRole, UserRole.role_id == RolePermission.role_id) .where(UserRole.user_id == user_id, Permission.is_active.is_(True)) ) return set(db.execute(q).scalars().all()) def _otp_required(bs: BranchSettings | None, roles: list[str]) -> bool: if not bs: return False required = { x.strip() for x in (bs.otp_required_roles_csv or "").split(",") if x.strip() } return any(r in required for r in roles) def _default_financial_year_code(db, tenant_id: int | None) -> str | None: if not tenant_id: return None fy = db.execute( select(FinancialYear).where( FinancialYear.tenant_id == tenant_id, FinancialYear.is_current.is_(True), ) ).scalar_one_or_none() if fy: return fy.year_code fy = db.execute( select(FinancialYear) .where(FinancialYear.tenant_id == tenant_id) .order_by(FinancialYear.start_date.desc(), FinancialYear.year_code.desc()) ).scalars().first() return fy.year_code if fy else None def _tenant_code(db, tenant_id: int | None) -> str | None: if not tenant_id: return None tenant = db.get(Tenant, int(tenant_id)) return tenant.code if tenant else None def _branch_code(db, branch_id: int | None) -> str | None: if not branch_id: return None branch = db.get(Branch, int(branch_id)) return branch.code if branch else None def _post_login_redirect(must_change_password: bool, permissions: set[str], roles: list[str]) -> str: if must_change_password: return "/change-password-required" role_set = set(roles or []) # Platform owner always lands on platform control centre first. if "System Admin" in role_set: return "/system-admin/dashboard" if "Client" in role_set: return "/client/dashboard" if "Consultant" in role_set: return "/consultant/dashboard" # If Firm Admin is also Partner, daily operations are more frequent; # the workspace switcher exposes Firm Administration when required. if "Firm Admin" in role_set and "Partner" in role_set: return "/partner/dashboard" if "Firm Admin" in role_set: return "/firm-admin/dashboard" if "Partner" in role_set: return "/partner/dashboard" if role_set.intersection({"Manager", "Branch Manager"}): return "/manager/dashboard" if ( "employees.ess.view" in permissions or "employees.work.view_self" in permissions or "employees.attendance.view_self" in permissions or "employees.leave.view_self" in permissions or "employees.documents.view_self" in permissions or "employees.payroll.view_self" in permissions or "Staff" in role_set ): return "/employee/dashboard" if "system.settings.view" in permissions or "users.view" in permissions: return "/system-settings" return "/employee/dashboard" def _is_user_login_allowed(user: User) -> tuple[bool, str | None]: if not user: return False, "Invalid credentials" if not getattr(user, "is_active", True): return False, "User account is inactive." if hasattr(user, "allow_login") and not bool(getattr(user, "allow_login", True)): return False, "Login is disabled for this account." if hasattr(user, "is_locked") and bool(getattr(user, "is_locked", False)): return False, "User account is locked." if hasattr(user, "deleted_at") and getattr(user, "deleted_at", None) is not None: return False, "User account is deleted." return True, None def _template_context( request: Request, db=None, *, title: str, flash: str | None = None, extra: dict | None = None, ) -> dict: ctx = { "request": request, "csrf_token": get_or_create_csrf_token(request), "flash": flash, "title": title, } if db is not None: current_user = get_current_user(request, db=db) if current_user: ctx.update( { "current_user": current_user, "current_user_roles": _user_roles(db, int(current_user.id)), "current_user_permissions": list( _user_permissions(db, int(current_user.id)) ), } ) if extra: ctx.update(extra) return ctx def _render_login(request: Request, flash: str | None = None, status_code: int = 200): return templates.TemplateResponse( "modules/core/iam/templates/login.html", _template_context(request, title="Professional Login", flash=flash), status_code=status_code, ) def _render_client_login(request: Request, flash: str | None = None, status_code: int = 200): return templates.TemplateResponse( "modules/core/iam/templates/client_login.html", _template_context(request, title="Client Login", flash=flash), status_code=status_code, ) def _is_client_only_roles(roles: list[str]) -> bool: role_set = set(roles or []) return "Client" in role_set and not bool(role_set.intersection(PROFESSIONAL_ROLE_NAMES)) def _client_identity_exists(db, user_id: int, bound_tenant_id: int | None) -> bool: q = select(ClientPortalIdentity.id).where(ClientPortalIdentity.user_id == int(user_id)) if bound_tenant_id is not None: q = q.where(ClientPortalIdentity.tenant_id == int(bound_tenant_id)) return db.execute(q).scalar_one_or_none() is not None def _login_path_for_user(db, user: User | None) -> str: if not user: return PROFESSIONAL_LOGIN_PATH return CLIENT_LOGIN_PATH if _is_client_only_roles(_user_roles(db, int(user.id))) else PROFESSIONAL_LOGIN_PATH def _render_otp(request: Request, flash: str | None = None, status_code: int = 200): db = CommonSessionLocal() try: return templates.TemplateResponse( "modules/core/iam/templates/otp.html", _template_context( request, db=db, title="OTP Verification", flash=flash, ), status_code=status_code, ) finally: db.close() def _render_change_password( request: Request, flash: str | None = None, status_code: int = 200 ): db = CommonSessionLocal() try: return templates.TemplateResponse( "modules/core/iam/templates/change_password.html", _template_context( request, db=db, title="Change Password", flash=flash, ), status_code=status_code, ) finally: db.close() def _render_change_password_otp( request: Request, flash: str | None = None, status_code: int = 200 ): db = CommonSessionLocal() try: return templates.TemplateResponse( "modules/core/iam/templates/change_password_otp.html", _template_context( request, db=db, title="Confirm Password Change", flash=flash, ), status_code=status_code, ) finally: db.close() def _render_forgot_password( request: Request, flash: str | None = None, status_code: int = 200 ): return templates.TemplateResponse( "modules/core/iam/templates/forgot_password.html", _template_context( request, title="Forgot Password", flash=flash, ), status_code=status_code, ) def _render_reset_password( request: Request, flash: str | None = None, status_code: int = 200 ): return templates.TemplateResponse( "modules/core/iam/templates/reset_password.html", _template_context( request, title="Reset Password", flash=flash, ), status_code=status_code, ) def _render_invite_accept( request: Request, token: str, flash: str | None = None, status_code: int = 200 ): return templates.TemplateResponse( "modules/core/iam/templates/invite_accept.html", _template_context( request, title="Accept Invite", flash=flash, extra={"token": token}, ), status_code=status_code, ) @router.get("/invite/accept") def invite_accept_page(request: Request, token: str = ""): if not token.strip(): return RedirectResponse(url="/login", status_code=303) return _render_invite_accept(request, token=token.strip()) @router.post("/invite/accept") def invite_accept_submit( request: Request, token: str = Form(...), password: str = Form(...), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) token_clean = token.strip() if not token_clean: return RedirectResponse(url="/login", status_code=303) db = CommonSessionLocal() try: try: user = accept_invite(db, token_clean, password.strip()) except ValueError as exc: return _render_invite_accept(request, token=token_clean, flash=str(exc), status_code=400) if not user: return _render_invite_accept(request, token=token_clean, flash="Invalid or expired invite link.", status_code=400) return RedirectResponse(url=_login_path_for_user(db, user), status_code=303) finally: db.close() @router.get("/login") def login_page(request: Request): return _render_login(request) @router.get("/client/login") def client_login_page(request: Request): return _render_client_login(request) def _submit_login( request: Request, *, identifier: str, password: str, csrf_token: str, client_portal: bool, ): validate_csrf(request, csrf_token) identifier_clean = identifier.strip().lower() render_login = _render_client_login if client_portal else _render_login login_path = CLIENT_LOGIN_PATH if client_portal else PROFESSIONAL_LOGIN_PATH if not client_portal and "@" not in identifier_clean: return render_login( request, flash="Professional users must sign in with their email address. Clients should use Client Login.", status_code=400, ) ip = _client_ip(request) key = _attempt_key(identifier_clean, ip) db = CommonSessionLocal() try: la = db.execute( select(LoginAttempt).where(LoginAttempt.key == key) ).scalar_one_or_none() now = datetime.now(timezone.utc) if la and la.locked_until_utc and la.locked_until_utc.replace( tzinfo=timezone.utc ) > now: return render_login( request, flash=f"Account temporarily locked. Try again after {la.locked_until_utc}.", status_code=429, ) bound_tenant_id = _bound_domain_tenant_id(request) user = resolve_login_user(db, identifier_clean, bound_tenant_id) roles = _user_roles(db, int(user.id)) if user else [] is_client_only = _is_client_only_roles(roles) has_client_identity = bool( user and _client_identity_exists(db, int(user.id), bound_tenant_id) ) portal_allowed = ( is_client_only and has_client_identity if client_portal else bool(user and not is_client_only) ) can_login, blocked_reason = _is_user_login_allowed(user) password_ok = bool(user and verify_password(password, user.password_hash)) if not user or not portal_allowed or not can_login or not password_ok: lock_attempts = 5 lock_minutes = 15 if user: bs = _get_branch_security_policy(db, user) if bs: lock_attempts = bs.lockout_attempts lock_minutes = bs.lockout_minutes if not la: la = LoginAttempt(key=key, attempts=0, updated_at_utc=now) db.add(la) la.attempts = int(la.attempts or 0) + 1 la.updated_at_utc = now if la.attempts >= lock_attempts: la.locked_until_utc = now + timedelta(minutes=lock_minutes) la.attempts = 0 db.commit() if user and can_login and password_ok and not portal_allowed: flash = ( "This login is for clients only. Please use Professional Login." if client_portal else "Client accounts must use the Client Login page." ) else: flash = blocked_reason or "Invalid credentials" return render_login(request, flash=flash, status_code=400) if la: la.attempts = 0 la.locked_until_utc = None la.updated_at_utc = now db.commit() user_id = int(user.id) user_email = str(user.email) tenant_id = getattr(user, "tenant_id", None) branch_id = getattr(user, "branch_id", None) must_change_password = bool(getattr(user, "must_change_password", False)) permissions = _user_permissions(db, user_id) bs = _get_branch_security_policy(db, user) # A verified active audit-firm domain is a hard tenant boundary. # System Admin retains the existing platform-support capability, but all # tenant users must belong to the tenant mapped to this hostname. is_system_admin = "System Admin" in set(roles or []) user_tenant_id = int(tenant_id) if tenant_id not in (None, "", 0, "0") else None if bound_tenant_id is not None and not is_system_admin and user_tenant_id != bound_tenant_id: _clear_login_session(request) return render_login( request, flash="This account does not belong to the firm associated with this domain. Please use your firm's login URL.", status_code=403, ) request.session[SESSION_USER_ID_KEY] = user_id request.session[SESSION_LOGIN_AT_KEY] = now.isoformat() request.session["user_email"] = user_email request.session[LOGIN_ENTRY_PATH_KEY] = login_path tenant_code = _tenant_code(db, tenant_id) branch_code = _branch_code(db, branch_id) request.session["tenant_id"] = tenant_id request.session["branch_id"] = branch_id request.session["active_tenant_id"] = tenant_id request.session["active_branch_id"] = branch_id if tenant_code: request.session["tenant_code"] = tenant_code request.session["active_tenant_code"] = tenant_code else: request.session.pop("tenant_code", None) request.session.pop("active_tenant_code", None) if branch_code: request.session["branch_code"] = branch_code request.session["active_branch_code"] = branch_code else: request.session.pop("branch_code", None) request.session.pop("active_branch_code", None) active_financial_year = _default_financial_year_code(db, tenant_id) if active_financial_year: request.session["active_financial_year"] = active_financial_year request.session["must_change_password"] = must_change_password default_post_login_redirect = _post_login_redirect( must_change_password, permissions, roles ) pending_post_login_redirect = None if must_change_password else _consume_safe_post_login_redirect(request) request.session["post_login_redirect"] = pending_post_login_redirect or default_post_login_redirect if _otp_required(bs, roles): code = start_otp(request) try: send_auth_otp_email(db, user=user, otp_code=code, purpose="login") db.commit() except Exception as exc: print(f"[EMAIL OTP ERROR] user={user_email} error={exc}") _log_dev_otp("login", user_email, code) request.session["otp_verified"] = False return RedirectResponse(url="/otp", status_code=303) request.session["otp_verified"] = True return RedirectResponse( url=request.session.get("post_login_redirect", "/system-settings"), status_code=303, ) finally: db.close() @router.post("/login") def login_submit( request: Request, email: str = Form(...), password: str = Form(...), csrf_token: str = Form(...), ): return _submit_login( request, identifier=email, password=password, csrf_token=csrf_token, client_portal=False, ) @router.post("/client/login") def client_login_submit( request: Request, identifier: str = Form(...), password: str = Form(...), csrf_token: str = Form(...), ): return _submit_login( request, identifier=identifier, password=password, csrf_token=csrf_token, client_portal=True, ) @router.get("/otp") def otp_page(request: Request): if not request.session.get(SESSION_USER_ID_KEY): return RedirectResponse(url="/login", status_code=303) return _render_otp(request) @router.post("/otp") def otp_submit( request: Request, otp: str = Form(...), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) if not request.session.get(SESSION_USER_ID_KEY): return RedirectResponse(url="/login", status_code=303) if verify_otp(request, otp): request.session["otp_verified"] = True return RedirectResponse( url=request.session.get("post_login_redirect", "/system-settings"), status_code=303, ) return _render_otp( request, flash="Invalid OTP. Please check the OTP sent to your registered email.", status_code=400, ) @router.get("/change-password") def change_password_page(request: Request): if not request.session.get(SESSION_USER_ID_KEY): return RedirectResponse(url="/login", status_code=303) return _render_change_password(request) @router.post("/change-password") def change_password_submit( request: Request, current_password: str = Form(...), new_password: str = Form(...), confirm_password: str = Form(...), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) user_id = request.session.get(SESSION_USER_ID_KEY) if not user_id: return RedirectResponse(url="/login", status_code=303) if new_password != confirm_password: return _render_change_password( request, flash="New password and confirm password do not match.", status_code=400, ) if len(new_password.strip()) < 8: return _render_change_password( request, flash="New password must be at least 8 characters.", status_code=400, ) db = CommonSessionLocal() try: user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() if not user: request.session.clear() return RedirectResponse(url="/login", status_code=303) if not verify_password(current_password, user.password_hash): return _render_change_password( request, flash="Current password is incorrect.", status_code=400, ) request.session["pending_password_change_hash"] = hash_password( new_password.strip() ) request.session["pending_password_change_user_id"] = int(user.id) code = start_otp(request) try: send_auth_otp_email(db, user=user, otp_code=code, purpose="password_change") db.commit() except Exception as exc: print(f"[EMAIL OTP ERROR] password-change user={user.email} error={exc}") _log_dev_otp("password-change", str(user.email), code) return RedirectResponse(url="/change-password/otp", status_code=303) finally: db.close() @router.get("/change-password/otp") def change_password_otp_page(request: Request): if not request.session.get(SESSION_USER_ID_KEY): return RedirectResponse(url="/login", status_code=303) if not request.session.get("pending_password_change_hash"): return RedirectResponse(url="/change-password", status_code=303) return _render_change_password_otp(request) @router.post("/change-password/otp") def change_password_otp_submit( request: Request, otp: str = Form(...), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) user_id = request.session.get(SESSION_USER_ID_KEY) pending_user_id = request.session.get("pending_password_change_user_id") pending_hash = request.session.get("pending_password_change_hash") if not user_id: return RedirectResponse(url="/login", status_code=303) if not pending_hash or not pending_user_id or int(user_id) != int(pending_user_id): return RedirectResponse(url="/change-password", status_code=303) if not verify_otp(request, otp): return _render_change_password_otp( request, flash="Invalid OTP. Please check the OTP sent to your registered email.", status_code=400, ) db = CommonSessionLocal() try: user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() if not user: request.session.clear() return RedirectResponse(url="/login", status_code=303) user.password_hash = pending_hash user.must_change_password = False user.password_changed_at_utc = datetime.now(timezone.utc) try: send_password_changed_email(db, user=user) except Exception as exc: print(f"[EMAIL PASSWORD CHANGED ERROR] user={user.email} error={exc}") db.commit() roles = _user_roles(db, int(user.id)) permissions = _user_permissions(db, int(user.id)) request.session.pop("pending_password_change_hash", None) request.session.pop("pending_password_change_user_id", None) request.session["must_change_password"] = False request.session["post_login_redirect"] = _post_login_redirect(False, permissions, roles) return RedirectResponse(url=request.session.get("post_login_redirect", "/system-settings"), status_code=303) finally: db.close() @router.get("/forgot-password") def forgot_password_page(request: Request): return _render_forgot_password(request) @router.post("/forgot-password") def forgot_password_submit( request: Request, email: str = Form(...), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) email_clean = email.strip().lower() db = CommonSessionLocal() try: user = resolve_login_user(db, email_clean, _bound_domain_tenant_id(request)) request.session.pop("password_reset_user_id", None) request.session.pop("password_reset_email", None) if not user: return _render_forgot_password( request, flash="If the login ID exists, password reset instructions have been sent to the registered email.", status_code=200, ) can_login, _ = _is_user_login_allowed(user) if not can_login: return _render_forgot_password( request, flash="If the login ID exists, password reset instructions have been sent to the registered email.", status_code=200, ) request.session["password_reset_user_id"] = int(user.id) request.session["password_reset_email"] = str(user.email) code = start_otp(request) try: send_auth_otp_email(db, user=user, otp_code=code, purpose="password_reset") db.commit() except Exception as exc: print(f"[EMAIL OTP ERROR] forgot-password user={user.email} error={exc}") _log_dev_otp("forgot-password", str(user.email), code) return RedirectResponse(url="/reset-password", status_code=303) finally: db.close() @router.get("/reset-password") def reset_password_page(request: Request): if not request.session.get("password_reset_user_id"): return RedirectResponse(url="/forgot-password", status_code=303) return _render_reset_password(request) @router.post("/reset-password") def reset_password_submit( request: Request, otp: str = Form(...), new_password: str = Form(...), confirm_password: str = Form(...), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) user_id = request.session.get("password_reset_user_id") if not user_id: return RedirectResponse(url="/forgot-password", status_code=303) if not verify_otp(request, otp): return _render_reset_password( request, flash="Invalid OTP. Please check the OTP sent to your registered email.", status_code=400, ) if new_password != confirm_password: return _render_reset_password( request, flash="New password and confirm password do not match.", status_code=400, ) if len(new_password.strip()) < 8: return _render_reset_password( request, flash="New password must be at least 8 characters.", status_code=400, ) db = CommonSessionLocal() try: user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none() if not user: request.session.pop("password_reset_user_id", None) request.session.pop("password_reset_email", None) return RedirectResponse(url="/forgot-password", status_code=303) user.password_hash = hash_password(new_password.strip()) user.must_change_password = False user.password_changed_at_utc = datetime.now(timezone.utc) try: send_password_changed_email(db, user=user) except Exception as exc: print(f"[EMAIL PASSWORD CHANGED ERROR] user={user.email} error={exc}") db.commit() request.session.pop("password_reset_user_id", None) request.session.pop("password_reset_email", None) return RedirectResponse(url="/login", status_code=303) finally: db.close() @router.get("/password-reset/accept") def password_reset_token_page(request: Request, token: str = ""): return templates.TemplateResponse( "modules/core/iam/templates/reset_password_token.html", _template_context(request, title="Reset Password", extra={"token": token.strip()}), ) @router.post("/password-reset/accept") def password_reset_token_submit( request: Request, token: str = Form(...), new_password: str = Form(...), confirm_password: str = Form(...), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) token_clean = token.strip() if new_password != confirm_password: return templates.TemplateResponse( "modules/core/iam/templates/reset_password_token.html", _template_context(request, title="Reset Password", flash="New password and confirm password do not match.", extra={"token": token_clean}), status_code=400, ) if len(new_password.strip()) < get_settings().PASSWORD_MIN_LENGTH: return templates.TemplateResponse( "modules/core/iam/templates/reset_password_token.html", _template_context(request, title="Reset Password", flash=f"New password must be at least {get_settings().PASSWORD_MIN_LENGTH} characters.", extra={"token": token_clean}), status_code=400, ) db = CommonSessionLocal() try: try: user = reset_password_with_token(db, token_clean, new_password.strip()) except ValueError as exc: return templates.TemplateResponse( "modules/core/iam/templates/reset_password_token.html", _template_context(request, title="Reset Password", flash=str(exc), extra={"token": token_clean}), status_code=400, ) if not user: return templates.TemplateResponse( "modules/core/iam/templates/reset_password_token.html", _template_context(request, title="Reset Password", flash="Invalid or expired password reset link.", extra={"token": token_clean}), status_code=400, ) try: send_password_changed_email(db, user=user) except Exception as exc: print(f"[EMAIL PASSWORD CHANGED ERROR] user={getattr(user, 'email', '')} error={exc}") db.commit() return RedirectResponse(url=_login_path_for_user(db, user), status_code=303) finally: db.close() @router.get("/change-password-required") def change_password_required(request: Request): return RedirectResponse(url="/change-password", status_code=303) @router.get("/logout") def logout(request: Request): request.session.clear() response = RedirectResponse(url="/login", status_code=303) settings = get_settings() response.delete_cookie( key=settings.COOKIE_SESSION_NAME, path="/", secure=settings.COOKIE_SECURE, httponly=True, samesite=settings.COOKIE_SAMESITE, ) response.headers["Cache-Control"] = ( "no-store, no-cache, must-revalidate, private, max-age=0" ) response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" response.headers["Clear-Site-Data"] = '"cache"' return response