Skip to content
Closed
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
70 changes: 32 additions & 38 deletions apps/gateway/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,23 +167,26 @@ async fn list_document_jobs(

async fn register(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<Value>,
) -> Result<impl IntoResponse, StatusCode> {
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<AppState>,
headers: HeaderMap,
Json(payload): Json<Value>,
) -> Result<impl IntoResponse, StatusCode> {
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<AppState>,
headers: HeaderMap,
Json(payload): Json<Value>,
) -> Result<impl IntoResponse, StatusCode> {
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(
Expand All @@ -209,30 +212,34 @@ async fn me(

async fn verify_email(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<Value>,
) -> Result<impl IntoResponse, StatusCode> {
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<AppState>,
headers: HeaderMap,
Json(payload): Json<Value>,
) -> Result<impl IntoResponse, StatusCode> {
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<AppState>,
headers: HeaderMap,
Json(payload): Json<Value>,
) -> Result<impl IntoResponse, StatusCode> {
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<AppState>,
headers: HeaderMap,
Json(payload): Json<Value>,
) -> Result<impl IntoResponse, StatusCode> {
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(
Expand Down Expand Up @@ -308,28 +315,6 @@ async fn answer_question(
.await
}

async fn proxy_json<T: Serialize>(
client: &Client,
base_url: &str,
path: &str,
payload: T,
) -> Result<impl IntoResponse, StatusCode> {
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::<serde_json::Value>()
.await
.map_err(|_| StatusCode::BAD_GATEWAY)?;

Ok((status, Json(body)))
}

async fn proxy_json_with_headers<T: Serialize>(
client: &Client,
base_url: &str,
Expand All @@ -339,9 +324,7 @@ async fn proxy_json_with_headers<T: Serialize>(
) -> Result<impl IntoResponse, StatusCode> {
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();
Expand All @@ -361,9 +344,7 @@ async fn proxy_get(
) -> Result<impl IntoResponse, StatusCode> {
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();
Expand All @@ -383,9 +364,7 @@ async fn proxy_delete(
) -> Result<impl IntoResponse, StatusCode> {
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();
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/api/web/auth/email/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export async function POST(request: NextRequest) {
return proxyPublic({
method: "POST",
path: "/api/auth/email/verify",
request,
body: await request.json()
});
}
2 changes: 1 addition & 1 deletion apps/web/app/api/web/auth/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
5 changes: 3 additions & 2 deletions apps/web/app/api/web/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
6 changes: 4 additions & 2 deletions apps/web/app/api/web/auth/me/route.ts
Original file line number Diff line number Diff line change
@@ -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
});
}
1 change: 1 addition & 0 deletions apps/web/app/api/web/auth/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export async function POST(request: NextRequest) {
return proxyPublic({
method: "POST",
path: "/api/auth/register",
request,
body: await request.json()
});
}
6 changes: 4 additions & 2 deletions apps/web/app/api/web/documents/route.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
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
});
}

export async function POST(request: NextRequest) {
return proxyPrivate({
method: "POST",
path: "/api/documents",
request,
body: await request.json()
});
}
1 change: 1 addition & 0 deletions apps/web/app/api/web/questions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export async function POST(request: NextRequest) {
return proxyPrivate({
method: "POST",
path: "/api/questions",
request,
body: await request.json()
});
}
22 changes: 18 additions & 4 deletions apps/web/lib/server-gateway.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
};

Expand Down Expand Up @@ -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 });

Expand All @@ -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." });
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
17 changes: 17 additions & 0 deletions packages/database/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions services/auth/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions services/auth/app/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 4 additions & 0 deletions services/auth/app/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading