Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions alembic/versions/022_password_resets.py
Original file line number Diff line number Diff line change
@@ -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")
114 changes: 89 additions & 25 deletions apps/api-gateway/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand All @@ -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"}
Expand Down Expand Up @@ -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"<p>Someone (hopefully you) asked to reset the SCIRM password for {account['email']}.</p>"
f"<p><a href=\"{link}\">Choose a new password</a> (works once, for {PASSWORD_RESET_MINUTES} minutes).</p>"
"<p>If you did not ask for this, ignore this email; your password stays the same.</p>")
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 ---

Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -68,6 +69,7 @@ export default function App() {

{/* Invitation links: public, whether or not someone is signed in on this browser */}
<Route path="/invite" element={<AcceptInvitePage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />

{/* 404 catch-all */}
<Route path="*" element={<NotFoundPage />} />
Expand Down
9 changes: 8 additions & 1 deletion apps/frontend/src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ interface AuthContextType {
verifyMfa: (mfaToken: string, code: string) => Promise<void>;
signup: (email: string, password: string, name: string) => Promise<void>;
acceptInvitation: (token: string, name: string, password: string) => Promise<void>;
/** Changes the password; other sessions end, this one continues with new tokens. */
changePassword: (current: string, next: string) => Promise<void>;
logout: () => void;
}

Expand All @@ -26,6 +28,7 @@ const AuthContext = createContext<AuthContextType>({
verifyMfa: async () => {},
signup: async () => {},
acceptInvitation: async () => {},
changePassword: async () => {},
logout: () => {},
});

Expand Down Expand Up @@ -92,8 +95,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
begin(await api.acceptInvitation(token, name, password));
};

const changePassword = async (current: string, next: string) => {
begin(await api.changePassword(current, next));
};

return (
<AuthContext.Provider value={{ user, token, isAuthenticated: !!token, login, verifyMfa, signup, acceptInvitation, logout }}>
<AuthContext.Provider value={{ user, token, isAuthenticated: !!token, login, verifyMfa, signup, acceptInvitation, changePassword, logout }}>
{children}
</AuthContext.Provider>
);
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@
"newPassword": "New password (min 8 characters)",
"confirmNewPassword": "Confirm new password",
"updatePassword": "Update Password",
"passwordUpdated": "Your password has been changed successfully.",
"passwordUpdated": "Your password has been changed. Other sessions are signed out.",
"dataExport": "Data Export",
"dataExportDesc": "Download reports for board meetings and auditors",
"exportRisks": "Export Risks (CSV)",
Expand Down
23 changes: 17 additions & 6 deletions apps/frontend/src/pages/ForgotPasswordPage.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { api } from '../services/api';

export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [sent, setSent] = useState(false);
const [sent, setSent] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const { t } = useTranslation();

const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// In production, call API to send reset email
setSent(true);
setError('');
setBusy(true);
try {
setSent((await api.forgotPassword(email.trim())).message);
} catch (err: any) {
setError(err?.response?.data?.detail || t('auth.resetFailed', 'The request could not be sent. Try again.'));
} finally {
setBusy(false);
}
};

return (
Expand All @@ -27,7 +37,7 @@ export default function ForgotPasswordPage() {
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
</div>
<p className="text-sm text-gray-700 dark:text-gray-300">{t('auth.resetSent', "If an account exists for this email, you'll receive a password reset email shortly.")}</p>
<p className="text-sm text-gray-700 dark:text-gray-300">{sent}</p>
<Link to="/login" className="inline-block mt-4 text-sm text-blue-600 hover:text-blue-800 font-medium">{t('auth.backToSignIn', 'Back to Sign In')}</Link>
</div>
) : (
Expand All @@ -41,7 +51,8 @@ export default function ForgotPasswordPage() {
placeholder={t('auth.emailPlaceholder', 'you@company.com')}
/>
</div>
<button type="submit" className="w-full py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700">
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={busy} className="w-full py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50">
{t('auth.sendResetLink', 'Send Reset Link')}
</button>
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">
Expand Down
Loading
Loading