diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index 046f36c..9f1e487 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -167,23 +167,26 @@ async fn list_document_jobs( async fn register( State(state): State, + headers: HeaderMap, Json(payload): Json, ) -> Result { - proxy_json(&state.client, &state.services.auth, "/auth/register", payload).await + proxy_json_with_headers(&state.client, &state.services.auth, "/auth/register", payload, &headers).await } async fn login( State(state): State, + headers: HeaderMap, Json(payload): Json, ) -> Result { - proxy_json(&state.client, &state.services.auth, "/auth/login", payload).await + proxy_json_with_headers(&state.client, &state.services.auth, "/auth/login", payload, &headers).await } async fn refresh( State(state): State, + headers: HeaderMap, Json(payload): Json, ) -> Result { - proxy_json(&state.client, &state.services.auth, "/auth/refresh", payload).await + proxy_json_with_headers(&state.client, &state.services.auth, "/auth/refresh", payload, &headers).await } async fn logout( @@ -209,30 +212,34 @@ async fn me( async fn verify_email( State(state): State, + headers: HeaderMap, Json(payload): Json, ) -> Result { - proxy_json(&state.client, &state.services.auth, "/auth/email/verify", payload).await + proxy_json_with_headers(&state.client, &state.services.auth, "/auth/email/verify", payload, &headers).await } async fn resend_verification( State(state): State, + headers: HeaderMap, Json(payload): Json, ) -> Result { - proxy_json(&state.client, &state.services.auth, "/auth/email/resend", payload).await + proxy_json_with_headers(&state.client, &state.services.auth, "/auth/email/resend", payload, &headers).await } async fn forgot_password( State(state): State, + headers: HeaderMap, Json(payload): Json, ) -> Result { - proxy_json(&state.client, &state.services.auth, "/auth/password/forgot", payload).await + proxy_json_with_headers(&state.client, &state.services.auth, "/auth/password/forgot", payload, &headers).await } async fn reset_password( State(state): State, + headers: HeaderMap, Json(payload): Json, ) -> Result { - proxy_json(&state.client, &state.services.auth, "/auth/password/reset", payload).await + proxy_json_with_headers(&state.client, &state.services.auth, "/auth/password/reset", payload, &headers).await } async fn change_password( @@ -308,28 +315,6 @@ async fn answer_question( .await } -async fn proxy_json( - client: &Client, - base_url: &str, - path: &str, - payload: T, -) -> Result { - let response = client - .post(format!("{base_url}{path}")) - .json(&payload) - .send() - .await - .map_err(|_| StatusCode::BAD_GATEWAY)?; - - let status = response.status(); - let body = response - .json::() - .await - .map_err(|_| StatusCode::BAD_GATEWAY)?; - - Ok((status, Json(body))) -} - async fn proxy_json_with_headers( client: &Client, base_url: &str, @@ -339,9 +324,7 @@ async fn proxy_json_with_headers( ) -> Result { let mut request = client.post(format!("{base_url}{path}")).json(&payload); - if let Some(value) = headers.get(header::AUTHORIZATION) { - request = request.header(header::AUTHORIZATION.as_str(), value.as_bytes()); - } + request = apply_forwarded_headers(request, headers); let response = request.send().await.map_err(|_| StatusCode::BAD_GATEWAY)?; let status = response.status(); @@ -361,9 +344,7 @@ async fn proxy_get( ) -> Result { let mut request = client.get(format!("{base_url}{path}")); - if let Some(value) = headers.get(header::AUTHORIZATION) { - request = request.header(header::AUTHORIZATION.as_str(), value.as_bytes()); - } + request = apply_forwarded_headers(request, headers); let response = request.send().await.map_err(|_| StatusCode::BAD_GATEWAY)?; let status = response.status(); @@ -383,9 +364,7 @@ async fn proxy_delete( ) -> Result { let mut request = client.delete(format!("{base_url}{path}")); - if let Some(value) = headers.get(header::AUTHORIZATION) { - request = request.header(header::AUTHORIZATION.as_str(), value.as_bytes()); - } + request = apply_forwarded_headers(request, headers); let response = request.send().await.map_err(|_| StatusCode::BAD_GATEWAY)?; let status = response.status(); @@ -397,6 +376,21 @@ async fn proxy_delete( Ok((status, Json(body))) } +fn apply_forwarded_headers(mut request: reqwest::RequestBuilder, headers: &HeaderMap) -> reqwest::RequestBuilder { + for name in [ + header::AUTHORIZATION.as_str(), + header::USER_AGENT.as_str(), + "x-forwarded-for", + "x-real-ip", + ] { + if let Some(value) = headers.get(name) { + request = request.header(name, value.as_bytes()); + } + } + + request +} + async fn check_service(client: &Client, base_url: &str) -> DependencyHealth { let url = format!("{base_url}/health"); let status = match client.get(&url).send().await { diff --git a/apps/web/app/api/web/auth/email/verify/route.ts b/apps/web/app/api/web/auth/email/verify/route.ts index e4e76aa..1fb786e 100644 --- a/apps/web/app/api/web/auth/email/verify/route.ts +++ b/apps/web/app/api/web/auth/email/verify/route.ts @@ -5,6 +5,7 @@ export async function POST(request: NextRequest) { return proxyPublic({ method: "POST", path: "/api/auth/email/verify", + request, body: await request.json() }); } diff --git a/apps/web/app/api/web/auth/login/route.ts b/apps/web/app/api/web/auth/login/route.ts index 77d3726..ea0a487 100644 --- a/apps/web/app/api/web/auth/login/route.ts +++ b/apps/web/app/api/web/auth/login/route.ts @@ -2,5 +2,5 @@ import { NextRequest } from "next/server"; import { loginThroughGateway } from "@/lib/server-gateway"; export async function POST(request: NextRequest) { - return loginThroughGateway(await request.json()); + return loginThroughGateway(request, await request.json()); } diff --git a/apps/web/app/api/web/auth/logout/route.ts b/apps/web/app/api/web/auth/logout/route.ts index cdbf659..77b1741 100644 --- a/apps/web/app/api/web/auth/logout/route.ts +++ b/apps/web/app/api/web/auth/logout/route.ts @@ -1,5 +1,6 @@ +import { NextRequest } from "next/server"; import { logoutThroughGateway } from "@/lib/server-gateway"; -export async function POST() { - return logoutThroughGateway(); +export async function POST(request: NextRequest) { + return logoutThroughGateway(request); } diff --git a/apps/web/app/api/web/auth/me/route.ts b/apps/web/app/api/web/auth/me/route.ts index da0a641..46ea94e 100644 --- a/apps/web/app/api/web/auth/me/route.ts +++ b/apps/web/app/api/web/auth/me/route.ts @@ -1,8 +1,10 @@ +import { NextRequest } from "next/server"; import { proxyPrivate } from "@/lib/server-gateway"; -export async function GET() { +export async function GET(request: NextRequest) { return proxyPrivate({ method: "GET", - path: "/api/auth/me" + path: "/api/auth/me", + request }); } diff --git a/apps/web/app/api/web/auth/register/route.ts b/apps/web/app/api/web/auth/register/route.ts index e5ac93f..eb3f3c6 100644 --- a/apps/web/app/api/web/auth/register/route.ts +++ b/apps/web/app/api/web/auth/register/route.ts @@ -5,6 +5,7 @@ export async function POST(request: NextRequest) { return proxyPublic({ method: "POST", path: "/api/auth/register", + request, body: await request.json() }); } diff --git a/apps/web/app/api/web/documents/route.ts b/apps/web/app/api/web/documents/route.ts index 543f534..b4aacde 100644 --- a/apps/web/app/api/web/documents/route.ts +++ b/apps/web/app/api/web/documents/route.ts @@ -1,10 +1,11 @@ import { NextRequest } from "next/server"; import { proxyPrivate } from "@/lib/server-gateway"; -export async function GET() { +export async function GET(request: NextRequest) { return proxyPrivate({ method: "GET", - path: "/api/documents" + path: "/api/documents", + request }); } @@ -12,6 +13,7 @@ export async function POST(request: NextRequest) { return proxyPrivate({ method: "POST", path: "/api/documents", + request, body: await request.json() }); } diff --git a/apps/web/app/api/web/questions/route.ts b/apps/web/app/api/web/questions/route.ts index 989cafb..b573507 100644 --- a/apps/web/app/api/web/questions/route.ts +++ b/apps/web/app/api/web/questions/route.ts @@ -5,6 +5,7 @@ export async function POST(request: NextRequest) { return proxyPrivate({ method: "POST", path: "/api/questions", + request, body: await request.json() }); } diff --git a/apps/web/lib/server-gateway.ts b/apps/web/lib/server-gateway.ts index 0f76f0c..e58a414 100644 --- a/apps/web/lib/server-gateway.ts +++ b/apps/web/lib/server-gateway.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; import { clearAuthCookies, getAccessToken, getRefreshToken, setAuthCookies } from "@/lib/server-auth"; import { parseResponse, resolveErrorMessage } from "@/lib/http"; @@ -7,6 +8,7 @@ const gatewayUrl = process.env.GROUNDED_GATEWAY_URL ?? process.env.NEXT_PUBLIC_G type ProxyOptions = { method: "GET" | "POST" | "DELETE"; path: string; + request?: NextRequest; body?: unknown; }; @@ -54,8 +56,8 @@ export async function proxyPrivate(options: ProxyOptions) { return nextResponse; } -export async function loginThroughGateway(body: unknown) { - const response = await callGateway({ method: "POST", path: "/api/auth/login", body }); +export async function loginThroughGateway(request: NextRequest, body: unknown) { + const response = await callGateway({ method: "POST", path: "/api/auth/login", request, body }); const payload = await parseResponse(response); const nextResponse = NextResponse.json(sanitizeAuthPayload(payload), { status: response.status }); @@ -66,11 +68,11 @@ export async function loginThroughGateway(body: unknown) { return nextResponse; } -export async function logoutThroughGateway() { +export async function logoutThroughGateway(request: NextRequest) { const accessToken = await getAccessToken(); if (accessToken) { - await callGateway({ method: "POST", path: "/api/auth/logout" }, accessToken); + await callGateway({ method: "POST", path: "/api/auth/logout", request }, accessToken); } const response = NextResponse.json({ message: "Logged out." }); @@ -110,6 +112,10 @@ async function callGateway(options: ProxyOptions, accessToken?: string) { headers.set("Authorization", `Bearer ${accessToken}`); } + forwardHeader(options.request, headers, "user-agent"); + forwardHeader(options.request, headers, "x-forwarded-for"); + forwardHeader(options.request, headers, "x-real-ip"); + return fetch(`${gatewayUrl}${options.path}`, { method: options.method, headers, @@ -118,6 +124,14 @@ async function callGateway(options: ProxyOptions, accessToken?: string) { }); } +function forwardHeader(request: NextRequest | undefined, headers: Headers, name: string) { + const value = request?.headers.get(name); + + if (value) { + headers.set(name, value); + } +} + function sanitizeAuthPayload(payload: unknown) { if (!isAuthPayload(payload)) { return payload; diff --git a/packages/database/prisma/migrations/0003_auth_hardening/migration.sql b/packages/database/prisma/migrations/0003_auth_hardening/migration.sql new file mode 100644 index 0000000..f2a0f17 --- /dev/null +++ b/packages/database/prisma/migrations/0003_auth_hardening/migration.sql @@ -0,0 +1,18 @@ +CREATE TABLE "AuthRateLimit" ( + "key" TEXT NOT NULL, + "action" TEXT NOT NULL, + "attempts" INTEGER NOT NULL DEFAULT 0, + "windowStart" TIMESTAMP(3) NOT NULL, + "lockedUntil" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "AuthRateLimit_pkey" PRIMARY KEY ("key") +); + +CREATE INDEX "AuthRateLimit_action_lockedUntil_idx" ON "AuthRateLimit"("action", "lockedUntil"); +CREATE INDEX "AuthRateLimit_updatedAt_idx" ON "AuthRateLimit"("updatedAt"); + +ALTER TYPE "AuthAuditEventType" ADD VALUE 'register_failed'; +ALTER TYPE "AuthAuditEventType" ADD VALUE 'password_change_failed'; +ALTER TYPE "AuthAuditEventType" ADD VALUE 'session_rejected'; +ALTER TYPE "AuthAuditEventType" ADD VALUE 'rate_limit_exceeded'; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index a69166f..60eb4ad 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -69,12 +69,16 @@ enum AuthAuditEventType { email_verified login_succeeded login_failed + register_failed token_refreshed refresh_reuse_detected password_change_succeeded + password_change_failed password_reset_requested password_reset_completed session_revoked + session_rejected + rate_limit_exceeded logout logout_all } @@ -204,6 +208,19 @@ model AuthAuditEvent { @@index([eventType, createdAt]) } +model AuthRateLimit { + key String @id + action String + attempts Int @default(0) + windowStart DateTime + lockedUntil DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([action, lockedUntil]) + @@index([updatedAt]) +} + model DevEmailOutbox { id String @id @default(cuid()) recipient String diff --git a/services/auth/app/config.py b/services/auth/app/config.py index a895317..b466f85 100644 --- a/services/auth/app/config.py +++ b/services/auth/app/config.py @@ -13,6 +13,10 @@ class Settings(BaseSettings): email_verification_hours: int = Field(default=24) password_reset_minutes: int = Field(default=30) expose_dev_tokens: bool = Field(default=True) + auth_rate_limit_window_minutes: int = Field(default=15) + auth_rate_limit_lockout_minutes: int = Field(default=15) + auth_rate_limit_ip_attempts: int = Field(default=20) + auth_rate_limit_email_attempts: int = Field(default=5) @lru_cache diff --git a/services/auth/app/dependencies.py b/services/auth/app/dependencies.py index ca86c69..d778b2b 100644 --- a/services/auth/app/dependencies.py +++ b/services/auth/app/dependencies.py @@ -18,13 +18,18 @@ async def identity_service(session: Annotated[AsyncSession, Depends(get_session) return IdentityService(IdentityRepository(session)) -async def current_claims(credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]) -> dict: +async def current_claims( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)], + context: Annotated[RequestContext, Depends(request_context)], + service: Annotated[IdentityService, Depends(identity_service)], +) -> dict: if not credentials: raise AuthError.invalid_token() - return TokenService().decode_access_token(credentials.credentials) + claims = TokenService().decode_access_token(credentials.credentials) + return await service.require_active_session(claims, context) async def request_context(request: Request) -> RequestContext: forwarded_for = request.headers.get("x-forwarded-for") ip_address = forwarded_for.split(",")[0].strip() if forwarded_for else (request.client.host if request.client else None) - return RequestContext(ip_address=ip_address, user_agent=request.headers.get("user-agent")) + return RequestContext(ip_address=ip_address, user_agent=request.headers.get("user-agent"), route=request.url.path, action=request.method) diff --git a/services/auth/app/errors.py b/services/auth/app/errors.py index 1714b16..4514202 100644 --- a/services/auth/app/errors.py +++ b/services/auth/app/errors.py @@ -10,6 +10,10 @@ def invalid_credentials() -> HTTPException: def invalid_token() -> HTTPException: return HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + @staticmethod + def rate_limited() -> HTTPException: + return HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many attempts. Try again later") + @staticmethod def email_not_verified() -> HTTPException: return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Email verification is required") diff --git a/services/auth/app/repositories.py b/services/auth/app/repositories.py index 9857980..95c1a15 100644 --- a/services/auth/app/repositories.py +++ b/services/auth/app/repositories.py @@ -158,6 +158,18 @@ async def find_refresh_token(self, token_hash: str) -> dict | None: row = result.mappings().first() return dict(row) if row else None + async def find_active_session(self, user_id: str, session_id: str) -> dict | None: + result = await self.session.execute( + text( + 'SELECT "id", "userId", "tenantId", "expiresAt", "revokedAt" ' + 'FROM "UserSession" ' + 'WHERE "id" = :session_id AND "userId" = :user_id AND "revokedAt" IS NULL AND "expiresAt" > now()' + ), + {"user_id": user_id, "session_id": session_id}, + ) + row = result.mappings().first() + return dict(row) if row else None + async def mark_refresh_token_used(self, token_id: str, replaced_by_id: str | None) -> None: await self.session.execute( text('UPDATE "RefreshToken" SET "usedAt" = now(), "replacedById" = :replaced_by_id WHERE "id" = :token_id'), @@ -317,6 +329,47 @@ async def audit(self, event_type: str, user_id: str | None = None, session_id: s }, ) + async def find_rate_limit(self, key: str) -> dict | None: + result = await self.session.execute( + text( + 'SELECT "key", "action", "attempts", "windowStart", "lockedUntil" ' + 'FROM "AuthRateLimit" ' + 'WHERE "key" = :key' + ), + {"key": key}, + ) + row = result.mappings().first() + return dict(row) if row else None + + async def save_rate_limit(self, key: str, action: str, attempts: int, window_start: datetime, locked_until: datetime | None) -> None: + await self.session.execute( + text( + 'INSERT INTO "AuthRateLimit" ("key", "action", "attempts", "windowStart", "lockedUntil", "updatedAt") ' + 'VALUES (:key, :action, :attempts, :window_start, :locked_until, now()) ' + 'ON CONFLICT ("key") DO UPDATE SET ' + '"attempts" = EXCLUDED."attempts", ' + '"windowStart" = EXCLUDED."windowStart", ' + '"lockedUntil" = EXCLUDED."lockedUntil", ' + '"updatedAt" = now()' + ), + { + "key": key, + "action": action, + "attempts": attempts, + "window_start": window_start, + "locked_until": locked_until, + }, + ) + + async def clear_rate_limits(self, keys: list[str]) -> None: + if not keys: + return + + await self.session.execute( + text('DELETE FROM "AuthRateLimit" WHERE "key" = ANY(:keys)'), + {"keys": keys}, + ) + async def commit(self) -> None: try: await self.session.commit() diff --git a/services/auth/app/services.py b/services/auth/app/services.py index e86b430..9c666f5 100644 --- a/services/auth/app/services.py +++ b/services/auth/app/services.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from uuid import uuid4 +from hashlib import sha256 import re from sqlalchemy.exc import IntegrityError from app.config import get_settings @@ -33,6 +34,8 @@ class RequestContext: ip_address: str | None user_agent: str | None + route: str | None = None + action: str | None = None class SlugService: @@ -41,6 +44,61 @@ def from_name(self, value: str) -> str: return slug or "workspace" +class RateLimitService: + def __init__(self, repository: IdentityRepository) -> None: + self.repository = repository + + async def enforce(self, action: str, context: RequestContext, subject: str | None = None) -> None: + settings = get_settings() + now = datetime.utcnow() + window = timedelta(minutes=settings.auth_rate_limit_window_minutes) + lockout = timedelta(minutes=settings.auth_rate_limit_lockout_minutes) + keys = [(self.ip_key(action, context.ip_address), settings.auth_rate_limit_ip_attempts)] + + if subject: + keys.append((self.subject_key(action, subject), settings.auth_rate_limit_email_attempts)) + + for key, limit in keys: + state = await self.repository.find_rate_limit(key) + + if state and state["lockedUntil"] and state["lockedUntil"] > now: + raise AuthError.rate_limited() + + attempts = 1 + window_start = now + + if state and state["windowStart"] + window > now: + attempts = state["attempts"] + 1 + window_start = state["windowStart"] + + locked_until = now + lockout if attempts > limit else None + await self.repository.save_rate_limit(key, action, attempts, window_start, locked_until) + + if locked_until: + await self.repository.audit("rate_limit_exceeded", ip_address=context.ip_address, user_agent=context.user_agent, metadata={ + "route": context.route, + "action": context.action, + "limit_action": action, + }) + await self.repository.commit() + raise AuthError.rate_limited() + + async def clear(self, action: str, context: RequestContext, subject: str | None = None) -> None: + keys = [self.ip_key(action, context.ip_address)] + + if subject: + keys.append(self.subject_key(action, subject)) + + await self.repository.clear_rate_limits(keys) + + def ip_key(self, action: str, ip_address: str | None) -> str: + return f"{action}:ip:{ip_address or 'unknown'}" + + def subject_key(self, action: str, subject: str) -> str: + digest = sha256(subject.strip().lower().encode("utf-8")).hexdigest() + return f"{action}:subject:{digest}" + + class IdentityService: def __init__(self, repository: IdentityRepository) -> None: self.repository = repository @@ -48,10 +106,14 @@ def __init__(self, repository: IdentityRepository) -> None: self.tokens = TokenService() self.secrets = SecretTokenService() self.slugs = SlugService() + self.rate_limits = RateLimitService(repository) async def register(self, request: RegisterRequest, context: RequestContext) -> RegisterResponse: + await self.rate_limits.enforce("register", context, request.email) existing_user = await self.repository.find_user_by_email(request.email) if existing_user: + await self.repository.audit("register_failed", existing_user["id"], ip_address=context.ip_address, user_agent=context.user_agent, metadata=self.audit_metadata(context, "email_taken")) + await self.repository.commit() raise AuthError.email_taken() slug = request.tenant_slug or self.slugs.from_name(request.tenant_name) @@ -61,7 +123,8 @@ async def register(self, request: RegisterRequest, context: RequestContext) -> R tenant = await self.repository.create_tenant(request.tenant_name, slug) membership = await self.repository.create_membership(tenant["id"], user["id"], "owner") verification_token = await self.issue_email_verification(user["id"], user["email"]) - await self.repository.audit("user_registered", user["id"], ip_address=context.ip_address, user_agent=context.user_agent) + await self.repository.audit("user_registered", user["id"], ip_address=context.ip_address, user_agent=context.user_agent, metadata=self.audit_metadata(context)) + await self.rate_limits.clear("register", context, request.email) await self.repository.commit() except IntegrityError as exc: await self.repository.rollback() @@ -74,21 +137,23 @@ async def register(self, request: RegisterRequest, context: RequestContext) -> R ) async def login(self, request: LoginRequest, context: RequestContext) -> AuthResponse: + await self.rate_limits.enforce("login", context, request.email) user = await self.repository.find_user_by_email(request.email) if not user or not user["passwordHash"] or not self.passwords.verify(request.password, user["passwordHash"]): - await self.repository.audit("login_failed", user["id"] if user else None, ip_address=context.ip_address, user_agent=context.user_agent) + await self.repository.audit("login_failed", user["id"] if user else None, ip_address=context.ip_address, user_agent=context.user_agent, metadata=self.audit_metadata(context, "invalid_credentials")) await self.repository.commit() raise AuthError.invalid_credentials() if not user["emailVerifiedAt"]: - await self.repository.audit("login_failed", user["id"], ip_address=context.ip_address, user_agent=context.user_agent, metadata={"reason": "email_not_verified"}) + await self.repository.audit("login_failed", user["id"], ip_address=context.ip_address, user_agent=context.user_agent, metadata=self.audit_metadata(context, "email_not_verified")) await self.repository.commit() raise AuthError.email_not_verified() tenants = await self.repository.list_tenants_for_user(user["id"]) tenant = tenants[0] if tenants else None response = await self.create_auth_response(user, tenant, context, request.device_label) - await self.repository.audit("login_succeeded", user["id"], ip_address=context.ip_address, user_agent=context.user_agent) + await self.repository.audit("login_succeeded", user["id"], ip_address=context.ip_address, user_agent=context.user_agent, metadata=self.audit_metadata(context)) + await self.rate_limits.clear("login", context, request.email) await self.repository.commit() return response @@ -102,7 +167,7 @@ async def refresh(self, request: RefreshRequest, context: RequestContext) -> Aut if stored["usedAt"] or stored["revokedAt"] or stored["sessionRevokedAt"] or stored["expiresAt"] <= now or stored["sessionExpiresAt"] <= now: await self.repository.revoke_family(stored["familyId"]) - await self.repository.audit("refresh_reuse_detected", stored["userId"], stored["sessionId"], context.ip_address, context.user_agent) + await self.repository.audit("refresh_reuse_detected", stored["userId"], stored["sessionId"], context.ip_address, context.user_agent, self.audit_metadata(context, "refresh_reuse_or_revoked_session")) await self.repository.commit() raise AuthError.invalid_token() @@ -112,7 +177,7 @@ async def refresh(self, request: RefreshRequest, context: RequestContext) -> Aut new_refresh = await self.repository.create_refresh_token(stored["userId"], stored["sessionId"], stored["familyId"], refresh_hash, refresh_expires_at) await self.repository.mark_refresh_token_used(stored["id"], new_refresh["id"]) await self.repository.touch_session(stored["sessionId"]) - await self.repository.audit("token_refreshed", stored["userId"], stored["sessionId"], context.ip_address, context.user_agent) + await self.repository.audit("token_refreshed", stored["userId"], stored["sessionId"], context.ip_address, context.user_agent, self.audit_metadata(context)) await self.repository.commit() tenant = await self.current_tenant_dict(stored["userId"], stored["tenantId"]) @@ -143,15 +208,20 @@ async def verify_email(self, request: VerifyEmailRequest, context: RequestContex return GenericMessage(message="Email verified") async def resend_verification(self, request: ResendVerificationRequest, context: RequestContext) -> GenericMessage: + await self.rate_limits.enforce("email_resend", context, request.email) user = await self.repository.find_user_by_email(request.email) dev_token = None if user and not user["emailVerifiedAt"]: dev_token = await self.issue_email_verification(user["id"], user["email"]) - await self.repository.audit("email_verification_requested", user["id"], ip_address=context.ip_address, user_agent=context.user_agent) + await self.repository.audit("email_verification_requested", user["id"], ip_address=context.ip_address, user_agent=context.user_agent, metadata=self.audit_metadata(context)) + await self.rate_limits.clear("email_resend", context, request.email) + await self.repository.commit() + else: await self.repository.commit() return GenericMessage(message="If the account exists and requires verification, an email has been sent", dev_token=dev_token if get_settings().expose_dev_tokens else None) async def forgot_password(self, request: PasswordForgotRequest, context: RequestContext) -> GenericMessage: + await self.rate_limits.enforce("password_forgot", context, request.email) user = await self.repository.find_user_by_email(request.email) dev_token = None if user: @@ -159,33 +229,41 @@ async def forgot_password(self, request: PasswordForgotRequest, context: Request expires_at = datetime.utcnow() + timedelta(minutes=get_settings().password_reset_minutes) await self.repository.create_password_reset(user["id"], self.secrets.hash(raw_token), expires_at) await self.repository.create_outbox_email(user["email"], "Reset your Grounded password", raw_token, {"purpose": "password_reset"}) - await self.repository.audit("password_reset_requested", user["id"], ip_address=context.ip_address, user_agent=context.user_agent) + await self.repository.audit("password_reset_requested", user["id"], ip_address=context.ip_address, user_agent=context.user_agent, metadata=self.audit_metadata(context)) + await self.rate_limits.clear("password_forgot", context, request.email) await self.repository.commit() dev_token = raw_token + else: + await self.repository.commit() return GenericMessage(message="If the account exists, a password reset email has been sent", dev_token=dev_token if get_settings().expose_dev_tokens else None) async def reset_password(self, request: PasswordResetRequest, context: RequestContext) -> GenericMessage: + await self.rate_limits.enforce("password_reset", context) stored = await self.repository.find_password_reset(self.secrets.hash(request.token)) now = datetime.utcnow() if not stored or stored["usedAt"] or stored["expiresAt"] <= now: + await self.repository.commit() raise AuthError.invalid_token() await self.repository.update_password(stored["userId"], self.passwords.hash(request.new_password)) await self.repository.mark_password_reset_used(stored["id"]) await self.repository.revoke_all_sessions(stored["userId"]) - await self.repository.audit("password_reset_completed", stored["userId"], ip_address=context.ip_address, user_agent=context.user_agent) + await self.repository.audit("password_reset_completed", stored["userId"], ip_address=context.ip_address, user_agent=context.user_agent, metadata=self.audit_metadata(context)) + await self.rate_limits.clear("password_reset", context) await self.repository.commit() return GenericMessage(message="Password reset completed") async def change_password(self, user_id: str, session_id: str, request: PasswordChangeRequest, context: RequestContext) -> GenericMessage: user = await self.repository.find_user_by_email((await self.repository.find_user_by_id(user_id))["email"]) if not user or not user["passwordHash"] or not self.passwords.verify(request.current_password, user["passwordHash"]): + await self.repository.audit("password_change_failed", user_id, session_id, context.ip_address, context.user_agent, self.audit_metadata(context, "invalid_current_password")) + await self.repository.commit() raise AuthError.invalid_credentials() await self.repository.update_password(user_id, self.passwords.hash(request.new_password)) if request.logout_other_sessions: await self.repository.revoke_all_sessions(user_id, session_id) - await self.repository.audit("password_change_succeeded", user_id, session_id, context.ip_address, context.user_agent) + await self.repository.audit("password_change_succeeded", user_id, session_id, context.ip_address, context.user_agent, self.audit_metadata(context)) await self.repository.commit() return GenericMessage(message="Password changed") @@ -212,6 +290,18 @@ async def revoke_session(self, user_id: str, session_id: str, context: RequestCo await self.repository.commit() return GenericMessage(message="Session revoked") + async def require_active_session(self, claims: dict, context: RequestContext) -> dict: + session = await self.repository.find_active_session(claims["sub"], claims["sid"]) + + if not session: + await self.repository.audit("session_rejected", claims.get("sub"), claims.get("sid"), context.ip_address, context.user_agent, self.audit_metadata(context, "inactive_session")) + await self.repository.commit() + raise AuthError.invalid_token() + + await self.repository.touch_session(session["id"]) + await self.repository.commit() + return claims + async def me(self, user_id: str, tenant_id: str | None) -> MeResponse: user = await self.repository.find_user_by_id(user_id) if not user: @@ -284,3 +374,14 @@ def session_summary(self, session: dict) -> SessionSummary: user_agent=session["userAgent"], device_label=session["deviceLabel"], ) + + def audit_metadata(self, context: RequestContext, failure_reason: str | None = None) -> dict: + metadata = { + "route": context.route, + "action": context.action, + } + + if failure_reason: + metadata["failure_reason"] = failure_reason + + return {key: value for key, value in metadata.items() if value is not None}