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 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
|
||||||
|
|
||||||
|
|||||||
52
auth/jwt.go
52
auth/jwt.go
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
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 ----
|
// ---- 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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
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"
|
"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)))
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user