diff --git a/alembic/versions/022_password_resets.py b/alembic/versions/022_password_resets.py new file mode 100644 index 0000000..6eb7146 --- /dev/null +++ b/alembic/versions/022_password_resets.py @@ -0,0 +1,118 @@ +"""Real password changes and resets. + +- users.password_changed_at: refresh tokens issued before it are refused, so changing or + resetting a password signs out other sessions (within the access token's lifetime). + scirm_user_by_id returns it. +- password_resets: single-use reset links. Only the SHA-256 of the token is stored; it + expires (30 minutes by default) and a new request invalidates the user's earlier ones. + Both steps happen before the user is signed in, so they go through SECURITY DEFINER + functions (like the login lookup): scirm_password_reset_request creates a token for an + active account, and scirm_password_reset_consume sets the new password and uses the + token in one statement, so a token works exactly once. + +Revision ID: 022 +Revises: 021 +Create Date: 2026-10-23 +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "022" +down_revision: Union[str, None] = "021" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +FUNCTIONS = ("scirm_password_reset_request(text, text, timestamptz)", "scirm_password_reset_consume(text, text)") + +USER_BY_ID = """ + CREATE FUNCTION scirm_user_by_id(p_id uuid) + RETURNS TABLE (id uuid, organization_id uuid, email text, name text, roles text[], + is_active boolean, mfa_secret text, mfa_enabled boolean{extra}) + LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$ + SELECT u.id, u.organization_id, u.email, u.name, u.roles::text[], u.is_active, + u.mfa_secret, u.mfa_enabled{extra_select} + FROM users u WHERE u.id = p_id + $$ +""" + + +def _user_by_id(with_changed_at: bool) -> None: + op.execute("DROP FUNCTION IF EXISTS scirm_user_by_id(uuid)") + op.execute(USER_BY_ID.format(extra=", password_changed_at timestamptz" if with_changed_at else "", + extra_select=", u.password_changed_at" if with_changed_at else "")) + op.execute("REVOKE ALL ON FUNCTION scirm_user_by_id(uuid) FROM PUBLIC") + + +def upgrade() -> None: + op.execute("ALTER TABLE users ADD COLUMN password_changed_at timestamptz NULL") + _user_by_id(True) + op.execute(""" + CREATE TABLE password_resets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organizations(id), + user_id uuid NOT NULL REFERENCES users(id), + token_hash text NOT NULL UNIQUE CHECK (token_hash ~ '^[0-9a-f]{64}$'), + expires_at timestamptz NOT NULL, + used_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now() + ) + """) + op.execute("CREATE INDEX ix_password_resets_user ON password_resets (user_id)") + # Enabled, not forced (like users): the application role sees nothing without its + # organization, while the definer functions (the owner) work before sign-in. + op.execute("ALTER TABLE password_resets ENABLE ROW LEVEL SECURITY") + op.execute(""" + CREATE POLICY password_resets_org_isolation ON password_resets + USING (organization_id = scirm_current_org()) + WITH CHECK (organization_id = scirm_current_org()) + """) + op.execute(""" + CREATE FUNCTION scirm_password_reset_request(p_email text, p_token_hash text, p_expires timestamptz) + RETURNS TABLE (user_id uuid, organization_id uuid, email text, name text) + LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $$ + DECLARE + u users%ROWTYPE; + BEGIN + SELECT * INTO u FROM users WHERE lower(users.email) = lower(p_email) AND users.is_active; + IF NOT FOUND THEN + RETURN; + END IF; + -- Only the newest link works. + UPDATE password_resets SET used_at = now() WHERE password_resets.user_id = u.id AND used_at IS NULL; + INSERT INTO password_resets (organization_id, user_id, token_hash, expires_at) + VALUES (u.organization_id, u.id, p_token_hash, p_expires); + RETURN QUERY SELECT u.id, u.organization_id, u.email::text, u.name::text; + END + $$ + """) + op.execute(""" + CREATE FUNCTION scirm_password_reset_consume(p_token_hash text, p_password_hash text) + RETURNS TABLE (user_id uuid, organization_id uuid, email text) + LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $$ + DECLARE + r password_resets%ROWTYPE; + BEGIN + UPDATE password_resets SET used_at = now() + WHERE token_hash = p_token_hash AND used_at IS NULL AND expires_at > now() + RETURNING * INTO r; + IF NOT FOUND THEN + RETURN; + END IF; + RETURN QUERY + UPDATE users SET hashed_password = p_password_hash, password_changed_at = now(), updated_at = now() + WHERE users.id = r.user_id AND users.is_active + RETURNING users.id, users.organization_id, users.email::text; + END + $$ + """) + for fn in FUNCTIONS: + op.execute(f"REVOKE ALL ON FUNCTION {fn} FROM PUBLIC") + + +def downgrade() -> None: + for fn in FUNCTIONS: + op.execute(f"DROP FUNCTION IF EXISTS {fn}") + op.execute("DROP TABLE IF EXISTS password_resets") + _user_by_id(False) + op.execute("ALTER TABLE users DROP COLUMN IF EXISTS password_changed_at") diff --git a/apps/api-gateway/main.py b/apps/api-gateway/main.py index 9d4e9ee..18cc665 100644 --- a/apps/api-gateway/main.py +++ b/apps/api-gateway/main.py @@ -331,7 +331,8 @@ def _is_sample_feature(path: str) -> bool: def _account_info(row: Dict[str, Any]) -> Dict[str, Any]: return {"id": str(row["id"]), "name": row["name"], "roles": list(row["roles"] or []), "org_id": str(row["organization_id"]), "mfa_secret": row.get("mfa_secret"), - "mfa_enabled": bool(row.get("mfa_enabled")), "stored": True} + "mfa_enabled": bool(row.get("mfa_enabled")), "password_changed_at": row.get("password_changed_at"), + "stored": True} def _mfa_on(user_info: Dict[str, Any]) -> bool: @@ -524,6 +525,17 @@ async def disable_mfa(body: MFACodeRequest, token: str = Depends(security)): return {"enabled": False} +def _issued_before_password_change(payload: Dict[str, Any], info: Dict[str, Any]) -> bool: + """A token issued before the account's last password change (change or reset).""" + changed = info.get("password_changed_at") + if not changed or "iat" not in payload: + return False + changed_at = datetime.fromisoformat(str(changed)) + if changed_at.tzinfo is None: + changed_at = changed_at.replace(tzinfo=timezone.utc) + return int(payload["iat"]) < int(changed_at.timestamp()) + + @app.post("/api/v1/auth/refresh") async def refresh_token(body: Dict[str, Any]): """Exchange a refresh token for a new access token.""" @@ -535,6 +547,8 @@ async def refresh_token(body: Dict[str, Any]): found = await _find_user_by_id(payload["sub"]) if not found: # unknown, or deactivated since the refresh token was issued raise HTTPException(status_code=401, detail="User not found") + if _issued_before_password_change(payload, found[1]): + raise HTTPException(status_code=401, detail="The password was changed: sign in again") tokens = _issue_tokens(*found) return {"access_token": tokens["access_token"], "token_type": "bearer"} @@ -1792,35 +1806,85 @@ async def export_full_report(token: str = Depends(security)): # --- Password Reset --- +PASSWORD_RESET_MINUTES = int(os.getenv("PASSWORD_RESET_MINUTES", "30")) +_RESET_SENT = "If an account exists for this email, a reset link has been sent. It works once, for 30 minutes." + + +class ForgotPasswordRequest(BaseModel): + email: str = Field(..., min_length=3, max_length=255) + + +class ResetPasswordRequest(BaseModel): + token: str = Field(..., min_length=20, max_length=200) + new_password: str + + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str + + +async def _send_reset_email(account: Dict[str, Any], token_value: str) -> None: + link = f"{PUBLIC_APP_URL}/reset-password#{token_value}" if PUBLIC_APP_URL else f"/reset-password#{token_value}" + text = (f"Hello {account.get('name') or ''},\n\nSomeone (hopefully you) asked to reset the SCIRM password for " + f"{account['email']}. Open this link within {PASSWORD_RESET_MINUTES} minutes to choose a new one:\n\n" + f"{link}\n\nIf you did not ask for this, ignore this email; your password stays the same.\n") + html_body = (f"
Someone (hopefully you) asked to reset the SCIRM password for {account['email']}.
" + f"Choose a new password (works once, for {PASSWORD_RESET_MINUTES} minutes).
" + "If you did not ask for this, ignore this email; your password stays the same.
") + try: + await reports.send_email([account["email"]], "Reset your SCIRM password", text, html_body) + except Exception as exc: + logger.error("Password reset email failed", error=type(exc).__name__) + + @app.post("/api/v1/auth/forgot-password") -async def forgot_password(body: Dict[str, Any]): - """Request a password reset email.""" - email = body.get("email", "") - # In production: generate token, send email via SMTP - # For dev: just acknowledge - logger.info("Password reset requested", email=email) - return {"message": "If an account exists for this email, a reset link has been sent."} +async def forgot_password(body: ForgotPasswordRequest): + """Email a single-use reset link. The answer is the same whether or not the account + exists, and the email is sent in the background so timing does not tell either.""" + if reports.smtp_config() is None: + raise HTTPException(status_code=503, detail="Password reset by email is not set up on this deployment. " + "Ask an administrator of your organization.") + import hashlib + token_value = secrets.token_urlsafe(32) + expires = (datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_MINUTES)).isoformat() + account = await audit_store.request_password_reset(body.email.strip().lower(), + hashlib.sha256(token_value.encode()).hexdigest(), expires) + if account: + await _audit_event(str(account["organization_id"]), str(account["user_id"]), account["email"], + "auth.password_reset_requested") + asyncio.create_task(_send_reset_email(account, token_value)) + return {"message": _RESET_SENT} + @app.post("/api/v1/auth/reset-password") -async def reset_password(body: Dict[str, Any]): - """Reset password with a token.""" - token_val = body.get("token", "") - new_password = body.get("new_password", "") - if len(new_password) < 8: - raise HTTPException(status_code=400, detail="Password must be at least 8 characters") - # In production: validate token, update password in DB - return {"message": "Password has been reset successfully."} +async def reset_password(body: ResetPasswordRequest): + """Set a new password with a reset link's token. Other sessions are signed out.""" + import hashlib + _password_ok(body.new_password) + done = await audit_store.consume_password_reset(hashlib.sha256(body.token.encode()).hexdigest(), + get_password_hash(body.new_password)) + if not done: + raise HTTPException(status_code=400, detail="This reset link is not valid any more. Ask for a new one.") + await _audit_event(str(done["organization_id"]), str(done["user_id"]), done["email"], "auth.password_reset") + return {"message": "Your password has been reset. Sign in with the new one."} + @app.post("/api/v1/auth/change-password") -async def change_password(body: Dict[str, Any], token: str = Depends(security)): - """Change password for authenticated user.""" - user = await verify_token(token.credentials) - current = body.get("current_password", "") - new_pass = body.get("new_password", "") - if len(new_pass) < 8: - raise HTTPException(status_code=400, detail="Password must be at least 8 characters") - # In production: verify current password, update in DB - return {"message": "Password changed successfully."} +async def change_password(body: ChangePasswordRequest, token: str = Depends(security)): + """Change the caller's password. Other sessions are signed out; this one gets new tokens.""" + user = await verify_token(token.credentials) + if DEV_USERS.get(user.email.lower()): + raise HTTPException(status_code=400, detail="Demo accounts use DEV_PASSWORD and cannot change it.") + _password_ok(body.new_password) + if body.new_password == body.current_password: + raise HTTPException(status_code=400, detail="Choose a password different from the current one.") + if not await _check_password(user.email, body.current_password): + raise HTTPException(status_code=400, detail="The current password is not correct.") + if not await audit_store.set_password(user.organization_id, user.id, get_password_hash(body.new_password)): + raise HTTPException(status_code=404, detail="Account not found") + found = await _find_user_by_id(user.id) + return {"message": "Password changed. Other sessions are signed out.", **_issue_tokens(*found)} # --- Components --- diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index dfe1b9c..0093439 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -7,6 +7,7 @@ import SampleRoute from './components/SampleRoute'; import LoginPage from './pages/LoginPage'; import SignupPage from './pages/SignupPage'; import ForgotPasswordPage from './pages/ForgotPasswordPage'; +import ResetPasswordPage from './pages/ResetPasswordPage'; import NotFoundPage from './pages/NotFoundPage'; import SupplierScanPage from './pages/SupplierScanPage'; import SapImportPage from './pages/SapImportPage'; @@ -68,6 +69,7 @@ export default function App() { {/* Invitation links: public, whether or not someone is signed in on this browser */}{t('auth.resetSent', "If an account exists for this email, you'll receive a password reset email shortly.")}
+{sent}
{t('auth.backToSignIn', 'Back to Sign In')} ) : ( @@ -41,7 +51,8 @@ export default function ForgotPasswordPage() { placeholder={t('auth.emailPlaceholder', 'you@company.com')} /> -