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
124 changes: 123 additions & 1 deletion backend/api/src/api/handlers/auth_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,107 @@ use crate::dtos::{
use crate::model::user::GitHubAccount;
use crate::services::auth_service::AuthService;
use crate::utils::error::AppError;
use axum::{Json, extract::State, http::header, response::IntoResponse};
use axum::{
Json,
extract::{ConnectInfo, Path, State},
http::{HeaderMap, header},
response::IntoResponse,
};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use hmac::{Hmac, KeyInit, Mac};
use serde_json::{Value, json};
use sha2::Sha256;
use std::net::SocketAddr;

type HmacSha256 = Hmac<Sha256>;

/// Derive a short "Browser on OS" label from User-Agent for the sessions UI.
fn device_label_from_headers(headers: &HeaderMap) -> String {
let ua = headers
.get(header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if ua.is_empty() {
return "Unknown device".to_string();
}

let browser = if ua.contains("Edg/") {
"Edge"
} else if ua.contains("Chrome/") {
"Chrome"
} else if ua.contains("Firefox/") {
"Firefox"
} else if ua.contains("Safari/") {
"Safari"
} else {
"Browser"
};

let os = if ua.contains("Windows") {
"Windows"
} else if ua.contains("Android") {
"Android"
} else if ua.contains("iPhone") || ua.contains("iPad") {
"iOS"
} else if ua.contains("Mac OS") || ua.contains("Macintosh") {
"macOS"
} else if ua.contains("Linux") {
"Linux"
} else {
"Unknown OS"
};

format!("{browser} on {os}")
}

/// Prefer reverse-proxy headers, then fall back to the TCP peer address.
fn client_ip_from_request(headers: &HeaderMap, addr: &SocketAddr) -> String {
if let Some(xff) = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
{
if let Some(first) = xff.split(',').next() {
let trimmed = first.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
}
if let Some(real) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
if !real.is_empty() {
return real.to_string();
}
}
addr.ip().to_string()
}

/// Persist a sessions-collection row for a freshly issued JWT (best-effort).
async fn record_login_session(
service: &AuthService,
token: &str,
headers: &HeaderMap,
addr: &SocketAddr,
) {
let Ok(claims) = service.verify_token(token) else {
return;
};
let Some(jti) = claims.jti.as_deref().filter(|j| !j.is_empty()) else {
return;
};
let _ = service
.create_session(
&claims.sub,
jti,
&device_label_from_headers(headers),
&client_ip_from_request(headers, addr),
)
.await;
}

pub async fn register(
State(service): State<AuthService>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(payload): Json<RegisterUserRequest>,
) -> Result<Json<AuthResponse>, AppError> {
use validator::Validate;
Expand All @@ -28,12 +119,15 @@ pub async fn register(
.map_err(|e| AppError::ValidationError(e.to_string()))?;

let response = service.register_user(payload).await?;
record_login_session(&service, &response.token, &headers, &addr).await;

Ok(Json(response))
}

pub async fn login(
State(service): State<AuthService>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(payload): Json<LoginRequest>,
) -> Result<Json<AuthResponse>, AppError> {
use validator::Validate;
Expand All @@ -42,10 +136,32 @@ pub async fn login(
.map_err(|e| AppError::ValidationError(e.to_string()))?;

let response = service.login_user(payload).await?;
record_login_session(&service, &response.token, &headers, &addr).await;

Ok(Json(response))
}

/// GET /auth/sessions — list active sessions for the authenticated user.
pub async fn list_sessions(
State(service): State<AuthService>,
claims: crate::utils::auth_jwt::Claims,
) -> Result<Json<Value>, AppError> {
let sessions = service
.list_sessions(&claims.sub, claims.jti.as_deref())
.await?;
Ok(Json(json!({ "sessions": sessions })))
}

/// DELETE /auth/sessions/:session_id — revoke one session owned by the caller.
pub async fn revoke_session(
State(service): State<AuthService>,
claims: crate::utils::auth_jwt::Claims,
Path(session_id): Path<String>,
) -> Result<Json<Value>, AppError> {
service.revoke_session(&claims.sub, &session_id).await?;
Ok(Json(json!({ "message": "Session revoked" })))
}

pub async fn request_otp(
State(service): State<AuthService>,
Json(payload): Json<OTPRequest>,
Expand Down Expand Up @@ -365,6 +481,7 @@ pub async fn google_callback(
State(service): State<AuthService>,
axum::extract::Query(query): axum::extract::Query<OAuthCallbackQuery>,
headers: axum::http::HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Result<axum::response::Response, AppError> {
let cookie_state = get_cookie(&headers, "oauth_state")
.ok_or(AppError::BadRequest("Missing OAuth state cookie".into()))?;
Expand Down Expand Up @@ -449,6 +566,8 @@ pub async fn google_callback(
.oauth_login_or_register(google_sub.to_string(), email.to_string())
.await?;

record_login_session(&service, &auth_res.token, &headers, &addr).await;

// Hand the JWT to the frontend via a URL fragment, not a cookie: the
// backend (this Render service) and the frontend (Vercel, a different
// eTLD+1) are different sites, so a cookie this response sets via
Expand Down Expand Up @@ -500,6 +619,7 @@ pub async fn github_callback(
State(service): State<AuthService>,
axum::extract::Query(query): axum::extract::Query<OAuthCallbackQuery>,
headers: axum::http::HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Result<axum::response::Response, AppError> {
let cookie_state = get_cookie(&headers, "oauth_state")
.ok_or(AppError::BadRequest("Missing OAuth state cookie".into()))?;
Expand Down Expand Up @@ -621,6 +741,8 @@ pub async fn github_callback(
.await?;
}

record_login_session(&service, &auth_res.token, &headers, &addr).await;

// See the matching comment in google_callback: a cookie can't bridge
// the backend's and frontend's separate domains, so the token goes in
// a URL fragment instead, which never reaches any server.
Expand Down
7 changes: 6 additions & 1 deletion backend/api/src/api/routers/auth_router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::api::handlers::auth_handler;
use crate::services::auth_service::AuthService;
use axum::{
Json, Router,
routing::{get, post},
routing::{delete, get, post},
};
use serde_json::json;
use std::sync::Arc;
Expand Down Expand Up @@ -44,6 +44,11 @@ pub fn router(service: AuthService) -> Router {
.route("/switch-network", post(auth_handler::switch_network))
.route("/rpc-log", post(auth_handler::log_rpc_call))
.route("/logout", post(auth_handler::logout))
.route("/sessions", get(auth_handler::list_sessions))
.route(
"/sessions/{session_id}",
delete(auth_handler::revoke_session),
)
.route(
"/google/login",
axum::routing::get(auth_handler::google_login),
Expand Down
83 changes: 83 additions & 0 deletions frontend/src/components/AuthModal/tabs/SecurityTab.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import React from 'react';

const getSessions = vi.fn();
const revokeSession = vi.fn();
const showToast = vi.fn();

vi.mock('../../../lib/store', () => ({
appStore: {
showToast: (...args: unknown[]) => showToast(...args),
},
}));

vi.mock('../../../lib/api', () => ({
apiClient: {
post: vi.fn(),
},
}));

vi.mock('../../../services/api', () => ({
apiService: {
getSessions: (...args: unknown[]) => getSessions(...args),
revokeSession: (...args: unknown[]) => revokeSession(...args),
},
}));

import { SecurityTab } from './SecurityTab';

describe('SecurityTab session review', () => {
beforeEach(() => {
vi.clearAllMocks();
getSessions.mockResolvedValue([
{
id: 'sess-current',
device_label: 'Chrome on macOS',
ip_address: '1.2.3.4',
created_at: new Date().toISOString(),
last_active_at: new Date().toISOString(),
is_current: true,
},
{
id: 'sess-other',
device_label: 'Firefox on Linux',
ip_address: '5.6.7.8',
created_at: new Date(Date.now() - 3_600_000).toISOString(),
last_active_at: new Date(Date.now() - 3_600_000).toISOString(),
is_current: false,
},
]);
revokeSession.mockResolvedValue(undefined);
});

it('loads and lists sessions when Review Sessions is clicked', async () => {
render(<SecurityTab />);

fireEvent.click(screen.getByRole('button', { name: 'Review Sessions' }));

await waitFor(() => {
expect(getSessions).toHaveBeenCalledTimes(1);
});

expect(await screen.findByText('Chrome on macOS')).toBeTruthy();
expect(screen.getByText('Firefox on Linux')).toBeTruthy();
expect(screen.getByText('Current')).toBeTruthy();
});

it('revokes a non-current session', async () => {
render(<SecurityTab />);

fireEvent.click(screen.getByRole('button', { name: 'Review Sessions' }));
await screen.findByText('Firefox on Linux');

fireEvent.click(
screen.getByRole('button', { name: 'Revoke session for Firefox on Linux' })
);

await waitFor(() => {
expect(revokeSession).toHaveBeenCalledWith('sess-other');
});
expect(showToast).toHaveBeenCalledWith('Session revoked', 'success');
});
});
Loading
Loading