Coverage for apps/borrower_portal/api.py: 98%
104 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
1"""Borrower account endpoints that need no session: register, password reset, one-time codes
2(mounted at "" beside the shared ``local_auth_api`` routes; add-borrower-portal-api)."""
4from __future__ import annotations
6import secrets
8from django.conf import settings
9from django.db import IntegrityError, transaction
10from django.utils import timezone
11from ninja import Router
13from apps.borrower_portal.models import AuthToken, BorrowerAccount
14from apps.borrower_portal.schemas import (
15 BorrowerAuthResultOut,
16 OtpRequestInput,
17 OtpRequestOut,
18 OtpVerifyInput,
19 RegisterInput,
20 ResetInput,
21)
22from apps.borrower_portal.services import (
23 RESET_TTL_MINUTES,
24 borrower_session,
25 company_name,
26 hash_token,
27 issue_auth_token,
28 portal_url,
29 send_borrower_message,
30)
31from apps.communications import emails
32from domain.pos import PASSWORD_MSG, first_error, is_strong_password, validate_register
33from domain.result import AppError, DomainError
35router = Router(tags=["borrower-auth"])
37EMAIL_TAKEN = "An account with that email already exists."
38STAFF_MIN_PASSWORD = 4
41@router.post("/auth/register", response={201: BorrowerAuthResultOut})
42def register(request, payload: RegisterInput):
43 problem = first_error(validate_register(payload.name, payload.email, payload.password))
44 if problem:
45 raise DomainError([problem])
46 email = payload.email.strip().lower()
47 if BorrowerAccount.objects.filter(email=email).exists():
48 raise DomainError([AppError("VALIDATION", EMAIL_TAKEN, "email")])
49 account = BorrowerAccount(email=email, name=payload.name.strip())
50 account.set_password(payload.password)
51 try:
52 with transaction.atomic():
53 account.save()
54 except IntegrityError as exc: # a concurrent registration won the race
55 raise DomainError([AppError("VALIDATION", EMAIL_TAKEN, "email")]) from exc
56 return 201, borrower_session(account)
59# --- password reset (borrower-auth: password reset; staff-auth delta) -------------------------
60def send_reset_link(email: str) -> str | None:
61 """``POST /api/auth/forgot`` for a borrower account: email a one-hour reset link to the
62 borrower portal. Returns the link (shown as ``devResetLink`` in development), or None when
63 the email has no active borrower account — staff accounts get nothing (design.md D10)."""
64 account = BorrowerAccount.objects.filter(
65 email=(email or "").strip().lower(), is_active=True
66 ).first()
67 if account is None:
68 return None
69 raw = issue_auth_token(account, AuthToken.KIND_RESET)
70 link = portal_url(f"/reset-password?token={raw}")
71 subject, content = emails.password_reset(company_name(), link)
72 send_borrower_message(
73 channel="email", to=account.email, subject=subject, content=content, trigger="auth:reset"
74 )
75 return link
78def send_staff_reset_link(email: str) -> str | None:
79 """``POST /api/auth/forgot`` for a staff user: email a one-hour reset link to the Staff LOS
80 (add-sendgrid-email). Returns the link (``devResetLink`` in development) or None when the email
81 has no active staff user."""
82 from datetime import timedelta
84 from apps.accounts.models import User
85 from apps.communications.delivery import deliver_email
87 user = User.objects.filter(email__iexact=(email or "").strip(), is_active=True).first()
88 if user is None:
89 return None
90 now = timezone.now()
91 AuthToken.objects.filter(
92 staff_user=user, kind=AuthToken.KIND_RESET, used_at__isnull=True
93 ).update(used_at=now)
94 raw = secrets.token_urlsafe(32)
95 AuthToken.objects.create(
96 staff_user=user,
97 kind=AuthToken.KIND_RESET,
98 token_hash=hash_token(raw),
99 expires_at=now + timedelta(minutes=RESET_TTL_MINUTES),
100 )
101 link = f"{settings.STAFF_LOS_URL}/reset-password?token={raw}"
102 subject, content = emails.password_reset(company_name(), link)
103 deliver_email(
104 to=user.email,
105 subject=subject,
106 content=content,
107 trigger="auth:reset",
108 automated=True,
109 track_clicks=False,
110 )
111 return link
114def _usable_token(raw: str, kind: str, account: BorrowerAccount | None = None):
115 """The token row for ``raw``: (row, None) when usable, else (row-or-None, error code)."""
116 query = AuthToken.objects.filter(token_hash=hash_token(raw or ""), kind=kind)
117 if account is not None:
118 query = query.filter(account=account)
119 token = query.select_related("account", "staff_user").order_by("-created_at").first()
120 owner = (token.account or token.staff_user) if token is not None else None
121 if token is None or token.used_at is not None or owner is None or not owner.is_active:
122 return token, "invalid"
123 if token.expires_at <= timezone.now():
124 return token, "expired"
125 return token, None
128@router.post("/auth/reset", response={204: None})
129def reset_password(request, payload: ResetInput):
130 token, problem = _usable_token(payload.token, AuthToken.KIND_RESET)
131 if problem == "invalid":
132 raise DomainError(
133 [AppError("RESET_INVALID", "This reset link is invalid or has already been used.")]
134 )
135 if problem == "expired":
136 raise DomainError(
137 [AppError("RESET_EXPIRED", "This reset link has expired. Request a new one.")]
138 )
139 if token.staff_user is not None:
140 # Staff keep the staff password rule (same as Staff LOS My account → Change password).
141 if len(payload.password) < STAFF_MIN_PASSWORD:
142 raise DomainError(
143 [AppError("VALIDATION", "New password must be at least 4 characters.", "password")]
144 )
145 elif not is_strong_password(payload.password):
146 raise DomainError([AppError("VALIDATION", PASSWORD_MSG, "password")])
147 with transaction.atomic():
148 owner = token.account or token.staff_user
149 owner.set_password(payload.password)
150 owner.save(update_fields=["password"] if token.staff_user else ["password", "updated_at"])
151 token.used_at = timezone.now()
152 token.save(update_fields=["used_at"])
153 return 204, None
156# --- one-time sign-in codes (borrower-auth: one-time sign-in code) ----------------------------
157@router.post("/auth/otp/request", response=OtpRequestOut, exclude_none=True)
158def request_otp(request, payload: OtpRequestInput):
159 account = BorrowerAccount.objects.filter(
160 email=(payload.email or "").strip().lower(), is_active=True
161 ).first()
162 if account is None:
163 return {"sent": True} # same answer whether or not the account exists
164 code = issue_auth_token(account, AuthToken.KIND_OTP)
165 subject, content = emails.sign_in_code(company_name(), code)
166 # Always by email: there is no SMS provider yet (add-sendgrid-email).
167 send_borrower_message(
168 channel="email", to=account.email, subject=subject, content=content, trigger="auth:otp"
169 )
170 return {"sent": True, "devOtp": code if settings.DEBUG else None}
173@router.post("/auth/otp/verify", response=BorrowerAuthResultOut)
174def verify_otp(request, payload: OtpVerifyInput):
175 account = BorrowerAccount.objects.filter(
176 email=(payload.email or "").strip().lower(), is_active=True
177 ).first()
178 token, problem = (
179 _usable_token(payload.code, AuthToken.KIND_OTP, account) if account else (None, "invalid")
180 )
181 if problem:
182 raise DomainError([AppError("MFA_INVALID", "That code is wrong or has expired.", "code")])
183 assert token is not None # _usable_token returns a token whenever problem is falsy
184 token.used_at = timezone.now()
185 token.save(update_fields=["used_at"])
186 return borrower_session(account)