From 76fbdcc1803bbe14f51d6130b20b97a1bbb26d58 Mon Sep 17 00:00:00 2001 From: YUSEP MAULANA Date: Sun, 26 Jul 2026 10:11:00 +0000 Subject: [PATCH] feat(auth): implement session review/revocation on Security tab Wire GET/DELETE /auth/sessions endpoints, record login sessions on password and OAuth sign-in, and replace the SecurityTab placeholder toast with a live session list + revoke UI. Closes #147. --- backend/api/src/api/handlers/auth_handler.rs | 124 +++++++- backend/api/src/api/routers/auth_router.rs | 7 +- .../AuthModal/tabs/SecurityTab.test.tsx | 83 +++++ .../components/AuthModal/tabs/SecurityTab.tsx | 289 +++++++++++++++++- 4 files changed, 496 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/AuthModal/tabs/SecurityTab.test.tsx diff --git a/backend/api/src/api/handlers/auth_handler.rs b/backend/api/src/api/handlers/auth_handler.rs index 76815fc4..0aef3848 100644 --- a/backend/api/src/api/handlers/auth_handler.rs +++ b/backend/api/src/api/handlers/auth_handler.rs @@ -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; +/// 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, + headers: HeaderMap, + ConnectInfo(addr): ConnectInfo, Json(payload): Json, ) -> Result, AppError> { use validator::Validate; @@ -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, + headers: HeaderMap, + ConnectInfo(addr): ConnectInfo, Json(payload): Json, ) -> Result, AppError> { use validator::Validate; @@ -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, + claims: crate::utils::auth_jwt::Claims, +) -> Result, 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, + claims: crate::utils::auth_jwt::Claims, + Path(session_id): Path, +) -> Result, AppError> { + service.revoke_session(&claims.sub, &session_id).await?; + Ok(Json(json!({ "message": "Session revoked" }))) +} + pub async fn request_otp( State(service): State, Json(payload): Json, @@ -365,6 +481,7 @@ pub async fn google_callback( State(service): State, axum::extract::Query(query): axum::extract::Query, headers: axum::http::HeaderMap, + ConnectInfo(addr): ConnectInfo, ) -> Result { let cookie_state = get_cookie(&headers, "oauth_state") .ok_or(AppError::BadRequest("Missing OAuth state cookie".into()))?; @@ -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 @@ -500,6 +619,7 @@ pub async fn github_callback( State(service): State, axum::extract::Query(query): axum::extract::Query, headers: axum::http::HeaderMap, + ConnectInfo(addr): ConnectInfo, ) -> Result { let cookie_state = get_cookie(&headers, "oauth_state") .ok_or(AppError::BadRequest("Missing OAuth state cookie".into()))?; @@ -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. diff --git a/backend/api/src/api/routers/auth_router.rs b/backend/api/src/api/routers/auth_router.rs index 016d1e04..b7450059 100644 --- a/backend/api/src/api/routers/auth_router.rs +++ b/backend/api/src/api/routers/auth_router.rs @@ -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; @@ -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), diff --git a/frontend/src/components/AuthModal/tabs/SecurityTab.test.tsx b/frontend/src/components/AuthModal/tabs/SecurityTab.test.tsx new file mode 100644 index 00000000..78d26f1b --- /dev/null +++ b/frontend/src/components/AuthModal/tabs/SecurityTab.test.tsx @@ -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(); + + 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(); + + 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'); + }); +}); diff --git a/frontend/src/components/AuthModal/tabs/SecurityTab.tsx b/frontend/src/components/AuthModal/tabs/SecurityTab.tsx index 8d3a0283..07c4b4cd 100644 --- a/frontend/src/components/AuthModal/tabs/SecurityTab.tsx +++ b/frontend/src/components/AuthModal/tabs/SecurityTab.tsx @@ -1,6 +1,8 @@ -import React, { useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { appStore } from '../../../lib/store'; import { apiClient } from '../../../lib/api'; +import { apiService } from '../../../services/api'; +import type { ActiveSession } from '../../../types'; interface PasswordFormData { currentPassword: string; @@ -8,9 +10,89 @@ interface PasswordFormData { confirmPassword: string; } +type SessionsState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'ready'; data: ActiveSession[] } + | { status: 'error'; message: string }; + +function formatSessionDate(isoString: string): string { + try { + const date = new Date(isoString); + const now = Date.now(); + const diffMs = now - date.getTime(); + const diffMins = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return 'Active now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays === 1) return '1 day ago'; + if (diffDays < 7) return `${diffDays} days ago`; + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } catch { + return isoString; + } +} + +function useActiveSessions(enabled: boolean) { + const [state, setState] = useState({ status: 'idle' }); + const [revokingId, setRevokingId] = useState(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const load = useCallback(async () => { + setState({ status: 'loading' }); + try { + const sessions = await apiService.getSessions(); + if (!mountedRef.current) return; + setState({ status: 'ready', data: sessions }); + } catch (err) { + if (!mountedRef.current) return; + const message = err instanceof Error ? err.message : 'Failed to load sessions.'; + setState({ status: 'error', message }); + } + }, []); + + useEffect(() => { + if (!enabled) return; + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional fetch when panel opens + load(); + }, [enabled, load]); + + const revoke = useCallback( + async (sessionId: string) => { + setRevokingId(sessionId); + try { + await apiService.revokeSession(sessionId); + if (!mountedRef.current) return; + appStore.showToast('Session revoked', 'success'); + await load(); + } catch (err) { + if (!mountedRef.current) return; + const message = err instanceof Error ? err.message : 'Could not revoke session.'; + appStore.showToast(message, 'error'); + } finally { + if (mountedRef.current) setRevokingId(null); + } + }, + [load] + ); + + return { state, revokingId, retry: load, revoke }; +} + export const SecurityTab: React.FC = () => { const [isLoading, setIsLoading] = useState(false); const [isPasswordFormVisible, setIsPasswordFormVisible] = useState(false); + const [isSessionReviewVisible, setIsSessionReviewVisible] = useState(false); const [formData, setFormData] = useState({ currentPassword: '', newPassword: '', @@ -18,6 +100,13 @@ export const SecurityTab: React.FC = () => { }); const [errors, setErrors] = useState>({}); + const { + state: sessionsState, + revokingId, + retry: retrySessions, + revoke: revokeSession, + } = useActiveSessions(isSessionReviewVisible); + const validatePasswordForm = (): boolean => { const newErrors: Partial = {}; @@ -41,7 +130,7 @@ export const SecurityTab: React.FC = () => { const handlePasswordRotation = async (e: React.FormEvent) => { e.preventDefault(); - + if (!validatePasswordForm()) { return; } @@ -56,7 +145,6 @@ export const SecurityTab: React.FC = () => { if (response.data.success) { appStore.showToast('Password rotated successfully!', 'success'); - // Reset form setFormData({ currentPassword: '', newPassword: '', @@ -79,12 +167,74 @@ export const SecurityTab: React.FC = () => { e: React.ChangeEvent ) => { setFormData((prev) => ({ ...prev, [field]: e.target.value })); - // Clear error for this field when user starts typing if (errors[field]) { setErrors((prev) => ({ ...prev, [field]: undefined })); } }; + const renderSessionsPanel = () => { + if (sessionsState.status === 'loading' || sessionsState.status === 'idle') { + return

Loading sessions…

; + } + + if (sessionsState.status === 'error') { + return ( +
+

{sessionsState.message}

+ +
+ ); + } + + if (sessionsState.data.length === 0) { + return

No active sessions found.

; + } + + return ( +
    + {sessionsState.data.map((session) => { + const isRevoking = revokingId === session.id; + return ( +
  • +
    +
    +
    +
    + {session.ip_address !== 'unknown' ? session.ip_address : 'IP unavailable'} + {' · '} + {formatSessionDate(session.last_active_at)} +
    +
    + {!session.is_current ? ( + + ) : null} +
  • + ); + })} +
+ ); + }; + return (
{/* Password Rotation Card */} @@ -182,6 +332,47 @@ export const SecurityTab: React.FC = () => { )}
+ {/* Session Review Card */} +
+
+

Session review

+

+ Audit active surfaces and revoke stale sessions when operators or devices change. +

+
+ + {!isSessionReviewVisible ? ( + + ) : ( +
+ {renderSessionsPanel()} +
+ + +
+
+ )} +
+ ); -}; \ No newline at end of file +};