- Add complete docs/systems/ reference (18 system docs) - Add federation relay status doc and prior spec/plan docs - Remove superseded docs/federation-dm-s2s.md (replaced by docs/systems/federation.md) - CLAUDE.md updates - Minor fixes in social.ts, types.ts, AddDmMemberModal, NewDmModal, UserSettings
514 lines
20 KiB
Markdown
514 lines
20 KiB
Markdown
# Authentication & Session System
|
|
|
|
Source files:
|
|
- `packages/server/src/routes/auth.ts` -- Registration, login, username availability endpoints
|
|
- `packages/server/src/routes/users.ts` -- Password change, account deletion endpoints (lines 58-156)
|
|
- `packages/server/src/routes/admin.ts` -- Admin password reset endpoint (lines 232-265)
|
|
- `packages/server/src/utils/auth.ts` -- Password hashing, JWT sign/verify, `authenticate` preHandler, `requireAdmin`
|
|
- `packages/server/src/utils/userDeletion.ts` -- `tombstoneUser()` transactional account erasure
|
|
- `packages/server/src/utils/sanitize.ts` -- `sanitizeUser()` strips internal fields, anonymizes deleted users
|
|
- `packages/server/src/ws/handler.ts` -- WebSocket auth handshake (lines 1282-1374)
|
|
- `packages/web/src/stores/authStore.ts` -- Client session state, login/register/logout/password/delete actions
|
|
- `packages/web/src/hooks/useAuth.ts` -- Route guard hook (redirect to `/login` when no token)
|
|
- `packages/web/src/App.tsx` -- `ProtectedRoute` and `AuthRedirect` route wrappers
|
|
- `packages/web/src/utils/identity.ts` -- Federation-aware identity helpers (`parseFederatedUsername`, `isSelf`, `canonicalUserMatch`)
|
|
- `packages/web/src/utils/federationOps.ts` -- Cross-instance password sync and account deletion propagation
|
|
- `packages/server/src/config.ts` -- `jwtSecret`, `jwtExpiresIn`, `registrationOpen` config
|
|
|
|
DB tables: `users`, `instanceSettings`. See `database.md` for full schemas.
|
|
|
|
---
|
|
|
|
## 1. Password Hashing
|
|
|
|
**Library:** `bcryptjs`
|
|
**Salt rounds:** 12 (constant `SALT_ROUNDS` in `auth.ts`)
|
|
|
|
```
|
|
hashPassword(password: string): Promise<string> -- bcrypt.hash(password, 12)
|
|
verifyPassword(password: string, hash: string): Promise<boolean> -- bcrypt.compare
|
|
```
|
|
|
|
**Federation stub marker:** Replicated user stubs have `passwordHash = '!federation-replicated'`. Since bcrypt never produces this value, login is impossible for stubs. See `federation.md` for identity resolution.
|
|
|
|
---
|
|
|
|
## 2. JWT Management
|
|
|
|
### Signing
|
|
|
|
```typescript
|
|
interface JwtPayload {
|
|
userId: string;
|
|
username: string;
|
|
iat?: number; // Auto-set by jsonwebtoken library (seconds since epoch)
|
|
}
|
|
```
|
|
|
|
- **Algorithm:** HS256 (enforced on verify via `{ algorithms: ['HS256'] }`)
|
|
- **Secret:** `config.jwtSecret` (env `JWT_SECRET`, minimum 32 characters -- startup crash if shorter)
|
|
- **Expiry:** `config.jwtExpiresIn` (env `JWT_EXPIRES_IN`, default `'30d'`)
|
|
- **Library:** `jsonwebtoken`
|
|
|
|
`signJwt(payload)` creates a token with `{ expiresIn }` option. The `iat` field is auto-injected by the library.
|
|
|
|
### Validation (`authenticate` preHandler)
|
|
|
|
Applied as `preHandler` on all authenticated routes. Flow:
|
|
|
|
1. Extract `Bearer <token>` from `Authorization` header
|
|
2. `verifyJwt(token)` -- checks signature (HS256) and expiry
|
|
3. DB lookup: fetch `id`, `isDeleted`, `passwordChangedAt` from `users` table
|
|
4. Reject if user not found or `isDeleted === 1`
|
|
5. **Token revocation check:** if `passwordChangedAt` is set and `payload.iat` exists, reject if `iat < Math.floor(passwordChangedAt / 1000)` (JWT `iat` is seconds, `passwordChangedAt` is milliseconds)
|
|
6. Attach `userId` and `username` to `request` object
|
|
|
|
### Token Revocation
|
|
|
|
There is **no token blocklist**. The only revocation mechanism is the `passwordChangedAt` timestamp:
|
|
|
|
- When a user changes their password (or an admin resets it), `passwordChangedAt` is set to `Date.now()`
|
|
- All tokens issued before that timestamp (`iat < passwordChangedAt/1000`) are rejected
|
|
- A fresh token is issued after password change
|
|
|
|
**Exception:** Federation password self-healing (see section 4) does NOT set `passwordChangedAt` -- it is a state correction, not a password change, so existing valid JWTs remain valid.
|
|
|
|
### WebSocket Auth
|
|
|
|
`ws/handler.ts:registerWebSocket()` -- WS connection at `/ws`:
|
|
|
|
1. Client connects, 10-second auth timeout starts
|
|
2. First message must be `{ type: 'auth', token: '<jwt>' }`
|
|
3. `verifyJwt(token)` validates signature and expiry
|
|
4. DB check: reject if user deleted or token revoked (same `passwordChangedAt` logic as REST)
|
|
5. On success: clears timeout, sets status to `'online'`, registers connection, sends `ready` payload, broadcasts presence
|
|
6. On failure: sends error message and closes socket
|
|
|
|
---
|
|
|
|
## 3. Registration Flow
|
|
|
|
**Endpoint:** `POST /api/auth/register`
|
|
**Rate limit:** 10 requests / 2 minutes per IP
|
|
**Auth:** None
|
|
|
|
### Input Validation
|
|
|
|
| Field | Rules |
|
|
|-------|-------|
|
|
| `username` | Required string. Trimmed, lowercased. |
|
|
| `password` | Required string. Minimum 8 characters. |
|
|
| `displayName` | Optional. Trimmed or null. |
|
|
| `avatarColor` | Optional. Must be in `AVATAR_COLORS` array, else random. |
|
|
| `homeInstance` | Optional (federation only). Max 253 chars, alphanumeric + `.` `-` `_`. |
|
|
| `homeUserId` | Optional (federation only). Stored if `homeInstance` is present. |
|
|
|
|
### Username Validation (Two Paths)
|
|
|
|
**Local registration** (`homeInstance` absent):
|
|
- Length: 3-32 characters
|
|
- Pattern: `/^[a-z0-9_]+$/` (lowercase alphanumeric + underscore)
|
|
- No `@` allowed
|
|
|
|
**Federated/replicated registration** (`homeInstance` present):
|
|
- MUST use `username@domain` format (plain usernames reserved for native users)
|
|
- Local part: 3-32 chars, `/^[a-z0-9_]+$/`
|
|
- Domain part: 1-253 chars, `/^[a-zA-Z0-9._-]+$/`
|
|
- Total: max 100 characters
|
|
|
|
### Registration Gate
|
|
|
|
Registration open/closed is determined by:
|
|
1. `instanceSettings.registrationOpen` (DB, id=1) -- if not null, this takes priority
|
|
2. `config.registrationOpen` (env `REGISTRATION_OPEN`, default `true`) -- fallback
|
|
|
|
Both are checked. DB value overrides env when explicitly set by admin.
|
|
|
|
### First-User Admin Promotion
|
|
|
|
```
|
|
const userCount = db.select().from(schema.users).all().length;
|
|
const isFirstUser = userCount === 0 && !homeInstance;
|
|
```
|
|
|
|
The very first user registered on the instance (and only if local, not replicated) gets `isAdmin = 1`.
|
|
|
|
### Avatar Color Assignment
|
|
|
|
```typescript
|
|
const AVATAR_COLORS = ['mint', 'sky', 'lavender', 'coral', 'rose', 'teal', 'amber'] as const;
|
|
```
|
|
|
|
If `requestedAvatarColor` is provided and is in `AVATAR_COLORS`, use it. Otherwise, pick randomly from the array.
|
|
|
|
### Registration Steps
|
|
|
|
1. Validate inputs (username format, password length)
|
|
2. Check registration is open
|
|
3. Check username uniqueness (exact match on lowercased username)
|
|
4. Hash password (bcrypt, 12 rounds)
|
|
5. Generate Snowflake ID
|
|
6. Insert user row with `status: 'online'`, admin flag if first user
|
|
7. Sign JWT with `{ userId, username }`
|
|
8. Return `{ token, user }` (user sanitized via `sanitizeUser(user, true)`)
|
|
|
|
### Username Availability Check
|
|
|
|
**Endpoint:** `GET /api/auth/check-username?username=<name>`
|
|
**Rate limit:** 30 requests / 1 minute per IP
|
|
**Auth:** None
|
|
|
|
Validates format (same rules as local registration: 3-32 chars, `/^[a-z0-9_]+$/`), checks registration gate, then queries `users` table for existence. Returns `{ available: boolean, reason?: string }`.
|
|
|
|
---
|
|
|
|
## 4. Login Flow
|
|
|
|
**Endpoint:** `POST /api/auth/login`
|
|
**Rate limit:** 15 requests / 2 minutes per IP
|
|
**Auth:** None
|
|
|
|
### Steps
|
|
|
|
1. Validate `username` and `password` are present strings
|
|
2. Look up user by `username` (trimmed, lowercased)
|
|
3. Reject if not found (generic "Invalid username or password")
|
|
4. Reject if `isDeleted === 1` ("This account has been deleted")
|
|
5. Verify password via bcrypt
|
|
6. **If password invalid AND user is federated:** attempt self-healing (see below)
|
|
7. **If password invalid AND user is local:** reject
|
|
8. Set user status to `'online'`
|
|
9. Sign JWT, return `{ token, user }`
|
|
|
|
### Federation Password Self-Healing
|
|
|
|
When local bcrypt verification fails for a user with `homeInstance` set:
|
|
|
|
1. Extract base username (strip `@domain` if present)
|
|
2. POST to `https://{homeInstance}/api/auth/login` with base username and provided password
|
|
3. Timeout: 10 seconds (`AbortController`)
|
|
4. **If home instance accepts (200):**
|
|
- Re-hash password locally: `hashPassword(password)`
|
|
- Update local `passwordHash` -- but **do NOT set `passwordChangedAt`** (this is a state correction, not a password change; setting it would invalidate existing valid JWTs on this instance)
|
|
- Log the self-healing event
|
|
- Continue with login success
|
|
5. **If home instance rejects:** return "Invalid username or password"
|
|
6. **If home instance unreachable (network error/timeout):** return "Invalid username or password" (fall back to local-only rejection)
|
|
|
|
This flow ensures that when a federated user changes their password on their home instance, they can still log in on remote instances even if the remote's hash is stale.
|
|
|
|
---
|
|
|
|
## 5. Password Change
|
|
|
|
### User Password Change
|
|
|
|
**Endpoint:** `POST /api/users/@me/change-password`
|
|
**Rate limit:** 5 requests / 15 minutes
|
|
**Auth:** JWT (`authenticate` preHandler)
|
|
|
|
**Request body:** `{ currentPassword?: string, newPassword: string }`
|
|
|
|
| User type | `currentPassword` | Behavior |
|
|
|-----------|-------------------|----------|
|
|
| Local (`homeInstance` is null) | Required | Verified via bcrypt against stored hash |
|
|
| Federated (`homeInstance` set) | Not required | JWT auth is sufficient (home instance already verified the change) |
|
|
|
|
**Steps:**
|
|
1. Validate `newPassword` is string, min 8 chars
|
|
2. Load user from DB
|
|
3. If local: require and verify `currentPassword`
|
|
4. Hash new password
|
|
5. Update `passwordHash` AND `passwordChangedAt = Date.now()` -- this invalidates all prior tokens
|
|
6. Sign fresh JWT, return `{ token }`
|
|
|
|
### Admin Password Reset
|
|
|
|
**Endpoint:** `POST /api/admin/users/:id/reset-password`
|
|
**Auth:** JWT + `requireAdmin` (instance admin only)
|
|
|
|
**Guards:**
|
|
- Target user must exist
|
|
- Target must not be deleted
|
|
- Target must not be federated (`homeInstance` must be null -- "Federated users authenticate via their home instance")
|
|
|
|
**Steps:**
|
|
1. Generate temporary password: `crypto.randomBytes(12).toString('base64url')` (16 chars)
|
|
2. Hash it and update `passwordHash` + `passwordChangedAt = Date.now()`
|
|
3. `connectionManager.forceDisconnectUser(targetId)` -- closes all WS connections, forcing re-auth
|
|
4. Return `{ temporaryPassword }` -- admin must relay this to the user out-of-band
|
|
|
|
### Cross-Instance Password Propagation (Client-Side)
|
|
|
|
When a user changes their password on their home instance, `authStore.changePassword()`:
|
|
|
|
1. Changes password on home instance via API
|
|
2. Updates local token in localStorage and Zustand state
|
|
3. Calls `changePasswordOnRemotes(newPassword)` from `federationOps.ts`
|
|
|
|
`changePasswordOnRemotes()` flow:
|
|
|
|
1. Gets all connected remote instances from `instanceStore`
|
|
2. Cancels any existing retry timers for those origins
|
|
3. For each connected instance, calls `inst.api.users.changePassword({ newPassword })` with retry:
|
|
- **Initial retry:** `retryWithBackoff()` -- 3 attempts, exponential backoff starting at 2000ms (2s, 4s, 8s)
|
|
- On success: updates cached token for that instance, clears pending sync flag
|
|
4. If initial retries fail, starts `scheduleBackgroundRetry()`:
|
|
- Schedule: 10 attempts at 30s intervals (5 min), then 12 attempts at 5min intervals (60 min)
|
|
- Each attempt looks up current instance from store (avoids stale references)
|
|
- Stops if instance disconnected or removed
|
|
- On exhaustion: sets `pendingPasswordSync` flag on the instance (UI indicator)
|
|
|
|
**Timer management:**
|
|
- `activeRetryTimers` map tracks per-origin retry timers
|
|
- `clearPasswordSyncTimers()` cancels all active retries (called on logout)
|
|
- New password change cancels existing retry loops for affected origins
|
|
|
|
---
|
|
|
|
## 6. Account Deletion
|
|
|
|
### Self-Deletion
|
|
|
|
**Endpoint:** `DELETE /api/users/@me`
|
|
**Rate limit:** 3 requests / 15 minutes
|
|
**Auth:** JWT (`authenticate` preHandler)
|
|
|
|
**Request body:** `{ password: string, username: string }`
|
|
|
|
**Pre-checks:**
|
|
1. `username` must match stored username (confirmation safeguard)
|
|
2. Local users must provide and verify `password`; federated users rely on JWT auth
|
|
3. Must not own any spaces (returns 400 with `ownedSpaces` list)
|
|
|
|
**Client-side flow** (`authStore.deleteAccount()`):
|
|
1. Call `deleteAccountOnRemotes()` first (best-effort, see below)
|
|
2. Call `api.users.deleteAccount()` on home instance
|
|
3. Clear localStorage token, reset all user-scoped stores
|
|
|
|
### Federation Account Deletion
|
|
|
|
`deleteAccountOnRemotes()` runs before home deletion:
|
|
- For each connected remote instance, calls `inst.api.users.deleteAccount({ password: '', username: inst.username })`
|
|
- Password is empty string (not needed for federated users on remotes)
|
|
- Best-effort: failures are caught and returned as `FederationOpResult[]` but do not block home deletion
|
|
|
|
### Tombstoning (`tombstoneUser()`)
|
|
|
|
All cleanup runs in a single SQLite transaction:
|
|
|
|
**Relationship cleanup (deletes):**
|
|
- `spaceMembers` -- removes from all spaces
|
|
- `memberRoles` -- removes all role assignments
|
|
- `friends` -- removes all friendships (both directions)
|
|
- `friendRequests` -- removes all friend requests (both directions)
|
|
- `dmMembers` -- removes from all DM channels
|
|
- `readStates` -- removes all read state records
|
|
- `reactions` -- removes all message reactions
|
|
- `dmReactions` -- removes all DM reactions
|
|
- `spaceFolders` -- removes all space folders
|
|
- `bans` -- removes bans where user is target (try/catch for table existence)
|
|
- `joinRequests` -- removes join requests (try/catch)
|
|
- `voiceRestrictions` -- removes voice restrictions (try/catch)
|
|
|
|
**Moderator reference cleanup (nullifies):**
|
|
- `bans.bannedBy` -- nullified where points to deleted user
|
|
- `voiceRestrictions.moderatorId` -- nullified
|
|
- `joinRequests.decidedBy` -- nullified
|
|
|
|
**Channel override cleanup:**
|
|
- Deletes `channelOverrides` where `targetType = 'member'` and `targetId = uid`
|
|
|
|
**Group DM ownership transfer:**
|
|
- For each group DM owned by the user, transfers to next remaining member
|
|
- If no remaining members, DM becomes orphaned
|
|
|
|
**Orphaned DM cleanup:**
|
|
- Finds DM channels with zero members after removal
|
|
- For each: collects attachment filenames, deletes attachments, reactions, messages, and the channel
|
|
|
|
**User row anonymization:**
|
|
```
|
|
username: '!deleted:{uid}' -- frees original username for reuse
|
|
passwordHash: crypto.randomBytes(32).toString('hex') -- random, unverifiable
|
|
displayName: null
|
|
avatar: null
|
|
banner: null
|
|
bio: null
|
|
customStatus: null
|
|
accentColor: null
|
|
avatarColor: null
|
|
replicatedInstances: '[]'
|
|
isDeleted: 1
|
|
status: 'offline'
|
|
isAdmin: 0
|
|
```
|
|
|
|
**Return value:** Array of filenames to delete from disk (avatar, banner, orphaned DM attachments). Caller handles disk cleanup.
|
|
|
|
**Post-transaction (in route handler):**
|
|
- Delete files from disk via `deleteUploadFile()`
|
|
- `connectionManager.forceDisconnectUser()` -- closes all WS connections, leaves voice rooms, broadcasts presence
|
|
|
|
### `sanitizeUser()` for Deleted Users
|
|
|
|
When `isDeleted === 1`, returns an anonymized profile:
|
|
- `username: 'Deleted User'`
|
|
- All profile fields null/empty/false
|
|
- `status: 'offline'`
|
|
- Only `id` and `createdAt` preserved
|
|
|
|
---
|
|
|
|
## 7. Client-Side Session Lifecycle
|
|
|
|
### State (`authStore`)
|
|
|
|
```typescript
|
|
interface AuthState {
|
|
token: string | null; // Persisted in localStorage as 'backspace_token'
|
|
user: User | null; // Current user object
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
}
|
|
```
|
|
|
|
**Initialization:** `token` is read from `localStorage.getItem('backspace_token')` on store creation.
|
|
|
|
### `initSession(token, user)`
|
|
|
|
Called after successful login or registration:
|
|
1. `resetUserStores()` -- clears all user-scoped stores (chat, space, social, voice, instance, activity) and `clearSelfIds()` from identity registry
|
|
2. Saves token to localStorage
|
|
3. Sets token + user in Zustand state
|
|
4. Fires `useInstanceStore.autoConnectAll()` (fire-and-forget) for federation
|
|
|
|
### `loadUser()`
|
|
|
|
Called by `useAuth()` hook when token exists but user object is null:
|
|
1. Calls `api.users.me()` to fetch current user
|
|
2. On success: sets user, triggers `autoConnectAll()`
|
|
3. On failure: removes token from localStorage, clears state (forces redirect to login)
|
|
|
|
### `logout()`
|
|
|
|
1. Removes token from localStorage
|
|
2. Calls `resetUserStores()` (clears all stores + self IDs)
|
|
3. Sets token and user to null
|
|
|
|
### Route Guards
|
|
|
|
**`ProtectedRoute`** (in `App.tsx`):
|
|
- Reads `token` from authStore
|
|
- If no token: `<Navigate to="/login" replace />`
|
|
- Used for `/channels/:spaceId/:channelId?` and `/explore`
|
|
|
|
**`AuthRedirect`** (in `App.tsx`):
|
|
- Reads `token` from authStore
|
|
- If token present: redirects to `?redirect` param or `/channels/@me`
|
|
- Used for `/login` and `/register` routes
|
|
- Prevents authenticated users from seeing auth pages
|
|
|
|
**`useAuth()` hook:**
|
|
- Watches `token`, `user`, `isLoading`
|
|
- If no token: navigates to `/login`
|
|
- If token but no user and not loading: calls `loadUser()`
|
|
- Returns `{ user, isLoading, isAuthenticated }`
|
|
|
|
### Login Page
|
|
|
|
- Fields: username, password
|
|
- Redirect support: reads `?redirect` param, navigates there on success (validated: must start with `/`, not `//`)
|
|
- Rate limit handling: catches `RateLimitError`, shows countdown timer
|
|
- Links to register page (preserves redirect param)
|
|
|
|
### Registration Page (Two-Step)
|
|
|
|
**Step 1 -- Credentials:**
|
|
- Fields: username, password, confirm password
|
|
- Client-side validation: 3-32 chars, `/^[a-z0-9_]+$/`, passwords match, min 6 chars
|
|
- Debounced username availability check (500ms delay, abort on new input)
|
|
- Continue button disabled if username taken or invalid
|
|
|
|
**Step 2 -- Personalization:**
|
|
- Fields: display name (optional), avatar color picker, avatar upload (with crop modal)
|
|
- "Get Started" button: registers with personalization
|
|
- "Skip for now" button: registers without personalization
|
|
- Registration flow:
|
|
1. Call `api.auth.register()` -- saves token to localStorage but NOT to Zustand (prevents premature `AuthRedirect`)
|
|
2. If avatar file selected: upload file, then `api.users.update({ avatar })` (failure is non-fatal)
|
|
3. `initSession(token, finalUser)` -- activates Zustand state, triggers redirect
|
|
4. Navigate to redirect param or `/channels/@me`
|
|
|
|
---
|
|
|
|
## 8. Federation-Aware Identity Utilities
|
|
|
|
### `parseFederatedUsername(username)`
|
|
|
|
Splits a potentially federated username:
|
|
```
|
|
"youruser@nova.ddns.net" -> { baseName: "youruser", domain: "nova.ddns.net" }
|
|
"youruser" -> { baseName: "youruser", domain: null }
|
|
```
|
|
|
|
### Self-ID Registry
|
|
|
|
Module-level `Set<string>` tracking all Snowflake IDs belonging to the current user across connected instances:
|
|
|
|
```
|
|
registerSelfId(id: string) -- adds ID (called from WS ready events)
|
|
clearSelfIds() -- clears all (called on logout/session reset)
|
|
```
|
|
|
|
### `isSelf(user, homeUser)`
|
|
|
|
Determines if a user object represents the current user. Cascading checks:
|
|
1. Same `id` (same instance, trivial)
|
|
2. `_knownSelfIds.has(user.id)` (cross-instance via registry)
|
|
3. `user.homeInstance === window.location.host` AND base usernames match
|
|
|
|
### `canonicalUserMatch(a, b)`
|
|
|
|
Federation-safe comparison of two user-like objects. Cascading strategies:
|
|
1. Same `id` -- trivial match
|
|
2. `homeUserId` cross-matching (both have it, or one matches the other's `id`)
|
|
3. Username + home instance fallback: parse base names, derive home from `homeInstance` or domain part of username, compare
|
|
|
|
### `resolveDisplayIdentity(user, homeUser)`
|
|
|
|
If `user` is a replicated alias of `homeUser` (via `isSelf`), returns `homeUser` for display purposes. Otherwise returns `user` unchanged.
|
|
|
|
---
|
|
|
|
## 9. Rate Limits Summary
|
|
|
|
| Endpoint | Max | Window |
|
|
|----------|-----|--------|
|
|
| `POST /api/auth/register` | 10 | 2 min |
|
|
| `GET /api/auth/check-username` | 30 | 1 min |
|
|
| `POST /api/auth/login` | 15 | 2 min |
|
|
| `POST /api/users/@me/change-password` | 5 | 15 min |
|
|
| `DELETE /api/users/@me` | 3 | 15 min |
|
|
|
|
All keyed by `request.ip`.
|
|
|
|
---
|
|
|
|
## 10. Configuration Reference
|
|
|
|
| Config key | Env var | Default | Notes |
|
|
|------------|---------|---------|-------|
|
|
| `jwtSecret` | `JWT_SECRET` | (required) | Min 32 chars, startup crash if shorter |
|
|
| `jwtExpiresIn` | `JWT_EXPIRES_IN` | `'30d'` | Passed to `jsonwebtoken` `expiresIn` option |
|
|
| `registrationOpen` | `REGISTRATION_OPEN` | `true` | Overridden by `instanceSettings.registrationOpen` in DB |
|
|
|
|
---
|
|
|
|
## 11. `requireAdmin` Guard
|
|
|
|
`auth.ts:requireAdmin()` -- used as preHandler alongside `authenticate`:
|
|
1. Loads full user from DB by `request.userId`
|
|
2. Rejects with 403 if user not found or `isAdmin !== 1`
|
|
3. Used by admin routes (user management, password reset, federation peer management)
|