Skip to content

Add Session-Based Web UI Authentication with Security Hardening - closes #815#819

Open
viktormohl wants to merge 2 commits intogessnerfl:mainfrom
viktormohl:issue-815
Open

Add Session-Based Web UI Authentication with Security Hardening - closes #815#819
viktormohl wants to merge 2 commits intogessnerfl:mainfrom
viktormohl:issue-815

Conversation

@viktormohl
Copy link

Pull Request: Add Session-Based Web UI Authentication with Security Hardening

Fixes

Summary

This PR implements comprehensive Web UI authentication with server-side session management, CSRF protection, rate limiting, and real-time notifications via SSE. The authentication system supports both enabled and disabled modes, ensuring backward compatibility for existing deployments.

Key Changes

🔐 Authentication & Session Management

Backend:

  • Implemented server-side session authentication using Spring Security
  • HttpOnly session cookies with SameSite=Lax protection
  • Configurable session timeout via FAKESMTP_WEBAPP_SESSION_TIMEOUT_MINUTES (default: 10 minutes, max: 24 hours)
  • Configurable concurrent sessions per user via FAKESMTP_WEBAPP_AUTHENTICATION_CONCURRENT_SESSIONS (default: 1)
    • Default behavior (1): New login invalidates existing session (secure for production)
    • Dev mode (-1): Unlimited concurrent sessions allowed
    • Custom value: Allow specified number of concurrent sessions
  • Session fixation protection (changeSessionId() on login)
  • CSRF token handling optimized for SPAs via SpaCsrfTokenRequestHandler

Frontend:

  • Login form integrated into React UI
  • Session timeout manager with activity tracking (mousemove, keydown, click)
  • Automatic logout on inactivity
  • Global 401/403 handling in RTK Query base layer

API:

  • New endpoints: POST /api/auth/login, POST /api/auth/logout, GET /api/auth/status
  • Meta-data endpoint (GET /api/meta-data) exposes auth status and session timeout

🛡️ Security Hardening

CSRF Protection:

  • Enabled for all state-changing requests (POST, PUT, DELETE)
  • Token exchanged via XSRF-TOKEN cookie and X-XSRF-TOKEN header
  • SPA-compatible token resolution with BREACH protection

Rate Limiting:

  • Brute-force protection for login endpoint
  • Configurable via environment variables:
    • FAKESMTP_WEBAPP_RATE_LIMITING_ENABLED (default: true)
    • FAKESMTP_WEBAPP_RATE_LIMITING_MAX_ATTEMPTS (default: 5, max: 100)
    • FAKESMTP_WEBAPP_RATE_LIMITING_WINDOW_MINUTES (default: 1, max: 60)
  • Returns HTTP 429 with Retry-After and X-RateLimit-Remaining headers
  • Supports X-Forwarded-For and X-Real-IP for proxy environments
  • Localhost (127.0.0.1, ::1) whitelisted by default

Security Headers:

  • Content Security Policy (CSP) on UI shell routes only (/, /emails/**)
  • X-Content-Type-Options: nosniff globally
  • Referrer-Policy: strict-origin-when-cross-origin globally
  • X-Frame-Options: sameorigin for clickjacking protection

Static Assets:

  • Restructured under /assets/** (previously root level)
  • Reduced public surface area
  • Gradle copyStaticAssets task switched to Sync to prevent stale assets

📡 Server-Sent Events (SSE) Enhancements

Performance:

  • Java 21 Virtual Threads for concurrent event delivery
  • Per-client timeout protection (default: 5s)
  • Non-blocking architecture prevents slow clients from blocking others
  • Automatic cleanup of dead connections after each broadcast

Reliability:

  • Server-sent heartbeat every 30 seconds (configurable)
  • Client monitors heartbeat and reconnects automatically if no ping received within 60s
  • Exponential backoff with jitter (max 30s) for reconnection attempts
  • Connection health indicator in UI (green/yellow/red status)

Authentication:

  • SSE connection only established when authenticated (when auth enabled)
  • Custom AuthenticatedEventSource using Fetch API with credentials: "same-origin"
  • Connection deferred until MetaData is loaded to avoid unnecessary 401s

🧪 Test Coverage

New Integration Tests:

  • WebappAuthenticationSecurityIntegrationTest (327 lines)
    • API protection with/without credentials
    • CSRF token validation
    • Delete operations with authentication
    • UI shell data leak prevention
  • WebappAuthenticationDisabledIntegrationTest (156 lines)
    • Full functionality without authentication
    • SSE access without auth
    • Delete operations without auth
  • RateLimitingFilterIntegrationTest (288 lines)
    • Rate limiting enforcement
    • IP detection (X-Forwarded-For, X-Real-IP)
    • Localhost whitelisting
    • Multi-client tracking

Updated Tests:

  • CustomAuthenticationEntryPointTest - SPA rendering validation
  • MetaDataControllerTest - Session timeout and auth status
  • EmailSseEmitterServiceTest - Virtual Thread delivery and heartbeats

📁 Files Changed

Backend (Java):

src/main/java/de/gessnerfl/fakesmtp/config/
  - SecurityConfig.java (modified)
  - CustomAuthenticationEntryPoint.java (modified)
  - WebappSessionProperties.java (new)
  - WebappAuthenticationProperties.java (new, extended with concurrent-sessions support)
  - RateLimitingFilter.java (new)
  - InMemoryRateLimiter.java (new)
  - RateLimitingProperties.java (new)

src/main/java/de/gessnerfl/fakesmtp/controller/
  - MetaDataController.java (modified)

src/main/java/de/gessnerfl/fakesmtp/service/
  - EmailSseEmitterService.java (modified)

src/main/java/de/gessnerfl/fakesmtp/model/
  - ApplicationMetaData.java (modified)

Frontend (React/TypeScript):

webapp/src/
  - components/
    - login.tsx (modified)
    - login.spec.tsx (modified)
    - navigation.tsx (modified)
    - session-timeout-manager.tsx (new)
    - session-timeout-manager.spec.tsx (new)
  - store/
    - rest-api.ts (modified)
    - auth-slice.ts (modified)
    - auth-session.ts (new)
    - auth-session.spec.ts (new)
  - pages/
    - email-list-page.tsx (modified)
    - email-list-page.spec.tsx (modified)
    - shell.tsx (modified)

Configuration:

src/main/resources/
  - application.yaml (modified)
  - application-develop.yaml (new)

build.gradle (modified)

Tests:

src/test/java/de/gessnerfl/fakesmtp/config/
  - WebappAuthenticationSecurityIntegrationTest.java (new)
  - WebappAuthenticationDisabledIntegrationTest.java (new)
  - RateLimitingFilterIntegrationTest.java (new)
  - CustomAuthenticationEntryPointTest.java (modified)
  - SecurityConfigAuthenticationEnabledIntegrationTest.java (modified)
  - WebappAuthenticationPropertiesTest.java (modified - added concurrent-sessions tests)

src/test/java/de/gessnerfl/fakesmtp/controller/
  - MetaDataControllerTest.java (modified)

src/test/java/de/gessnerfl/fakesmtp/service/
  - EmailSseEmitterServiceTest.java (modified)

src/test/http/
  - webapp-session-api.http (new)

Documentation:

README.md (modified)

Breaking Changes

None. The implementation is fully backward compatible:

  • When FAKESMTP_WEBAPP_AUTH_USERNAME and FAKESMTP_WEBAPP_AUTH_PASSWORD are not set, authentication is disabled and all endpoints work as before
  • Static assets moved to /assets/** - existing deployments continue to work as the Spring Boot resource handler serves them correctly

Migration Guide

For Existing Deployments (No Auth)

No action required. The application will continue to work without authentication if credentials are not configured.

For New Auth-Enabled Deployments

  1. Set environment variables:

    FAKESMTP_WEBAPP_AUTH_USERNAME=admin
    FAKESMTP_WEBAPP_AUTH_PASSWORD=securepassword
    FAKESMTP_WEBAPP_SESSION_TIMEOUT_MINUTES=10
  2. Access the UI and login with the configured credentials

  3. For API clients: Obtain CSRF token from /api/meta-data and include in state-changing requests

For Development/Testing (Multiple Concurrent Sessions)

To allow multiple browsers/devices to stay logged in simultaneously with the same credentials:

FAKESMTP_WEBAPP_AUTH_USERNAME=admin
FAKESMTP_WEBAPP_AUTH_PASSWORD=securepassword
FAKESMTP_WEBAPP_AUTHENTICATION_CONCURRENT_SESSIONS=-1  # Unlimited sessions

Or set a specific limit:

FAKESMTP_WEBAPP_AUTHENTICATION_CONCURRENT_SESSIONS=5  # Max 5 concurrent sessions

Configuration Reference

Environment Variables

Variable Default Description
FAKESMTP_WEBAPP_AUTH_USERNAME - Username for Web UI authentication
FAKESMTP_WEBAPP_AUTH_PASSWORD - Password for Web UI authentication
FAKESMTP_WEBAPP_SESSION_TIMEOUT_MINUTES 10 Session timeout in minutes (max: 1440)
FAKESMTP_WEBAPP_AUTHENTICATION_CONCURRENT_SESSIONS 1 Max concurrent sessions per user (-1 for unlimited)
FAKESMTP_WEBAPP_SSE_HEARTBEAT_INTERVAL_SECONDS 30 SSE heartbeat interval
FAKESMTP_WEBAPP_SSE_EVENT_SEND_TIMEOUT_SECONDS 5 Per-client SSE send timeout
FAKESMTP_WEBAPP_RATE_LIMITING_ENABLED true Enable login rate limiting
FAKESMTP_WEBAPP_RATE_LIMITING_MAX_ATTEMPTS 5 Max login attempts per window (max: 100)
FAKESMTP_WEBAPP_RATE_LIMITING_WINDOW_MINUTES 1 Rate limiting window in minutes (max: 60)
FAKESMTP_WEBAPP_RATE_LIMITING_WHITELIST_LOCALHOST true Exempt localhost from rate limiting

Authentication Modes

Mode 1: Authentication Disabled (Default)

fakesmtp:
  webapp:
    authentication:
      username:  # empty
      password:  # empty
  • All endpoints accessible without authentication
  • CSRF disabled for API endpoints
  • SSE accessible without authentication

Mode 2: Authentication Enabled

fakesmtp:
  webapp:
    authentication:
      username: admin
      password: securepassword
      concurrent-sessions: 1  # Set to -1 for unlimited (dev mode)
  • UI shell (/, /emails/**) loads without auth to render login form
  • /api/meta-data accessible to check auth status
  • All other /api/** endpoints require authentication
  • SSE requires authentication
  • CSRF protection enabled
  • Rate limiting active
  • Concurrent session control: new logins invalidate existing sessions by default (secure); set to -1 for dev mode

Testing

Manual Testing Checklist

Without Authentication:

  • Access / without login
  • Access /api/emails without 401
  • Delete emails without 403
  • SSE connection works

With Authentication:

  • Access / shows login form
  • Login with valid credentials works
  • /api/emails requires authentication
  • CSRF token validation works (403 without token)
  • Session timeout after inactivity
  • Logout clears session
  • SSE connects only after login
  • Rate limiting blocks after 5 failed attempts
  • Refresh (F5) preserves login (session cookie)
  • Concurrent sessions behavior (default: old session invalidated; with -1: multiple sessions allowed)

Automated Tests

Run all tests:

./gradlew test

Run specific test classes:

./gradlew test --tests '*WebappAuthenticationSecurityIntegration*'
./gradlew test --tests '*RateLimitingFilterIntegration*'

Performance Considerations

  • Virtual Threads: SSE delivery uses Java 21 Virtual Threads for minimal overhead
  • Connection Limits: No hard limit on SSE connections, but timeouts prevent resource exhaustion
  • Memory: Rate limiter uses bounded in-memory storage with automatic cleanup
  • Session Storage: In-memory sessions (default Spring Boot), suitable for single-instance deployments

Security Considerations

  1. Session Security: Sessions are stored server-side, only session ID in HttpOnly cookie
  2. CSRF Protection: All state-changing requests require valid CSRF token
  3. Rate Limiting: Login endpoint protected against brute-force attacks
  4. CSP: Content Security Policy prevents XSS attacks on UI routes
  5. Session Fixation: Session ID changed after login to prevent fixation attacks
  6. Credentials: Passwords hashed with BCrypt (strength 10)
  7. Concurrent Session Control: Default limits user to single session (new login invalidates old); configurable for development environments

Additional Notes

  • The UI shell (/, /emails/**) is intentionally public to allow the React app to load and render its own login form
  • Email data is never included in the initial HTML; all email data comes from authenticated /api/** endpoints
  • The XSRF-TOKEN cookie is not HttpOnly (must be accessible to JavaScript for SPA), but is protected by SameSite=Lax
  • H2 Console and Swagger UI remain accessible for development (can be disabled via properties)

Checklist

  • Code follows project style guidelines
  • All tests pass (./gradlew test)
  • New tests added for authentication flows
  • README updated with configuration documentation
  • No breaking changes for existing deployments
  • Both auth modes tested (enabled and disabled)
  • Frontend builds successfully (npm run build in webapp/)
  • Static assets properly packaged in JAR

@viktormohl viktormohl changed the title #815 Web UI authentication enabled → Browser gets 401 instead of showing the login page (v2.5.0) Add Session-Based Web UI Authentication with Security Hardening - closes #815 Feb 13, 2026
…nerfl#815)

- Implement server-side session authentication with HttpOnly cookies
- Add configurable session timeout (FAKESMTP_WEBAPP_SESSION_TIMEOUT_MINUTES)
- Enable CSRF protection for API requests
- Add CSP headers on UI shell routes only
- Restructure static assets under /assets/ with reduced public surface
- Implement authenticated SSE with heartbeats, connection health indicator,
  Virtual Threads for high-performance delivery, and exponential backoff
- Expose session metadata via /api/meta-data for UI consumption
- Add SessionTimeoutManager with inactivity tracking
- Update build process to include assets in bootRun/test tasks
- Add configurable rate limiting for login attempts
- Add configurable support for concurrent sessions
Copy link
Owner

@gessnerfl gessnerfl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@viktormohl The change LGTM. However it seems there are failing tests. Can you please double check?

@viktormohl
Copy link
Author

Thanks for the note. I double-checked locally.
./gradlew test and ./gradlew check pass, and the previously failing MVC test is now stable.

Root cause was a flaky assertion using a fixed ID (123) in EmailRestControllerMVCIntegrationTest (src/test/java/de/gessnerfl/fakesmtp/controller/EmailRestControllerMVCIntegrationTest.java). Depending on sequence state, that ID could exist and return 200 instead of 404. I replaced the hardcoded IDs with guaranteed invalid IDs (Long.MAX_VALUE).

I also reran ./gradlew build jacocoTestReport sonarqube --info: tests are green; only sonarqube fails due project/token permissions (Project not found), which is unrelated to test execution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Web UI authentication enabled → Browser gets 401 instead of showing the login page (v2.5.0)

2 participants