feature/5-mfa-support #8

Merged
MiauRizius merged 6 commits from feature/5-mfa-support into main 2026-06-10 01:46:56 +02:00
24 changed files with 2250 additions and 539 deletions
Showing only changes of commit 58f098d4ca - Show all commits

View File

@@ -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 are stored only as hashes.
- Recovery codes can be downloaded as a text file after generation. - Recovery codes can be downloaded as a text file after generation.
- Recovery codes can be regenerated from account settings. - Recovery codes can be regenerated from account settings.
- Recovery-code count warnings in account settings.
- Recovery codes are one-time use. - 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 ## 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. - Avatar support is currently only represented by a placeholder in the UI.
- There is no dedicated admin panel yet. - There is no dedicated admin panel yet.
- There is no rate limiting yet for login, 2FA, or recovery-code attempts. - Basic in-memory rate limiting protects login, 2FA, refresh, registration, and sensitive account endpoints.
- Automated test coverage is still incomplete and should be expanded around authentication, 2FA, recovery codes, and inventory handlers. - 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. - 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 ## Technical Stack
@@ -101,7 +104,7 @@ More detailed documentation is available in:
- [Authentication](docs/AUTHENTICATION.md) - [Authentication](docs/AUTHENTICATION.md)
- [Database](docs/DATABASE.md) - [Database](docs/DATABASE.md)
- [Testing](docs/TESTING.md) - [Security](docs/SECURITY.md)
## Configuration ## Configuration
@@ -168,8 +171,8 @@ openssl rand -base64 48
| `/api/userinfo` | `GET` | Yes | Same user information handler as `/api/profile`. | | `/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/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/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/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 a TOTP code. Returns one-time recovery codes. | | `/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/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. | | `/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 ## 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. - `JWT_SECRET` must be random and private.
- Access tokens expire after 15 minutes. - Access tokens expire after 15 minutes.
- Refresh tokens expire after 7 days and are rotated on refresh. - Refresh tokens expire after 7 days and are rotated on refresh.
- Refresh tokens and recovery codes are stored in the database as hashes. - 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. - 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. - 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. - 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.
- The project should add more automated tests around login, 2FA, recovery codes, and account settings before being considered production-ready. - Automated testing is currently limited. Authentication, 2FA, recovery codes, rate limiting, account settings, and inventory handlers should be covered before production use.
## Screenshots ## Screenshots

View File

@@ -7,6 +7,11 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
) )
const (
PurposeTwoFactorLogin = "2fa_login"
PurposeTwoFactorSetup = "2fa_setup"
)
type Claims struct { type Claims struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
Role string `json:"role"` Role string `json:"role"`
@@ -19,6 +24,13 @@ type PurposeClaims struct {
jwt.RegisteredClaims 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) { func GenerateJWT(userID, role string, secret []byte) (string, error) {
claims := Claims{ claims := Claims{
UserID: userID, UserID: userID,
@@ -47,6 +59,21 @@ func GeneratePurposeJWT(userID, purpose string, secret []byte, ttl time.Duration
return token.SignedString(secret) 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) { func ValidateJWT(tokenStr string, secret []byte) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if token.Method != jwt.SigningMethodHS256 { if token.Method != jwt.SigningMethodHS256 {
@@ -87,3 +114,28 @@ func ValidatePurposeJWT(tokenStr, expectedPurpose string, secret []byte) (*Purpo
return claims, nil 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
}

View File

@@ -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. 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 ## 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. | | 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. | | 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 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 ## 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`. 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. 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. 3. Server creates a short-lived setup token containing the not-yet-enabled TOTP secret.
4. Server returns: 4. The secret is not written to `users.two_factor_secret` during setup.
5. Server returns:
```json ```json
{ {
"secret": "BASE32SECRET", "secret": "BASE32SECRET",
"setup_token": "...",
"otpauth_url": "otpauth://totp/...", "otpauth_url": "otpauth://totp/...",
"qr_code": "data:image/png;base64,..." "qr_code": "data:image/png;base64,..."
} }
``` ```
5. The frontend displays the QR code and the manual setup key. 6. 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 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. User submits the setup token and a current TOTP code to `POST /api/2fa/enable`.
8. Server validates the code. 9. Server validates the setup token and the TOTP code.
9. Server enables 2FA and generates recovery codes. 10. Server stores the TOTP secret, enables 2FA, invalidates any previous recovery codes, and generates a fresh recovery-code set.
10. Recovery codes are returned once to the client for download/copying. 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
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: 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 ## 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. 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 ## Middleware Behavior
Protected routes use `AuthMiddleware`. 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: 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 audit logging for account security changes.
- Add optional session/device management UI. - Add optional session/device management UI.
- Consider encrypting TOTP secrets at rest if the deployment threat model includes database disclosure. - Consider encrypting TOTP secrets at rest if the deployment threat model includes database disclosure.

View File

@@ -31,7 +31,7 @@ Stores account credentials, roles, and 2FA state.
| `password` | `TEXT` | Not null | bcrypt password hash. | | `password` | `TEXT` | Not null | bcrypt password hash. |
| `role` | `TEXT` | Not null | User role, for example `user` or `admin`. | | `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_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. 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: When 2FA is enabled:
1. The TOTP secret must already exist from `/api/2fa/setup`. 1. The temporary setup token is validated.
2. The supplied TOTP code is validated. 2. The supplied TOTP code is validated against the setup secret.
3. Existing recovery codes are deleted. 3. Existing recovery codes are deleted.
4. New recovery-code hashes are inserted. 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 ### 2FA disable

83
docs/SECURITY.md Normal file
View File

@@ -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.

View File

@@ -486,6 +486,7 @@ async function loadProfile() {
// ---- ACCOUNT SETTINGS ---- // ---- ACCOUNT SETTINGS ----
let latestRecoveryCodes = []; let latestRecoveryCodes = [];
let pendingTwoFactorSetupToken = "";
function showAccountSettingsMessage(message, type = 'success') { function showAccountSettingsMessage(message, type = 'success') {
const box = document.getElementById('account-settings-message'); 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) { function renderRecoveryCodes(codes) {
latestRecoveryCodes = codes || []; latestRecoveryCodes = codes || [];
const panel = document.getElementById('recovery-codes-panel'); const panel = document.getElementById('recovery-codes-panel');
@@ -545,6 +558,7 @@ async function loadAccountSettings() {
if (usernameInput) usernameInput.value = data.username || ''; if (usernameInput) usernameInput.value = data.username || '';
if (avatarPreview && data.username) avatarPreview.innerText = data.username[0].toLocaleUpperCase(); if (avatarPreview && data.username) avatarPreview.innerText = data.username[0].toLocaleUpperCase();
if (remaining) remaining.innerText = data.recovery_codes_remaining || 0; if (remaining) remaining.innerText = data.recovery_codes_remaining || 0;
updateRecoveryCodeWarning(data.recovery_codes_remaining || 0, !!data.recovery_codes_warning);
setTwoFactorPanels(!!data.two_factor_enabled); setTwoFactorPanels(!!data.two_factor_enabled);
renderRecoveryCodes([]); renderRecoveryCodes([]);
@@ -614,6 +628,8 @@ async function startTwoFactorSetup() {
const secret = document.getElementById('two-factor-secret'); const secret = document.getElementById('two-factor-secret');
const otpauth = document.getElementById('two-factor-otpauth'); const otpauth = document.getElementById('two-factor-otpauth');
pendingTwoFactorSetupToken = data.setup_token || '';
if (panel) panel.style.display = 'block'; if (panel) panel.style.display = 'block';
if (qr) { if (qr) {
qr.src = data.qr_code; qr.src = data.qr_code;
@@ -636,16 +652,24 @@ async function enableTwoFactor(event) {
try { try {
const data = await apiRequest('/api/2fa/enable', 'POST', { 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-enable-form').reset();
document.getElementById('two-factor-setup-panel').style.display = 'none'; document.getElementById('two-factor-setup-panel').style.display = 'none';
pendingTwoFactorSetupToken = '';
setTwoFactorPanels(true); setTwoFactorPanels(true);
renderRecoveryCodes(data.recovery_codes || []); renderRecoveryCodes(data.recovery_codes || []);
const remaining = document.getElementById('recovery-codes-remaining'); 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.'); showAccountSettingsMessage('2FA enabled. Download your recovery codes now.');
loadProfile(); loadProfile();
@@ -670,6 +694,7 @@ async function disableTwoFactor(event) {
localStorage.removeItem('refresh_token'); localStorage.removeItem('refresh_token');
setTwoFactorPanels(false); setTwoFactorPanels(false);
renderRecoveryCodes([]); renderRecoveryCodes([]);
updateRecoveryCodeWarning(0, false);
showAccountSettingsMessage('2FA disabled. Redirecting to login because sessions were revoked.'); showAccountSettingsMessage('2FA disabled. Redirecting to login because sessions were revoked.');
setTimeout(() => { setTimeout(() => {
window.location.href = '/login'; window.location.href = '/login';
@@ -695,6 +720,7 @@ async function regenerateRecoveryCodes(event) {
const remaining = document.getElementById('recovery-codes-remaining'); const remaining = document.getElementById('recovery-codes-remaining');
if (remaining) remaining.innerText = (data.recovery_codes || []).length; if (remaining) remaining.innerText = (data.recovery_codes || []).length;
updateRecoveryCodeWarning((data.recovery_codes || []).length, false);
showAccountSettingsMessage('New recovery codes generated. Download them now.'); showAccountSettingsMessage('New recovery codes generated. Download them now.');
} catch (err) { } catch (err) {

View File

@@ -95,7 +95,8 @@
</div> </div>
<div id="two-factor-enabled-panel" style="display: none;"> <div id="two-factor-enabled-panel" style="display: none;">
<p style="color: var(--text-muted); margin-bottom: 1rem;">Recovery codes remaining: <strong id="recovery-codes-remaining">0</strong></p> <p style="color: var(--text-muted); margin-bottom: 0.5rem;">Recovery codes remaining: <strong id="recovery-codes-remaining">0</strong></p>
<p id="recovery-codes-warning" class="message error" style="display: none; margin-bottom: 1rem;">You are running low on recovery codes. Generate and download new codes soon.</p>
<div id="recovery-codes-panel" style="display: none; margin-bottom: 1.5rem; padding: 1rem; border: 1px solid var(--border); border-radius: 12px; background: #111827;"> <div id="recovery-codes-panel" style="display: none; margin-bottom: 1.5rem; padding: 1rem; border: 1px solid var(--border); border-radius: 12px; background: #111827;">
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Recovery codes</h3> <h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Recovery codes</h3>

View File

@@ -20,6 +20,8 @@ import (
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
) )
const recoveryCodeWarningThreshold = 3
func APIRegister(w http.ResponseWriter, r *http.Request) { func APIRegister(w http.ResponseWriter, r *http.Request) {
var user models.User var user models.User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil { if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
@@ -59,6 +61,7 @@ func APIRegister(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
log.Println("POST [api/register] " + r.RemoteAddr + ": Successfully created user") log.Println("POST [api/register] " + r.RemoteAddr + ": Successfully created user")
} }
func APILogin(w http.ResponseWriter, r *http.Request) { func APILogin(w http.ResponseWriter, r *http.Request) {
var creds struct { var creds struct {
Username string `json:"username"` Username string `json:"username"`
@@ -91,7 +94,7 @@ func APILogin(w http.ResponseWriter, r *http.Request) {
} }
if user.TwoFactorEnabled { if user.TwoFactorEnabled {
twoFactorToken, err := auth.GeneratePurposeJWT(user.ID, "2fa_login", secret, 5*time.Minute) twoFactorToken, err := auth.GeneratePurposeJWT(user.ID, auth.PurposeTwoFactorLogin, secret, 5*time.Minute)
if err != nil { if err != nil {
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error()) log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate 2FA challenge", http.StatusInternalServerError) http.Error(w, "Could not generate 2FA challenge", http.StatusInternalServerError)
@@ -109,6 +112,7 @@ func APILogin(w http.ResponseWriter, r *http.Request) {
issueLoginSession(w, r, user) issueLoginSession(w, r, user)
log.Println("POST [api/login] " + r.RemoteAddr + ": Successfully logged in") log.Println("POST [api/login] " + r.RemoteAddr + ": Successfully logged in")
} }
func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) { func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
TwoFactorToken string `json:"two_factor_token"` TwoFactorToken string `json:"two_factor_token"`
@@ -121,7 +125,7 @@ func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) {
} }
secret := []byte(os.Getenv("JWT_SECRET")) secret := []byte(os.Getenv("JWT_SECRET"))
claims, err := auth.ValidatePurposeJWT(req.TwoFactorToken, "2fa_login", secret) claims, err := auth.ValidatePurposeJWT(req.TwoFactorToken, auth.PurposeTwoFactorLogin, secret)
if err != nil { if err != nil {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": " + err.Error()) log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid or expired 2FA challenge", http.StatusUnauthorized) http.Error(w, "Invalid or expired 2FA challenge", http.StatusUnauthorized)
@@ -209,6 +213,7 @@ func AccountUpdateUsername(w http.ResponseWriter, r *http.Request) {
}) })
log.Println("POST [api/account/username] " + r.RemoteAddr + ": Updated username") log.Println("POST [api/account/username] " + r.RemoteAddr + ": Updated username")
} }
func AccountUpdatePassword(w http.ResponseWriter, r *http.Request) { func AccountUpdatePassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -274,6 +279,7 @@ func AccountUpdatePassword(w http.ResponseWriter, r *http.Request) {
issueLoginSession(w, r, user) issueLoginSession(w, r, user)
log.Println("POST [api/account/password] " + r.RemoteAddr + ": Updated password") log.Println("POST [api/account/password] " + r.RemoteAddr + ": Updated password")
} }
func TwoFactorSetup(w http.ResponseWriter, r *http.Request) { func TwoFactorSetup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -304,9 +310,17 @@ func TwoFactorSetup(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := storage.SetUserTwoFactorSecret(user.ID, key.Secret()); err != nil { secret := []byte(os.Getenv("JWT_SECRET"))
if len(secret) == 0 {
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": Server misconfiguration")
http.Error(w, "Server misconfiguration", http.StatusInternalServerError)
return
}
setupToken, err := auth.GenerateTwoFactorSetupJWT(user.ID, key.Secret(), secret, 10*time.Minute)
if err != nil {
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": " + err.Error()) log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not save 2FA secret", http.StatusInternalServerError) http.Error(w, "Could not create 2FA setup challenge", http.StatusInternalServerError)
return return
} }
@@ -326,11 +340,13 @@ func TwoFactorSetup(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{ writeJSON(w, http.StatusOK, map[string]interface{}{
"secret": key.Secret(), "secret": key.Secret(),
"setup_token": setupToken,
"otpauth_url": key.URL(), "otpauth_url": key.URL(),
"qr_code": "data:image/png;base64," + base64.StdEncoding.EncodeToString(qr.Bytes()), "qr_code": "data:image/png;base64," + base64.StdEncoding.EncodeToString(qr.Bytes()),
}) })
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": Created 2FA setup challenge") log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": Created 2FA setup challenge")
} }
func TwoFactorEnable(w http.ResponseWriter, r *http.Request) { func TwoFactorEnable(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -339,6 +355,7 @@ func TwoFactorEnable(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Code string `json:"code"` Code string `json:"code"`
SetupToken string `json:"setup_token"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error()) log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
@@ -354,12 +371,31 @@ func TwoFactorEnable(w http.ResponseWriter, r *http.Request) {
return return
} }
if user.TwoFactorSecret == "" { setupSecret := ""
secret := []byte(os.Getenv("JWT_SECRET"))
if req.SetupToken != "" {
setupClaims, err := auth.ValidateTwoFactorSetupJWT(req.SetupToken, secret)
if err != nil {
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid or expired 2FA setup challenge", http.StatusUnauthorized)
return
}
if setupClaims.UserID != user.ID {
http.Error(w, "Invalid 2FA setup challenge", http.StatusUnauthorized)
return
}
setupSecret = setupClaims.Secret
} else if !user.TwoFactorEnabled && user.TwoFactorSecret != "" {
// Compatibility for accounts that started setup before temporary setup tokens existed.
setupSecret = user.TwoFactorSecret
}
if setupSecret == "" {
http.Error(w, "2FA setup has not been started", http.StatusBadRequest) http.Error(w, "2FA setup has not been started", http.StatusBadRequest)
return return
} }
if !totp.Validate(strings.TrimSpace(req.Code), user.TwoFactorSecret) { if !totp.Validate(strings.TrimSpace(req.Code), setupSecret) {
http.Error(w, "Invalid 2FA code", http.StatusUnauthorized) http.Error(w, "Invalid 2FA code", http.StatusUnauthorized)
return return
} }
@@ -371,18 +407,30 @@ func TwoFactorEnable(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := storage.EnableUserTwoFactorWithRecoveryCodes(user.ID, recoveryCodeHashes); err != nil { if err := storage.EnableUserTwoFactorWithSecretAndRecoveryCodes(user.ID, setupSecret, recoveryCodeHashes); err != nil {
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error()) log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not enable 2FA", http.StatusInternalServerError) http.Error(w, "Could not enable 2FA", http.StatusInternalServerError)
return return
} }
writeJSON(w, http.StatusOK, map[string]interface{}{ if err := storage.RevokeAllRefreshTokensForUser(user.ID); err != nil {
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not revoke old sessions", http.StatusInternalServerError)
return
}
user.TwoFactorEnabled = true
user.TwoFactorSecret = setupSecret
issueLoginSessionWithExtra(w, r, user, map[string]interface{}{
"two_factor_enabled": true, "two_factor_enabled": true,
"recovery_codes": recoveryCodes, "recovery_codes": recoveryCodes,
"recovery_codes_remaining": len(recoveryCodes),
"recovery_codes_warning": false,
"recovery_codes_warning_at": recoveryCodeWarningThreshold,
}) })
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": Enabled 2FA and generated recovery codes") log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": Enabled 2FA, replaced recovery codes, and revoked old sessions")
} }
func TwoFactorDisable(w http.ResponseWriter, r *http.Request) { func TwoFactorDisable(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -433,6 +481,7 @@ func TwoFactorDisable(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{"two_factor_enabled": false}) writeJSON(w, http.StatusOK, map[string]interface{}{"two_factor_enabled": false})
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": Disabled 2FA") log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": Disabled 2FA")
} }
func TwoFactorRegenerateRecoveryCodes(w http.ResponseWriter, r *http.Request) { func TwoFactorRegenerateRecoveryCodes(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -502,6 +551,7 @@ func Logout(w http.ResponseWriter, r *http.Request) {
clearAuthCookies(w) clearAuthCookies(w)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func TestHandler(w http.ResponseWriter, r *http.Request) { func TestHandler(w http.ResponseWriter, r *http.Request) {
claims, _ := utils.IsLoggedIn(w, r) claims, _ := utils.IsLoggedIn(w, r)
@@ -517,6 +567,7 @@ func TestHandler(w http.ResponseWriter, r *http.Request) {
} }
log.Println("GET [api/ping] " + r.RemoteAddr + ": Successfully tested connection") log.Println("GET [api/ping] " + r.RemoteAddr + ": Successfully tested connection")
} }
func RefreshToken(w http.ResponseWriter, r *http.Request) { func RefreshToken(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
@@ -560,6 +611,7 @@ func RefreshToken(w http.ResponseWriter, r *http.Request) {
issueLoginSession(w, r, user) issueLoginSession(w, r, user)
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Successfully refreshed token") log.Println("POST [api/refresh] " + r.RemoteAddr + ": Successfully refreshed token")
} }
func UserInfo(w http.ResponseWriter, r *http.Request) { func UserInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Method " + r.Method + " not allowed") log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Method " + r.Method + " not allowed")
@@ -613,6 +665,13 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
recoveryCodesRemaining = count recoveryCodesRemaining = count
} }
} }
twoFactorStatus := "disabled"
if user.TwoFactorEnabled {
twoFactorStatus = "enabled"
} else if user.TwoFactorSecret != "" {
twoFactorStatus = "setup_pending"
}
recoveryCodesWarning := user.TwoFactorEnabled && recoveryCodesRemaining <= recoveryCodeWarningThreshold
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(map[string]interface{}{ err = json.NewEncoder(w).Encode(map[string]interface{}{
@@ -620,7 +679,10 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
"username": user.Username, "username": user.Username,
"avatar_url": "", "avatar_url": "",
"two_factor_enabled": user.TwoFactorEnabled, "two_factor_enabled": user.TwoFactorEnabled,
"two_factor_status": twoFactorStatus,
"recovery_codes_remaining": recoveryCodesRemaining, "recovery_codes_remaining": recoveryCodesRemaining,
"recovery_codes_warning": recoveryCodesWarning,
"recovery_codes_warning_at": recoveryCodeWarningThreshold,
}) })
if err != nil { if err != nil {
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error()) log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error())
@@ -628,7 +690,12 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
} }
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Successfully retrieved user info of " + user.Username + " (" + user.ID + ")") log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Successfully retrieved user info of " + user.Username + " (" + user.ID + ")")
} }
func issueLoginSession(w http.ResponseWriter, r *http.Request, user models.User) { func issueLoginSession(w http.ResponseWriter, r *http.Request, user models.User) {
issueLoginSessionWithExtra(w, r, user, nil)
}
func issueLoginSessionWithExtra(w http.ResponseWriter, r *http.Request, user models.User, extra map[string]interface{}) {
secret := []byte(os.Getenv("JWT_SECRET")) secret := []byte(os.Getenv("JWT_SECRET"))
if len(secret) == 0 { if len(secret) == 0 {
log.Println("AUTH " + r.RemoteAddr + ": Server misconfiguration") log.Println("AUTH " + r.RemoteAddr + ": Server misconfiguration")
@@ -666,7 +733,7 @@ func issueLoginSession(w http.ResponseWriter, r *http.Request, user models.User)
} }
setAuthCookies(w, accessToken, refreshTokenPlain) setAuthCookies(w, accessToken, refreshTokenPlain)
writeJSON(w, http.StatusOK, map[string]interface{}{ response := map[string]interface{}{
"access_token": accessToken, "access_token": accessToken,
"refresh_token": refreshTokenPlain, "refresh_token": refreshTokenPlain,
"user": map[string]interface{}{ "user": map[string]interface{}{
@@ -675,7 +742,11 @@ func issueLoginSession(w http.ResponseWriter, r *http.Request, user models.User)
"role": user.Role, "role": user.Role,
"two_factor_enabled": user.TwoFactorEnabled, "two_factor_enabled": user.TwoFactorEnabled,
}, },
}) }
for key, value := range extra {
response[key] = value
}
writeJSON(w, http.StatusOK, response)
} }
func generateRecoveryCodes(count int) ([]string, []string, error) { func generateRecoveryCodes(count int) ([]string, []string, error) {
@@ -701,6 +772,7 @@ func generateRecoveryCodes(count int) ([]string, []string, error) {
return codes, hashes, nil return codes, hashes, nil
} }
func generateRecoveryCode() (string, error) { func generateRecoveryCode() (string, error) {
bytes := make([]byte, 10) bytes := make([]byte, 10)
if _, err := rand.Read(bytes); err != nil { if _, err := rand.Read(bytes); err != nil {
@@ -710,6 +782,7 @@ func generateRecoveryCode() (string, error) {
raw := hex.EncodeToString(bytes) raw := hex.EncodeToString(bytes)
return raw[0:5] + "-" + raw[5:10] + "-" + raw[10:15] + "-" + raw[15:20], nil return raw[0:5] + "-" + raw[5:10] + "-" + raw[10:15] + "-" + raw[15:20], nil
} }
func normalizeRecoveryCode(code string) string { func normalizeRecoveryCode(code string) string {
code = strings.TrimSpace(code) code = strings.TrimSpace(code)
code = strings.ReplaceAll(code, "-", "") code = strings.ReplaceAll(code, "-", "")
@@ -737,6 +810,7 @@ func setAuthCookies(w http.ResponseWriter, accessToken, refreshToken string) {
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
} }
func clearAuthCookies(w http.ResponseWriter) { func clearAuthCookies(w http.ResponseWriter) {
for _, name := range []string{"access_token", "refresh_token"} { for _, name := range []string{"access_token", "refresh_token"} {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{

101
server/ratelimit.go Normal file
View File

@@ -0,0 +1,101 @@
package server
import (
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
type rateLimitState struct {
count int
resetAt time.Time
blockedUntil time.Time
}
type rateLimiter struct {
mu sync.Mutex
states map[string]*rateLimitState
maxRequests int
window time.Duration
blockFor time.Duration
}
func newRateLimiter(maxRequests int, window, blockFor time.Duration) *rateLimiter {
return &rateLimiter{
states: make(map[string]*rateLimitState),
maxRequests: maxRequests,
window: window,
blockFor: blockFor,
}
}
func (limiter *rateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.allow(r) {
w.Header().Set("Retry-After", strconvSeconds(limiter.blockFor))
http.Error(w, "Too many requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
func (limiter *rateLimiter) allow(r *http.Request) bool {
key := clientIP(r) + ":" + r.URL.Path
now := time.Now()
limiter.mu.Lock()
defer limiter.mu.Unlock()
state, ok := limiter.states[key]
if ok && now.Before(state.blockedUntil) {
return false
}
if !ok || now.After(state.resetAt) {
limiter.states[key] = &rateLimitState{
count: 1,
resetAt: now.Add(limiter.window),
}
return true
}
state.count++
if state.count > limiter.maxRequests {
state.blockedUntil = now.Add(limiter.blockFor)
state.resetAt = now.Add(limiter.window)
state.count = 0
log.Printf("Rate limit triggered for %s on %s", clientIP(r), r.URL.Path)
return false
}
return true
}
func clientIP(r *http.Request) string {
if forwardedFor := r.Header.Get("X-Forwarded-For"); forwardedFor != "" {
parts := strings.Split(forwardedFor, ",")
if ip := strings.TrimSpace(parts[0]); ip != "" {
return ip
}
}
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
return realIP
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
func strconvSeconds(duration time.Duration) string {
seconds := int(duration.Seconds())
if seconds < 1 {
seconds = 1
}
return strconv.Itoa(seconds)
}

View File

@@ -10,6 +10,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"time"
) )
type Server struct { type Server struct {
@@ -82,20 +83,23 @@ func (this *Server) Run() {
// //
// API // API
// //
mux.HandleFunc("/api/login", handlers.APILogin) loginLimiter := newRateLimiter(10, time.Minute, 5*time.Minute)
mux.HandleFunc("/api/login/2fa", handlers.APILoginTwoFactor) accountLimiter := newRateLimiter(20, time.Minute, 2*time.Minute)
mux.HandleFunc("/api/refresh", handlers.RefreshToken)
mux.Handle("/api/login", loginLimiter.Middleware(http.HandlerFunc(handlers.APILogin)))
mux.Handle("/api/login/2fa", loginLimiter.Middleware(http.HandlerFunc(handlers.APILoginTwoFactor)))
mux.Handle("/api/refresh", loginLimiter.Middleware(http.HandlerFunc(handlers.RefreshToken)))
mux.Handle("/api/logout", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Logout))) mux.Handle("/api/logout", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Logout)))
mux.Handle("/api/profile", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo))) mux.Handle("/api/profile", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo)))
mux.Handle("/api/2fa/setup", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorSetup))) mux.Handle("/api/2fa/setup", accountLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorSetup))))
mux.Handle("/api/2fa/enable", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorEnable))) mux.Handle("/api/2fa/enable", loginLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorEnable))))
mux.Handle("/api/2fa/disable", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorDisable))) mux.Handle("/api/2fa/disable", loginLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorDisable))))
mux.Handle("/api/2fa/recovery-codes/regenerate", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorRegenerateRecoveryCodes))) mux.Handle("/api/2fa/recovery-codes/regenerate", loginLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorRegenerateRecoveryCodes))))
mux.Handle("/api/userinfo", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo))) mux.Handle("/api/userinfo", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo)))
mux.Handle("/api/account/username", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdateUsername))) mux.Handle("/api/account/username", accountLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdateUsername))))
mux.Handle("/api/account/password", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdatePassword))) mux.Handle("/api/account/password", loginLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdatePassword))))
if this.AllowRegistration { if this.AllowRegistration {
mux.HandleFunc("/api/register", handlers.APIRegister) mux.Handle("/api/register", loginLimiter.Middleware(http.HandlerFunc(handlers.APIRegister)))
} }
mux.Handle("/api/item", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Item))) mux.Handle("/api/item", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Item)))

View File

@@ -185,7 +185,7 @@ func SetUserTwoFactorSecret(userID, secret string) error {
return err return err
} }
func EnableUserTwoFactorWithRecoveryCodes(userID string, recoveryCodeHashes []string) error { func EnableUserTwoFactorWithSecretAndRecoveryCodes(userID, twoFactorSecret string, recoveryCodeHashes []string) error {
tx, err := DB.Begin() tx, err := DB.Begin()
if err != nil { if err != nil {
return err return err
@@ -206,7 +206,7 @@ func EnableUserTwoFactorWithRecoveryCodes(userID string, recoveryCodeHashes []st
} }
} }
if _, err := tx.Exec("UPDATE users SET two_factor_enabled = 1 WHERE id = ?", userID); err != nil { if _, err := tx.Exec("UPDATE users SET two_factor_enabled = 1, two_factor_secret = ? WHERE id = ?", twoFactorSecret, userID); err != nil {
return err return err
} }