diff --git a/alembic/versions/20260803_1000_0024_password_reset_tokens.py b/alembic/versions/20260803_1000_0024_password_reset_tokens.py
new file mode 100644
index 00000000..bd9156d6
--- /dev/null
+++ b/alembic/versions/20260803_1000_0024_password_reset_tokens.py
@@ -0,0 +1,96 @@
+"""password_reset_tokens table + RLS (Phase 7 — account lifecycle)
+
+Before this, the platform had no self-service password recovery and no way
+to invite a user with a set-your-own-password link: password_hash could
+only be set by seed scripts or bootstrap_admin. This table backs both
+flows: forgot-password (purpose='reset', 30 min) and admin invites
+(purpose='invite', 7 days). Only the SHA-256 of the token is stored.
+
+Revision ID: 20260803_1000_0024
+Revises: 20260717_0900_0023
+Create Date: 2026-08-03
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects.postgresql import UUID
+
+revision: str = "20260803_1000_0024"
+down_revision: Union[str, Sequence[str], None] = "20260717_0900_0023"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+GUC = "spectra.current_org_id"
+ADMIN = "admin"
+TABLE = "password_reset_tokens"
+
+
+def _is_postgres() -> bool:
+ return op.get_bind().dialect.name == "postgresql"
+
+
+def upgrade() -> None:
+ op.create_table(
+ TABLE,
+ sa.Column(
+ "id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")
+ ),
+ sa.Column(
+ "organization_id",
+ UUID(as_uuid=True),
+ sa.ForeignKey("organizations.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column(
+ "user_id",
+ UUID(as_uuid=True),
+ sa.ForeignKey("users.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("token_hash", sa.String(64), nullable=False),
+ sa.Column("purpose", sa.String(20), nullable=False, server_default="reset"),
+ sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=False),
+ sa.Column("used_at", sa.TIMESTAMP(timezone=True), nullable=True),
+ sa.Column(
+ "created_at",
+ sa.TIMESTAMP(timezone=True),
+ server_default=sa.func.now(),
+ nullable=False,
+ ),
+ sa.Column(
+ "updated_at",
+ sa.TIMESTAMP(timezone=True),
+ server_default=sa.func.now(),
+ nullable=False,
+ ),
+ )
+ op.create_index("ix_password_reset_tokens_organization_id", TABLE, ["organization_id"])
+ op.create_index("ix_password_reset_tokens_user_id", TABLE, ["user_id"])
+ op.create_index("ix_password_reset_tokens_hash", TABLE, ["token_hash"], unique=True)
+
+ if not _is_postgres():
+ return
+
+ # Same tenant_isolation shape as every other org-scoped table.
+ predicate = (
+ f"current_setting('{GUC}', true) = '{ADMIN}'"
+ f" OR organization_id::text = current_setting('{GUC}', true)"
+ )
+ op.execute(
+ f"ALTER TABLE {TABLE} ALTER COLUMN organization_id SET DEFAULT "
+ f"NULLIF(current_setting('{GUC}', true), '{ADMIN}')::uuid"
+ )
+ op.execute(f"ALTER TABLE {TABLE} ENABLE ROW LEVEL SECURITY")
+ op.execute(f"ALTER TABLE {TABLE} FORCE ROW LEVEL SECURITY")
+ op.execute(
+ f"CREATE POLICY tenant_isolation ON {TABLE} "
+ f"USING ({predicate}) WITH CHECK ({predicate})"
+ )
+
+
+def downgrade() -> None:
+ if _is_postgres():
+ op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {TABLE}")
+ op.drop_table(TABLE)
diff --git a/apps/web/src/app/ClientLayout.tsx b/apps/web/src/app/ClientLayout.tsx
index 385e685e..fadbc54f 100644
--- a/apps/web/src/app/ClientLayout.tsx
+++ b/apps/web/src/app/ClientLayout.tsx
@@ -18,7 +18,7 @@ import { useTokenRefresh } from '@/hooks/useTokenRefresh';
// a session — the
// app's home (/) just redirects to /dashboard. Listed here so the guard below
// can let the login page through without a redirect loop.
-const PUBLIC_PATHS = new Set(['/login', '/auth/callback']);
+const PUBLIC_PATHS = new Set(['/login', '/auth/callback', '/forgot-password', '/reset-password']);
// Session 7.1: AuthProvider removed. The auth store hydrates from
// localStorage at module load time (services/web/src/stores/useAuthStore.ts).
diff --git a/apps/web/src/app/forgot-password/page.tsx b/apps/web/src/app/forgot-password/page.tsx
new file mode 100644
index 00000000..ba6c0cbd
--- /dev/null
+++ b/apps/web/src/app/forgot-password/page.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+/**
+ * Forgot-password page (Phase 7 — account lifecycle).
+ *
+ * Posts the email to /api/auth/forgot-password and shows the same neutral
+ * confirmation whatever the server found — the endpoint is deliberately
+ * enumeration-safe, and so is this UI.
+ */
+
+import { useState } from 'react';
+import Link from 'next/link';
+
+export default function ForgotPasswordPage() {
+ const [email, setEmail] = useState('');
+ const [sent, setSent] = useState(false);
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+ try {
+ const res = await fetch('/api/v1/lims/auth/forgot-password', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email }),
+ });
+ if (res.status === 429) {
+ setError('Too many attempts — wait a minute and try again.');
+ } else if (!res.ok) {
+ setError('Something went wrong. Try again.');
+ } else {
+ setSent(true);
+ }
+ } catch {
+ setError('Network error. Try again.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ Reset your password
+
+
+ Enter your account email and we'll send a reset link
+
+
+
+ {sent ? (
+
+ If an account exists for {email}, a reset link is on
+ its way. The link is valid for 30 minutes.
+
+ ) : (
+
+ )}
+
+
+
+ Back to sign in
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx
index 68e47c3a..9b668e17 100644
--- a/apps/web/src/app/login/page.tsx
+++ b/apps/web/src/app/login/page.tsx
@@ -103,6 +103,16 @@ export default function LoginPage() {
{loading ? 'Signing in...' : 'Sign in'}
+
+
+
+ Forgot your password?
+
+
{/* SSO entry point (Phase 6.6). Rendered only when the deploy sets
diff --git a/apps/web/src/app/reset-password/page.tsx b/apps/web/src/app/reset-password/page.tsx
new file mode 100644
index 00000000..79851e38
--- /dev/null
+++ b/apps/web/src/app/reset-password/page.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+/**
+ * Reset-password page (Phase 7 — account lifecycle).
+ *
+ * Terminal page for BOTH flows that email a set-password link:
+ * - forgot-password (?token=...) — "Reset your password"
+ * - admin invite (?token=...&welcome=1) — "Set your password"
+ * Posts to /api/auth/reset-password; the token is single-use and expiring,
+ * so a 400 sends the user back to request a fresh link.
+ */
+
+import { Suspense, useState } from 'react';
+import Link from 'next/link';
+import { useRouter, useSearchParams } from 'next/navigation';
+
+const MIN_LEN = 12;
+
+function ResetPasswordForm() {
+ const params = useSearchParams();
+ const router = useRouter();
+ const token = params.get('token') ?? '';
+ const isWelcome = params.get('welcome') === '1';
+
+ const [password, setPassword] = useState('');
+ const [confirm, setConfirm] = useState('');
+ const [error, setError] = useState('');
+ const [done, setDone] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ if (password.length < MIN_LEN) {
+ setError(`Password must be at least ${MIN_LEN} characters.`);
+ return;
+ }
+ if (password !== confirm) {
+ setError('Passwords do not match.');
+ return;
+ }
+ setLoading(true);
+ try {
+ const res = await fetch('/api/v1/lims/auth/reset-password', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token, new_password: password }),
+ });
+ if (res.ok) {
+ setDone(true);
+ setTimeout(() => router.push('/login'), 2500);
+ } else if (res.status === 400) {
+ setError('This link is invalid or has expired. Request a new one.');
+ } else if (res.status === 422) {
+ setError(`Password must be at least ${MIN_LEN} characters.`);
+ } else if (res.status === 429) {
+ setError('Too many attempts — wait a minute and try again.');
+ } else {
+ setError('Something went wrong. Try again.');
+ }
+ } catch {
+ setError('Network error. Try again.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {isWelcome ? 'Welcome — set your password' : 'Choose a new password'}
+
+
+ At least {MIN_LEN} characters
+
+
+
+ {!token && (
+
+ This link is missing its token.{' '}
+
+ Request a new reset link
+
+ .
+
+ )}
+
+ {done ? (
+
+ Password set. Redirecting you to sign in…
+
+ ) : token ? (
+
+ ) : null}
+
+
+
+ Back to sign in
+
+
+
+
+ );
+}
+
+export default function ResetPasswordPage() {
+ // useSearchParams needs a Suspense boundary in the app router.
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/app/system/account/page.tsx b/apps/web/src/app/system/account/page.tsx
new file mode 100644
index 00000000..95e20b07
--- /dev/null
+++ b/apps/web/src/app/system/account/page.tsx
@@ -0,0 +1,164 @@
+'use client'
+
+/**
+ * My Account (Phase 7 — account lifecycle).
+ *
+ * Personal account page: identity summary from /api/auth/me plus the
+ * change-password form (POST /api/auth/change-password). Org-level admin
+ * lives in /system/users; this page is strictly the caller's own account.
+ */
+
+import { useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { KeyRound, Loader2, ShieldCheck, UserCircle2 } from 'lucide-react'
+import { authAPI, getErrorMessage } from '@/lib/api-client'
+import { Button } from '@/components/ui/button'
+import { Card } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+
+const MIN_LEN = 12
+
+export default function AccountPage() {
+ const { data: me, isLoading } = useQuery({
+ queryKey: ['auth', 'me'],
+ queryFn: authAPI.me,
+ })
+
+ const [current, setCurrent] = useState('')
+ const [next, setNext] = useState('')
+ const [confirm, setConfirm] = useState('')
+ const [error, setError] = useState('')
+ const [saved, setSaved] = useState(false)
+ const [saving, setSaving] = useState(false)
+
+ const submit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setError('')
+ setSaved(false)
+ if (next.length < MIN_LEN) {
+ setError(`New password must be at least ${MIN_LEN} characters.`)
+ return
+ }
+ if (next !== confirm) {
+ setError('New passwords do not match.')
+ return
+ }
+ setSaving(true)
+ try {
+ await authAPI.changePassword(current, next)
+ setSaved(true)
+ setCurrent('')
+ setNext('')
+ setConfirm('')
+ } catch (err) {
+ setError(getErrorMessage(err))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+
+
+ My Account
+
+
+ Your identity and credentials on this SPECTRA-Lab organization
+
+
+
+
+ {isLoading ? (
+
+ ) : me ? (
+
+
+
- Name
+ - {me.name}
+
+
+
- Email
+ - {me.email}
+
+
+
- Role
+ -
+
+ {me.role}
+
+
+
+
- Organization
+ - {me.organization_id}
+
+
+ ) : (
+ Could not load your account.
+ )}
+
+
+
+
+
+ Change password
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/layout/AppSidebar.tsx b/apps/web/src/components/layout/AppSidebar.tsx
index de1d8ae6..09724d45 100644
--- a/apps/web/src/components/layout/AppSidebar.tsx
+++ b/apps/web/src/components/layout/AppSidebar.tsx
@@ -213,6 +213,7 @@ const navigation: NavItem[] = [
children: [
{ name: 'Instruments', href: '/system/instruments' },
{ name: 'Users & Roles', href: '/system/users' },
+ { name: 'My Account', href: '/system/account' },
{ name: 'Settings', href: '/system/settings' },
],
},
diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts
index 6ef06e19..ef677dfd 100644
--- a/apps/web/src/lib/api-client.ts
+++ b/apps/web/src/lib/api-client.ts
@@ -1178,6 +1178,57 @@ export const processControlAPI = {
// ==================== Helper Functions ====================
+// ==================== Auth API (Phase 7 — account lifecycle) ====================
+// Bespoke (not fetchAPI): these endpoints return 204 No Content on success,
+// and their error bodies carry a `detail` the user must actually see
+// ("Current password is incorrect"), not a generic statusText.
+
+export interface AuthMe {
+ id: string
+ email: string
+ name: string
+ role: string
+ organization_id: string
+}
+
+async function authFetch(endpoint: string, options: RequestInit = {}): Promise {
+ const response = await fetch(endpoint, {
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...authHeader(),
+ ...options.headers,
+ },
+ })
+ if (!response.ok) {
+ // NOT handleUnauthorized() here: a 403 from change-password is a wrong
+ // current password, not a dead session.
+ let detail = response.statusText
+ try {
+ const body = await response.json()
+ if (typeof body?.detail === 'string') detail = body.detail
+ } catch {
+ /* non-JSON body — keep statusText */
+ }
+ if (response.status === 401) handleUnauthorized()
+ throw new APIError(response.status, detail)
+ }
+ return response
+}
+
+export const authAPI = {
+ me: async (): Promise => {
+ const r = await authFetch('/api/v1/lims/auth/me')
+ return r.json()
+ },
+ changePassword: async (currentPassword: string, newPassword: string): Promise => {
+ await authFetch('/api/v1/lims/auth/change-password', {
+ method: 'POST',
+ body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
+ })
+ },
+}
+
export function isAPIError(error: unknown): error is APIError {
return error instanceof APIError
}
diff --git a/services/lims/app/api/auth.py b/services/lims/app/api/auth.py
index f0d1584b..d10b6764 100644
--- a/services/lims/app/api/auth.py
+++ b/services/lims/app/api/auth.py
@@ -17,28 +17,90 @@
found nothing. Login identity now == DB identity, by construction.
"""
+import hashlib
+import secrets
+from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.security import OAuth2PasswordRequestForm
from jose import JWTError
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
-from services.shared.auth.jwt import create_token_pair, decode_token, verify_password
+from services.shared.auth.jwt import (
+ create_token_pair,
+ decode_token,
+ hash_password,
+ verify_password,
+)
from services.shared.auth.revocation import revoke_jti
from services.shared.db.deps import get_current_user, get_db
-from services.shared.db.models import User
+from services.shared.db.models import PasswordResetToken, User
+from services.shared.mailer import app_base_url, send_email
from services.shared.middleware.rate_limit import login_limit
from services.shared.tenancy import with_admin_tenancy
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
+# Matches bootstrap_admin.py's floor — one policy everywhere.
+MIN_PASSWORD_LEN = 12
+
+RESET_TOKEN_TTL = timedelta(minutes=30)
+INVITE_TOKEN_TTL = timedelta(days=7)
+
class RefreshRequest(BaseModel):
refresh_token: str
+class ForgotPasswordRequest(BaseModel):
+ email: str
+
+
+class ResetPasswordRequest(BaseModel):
+ token: str
+ new_password: str = Field(min_length=1)
+
+
+class ChangePasswordRequest(BaseModel):
+ current_password: str
+ new_password: str = Field(min_length=1)
+
+
+def _hash_token(raw: str) -> str:
+ return hashlib.sha256(raw.encode()).hexdigest()
+
+
+def _validate_new_password(pw: str) -> None:
+ if len(pw) < MIN_PASSWORD_LEN:
+ raise HTTPException(
+ status_code=422,
+ detail=f"Password must be at least {MIN_PASSWORD_LEN} characters",
+ )
+
+
+def issue_password_token(db: Session, user: User, *, purpose: str = "reset") -> str:
+ """Create a single-use password token row and return the RAW token.
+
+ Shared by forgot-password (purpose='reset') and the admin invite flow
+ (purpose='invite'). The raw token goes into the emailed link only —
+ the DB stores its SHA-256.
+ """
+ raw = secrets.token_urlsafe(32)
+ ttl = INVITE_TOKEN_TTL if purpose == "invite" else RESET_TOKEN_TTL
+ db.add(
+ PasswordResetToken(
+ organization_id=user.organization_id,
+ user_id=user.id,
+ token_hash=_hash_token(raw),
+ purpose=purpose,
+ expires_at=datetime.now(timezone.utc) + ttl,
+ )
+ )
+ return raw
+
+
class LogoutRequest(BaseModel):
# Optional: the client sends its refresh token so BOTH halves of the
# session die at logout, not just the 15-minute access token.
@@ -196,6 +258,116 @@ async def logout(
return Response(status_code=status.HTTP_204_NO_CONTENT)
+@router.post("/forgot-password", status_code=status.HTTP_204_NO_CONTENT)
+@login_limit
+async def forgot_password(
+ request: Request,
+ body: ForgotPasswordRequest,
+ db: Session = Depends(get_db),
+):
+ """Start self-service password recovery.
+
+ ALWAYS returns 204, whether or not the email matches a user —
+ anything else is an account-enumeration oracle. When it does match an
+ active password-auth user, a 30-minute single-use token is created and
+ the reset link is emailed (or logged, when SMTP is unconfigured — the
+ dev flow reads the link from the lims log). Rate-limited like login:
+ this endpoint sends email on attacker-controlled input.
+ """
+ with with_admin_tenancy(db):
+ user = db.query(User).filter(User.email == body.email, User.is_active.is_(True)).first()
+ # OIDC users have no password to reset — their IdP owns recovery.
+ if user is not None and user.password_hash:
+ raw = issue_password_token(db, user, purpose="reset")
+ db.commit()
+ send_email(
+ to=user.email,
+ subject="SPECTRA-Lab password reset",
+ body_text=(
+ f"Hello {user.name},\n\n"
+ f"A password reset was requested for this address. The link below\n"
+ f"is valid for 30 minutes and can be used once:\n\n"
+ f"{app_base_url()}/reset-password?token={raw}\n\n"
+ f"If you did not request this, ignore this email — your password\n"
+ f"is unchanged."
+ ),
+ )
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
+
+
+@router.post("/reset-password", status_code=status.HTTP_204_NO_CONTENT)
+@login_limit
+async def reset_password(
+ request: Request,
+ body: ResetPasswordRequest,
+ db: Session = Depends(get_db),
+):
+ """Set a new password with a valid reset/invite token.
+
+ One generic 400 for unknown, expired, and already-used tokens — the
+ error must not reveal which. On success the token is consumed and the
+ user's password hash replaced; existing sessions stay valid until
+ their tokens expire (revocation is per-jti, and we don't know them).
+ """
+ _validate_new_password(body.new_password)
+ now = datetime.now(timezone.utc)
+ with with_admin_tenancy(db):
+ token = (
+ db.query(PasswordResetToken)
+ .filter(PasswordResetToken.token_hash == _hash_token(body.token))
+ .first()
+ )
+ expires_at = token.expires_at if token else None
+ if (
+ token is None
+ or token.used_at is not None
+ or (
+ expires_at.replace(tzinfo=timezone.utc) if expires_at.tzinfo is None else expires_at
+ )
+ < now
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid or expired reset token",
+ )
+ user = db.query(User).filter(User.id == token.user_id, User.is_active.is_(True)).first()
+ if user is None:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid or expired reset token",
+ )
+ user.password_hash = hash_password(body.new_password)
+ token.used_at = now
+ db.commit()
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
+
+
+@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
+async def change_password(
+ body: ChangePasswordRequest,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ """Authenticated password change: verify the current password, set the
+ new one. OIDC-only accounts (no password_hash) get an explicit 400 —
+ their identity provider owns the password.
+ """
+ if not current_user.password_hash:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="This account signs in via SSO and has no local password",
+ )
+ if not verify_password(body.current_password, current_user.password_hash):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Current password is incorrect",
+ )
+ _validate_new_password(body.new_password)
+ current_user.password_hash = hash_password(body.new_password)
+ db.commit()
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
+
+
@router.get("/me")
async def read_current_user(current_user: User = Depends(get_current_user)):
"""Return the authenticated user. Validates the bearer token for real
diff --git a/services/lims/tests/integration/test_auth_password_reset.py b/services/lims/tests/integration/test_auth_password_reset.py
new file mode 100644
index 00000000..1c465c36
--- /dev/null
+++ b/services/lims/tests/integration/test_auth_password_reset.py
@@ -0,0 +1,173 @@
+"""Password reset + change-password flows (Phase 7 — account lifecycle).
+
+Covers the full token lifecycle (request → email link → set new password →
+login with it), the misuse space (reuse, expiry, garbage tokens, weak
+passwords), enumeration safety, and the authed change-password path.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+
+from sqlalchemy.orm import Session
+
+import pytest
+
+from services.lims.tests.conftest import * # noqa: F401,F403
+from services.shared.db.models import PasswordResetToken, User
+
+NEW_PW = "brand-new-password-42"
+
+
+@pytest.fixture(autouse=True)
+def _reset_rate_limiter():
+ """forgot/reset share the 5/min login tier; this suite makes more than
+ five calls per minute by design. Reset the in-memory bucket per test —
+ the limit itself stays enforced in production."""
+ from services.shared.middleware.rate_limit import limiter
+
+ limiter.reset()
+ yield
+
+
+def _request_reset(lims_client, email: str) -> None:
+ r = lims_client.post("/api/auth/forgot-password", json={"email": email})
+ assert r.status_code == 204
+
+
+def _mint_token_via_api(lims_client, db_session: Session, user: User) -> str:
+ """Mint a token through the same issuing helper the endpoints use.
+
+ The raw token only exists inside the emailed link (the DB stores its
+ one-way hash), so tests get it from issue_password_token directly;
+ the endpoint's own row-creation behavior is asserted separately."""
+ from services.lims.app.api.auth import issue_password_token
+
+ raw = issue_password_token(db_session, user, purpose="reset")
+ db_session.commit()
+ return raw
+
+
+def test_forgot_password_always_204_and_no_enumeration(lims_client, engineer_user):
+ # Real user and nonexistent user are indistinguishable.
+ r1 = lims_client.post("/api/auth/forgot-password", json={"email": engineer_user.email})
+ r2 = lims_client.post("/api/auth/forgot-password", json={"email": "ghost@nowhere.example"})
+ assert r1.status_code == r2.status_code == 204
+ assert r1.content == r2.content
+
+
+def test_forgot_password_creates_token_row(lims_client, db_session, engineer_user):
+ _request_reset(lims_client, engineer_user.email)
+ rows = (
+ db_session.query(PasswordResetToken)
+ .filter(PasswordResetToken.user_id == engineer_user.id)
+ .all()
+ )
+ assert len(rows) == 1
+ assert rows[0].purpose == "reset"
+ assert rows[0].used_at is None
+ # Stored value is a 64-hex sha256, not the raw token.
+ assert len(rows[0].token_hash) == 64
+
+
+def test_forgot_password_no_token_for_unknown_email(lims_client, db_session, engineer_user):
+ _request_reset(lims_client, "ghost@nowhere.example")
+ assert db_session.query(PasswordResetToken).count() == 0
+
+
+def test_reset_password_full_round_trip(lims_client, db_session, engineer_user):
+ raw = _mint_token_via_api(lims_client, db_session, engineer_user)
+
+ r = lims_client.post("/api/auth/reset-password", json={"token": raw, "new_password": NEW_PW})
+ assert r.status_code == 204
+
+ # Old password dead, new password lives.
+ r_old = lims_client.post(
+ "/api/auth/login", data={"username": engineer_user.email, "password": "eng123"}
+ )
+ assert r_old.status_code == 401
+ r_new = lims_client.post(
+ "/api/auth/login", data={"username": engineer_user.email, "password": NEW_PW}
+ )
+ assert r_new.status_code == 200
+ assert "access_token" in r_new.json()
+
+
+def test_reset_token_single_use(lims_client, db_session, engineer_user):
+ raw = _mint_token_via_api(lims_client, db_session, engineer_user)
+ assert (
+ lims_client.post(
+ "/api/auth/reset-password", json={"token": raw, "new_password": NEW_PW}
+ ).status_code
+ == 204
+ )
+ r = lims_client.post(
+ "/api/auth/reset-password", json={"token": raw, "new_password": "another-long-pw-1"}
+ )
+ assert r.status_code == 400
+
+
+def test_reset_token_expired(lims_client, db_session, engineer_user):
+ raw = _mint_token_via_api(lims_client, db_session, engineer_user)
+ row = db_session.query(PasswordResetToken).one()
+ row.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
+ db_session.commit()
+ r = lims_client.post("/api/auth/reset-password", json={"token": raw, "new_password": NEW_PW})
+ assert r.status_code == 400
+
+
+def test_reset_password_garbage_token(lims_client, engineer_user):
+ r = lims_client.post(
+ "/api/auth/reset-password",
+ json={"token": "not-a-real-token", "new_password": NEW_PW},
+ )
+ assert r.status_code == 400
+
+
+def test_reset_password_rejects_short_password(lims_client, db_session, engineer_user):
+ raw = _mint_token_via_api(lims_client, db_session, engineer_user)
+ r = lims_client.post("/api/auth/reset-password", json={"token": raw, "new_password": "short"})
+ assert r.status_code == 422
+ # Token NOT consumed by the failed attempt.
+ assert db_session.query(PasswordResetToken).one().used_at is None
+
+
+def test_change_password_round_trip(lims_client, engineer_user, engineer_token, auth_header):
+ r = lims_client.post(
+ "/api/auth/change-password",
+ json={"current_password": "eng123", "new_password": NEW_PW},
+ headers=auth_header(engineer_token),
+ )
+ assert r.status_code == 204
+ assert (
+ lims_client.post(
+ "/api/auth/login", data={"username": engineer_user.email, "password": NEW_PW}
+ ).status_code
+ == 200
+ )
+
+
+def test_change_password_wrong_current(lims_client, engineer_token, auth_header):
+ r = lims_client.post(
+ "/api/auth/change-password",
+ json={"current_password": "wrong-password", "new_password": NEW_PW},
+ headers=auth_header(engineer_token),
+ )
+ assert r.status_code == 403
+
+
+def test_change_password_requires_auth(lims_client):
+ r = lims_client.post(
+ "/api/auth/change-password",
+ json={"current_password": "x", "new_password": NEW_PW},
+ )
+ assert r.status_code in (401, 403)
+
+
+def test_change_password_rejects_short(lims_client, engineer_token, auth_header):
+ r = lims_client.post(
+ "/api/auth/change-password",
+ json={"current_password": "eng123", "new_password": "short"},
+ headers=auth_header(engineer_token),
+ )
+ assert r.status_code == 422
diff --git a/services/shared/db/models.py b/services/shared/db/models.py
index c9570019..9d2b877e 100644
--- a/services/shared/db/models.py
+++ b/services/shared/db/models.py
@@ -1198,6 +1198,42 @@ def __repr__(self):
return f""
+class PasswordResetToken(Base, UUIDMixin, TimestampMixin):
+ """Single-use token for password reset and user-invite flows (Phase 7).
+
+ Only the SHA-256 hash of the token is stored — the raw value goes into
+ the emailed link and is never persisted. ``purpose`` distinguishes a
+ forgot-password reset (30-minute expiry) from an admin invite
+ (7-day expiry); both terminate in the same set-password endpoint.
+ ``used_at`` non-NULL means consumed. RLS (tenant_isolation, migration
+ 0024) scopes rows; the pre-auth reset endpoints run in admin tenancy
+ exactly like login does.
+ """
+
+ __tablename__ = "password_reset_tokens"
+ __table_args__ = (Index("ix_password_reset_tokens_hash", "token_hash", unique=True),)
+
+ organization_id = Column(
+ UUID(as_uuid=True),
+ ForeignKey("organizations.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+ user_id = Column(
+ UUID(as_uuid=True),
+ ForeignKey("users.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+ token_hash = Column(String(64), nullable=False)
+ purpose = Column(String(20), nullable=False, default="reset") # reset | invite
+ expires_at = Column(TIMESTAMP(timezone=True), nullable=False)
+ used_at = Column(TIMESTAMP(timezone=True), nullable=True)
+
+ def __repr__(self):
+ return f""
+
+
# ============================================================================
# Audit Trail
# ============================================================================
diff --git a/services/shared/mailer.py b/services/shared/mailer.py
new file mode 100644
index 00000000..d20e199f
--- /dev/null
+++ b/services/shared/mailer.py
@@ -0,0 +1,86 @@
+"""Outbound email for the platform (Phase 7 — account lifecycle).
+
+Named mailer.py, NOT email.py: the services containers put
+services/shared directly on PYTHONPATH, where email.py would shadow
+the stdlib `email` package and crash smtplib itself on import
+(ModuleNotFoundError: 'email' is not a package).
+
+One honest module, env-driven:
+
+ SMTP_HOST — when unset, emails are NOT sent; the full message is
+ logged at INFO instead (dev/CI mode). No fake success:
+ send_email() returns False so callers know.
+ SMTP_PORT — default 587.
+ SMTP_USERNAME / SMTP_PASSWORD — optional (unauthenticated relay if unset).
+ SMTP_STARTTLS — "true" (default) upgrades the connection; "false" for
+ already-TLS or plaintext relays (e.g. in-cluster).
+ SMTP_FROM — sender address; default no-reply@spectra-lab.local.
+ APP_BASE_URL — public URL of the web app, used by callers to build
+ links (e.g. https://lab.example.com). Default
+ http://localhost:3012 (the compose web container).
+
+Sending is synchronous smtplib on purpose: the only senders are low-volume
+account flows (password reset, invites). If email ever becomes high-volume
+(report schedules, alert digests), move sends onto the celery queue.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import smtplib
+from email.message import EmailMessage
+
+logger = logging.getLogger(__name__)
+
+
+def app_base_url() -> str:
+ """Public base URL of the web app, no trailing slash."""
+ return os.getenv("APP_BASE_URL", "http://localhost:3012").rstrip("/")
+
+
+def email_enabled() -> bool:
+ """True when a real SMTP relay is configured."""
+ return bool(os.getenv("SMTP_HOST"))
+
+
+def send_email(to: str, subject: str, body_text: str) -> bool:
+ """Send a plain-text email. Returns True only on a real SMTP send.
+
+ Without SMTP_HOST the message is logged (so dev flows are inspectable —
+ the reset link appears in the lims service log) and False is returned.
+ Failures raise: account flows must surface a 5xx rather than pretend
+ the email went out.
+ """
+ if not email_enabled():
+ # warning, not info: an email that could not be delivered (no SMTP
+ # relay configured) deserves the louder level. In dev this is also
+ # how the reset/invite link is read out of the service log.
+ logger.warning(
+ "EMAIL NOT SENT (SMTP_HOST unset)\nTo: %s\nSubject: %s\n%s",
+ to,
+ subject,
+ body_text,
+ )
+ return False
+
+ msg = EmailMessage()
+ msg["From"] = os.getenv("SMTP_FROM", "no-reply@spectra-lab.local")
+ msg["To"] = to
+ msg["Subject"] = subject
+ msg.set_content(body_text)
+
+ host = os.environ["SMTP_HOST"]
+ port = int(os.getenv("SMTP_PORT", "587"))
+ starttls = os.getenv("SMTP_STARTTLS", "true").lower() != "false"
+ username = os.getenv("SMTP_USERNAME")
+ password = os.getenv("SMTP_PASSWORD")
+
+ with smtplib.SMTP(host, port, timeout=15) as smtp:
+ if starttls:
+ smtp.starttls()
+ if username and password:
+ smtp.login(username, password)
+ smtp.send_message(msg)
+ logger.info("email sent to %s: %s", to, subject)
+ return True
diff --git a/services/shared/tests/test_tenancy.py b/services/shared/tests/test_tenancy.py
index 109d9547..7b3402f1 100644
--- a/services/shared/tests/test_tenancy.py
+++ b/services/shared/tests/test_tenancy.py
@@ -125,6 +125,7 @@ def _load(fname):
src_0019 = fh.read()
covered |= set(_re.findall(r'"((?:pvd|cmp)_\w+)"', src_0019))
covered |= {_load("20260716_1200_0021_notifications.py").TABLE} # Phase 6.2
+ covered |= {_load("20260803_1000_0024_password_reset_tokens.py").TABLE} # Phase 7
tenant_in_migration = covered