From 58f098d4cafe4b629ffffed1f3ba77e09011ea3c Mon Sep 17 00:00:00 2001 From: miaurizius Date: Wed, 10 Jun 2026 01:35:36 +0200 Subject: [PATCH] add rate limiting and 2fa hardening --- README.md | 21 ++-- auth/jwt.go | 52 ++++++++ docs/AUTHENTICATION.md | 30 +++-- docs/DATABASE.md | 9 +- docs/SECURITY.md | 83 +++++++++++++ frontend/assets/js/api.js | 30 ++++- .../htmx/contents/dash/account_settings.html | 3 +- handlers/account.go | 112 +++++++++++++++--- server/ratelimit.go | 101 ++++++++++++++++ server/server.go | 24 ++-- storage/storage.go | 4 +- 11 files changed, 410 insertions(+), 59 deletions(-) create mode 100644 docs/SECURITY.md create mode 100644 server/ratelimit.go diff --git a/README.md b/README.md index 87bf090..ee2575c 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,10 @@ The project is designed for self-hosted/private deployments. It is not a full en - Recovery codes are stored only as hashes. - Recovery codes can be downloaded as a text file after generation. - Recovery codes can be regenerated from account settings. +- Recovery-code count warnings in account settings. - Recovery codes are one-time use. +- The setup secret is only stored after the first valid authenticator code. +- Existing refresh sessions are revoked when 2FA is enabled or disabled. ## Current Status @@ -60,8 +63,8 @@ MiauInv is an active private project. The current version supports core inventor - Avatar support is currently only represented by a placeholder in the UI. - There is no dedicated admin panel yet. -- There is no rate limiting yet for login, 2FA, or recovery-code attempts. -- Automated test coverage is still incomplete and should be expanded around authentication, 2FA, recovery codes, and inventory handlers. +- Basic in-memory rate limiting protects login, 2FA, refresh, registration, and sensitive account endpoints. +- Automated testing is currently limited and will be expanded in future releases. - The application currently uses native TLS. If deployed behind a reverse proxy, the proxy must connect to the backend over HTTPS or the backend TLS behavior must be adjusted intentionally. ## Technical Stack @@ -101,7 +104,7 @@ More detailed documentation is available in: - [Authentication](docs/AUTHENTICATION.md) - [Database](docs/DATABASE.md) -- [Testing](docs/TESTING.md) +- [Security](docs/SECURITY.md) ## Configuration @@ -168,8 +171,8 @@ openssl rand -base64 48 | `/api/userinfo` | `GET` | Yes | Same user information handler as `/api/profile`. | | `/api/account/username` | `POST` | Yes | Changes the current username after password confirmation. | | `/api/account/password` | `POST` | Yes | Changes the current password, revokes old refresh tokens, and issues a new session. | -| `/api/2fa/setup` | `POST` | Yes | Creates a pending TOTP secret and returns `secret`, `otpauth_url`, and a base64 PNG QR code. | -| `/api/2fa/enable` | `POST` | Yes | Enables 2FA after validating a TOTP code. Returns one-time recovery codes. | +| `/api/2fa/setup` | `POST` | Yes | Creates a pending TOTP secret and returns `secret`, `setup_token`, `otpauth_url`, and a base64 PNG QR code. | +| `/api/2fa/enable` | `POST` | Yes | Enables 2FA after validating the temporary setup token and a TOTP code. Replaces recovery codes and revokes old sessions. | | `/api/2fa/disable` | `POST` | Yes | Disables 2FA after password and TOTP confirmation. Revokes sessions and clears auth cookies. | | `/api/2fa/recovery-codes/regenerate` | `POST` | Yes | Invalidates existing recovery codes and returns a new set after password and TOTP confirmation. | @@ -295,16 +298,16 @@ For Docker deployments, place Caddy and MiauInv on the same Docker network and r ## Security Notes -- JWTs are signed, not encrypted. Do not put secrets into JWT claims. +- JWTs are signed, not encrypted. Normal access and purpose tokens must not contain secrets. The temporary 2FA setup token is a narrow exception because it carries the not-yet-enabled TOTP secret back to the authenticated browser until confirmation. - `JWT_SECRET` must be random and private. - Access tokens expire after 15 minutes. - Refresh tokens expire after 7 days and are rotated on refresh. - Refresh tokens and recovery codes are stored in the database as hashes. - TOTP secrets are currently stored in the database because the server must validate codes. Protect the database file accordingly. -- Recovery codes are only shown when generated. Users should download or copy them immediately. +- Recovery codes are only shown when generated. Users should download or copy them immediately. The UI warns when few unused codes remain. - 2FA disable and recovery-code regeneration require both the current password and a valid TOTP code. -- The project should add rate limiting before being exposed to untrusted public traffic. -- The project should add more automated tests around login, 2FA, recovery codes, and account settings before being considered production-ready. +- Basic in-memory rate limiting is enabled for login, 2FA, refresh, registration, and sensitive account endpoints. Use persistent or distributed rate limiting for multi-instance deployments. +- Automated testing is currently limited. Authentication, 2FA, recovery codes, rate limiting, account settings, and inventory handlers should be covered before production use. ## Screenshots diff --git a/auth/jwt.go b/auth/jwt.go index 51cd772..c59c646 100644 --- a/auth/jwt.go +++ b/auth/jwt.go @@ -7,6 +7,11 @@ import ( "github.com/golang-jwt/jwt/v5" ) +const ( + PurposeTwoFactorLogin = "2fa_login" + PurposeTwoFactorSetup = "2fa_setup" +) + type Claims struct { UserID string `json:"user_id"` Role string `json:"role"` @@ -19,6 +24,13 @@ type PurposeClaims struct { jwt.RegisteredClaims } +type TwoFactorSetupClaims struct { + UserID string `json:"user_id"` + Purpose string `json:"purpose"` + Secret string `json:"secret"` + jwt.RegisteredClaims +} + func GenerateJWT(userID, role string, secret []byte) (string, error) { claims := Claims{ UserID: userID, @@ -47,6 +59,21 @@ func GeneratePurposeJWT(userID, purpose string, secret []byte, ttl time.Duration return token.SignedString(secret) } +func GenerateTwoFactorSetupJWT(userID, twoFactorSecret string, secret []byte, ttl time.Duration) (string, error) { + claims := TwoFactorSetupClaims{ + UserID: userID, + Purpose: PurposeTwoFactorSetup, + Secret: twoFactorSecret, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(secret) +} + func ValidateJWT(tokenStr string, secret []byte) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) { if token.Method != jwt.SigningMethodHS256 { @@ -87,3 +114,28 @@ func ValidatePurposeJWT(tokenStr, expectedPurpose string, secret []byte) (*Purpo return claims, nil } + +func ValidateTwoFactorSetupJWT(tokenStr string, secret []byte) (*TwoFactorSetupClaims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &TwoFactorSetupClaims{}, func(token *jwt.Token) (interface{}, error) { + if token.Method != jwt.SigningMethodHS256 { + return nil, errors.New("unexpected signing method") + } + return secret, nil + }) + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(*TwoFactorSetupClaims) + if !ok || !token.Valid { + return nil, errors.New("invalid token") + } + if claims.Purpose != PurposeTwoFactorSetup { + return nil, errors.New("invalid token purpose") + } + if claims.Secret == "" { + return nil, errors.New("missing 2FA setup secret") + } + + return claims, nil +} diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index ef161b4..76190bf 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -2,7 +2,7 @@ MiauInv uses signed JWT access tokens, database-backed refresh tokens, and optional TOTP-based two-factor authentication. The authentication flow is implemented in the `auth/`, `handlers/`, `storage/`, and `frontend/assets/js/` packages. -JWTs are signed with a symmetric secret from the `JWT_SECRET` environment variable. They are not encrypted. Claims should therefore contain identity and authorization metadata only, not secrets. +JWTs are signed with a symmetric secret from the `JWT_SECRET` environment variable. They are not encrypted. Access tokens and normal purpose tokens should therefore contain identity and authorization metadata only, not secrets. The short-lived 2FA setup token intentionally carries the not-yet-enabled TOTP secret because the same secret is already returned to the authenticated browser for QR/manual setup and is not stored server-side until confirmation. ## Components @@ -22,6 +22,7 @@ JWTs are signed with a symmetric secret from the `JWT_SECRET` environment variab | Access token | `access_token` HTTP-only secure cookie and JSON response body | 15 minutes | Authenticates normal API and page requests. | | Refresh token | `refresh_token` HTTP-only secure cookie and JSON response body | 7 days | Rotates sessions after access-token expiry. Stored in the database only as a hash. | | 2FA challenge token | JSON response from `/api/login` | 5 minutes | Allows `/api/login/2fa` to complete login after password verification. It is purpose-bound to `2fa_login`. | +| 2FA setup token | JSON response from `/api/2fa/setup` | 10 minutes | Carries the not-yet-enabled TOTP secret until `/api/2fa/enable` validates the first code. It is purpose-bound to `2fa_setup`. | ## Normal Login Flow Without 2FA @@ -72,27 +73,30 @@ The account settings page at `/profile/settings` exposes the UI for TOTP setup. 1. Authenticated user calls `POST /api/2fa/setup`. 2. Server creates a TOTP secret using issuer `MiauInv` and the current username as the account name. -3. Server stores the secret in `users.two_factor_secret`, but does not enable 2FA yet. -4. Server returns: +3. Server creates a short-lived setup token containing the not-yet-enabled TOTP secret. +4. The secret is not written to `users.two_factor_secret` during setup. +5. Server returns: ```json { "secret": "BASE32SECRET", + "setup_token": "...", "otpauth_url": "otpauth://totp/...", "qr_code": "data:image/png;base64,..." } ``` -5. The frontend displays the QR code and the manual setup key. -6. User scans the QR code or enters the secret manually into an authenticator app. -7. User submits a current TOTP code to `POST /api/2fa/enable`. -8. Server validates the code. -9. Server enables 2FA and generates recovery codes. -10. Recovery codes are returned once to the client for download/copying. +6. The frontend displays the QR code and the manual setup key. +7. User scans the QR code or enters the secret manually into an authenticator app. +8. User submits the setup token and a current TOTP code to `POST /api/2fa/enable`. +9. Server validates the setup token and the TOTP code. +10. Server stores the TOTP secret, enables 2FA, invalidates any previous recovery codes, and generates a fresh recovery-code set. +11. Server revokes existing refresh sessions and issues a new current session. +12. Recovery codes are returned once to the client for download/copying. ## Recovery Codes -Recovery codes are generated when 2FA is enabled and when the user regenerates them. +Recovery codes are generated when 2FA is enabled and when the user regenerates them. Enabling 2FA deletes any old recovery-code rows before inserting the new set. Properties: @@ -123,7 +127,7 @@ Recovery-code login flow: } ``` -The server verifies the current password and a current TOTP code. If both are valid, all previous recovery codes are deleted and a new set is inserted. The new plaintext codes are returned once. +The server verifies the current password and a current TOTP code. If both are valid, all previous recovery codes are deleted and a new set is inserted. The new plaintext codes are returned once. The account settings UI warns the user when the number of unused recovery codes is low. ## Disabling 2FA @@ -183,6 +187,8 @@ Username changes require the current password. Password changes require the current password, reject passwords longer than bcrypt's 72-byte effective limit, revoke existing refresh tokens, and issue a new session. +2FA activation also revokes existing refresh tokens and issues a new session for the current browser. Other devices must log in again and complete 2FA. + ## Middleware Behavior Protected routes use `AuthMiddleware`. @@ -206,7 +212,7 @@ If token validation fails: The current implementation is usable for private/self-hosted deployments, but these improvements should be prioritized before exposing it to untrusted public traffic: -- Add rate limiting for login, 2FA, refresh, and recovery-code attempts. +- Replace the current in-memory rate limiter with persistent or distributed rate limiting if the app is deployed across multiple instances. - Add audit logging for account security changes. - Add optional session/device management UI. - Consider encrypting TOTP secrets at rest if the deployment threat model includes database disclosure. diff --git a/docs/DATABASE.md b/docs/DATABASE.md index b220911..cf17da8 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -31,7 +31,7 @@ Stores account credentials, roles, and 2FA state. | `password` | `TEXT` | Not null | bcrypt password hash. | | `role` | `TEXT` | Not null | User role, for example `user` or `admin`. | | `two_factor_enabled` | `INTEGER` | Not null, default `0` | Boolean flag for TOTP 2FA state. | -| `two_factor_secret` | `TEXT` | Not null, default `''` | TOTP secret used to validate authenticator codes. Empty when 2FA is disabled. | +| `two_factor_secret` | `TEXT` | Not null, default `''` | TOTP secret used to validate authenticator codes. Empty when 2FA is disabled. During setup the secret is held in a short-lived signed setup token and is only stored after the first valid TOTP code. | Migration note: existing databases are migrated with `ALTER TABLE` statements for `two_factor_enabled` and `two_factor_secret` if those columns do not exist yet. @@ -166,11 +166,12 @@ When the password is updated: When 2FA is enabled: -1. The TOTP secret must already exist from `/api/2fa/setup`. -2. The supplied TOTP code is validated. +1. The temporary setup token is validated. +2. The supplied TOTP code is validated against the setup secret. 3. Existing recovery codes are deleted. 4. New recovery-code hashes are inserted. -5. `users.two_factor_enabled` is set to `1`. +5. `users.two_factor_enabled` is set to `1` and `users.two_factor_secret` is set to the confirmed secret. +6. Existing refresh tokens are revoked and a new session is issued for the current browser. ### 2FA disable diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..1ab9d65 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,83 @@ +# Security Notes + +This document summarizes the current security-relevant behavior of MiauInv. It is intended as implementation documentation, not as a guarantee that the application is production-ready for untrusted public deployments. + +## Authentication + +MiauInv uses signed JWT access tokens, database-backed refresh tokens, and optional TOTP-based two-factor authentication. + +JWTs are signed with `JWT_SECRET`. They are not encrypted. Normal access tokens and purpose tokens should therefore contain only identity and authorization metadata. + +The short-lived 2FA setup token is a narrow exception: it carries the not-yet-enabled TOTP secret until the first authenticator code is validated. This avoids storing the setup secret in the database before 2FA is confirmed. + +## Passwords + +Passwords are hashed with bcrypt. Password updates require the current password. New passwords longer than bcrypt's effective 72-byte limit are rejected. + +When a password is changed: + +1. The new password is bcrypt-hashed. +2. The stored password hash is updated. +3. Existing refresh tokens for the user are revoked. +4. A new session is issued for the current browser. + +## Sessions + +Access tokens expire after 15 minutes. Refresh tokens expire after 7 days. + +Refresh tokens are stored only as hashes in the database. Refresh-token rotation revokes the used refresh token and inserts a new token hash. + +Security-sensitive account changes revoke existing refresh-token sessions. + +## Cookies + +Authentication cookies are set as HTTP-only secure cookies using `SameSite=Lax`. + +Because the cookies are marked `Secure`, local development should use HTTPS. If the application is placed behind a reverse proxy, the deployment should preserve HTTPS semantics between the user and the proxy. + +## Two-Factor Authentication + +TOTP 2FA is optional per account. + +The setup flow returns a QR code, a manual setup key, and a short-lived setup token. The TOTP secret is stored only after the user submits a valid code from their authenticator app. + +When 2FA is enabled: + +1. Any previous recovery codes are deleted. +2. A new recovery-code set is generated. +3. The TOTP secret is stored. +4. Existing refresh sessions are revoked. +5. A new current session is issued. + +When 2FA is disabled: + +1. The TOTP secret is cleared. +2. Recovery codes are deleted. +3. Existing refresh sessions are revoked. +4. Authentication cookies are cleared. + +## Recovery Codes + +Recovery codes are generated with cryptographically secure randomness and stored only as hashes. + +They are displayed only immediately after generation or regeneration. They cannot be recovered later because the plaintext values are not stored. + +Recovery codes are single-use. During login, a submitted value is first checked as a TOTP code. If that fails, the value is normalized, hashed, and matched against unused recovery-code hashes. + +The account settings UI warns the user when the remaining unused recovery-code count is low. + +## Rate Limiting + +Basic in-memory rate limiting protects login, 2FA, refresh, registration, and sensitive account endpoints. + +This is suitable for a single-instance private deployment. It is not sufficient for multi-instance deployments because limiter state is process-local. A public or multi-instance deployment should use persistent or distributed rate limiting at the application, reverse proxy, or infrastructure layer. + +## Known Limitations + +- Automated testing is currently limited. +- TOTP secrets are stored in the database after confirmation because the server must validate future codes. +- TOTP secrets are not encrypted at rest. +- There is no dedicated session/device management UI yet. +- There is no audit log for account security changes yet. +- The current rate limiter is process-local and memory-only. +- Passkeys/WebAuthn are intentionally not implemented yet. diff --git a/frontend/assets/js/api.js b/frontend/assets/js/api.js index c66dcee..bd8c737 100644 --- a/frontend/assets/js/api.js +++ b/frontend/assets/js/api.js @@ -486,6 +486,7 @@ async function loadProfile() { // ---- ACCOUNT SETTINGS ---- let latestRecoveryCodes = []; +let pendingTwoFactorSetupToken = ""; function showAccountSettingsMessage(message, type = 'success') { const box = document.getElementById('account-settings-message'); @@ -518,6 +519,18 @@ function setTwoFactorPanels(enabled) { } } +function updateRecoveryCodeWarning(remaining, warning) { + const warningBox = document.getElementById('recovery-codes-warning'); + if (!warningBox) return; + + if (warning) { + warningBox.textContent = `You only have ${remaining} recovery code${remaining === 1 ? '' : 's'} left. Generate and download new codes soon.`; + warningBox.style.display = 'block'; + } else { + warningBox.style.display = 'none'; + } +} + function renderRecoveryCodes(codes) { latestRecoveryCodes = codes || []; const panel = document.getElementById('recovery-codes-panel'); @@ -545,6 +558,7 @@ async function loadAccountSettings() { if (usernameInput) usernameInput.value = data.username || ''; if (avatarPreview && data.username) avatarPreview.innerText = data.username[0].toLocaleUpperCase(); if (remaining) remaining.innerText = data.recovery_codes_remaining || 0; + updateRecoveryCodeWarning(data.recovery_codes_remaining || 0, !!data.recovery_codes_warning); setTwoFactorPanels(!!data.two_factor_enabled); renderRecoveryCodes([]); @@ -614,6 +628,8 @@ async function startTwoFactorSetup() { const secret = document.getElementById('two-factor-secret'); const otpauth = document.getElementById('two-factor-otpauth'); + pendingTwoFactorSetupToken = data.setup_token || ''; + if (panel) panel.style.display = 'block'; if (qr) { qr.src = data.qr_code; @@ -636,16 +652,24 @@ async function enableTwoFactor(event) { try { const data = await apiRequest('/api/2fa/enable', 'POST', { - code: document.getElementById('two-factor-enable-code').value.trim() + code: document.getElementById('two-factor-enable-code').value.trim(), + setup_token: pendingTwoFactorSetupToken }); document.getElementById('two-factor-enable-form').reset(); document.getElementById('two-factor-setup-panel').style.display = 'none'; + pendingTwoFactorSetupToken = ''; setTwoFactorPanels(true); renderRecoveryCodes(data.recovery_codes || []); const remaining = document.getElementById('recovery-codes-remaining'); - if (remaining) remaining.innerText = (data.recovery_codes || []).length; + if (remaining) remaining.innerText = data.recovery_codes_remaining || (data.recovery_codes || []).length; + updateRecoveryCodeWarning(data.recovery_codes_remaining || (data.recovery_codes || []).length, !!data.recovery_codes_warning); + + if (data.access_token && data.refresh_token) { + localStorage.setItem('access_token', data.access_token); + localStorage.setItem('refresh_token', data.refresh_token); + } showAccountSettingsMessage('2FA enabled. Download your recovery codes now.'); loadProfile(); @@ -670,6 +694,7 @@ async function disableTwoFactor(event) { localStorage.removeItem('refresh_token'); setTwoFactorPanels(false); renderRecoveryCodes([]); + updateRecoveryCodeWarning(0, false); showAccountSettingsMessage('2FA disabled. Redirecting to login because sessions were revoked.'); setTimeout(() => { window.location.href = '/login'; @@ -695,6 +720,7 @@ async function regenerateRecoveryCodes(event) { const remaining = document.getElementById('recovery-codes-remaining'); if (remaining) remaining.innerText = (data.recovery_codes || []).length; + updateRecoveryCodeWarning((data.recovery_codes || []).length, false); showAccountSettingsMessage('New recovery codes generated. Download them now.'); } catch (err) { diff --git a/frontend/htmx/contents/dash/account_settings.html b/frontend/htmx/contents/dash/account_settings.html index 068db94..cee223d 100644 --- a/frontend/htmx/contents/dash/account_settings.html +++ b/frontend/htmx/contents/dash/account_settings.html @@ -95,7 +95,8 @@