feature/5-mfa-support #8
21
README.md
21
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
|
||||
|
||||
|
||||
52
auth/jwt.go
52
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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
83
docs/SECURITY.md
Normal file
83
docs/SECURITY.md
Normal 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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -95,7 +95,8 @@
|
||||
</div>
|
||||
|
||||
<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;">
|
||||
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Recovery codes</h3>
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
const recoveryCodeWarningThreshold = 3
|
||||
|
||||
func APIRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var user models.User
|
||||
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)
|
||||
log.Println("POST [api/register] " + r.RemoteAddr + ": Successfully created user")
|
||||
}
|
||||
|
||||
func APILogin(w http.ResponseWriter, r *http.Request) {
|
||||
var creds struct {
|
||||
Username string `json:"username"`
|
||||
@@ -91,7 +94,7 @@ func APILogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
|
||||
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)
|
||||
log.Println("POST [api/login] " + r.RemoteAddr + ": Successfully logged in")
|
||||
}
|
||||
|
||||
func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
TwoFactorToken string `json:"two_factor_token"`
|
||||
@@ -121,7 +125,7 @@ func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": " + err.Error())
|
||||
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")
|
||||
}
|
||||
|
||||
func AccountUpdatePassword(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -274,6 +279,7 @@ func AccountUpdatePassword(w http.ResponseWriter, r *http.Request) {
|
||||
issueLoginSession(w, r, user)
|
||||
log.Println("POST [api/account/password] " + r.RemoteAddr + ": Updated password")
|
||||
}
|
||||
|
||||
func TwoFactorSetup(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -304,9 +310,17 @@ func TwoFactorSetup(w http.ResponseWriter, r *http.Request) {
|
||||
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())
|
||||
http.Error(w, "Could not save 2FA secret", http.StatusInternalServerError)
|
||||
http.Error(w, "Could not create 2FA setup challenge", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -326,11 +340,13 @@ func TwoFactorSetup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"secret": key.Secret(),
|
||||
"setup_token": setupToken,
|
||||
"otpauth_url": key.URL(),
|
||||
"qr_code": "data:image/png;base64," + base64.StdEncoding.EncodeToString(qr.Bytes()),
|
||||
})
|
||||
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": Created 2FA setup challenge")
|
||||
}
|
||||
|
||||
func TwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -339,6 +355,7 @@ func TwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
SetupToken string `json:"setup_token"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
|
||||
@@ -354,12 +371,31 @@ func TwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -371,18 +407,30 @@ func TwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
||||
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())
|
||||
http.Error(w, "Could not enable 2FA", http.StatusInternalServerError)
|
||||
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,
|
||||
"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) {
|
||||
if r.Method != http.MethodPost {
|
||||
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})
|
||||
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": Disabled 2FA")
|
||||
}
|
||||
|
||||
func TwoFactorRegenerateRecoveryCodes(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -502,6 +551,7 @@ func Logout(w http.ResponseWriter, r *http.Request) {
|
||||
clearAuthCookies(w)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func TestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
}
|
||||
|
||||
func RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
@@ -560,6 +611,7 @@ func RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
issueLoginSession(w, r, user)
|
||||
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Successfully refreshed token")
|
||||
}
|
||||
|
||||
func UserInfo(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
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
|
||||
}
|
||||
}
|
||||
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")
|
||||
err = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
@@ -620,7 +679,10 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
|
||||
"username": user.Username,
|
||||
"avatar_url": "",
|
||||
"two_factor_enabled": user.TwoFactorEnabled,
|
||||
"two_factor_status": twoFactorStatus,
|
||||
"recovery_codes_remaining": recoveryCodesRemaining,
|
||||
"recovery_codes_warning": recoveryCodesWarning,
|
||||
"recovery_codes_warning_at": recoveryCodeWarningThreshold,
|
||||
})
|
||||
if err != nil {
|
||||
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 + ")")
|
||||
}
|
||||
|
||||
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"))
|
||||
if len(secret) == 0 {
|
||||
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)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
response := map[string]interface{}{
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshTokenPlain,
|
||||
"user": map[string]interface{}{
|
||||
@@ -675,7 +742,11 @@ func issueLoginSession(w http.ResponseWriter, r *http.Request, user models.User)
|
||||
"role": user.Role,
|
||||
"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) {
|
||||
@@ -701,6 +772,7 @@ func generateRecoveryCodes(count int) ([]string, []string, error) {
|
||||
|
||||
return codes, hashes, nil
|
||||
}
|
||||
|
||||
func generateRecoveryCode() (string, error) {
|
||||
bytes := make([]byte, 10)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
@@ -710,6 +782,7 @@ func generateRecoveryCode() (string, error) {
|
||||
raw := hex.EncodeToString(bytes)
|
||||
return raw[0:5] + "-" + raw[5:10] + "-" + raw[10:15] + "-" + raw[15:20], nil
|
||||
}
|
||||
|
||||
func normalizeRecoveryCode(code string) string {
|
||||
code = strings.TrimSpace(code)
|
||||
code = strings.ReplaceAll(code, "-", "")
|
||||
@@ -737,6 +810,7 @@ func setAuthCookies(w http.ResponseWriter, accessToken, refreshToken string) {
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func clearAuthCookies(w http.ResponseWriter) {
|
||||
for _, name := range []string{"access_token", "refresh_token"} {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
|
||||
101
server/ratelimit.go
Normal file
101
server/ratelimit.go
Normal 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)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
@@ -82,20 +83,23 @@ func (this *Server) Run() {
|
||||
//
|
||||
// API
|
||||
//
|
||||
mux.HandleFunc("/api/login", handlers.APILogin)
|
||||
mux.HandleFunc("/api/login/2fa", handlers.APILoginTwoFactor)
|
||||
mux.HandleFunc("/api/refresh", handlers.RefreshToken)
|
||||
loginLimiter := newRateLimiter(10, time.Minute, 5*time.Minute)
|
||||
accountLimiter := newRateLimiter(20, time.Minute, 2*time.Minute)
|
||||
|
||||
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/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/enable", 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/recovery-codes/regenerate", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorRegenerateRecoveryCodes)))
|
||||
mux.Handle("/api/2fa/setup", accountLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorSetup))))
|
||||
mux.Handle("/api/2fa/enable", loginLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorEnable))))
|
||||
mux.Handle("/api/2fa/disable", loginLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorDisable))))
|
||||
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/account/username", 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/username", accountLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdateUsername))))
|
||||
mux.Handle("/api/account/password", loginLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdatePassword))))
|
||||
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)))
|
||||
|
||||
@@ -185,7 +185,7 @@ func SetUserTwoFactorSecret(userID, secret string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func EnableUserTwoFactorWithRecoveryCodes(userID string, recoveryCodeHashes []string) error {
|
||||
func EnableUserTwoFactorWithSecretAndRecoveryCodes(userID, twoFactorSecret string, recoveryCodeHashes []string) error {
|
||||
tx, err := DB.Begin()
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user