Merge pull request 'Player accounts: self-service player role, registration, and portal' (#43) from feature/player-accounts into main
Reviewed-on: UOM/website#43 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -31,5 +31,8 @@ Thumbs.db
|
|||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# local planning docs (not part of the tracked codebase)
|
||||||
|
.plans/
|
||||||
|
|
||||||
# scratch / temp scripts
|
# scratch / temp scripts
|
||||||
_*.ps1
|
_*.ps1
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AuthProvider } from './contexts/AuthContext.jsx'
|
|||||||
import { SiteProvider } from './contexts/SiteContext.jsx'
|
import { SiteProvider } from './contexts/SiteContext.jsx'
|
||||||
import MaintenanceGate from './components/MaintenanceGate.jsx'
|
import MaintenanceGate from './components/MaintenanceGate.jsx'
|
||||||
import RequireAuth from './components/RequireAuth.jsx'
|
import RequireAuth from './components/RequireAuth.jsx'
|
||||||
|
import RequirePlayer from './components/RequirePlayer.jsx'
|
||||||
import RoleGate from './components/RoleGate.jsx'
|
import RoleGate from './components/RoleGate.jsx'
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
@@ -35,6 +36,11 @@ import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
|||||||
import Moderation from './routes/admin/views/Moderation.jsx'
|
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||||
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
||||||
|
|
||||||
|
// Player portal
|
||||||
|
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
||||||
|
import PlayerRegister from './routes/player/PlayerRegister.jsx'
|
||||||
|
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
@@ -96,6 +102,18 @@ export default function App() {
|
|||||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{/* Player portal */}
|
||||||
|
<Route path="/account/login" element={<PlayerLogin />} />
|
||||||
|
<Route path="/account/register" element={<PlayerRegister />} />
|
||||||
|
<Route
|
||||||
|
path="/account"
|
||||||
|
element={
|
||||||
|
<RequirePlayer>
|
||||||
|
<PlayerAccount />
|
||||||
|
</RequirePlayer>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</SiteProvider>
|
</SiteProvider>
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ export const api = {
|
|||||||
// `extra` carries the honeypot field (and any future login fields).
|
// `extra` carries the honeypot field (and any future login fields).
|
||||||
login: (username, password, extra = {}) =>
|
login: (username, password, extra = {}) =>
|
||||||
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
|
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
|
||||||
|
// Public self-registration (player accounts). `extra` carries the honeypot +
|
||||||
|
// optional email. Returns { user } and sets the session cookie on success.
|
||||||
|
register: (username, password, extra = {}) =>
|
||||||
|
req('/auth/register', { method: 'POST', body: { username, password, ...extra } }),
|
||||||
loginTotp: (challenge, code) =>
|
loginTotp: (challenge, code) =>
|
||||||
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
||||||
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
|
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
|
||||||
@@ -186,6 +190,22 @@ export const api = {
|
|||||||
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
||||||
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ----- player self-service (role: 'player') -----
|
||||||
|
// Mirrors the admin account methods but self-scoped under /player. The change
|
||||||
|
// endpoints re-issue the session cookie server-side, so the caller stays signed in.
|
||||||
|
player: {
|
||||||
|
getAccount: () => req('/player/account'),
|
||||||
|
changeUsername: (username) =>
|
||||||
|
req('/player/account/username', { method: 'PATCH', body: { username } }),
|
||||||
|
changePassword: (newPassword, currentPassword) =>
|
||||||
|
req('/player/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
|
||||||
|
totpSetup: () => req('/player/account/totp/setup', { method: 'POST' }),
|
||||||
|
totpEnable: (code) => req('/player/account/totp/enable', { method: 'POST', body: { code } }),
|
||||||
|
totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
|
||||||
|
linkedIdentities: () => req('/player/account/identities'),
|
||||||
|
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ApiError }
|
export { ApiError }
|
||||||
|
|||||||
23
client/src/components/RequirePlayer.jsx
Normal file
23
client/src/components/RequirePlayer.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
|
|
||||||
|
// Gate for the /account player portal. Redirects to the player login when there
|
||||||
|
// is no session, or when the signed-in user is not a player (staff manage their
|
||||||
|
// own account under /admin/account). Server-side requireRole('player') is the
|
||||||
|
// real enforcement; this just keeps the UI honest.
|
||||||
|
export default function RequirePlayer({ children }) {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', background: 'var(--bg-deep)' }}>
|
||||||
|
<span className="spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!user || user.role !== 'player') {
|
||||||
|
return <Navigate to="/account/login" state={{ from: location }} replace />
|
||||||
|
}
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -30,6 +30,14 @@ export function AuthProvider({ children }) {
|
|||||||
return data
|
return data
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Public self-registration (player). Creates the account, sets the session
|
||||||
|
// cookie, and returns { user }. `extra` carries the honeypot + optional email.
|
||||||
|
const register = useCallback(async (username, password, extra) => {
|
||||||
|
const data = await api.register(username, password, extra)
|
||||||
|
if (data.user) setUser(data.user)
|
||||||
|
return data
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Step 2 for TOTP users: exchange the challenge + code for a real session.
|
// Step 2 for TOTP users: exchange the challenge + code for a real session.
|
||||||
const loginTotp = useCallback(async (challenge, code) => {
|
const loginTotp = useCallback(async (challenge, code) => {
|
||||||
const data = await api.loginTotp(challenge, code)
|
const data = await api.loginTotp(challenge, code)
|
||||||
@@ -54,7 +62,7 @@ export function AuthProvider({ children }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, loginTotp, ssoLoginTotp, logout, refresh }}>
|
<AuthContext.Provider value={{ user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { api } from '../../api/client.js'
|
|||||||
// Friendly copy for the ?sso_error codes the SSO callback can redirect back with.
|
// Friendly copy for the ?sso_error codes the SSO callback can redirect back with.
|
||||||
const SSO_ERRORS = {
|
const SSO_ERRORS = {
|
||||||
not_linked: 'That account is not linked to an admin user. Sign in with your password, then link it under Account.',
|
not_linked: 'That account is not linked to an admin user. Sign in with your password, then link it under Account.',
|
||||||
|
disabled: 'This account is not active. Contact an administrator.',
|
||||||
denied: 'Sign-in was cancelled.',
|
denied: 'Sign-in was cancelled.',
|
||||||
unavailable: 'That sign-in method is not available right now.',
|
unavailable: 'That sign-in method is not available right now.',
|
||||||
bad_state: 'Your sign-in session expired. Please try again.',
|
bad_state: 'Your sign-in session expired. Please try again.',
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ const FIELDS = [
|
|||||||
{ key: 'maintenance_message', label: 'Maintenance message', long: true },
|
{ key: 'maintenance_message', label: 'Maintenance message', long: true },
|
||||||
{ key: 'status_message', label: 'Status message' },
|
{ key: 'status_message', label: 'Status message' },
|
||||||
{ key: 'contact_email', label: 'Contact email' },
|
{ key: 'contact_email', label: 'Contact email' },
|
||||||
|
{
|
||||||
|
key: 'player_registration',
|
||||||
|
label: 'Player registration',
|
||||||
|
help: 'Who can create a player account, and how. Off by default.',
|
||||||
|
options: [
|
||||||
|
{ value: 'disabled', label: 'Disabled — no self-registration' },
|
||||||
|
{ value: 'password', label: 'Password — username + password sign-up' },
|
||||||
|
{ value: 'sso', label: 'SSO — sign up with a linked provider' },
|
||||||
|
{ value: 'both', label: 'Both — password and SSO' },
|
||||||
|
],
|
||||||
|
fallback: 'disabled',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function SettingsAdmin() {
|
export default function SettingsAdmin() {
|
||||||
@@ -28,7 +40,7 @@ export default function SettingsAdmin() {
|
|||||||
.then((all) => {
|
.then((all) => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
const v = {}
|
const v = {}
|
||||||
FIELDS.forEach((f) => (v[f.key] = all[f.key] ?? ''))
|
FIELDS.forEach((f) => (v[f.key] = all[f.key] ?? f.fallback ?? ''))
|
||||||
setValues(v)
|
setValues(v)
|
||||||
setInitial(v)
|
setInitial(v)
|
||||||
})
|
})
|
||||||
@@ -68,11 +80,24 @@ export default function SettingsAdmin() {
|
|||||||
{FIELDS.map((f) => (
|
{FIELDS.map((f) => (
|
||||||
<label key={f.key} style={{ display: 'block' }}>
|
<label key={f.key} style={{ display: 'block' }}>
|
||||||
<span className="field-label">{f.label}</span>
|
<span className="field-label">{f.label}</span>
|
||||||
{f.long ? (
|
{f.options ? (
|
||||||
|
<select value={values[f.key]} onChange={set(f.key)} className="select">
|
||||||
|
{f.options.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : f.long ? (
|
||||||
<textarea value={values[f.key]} onChange={set(f.key)} className="textarea" style={{ minHeight: 90 }} />
|
<textarea value={values[f.key]} onChange={set(f.key)} className="textarea" style={{ minHeight: 90 }} />
|
||||||
) : (
|
) : (
|
||||||
<input type="text" value={values[f.key]} onChange={set(f.key)} className="input" />
|
<input type="text" value={values[f.key]} onChange={set(f.key)} className="input" />
|
||||||
)}
|
)}
|
||||||
|
{f.help && (
|
||||||
|
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
|
||||||
|
{f.help}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
<div style={{ display: 'flex', gap: 10, marginTop: 6, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 10, marginTop: 6, alignItems: 'center' }}>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export default function UserEditor({ user, onClose, onSaved }) {
|
|||||||
username: user?.username || '',
|
username: user?.username || '',
|
||||||
password: '',
|
password: '',
|
||||||
role: user?.role || 'admin',
|
role: user?.role || 'admin',
|
||||||
|
status: user?.status || 'active',
|
||||||
|
email: user?.email || '',
|
||||||
})
|
})
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -21,12 +23,19 @@ export default function UserEditor({ user, onClose, onSaved }) {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
|
const email = form.email.trim() || null
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
const payload = { username: form.username.trim(), role: form.role }
|
const payload = { username: form.username.trim(), role: form.role, status: form.status, email }
|
||||||
if (form.password) payload.password = form.password
|
if (form.password) payload.password = form.password
|
||||||
await api.admin.updateUser(user.id, payload)
|
await api.admin.updateUser(user.id, payload)
|
||||||
} else {
|
} else {
|
||||||
await api.admin.createUser({ username: form.username.trim(), password: form.password, role: form.role })
|
await api.admin.createUser({
|
||||||
|
username: form.username.trim(),
|
||||||
|
password: form.password,
|
||||||
|
role: form.role,
|
||||||
|
status: form.status,
|
||||||
|
email,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -75,17 +84,41 @@ export default function UserEditor({ user, onClose, onSaved }) {
|
|||||||
<input type="text" value={form.username} onChange={set('username')} className="input" autoComplete="off" />
|
<input type="text" value={form.username} onChange={set('username')} className="input" autoComplete="off" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span className="field-label">{isEdit ? 'New password (leave blank to keep)' : 'Password'}</span>
|
<span className="field-label">
|
||||||
|
{isEdit ? 'Reset password (leave blank to keep)' : 'Password'}
|
||||||
|
</span>
|
||||||
<input type="password" value={form.password} onChange={set('password')} className="input" autoComplete="new-password" />
|
<input type="password" value={form.password} onChange={set('password')} className="input" autoComplete="new-password" />
|
||||||
|
{isEdit && (
|
||||||
|
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
|
||||||
|
Setting a new password here is the supported reset for a player who is locked out. It logs
|
||||||
|
their other sessions out.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span className="field-label">Role</span>
|
<span className="field-label">Email (optional)</span>
|
||||||
<select value={form.role} onChange={set('role')} className="select">
|
<input type="email" value={form.email} onChange={set('email')} className="input" autoComplete="off" placeholder="player@example.com" />
|
||||||
<option value="admin">admin</option>
|
|
||||||
<option value="editor">editor</option>
|
|
||||||
<option value="moderator">moderator</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<label style={{ flex: 1 }}>
|
||||||
|
<span className="field-label">Role</span>
|
||||||
|
<select value={form.role} onChange={set('role')} className="select">
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
<option value="editor">editor</option>
|
||||||
|
<option value="moderator">moderator</option>
|
||||||
|
<option value="player">player</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style={{ flex: 1 }}>
|
||||||
|
<span className="field-label">Status</span>
|
||||||
|
<select value={form.status} onChange={set('status')} className="select">
|
||||||
|
<option value="active">active</option>
|
||||||
|
<option value="disabled">disabled</option>
|
||||||
|
<option value="banned">banned</option>
|
||||||
|
<option value="pending">pending</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import { dateTime } from '../../../lib/format.js'
|
|||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
import UserEditor from './UserEditor.jsx'
|
import UserEditor from './UserEditor.jsx'
|
||||||
|
|
||||||
const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator' }
|
const ROLE_BADGE = {
|
||||||
|
admin: 'badge-admin',
|
||||||
|
editor: 'badge-editor',
|
||||||
|
moderator: 'badge-moderator',
|
||||||
|
player: 'badge-player',
|
||||||
|
}
|
||||||
|
|
||||||
export default function UsersAdmin() {
|
export default function UsersAdmin() {
|
||||||
const [tick, setTick] = useState(0)
|
const [tick, setTick] = useState(0)
|
||||||
@@ -18,7 +23,7 @@ export default function UsersAdmin() {
|
|||||||
<section>
|
<section>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
|
||||||
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||||
Manage admin, editor, and moderator accounts
|
Manage admin, editor, moderator, and player accounts
|
||||||
</p>
|
</p>
|
||||||
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
||||||
+ Add user
|
+ Add user
|
||||||
@@ -35,6 +40,7 @@ export default function UsersAdmin() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="adm-th">Username</th>
|
<th className="adm-th">Username</th>
|
||||||
<th className="adm-th">Role</th>
|
<th className="adm-th">Role</th>
|
||||||
|
<th className="adm-th">Status</th>
|
||||||
<th className="adm-th">Last login</th>
|
<th className="adm-th">Last login</th>
|
||||||
<th className="adm-th" />
|
<th className="adm-th" />
|
||||||
</tr>
|
</tr>
|
||||||
@@ -48,6 +54,14 @@ export default function UsersAdmin() {
|
|||||||
<td className="adm-td">
|
<td className="adm-td">
|
||||||
<span className={`badge ${ROLE_BADGE[u.role] || 'badge-editor'}`}>{u.role}</span>
|
<span className={`badge ${ROLE_BADGE[u.role] || 'badge-editor'}`}>{u.role}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="adm-td">
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{ fontSize: '0.82rem', color: u.status && u.status !== 'active' ? '#d98b84' : 'var(--muted)' }}
|
||||||
|
>
|
||||||
|
{u.status || 'active'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
|
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
|
||||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||||
<span className="link-accent" onClick={() => setEditing(u)}>
|
<span className="link-accent" onClick={() => setEditing(u)}>
|
||||||
|
|||||||
375
client/src/routes/player/PlayerAccount.jsx
Normal file
375
client/src/routes/player/PlayerAccount.jsx
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import MoonDot from '../../components/MoonDot.jsx'
|
||||||
|
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// ── Change username ────────────────────────────────────────────────────────
|
||||||
|
function ChangeUsername({ account, onChanged }) {
|
||||||
|
const [username, setUsername] = useState(account.username)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function save(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
if (username.trim().length < 3) return setError('Username must be at least 3 characters.')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const { username: next } = await api.player.changeUsername(username.trim())
|
||||||
|
setMsg('Username updated.')
|
||||||
|
await onChanged(next)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 409) setError('That username is already taken.')
|
||||||
|
else setError(err.message || 'Could not change your username.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title="Username">
|
||||||
|
<form onSubmit={save} style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 320 }}>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Username</span>
|
||||||
|
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} className="input" autoComplete="username" />
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button type="submit" disabled={busy || username.trim() === account.username} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Saving…' : 'Change username'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Note msg={msg} error={error} />
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Change / set password ──────────────────────────────────────────────────
|
||||||
|
function ChangePassword({ account }) {
|
||||||
|
const hasPassword = account.has_password
|
||||||
|
const [current, setCurrent] = useState('')
|
||||||
|
const [next, setNext] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function save(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
if (next.length < 8) return setError('New password must be at least 8 characters.')
|
||||||
|
if (hasPassword && !current) return setError('Enter your current password.')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.player.changePassword(next, hasPassword ? current : undefined)
|
||||||
|
setMsg(hasPassword ? 'Password changed.' : 'Password set. You can now sign in with it.')
|
||||||
|
setCurrent('')
|
||||||
|
setNext('')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not change your password.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title={hasPassword ? 'Password' : 'Set a password'}>
|
||||||
|
{!hasPassword && (
|
||||||
|
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
Your account was created through a linked provider and has no password yet. Set one to also be
|
||||||
|
able to sign in with a username and password.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<form onSubmit={save} style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 320 }}>
|
||||||
|
{hasPassword && (
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Current password</span>
|
||||||
|
<input type="password" value={current} onChange={(e) => setCurrent(e.target.value)} className="input" autoComplete="current-password" />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
<span className="field-label">New password</span>
|
||||||
|
<input type="password" value={next} onChange={(e) => setNext(e.target.value)} className="input" autoComplete="new-password" />
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Saving…' : hasPassword ? 'Change password' : 'Set password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Note msg={msg} error={error} />
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Two-factor (TOTP) ──────────────────────────────────────────────────────
|
||||||
|
function TwoFactor({ account, reload }) {
|
||||||
|
const enabled = account.totp_enabled
|
||||||
|
const [setup, setSetup] = useState(null)
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function begin() {
|
||||||
|
setBusy(true); setMsg(''); setError('')
|
||||||
|
try {
|
||||||
|
setSetup(await api.player.totpSetup())
|
||||||
|
setCode('')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not start setup.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function confirm() {
|
||||||
|
setBusy(true); setMsg(''); setError('')
|
||||||
|
try {
|
||||||
|
await api.player.totpEnable(code.trim())
|
||||||
|
setSetup(null); setCode(''); setMsg('Two-factor is now enabled.')
|
||||||
|
await reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not enable two-factor.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function disable() {
|
||||||
|
setBusy(true); setMsg(''); setError('')
|
||||||
|
try {
|
||||||
|
await api.player.totpDisable(code.trim())
|
||||||
|
setCode(''); setMsg('Two-factor has been disabled.')
|
||||||
|
await reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not disable two-factor.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title="Two-factor authentication">
|
||||||
|
<div className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '6px 12px', borderRadius: 999, border: '1px solid var(--line)', fontSize: '0.82rem', color: enabled ? '#7fd0a4' : 'var(--muted)', marginBottom: 18 }}>
|
||||||
|
<span style={{ width: 9, height: 9, borderRadius: '50%', background: enabled ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{enabled ? 'Enabled' : 'Not enabled'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!enabled && !setup && (
|
||||||
|
<div>
|
||||||
|
<button onClick={begin} disabled={busy} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Preparing…' : 'Set up two-factor'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!enabled && setup && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem' }}>
|
||||||
|
Scan this QR code with your authenticator app, then enter the current 6-digit code.
|
||||||
|
</p>
|
||||||
|
<img src={setup.qr} alt="TOTP QR code" width={180} height={180} style={{ borderRadius: 8, background: '#fff', padding: 8, alignSelf: 'flex-start' }} />
|
||||||
|
<label style={{ display: 'block', maxWidth: 220 }}>
|
||||||
|
<span className="field-label">Verification code</span>
|
||||||
|
<input type="text" inputMode="numeric" autoComplete="one-time-code" placeholder="6-digit code" value={code} onChange={(e) => setCode(e.target.value)} className="input" />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
<button onClick={confirm} disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Enabling…' : 'Confirm & enable'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setSetup(null)} disabled={busy} className="pill">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{enabled && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem' }}>
|
||||||
|
Enter a current code from your authenticator to turn two-factor off.
|
||||||
|
</p>
|
||||||
|
<label style={{ display: 'block', maxWidth: 220 }}>
|
||||||
|
<span className="field-label">Verification code</span>
|
||||||
|
<input type="text" inputMode="numeric" autoComplete="one-time-code" placeholder="6-digit code" value={code} onChange={(e) => setCode(e.target.value)} className="input" />
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button onClick={disable} disabled={busy || !code.trim()} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>
|
||||||
|
{busy ? 'Disabling…' : 'Disable two-factor'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Note msg={msg} error={error} />
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Linked SSO identities ──────────────────────────────────────────────────
|
||||||
|
function LinkedAccounts() {
|
||||||
|
const [linked, setLinked] = useState(null)
|
||||||
|
const [available, setAvailable] = useState([])
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const banner = (() => {
|
||||||
|
const q = new URLSearchParams(window.location.search)
|
||||||
|
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
|
||||||
|
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
|
||||||
|
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
|
||||||
|
return null
|
||||||
|
})()
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [ids, avail] = await Promise.all([
|
||||||
|
api.player.linkedIdentities(),
|
||||||
|
api.authProviders().catch(() => []),
|
||||||
|
])
|
||||||
|
setLinked(ids)
|
||||||
|
setAvailable(Array.isArray(avail) ? avail : [])
|
||||||
|
} catch {
|
||||||
|
setError('Could not load linked accounts.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
|
||||||
|
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
|
||||||
|
|
||||||
|
async function unlink(provider) {
|
||||||
|
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||||
|
try {
|
||||||
|
await api.player.unlinkIdentity(provider)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not unlink.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
if (!linked) return null
|
||||||
|
|
||||||
|
const linkedIds = new Set(linked.map((i) => i.provider))
|
||||||
|
const linkable = available.filter((p) => !linkedIds.has(p.id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title="Linked accounts">
|
||||||
|
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
Link a Google, Discord, or other provider so you can sign in with it.
|
||||||
|
</p>
|
||||||
|
{banner && (
|
||||||
|
<p className="sans" style={{ color: banner.ok ? '#7fd0a4' : '#d98b84', fontSize: '0.86rem' }}>{banner.text}</p>
|
||||||
|
)}
|
||||||
|
{linked.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||||
|
{linked.map((i) => (
|
||||||
|
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||||
|
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
|
||||||
|
<ProviderIcon icon={iconFor(i.provider)} size={20} />
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
|
||||||
|
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>Unlink</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{linkable.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 6 }}>
|
||||||
|
{linkable.map((p) => (
|
||||||
|
<button key={p.id} onClick={() => window.location.assign(`/api/v1/auth/sso/${p.id}/link?returnTo=${encodeURIComponent('/account')}`)} className="btn" style={{ display: 'flex', alignItems: 'center', gap: 10, justifyContent: 'center', width: '100%', maxWidth: 320, borderRadius: 8, padding: 10, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.04)', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
||||||
|
<ProviderIcon icon={p.icon} size={18} />
|
||||||
|
</span>
|
||||||
|
Link {p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{linked.length === 0 && linkable.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.86rem' }}>No SSO providers are enabled.</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared bits ────────────────────────────────────────────────────────────
|
||||||
|
function Section({ title, children }) {
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 26, marginTop: 26 }}>
|
||||||
|
<h2 className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: 'var(--head)' }}>{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function Note({ msg, error }) {
|
||||||
|
if (!msg && !error) return null
|
||||||
|
return <p className="sans" style={{ margin: '4px 0 0', color: error ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}>{error || msg}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ───────────────────────────────────────────────────────────────────
|
||||||
|
export default function PlayerAccount() {
|
||||||
|
const { logout, refresh } = useAuth()
|
||||||
|
const [account, setAccount] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setAccount(await api.player.getAccount())
|
||||||
|
} catch {
|
||||||
|
setError('Could not load your account.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
// After a username change: reload local account + refresh the auth context so
|
||||||
|
// the header reflects the new name.
|
||||||
|
const onUsernameChanged = useCallback(async () => {
|
||||||
|
await Promise.all([load(), refresh()])
|
||||||
|
}, [load, refresh])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ minHeight: '100vh', background: 'var(--bg-deep)', color: 'var(--ink)' }}>
|
||||||
|
<header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '18px 20px', borderBottom: '1px solid var(--line)', flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<MoonDot size={12} glow={0.5} />
|
||||||
|
<span className="display" style={{ color: 'var(--head)', fontSize: '1.1rem', letterSpacing: '0.04em' }}>
|
||||||
|
My Account
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
|
||||||
|
← Site
|
||||||
|
</Link>
|
||||||
|
<button onClick={logout} className="pill">Sign out</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style={{ maxWidth: 620, margin: '0 auto', padding: '10px 20px 60px' }}>
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message={error} />}
|
||||||
|
{!loading && !error && account && (
|
||||||
|
<>
|
||||||
|
<div style={{ paddingTop: 24 }}>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
|
||||||
|
Signed in as <strong style={{ color: 'var(--head)' }}>{account.username}</strong>
|
||||||
|
{account.email ? ` · ${account.email}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
||||||
|
<ChangePassword account={account} />
|
||||||
|
<TwoFactor account={account} reload={load} />
|
||||||
|
<LinkedAccounts />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
205
client/src/routes/player/PlayerLogin.jsx
Normal file
205
client/src/routes/player/PlayerLogin.jsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
|
||||||
|
|
||||||
|
// Friendly copy for the ?sso_error codes the SSO callback can bounce back with.
|
||||||
|
const SSO_ERRORS = {
|
||||||
|
not_linked:
|
||||||
|
'That account is not linked to a player. Enable SSO sign-up, or sign in with a password and link it under your account.',
|
||||||
|
disabled: 'This account is not active. Contact an administrator.',
|
||||||
|
denied: 'Sign-in was cancelled.',
|
||||||
|
unavailable: 'That sign-in method is not available right now.',
|
||||||
|
bad_state: 'Your sign-in session expired. Please try again.',
|
||||||
|
error: 'Could not complete sign-in. Please try again.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerLogin() {
|
||||||
|
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const dest = location.state?.from?.pathname || '/account'
|
||||||
|
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [company, setCompany] = useState('') // honeypot — must stay empty
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
|
||||||
|
const [challenge, setChallenge] = useState('')
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [ssoTotp, setSsoTotp] = useState(false)
|
||||||
|
|
||||||
|
const [providers, setProviders] = useState([])
|
||||||
|
const [canRegister, setCanRegister] = useState(false)
|
||||||
|
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
|
||||||
|
|
||||||
|
// A signed-in player goes straight to their account.
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && user.role === 'player') navigate(dest, { replace: true })
|
||||||
|
}, [user, dest, navigate])
|
||||||
|
|
||||||
|
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1.
|
||||||
|
useEffect(() => {
|
||||||
|
if (new URLSearchParams(location.search).get('sso_totp')) {
|
||||||
|
setStage('totp')
|
||||||
|
setSsoTotp(true)
|
||||||
|
}
|
||||||
|
}, [location.search])
|
||||||
|
|
||||||
|
// SSO providers (for buttons) + whether password registration is open.
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
api
|
||||||
|
.authProviders()
|
||||||
|
.then((list) => active && setProviders(Array.isArray(list) ? list : []))
|
||||||
|
.catch(() => active && setProviders([]))
|
||||||
|
api
|
||||||
|
.publicSettings()
|
||||||
|
.then((s) => active && setCanRegister(Boolean(s?.registration?.password)))
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function startSso(provider) {
|
||||||
|
// Always return into the player portal so the callback lands on /account*.
|
||||||
|
const q = `?returnTo=${encodeURIComponent(dest.startsWith('/account') ? dest : '/account')}`
|
||||||
|
window.location.assign(provider.loginUrl + q)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const data = await login(username, password, { company })
|
||||||
|
if (data.totpRequired) {
|
||||||
|
setChallenge(data.challenge)
|
||||||
|
setStage('totp')
|
||||||
|
setBusy(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate(dest, { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 403) setError('This account is not active. Contact an administrator.')
|
||||||
|
else setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmitTotp(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
if (ssoTotp) {
|
||||||
|
const { returnTo } = await ssoLoginTotp(code)
|
||||||
|
navigate(returnTo || '/account', { replace: true })
|
||||||
|
} else {
|
||||||
|
await loginTotp(challenge, code)
|
||||||
|
navigate(dest, { replace: true })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const expired = err.status === 401 && /expired/i.test(err.message)
|
||||||
|
setError(expired ? 'Your verification session expired. Please sign in again.' : 'Invalid verification code.')
|
||||||
|
setBusy(false)
|
||||||
|
if (expired) {
|
||||||
|
setStage('creds')
|
||||||
|
setSsoTotp(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlayerShell
|
||||||
|
subtitle="Player sign-in"
|
||||||
|
footer={
|
||||||
|
canRegister && (
|
||||||
|
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||||
|
New here?{' '}
|
||||||
|
<Link to="/account/register" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||||
|
Create an account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}>
|
||||||
|
{stage === 'creds' ? (
|
||||||
|
<>
|
||||||
|
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||||
|
<span className="field-label">Username</span>
|
||||||
|
<input type="text" autoComplete="username" autoFocus value={username} onChange={(e) => setUsername(e.target.value)} className="input" />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||||
|
<span className="field-label">Password</span>
|
||||||
|
<input type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} className="input" />
|
||||||
|
</label>
|
||||||
|
<div style={honeypotStyle} aria-hidden="true">
|
||||||
|
<label>
|
||||||
|
Company
|
||||||
|
<input type="text" name="company" tabIndex={-1} autoComplete="off" value={company} onChange={(e) => setCompany(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||||
|
<span className="field-label">Authentication code</span>
|
||||||
|
<input type="text" inputMode="numeric" autoComplete="one-time-code" autoFocus placeholder="6-digit code" value={code} onChange={(e) => setCode(e.target.value)} className="input" />
|
||||||
|
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||||
|
Enter the code from your authenticator app.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(error || (stage === 'creds' && ssoError)) && (
|
||||||
|
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center', lineHeight: 1.5 }}>
|
||||||
|
{error || ssoError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}>
|
||||||
|
{busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{stage === 'creds' && providers.length > 0 && (
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '0 0 16px', color: 'var(--dim)' }}>
|
||||||
|
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||||
|
<span className="sans" style={{ fontSize: '0.72rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>or</span>
|
||||||
|
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<button key={p.id} type="button" onClick={() => startSso(p)} className="btn" style={ssoBtnStyle}>
|
||||||
|
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
||||||
|
<ProviderIcon icon={p.icon} size={18} />
|
||||||
|
</span>
|
||||||
|
Continue with {p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</PlayerShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssoBtnStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 10,
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 11,
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
}
|
||||||
163
client/src/routes/player/PlayerRegister.jsx
Normal file
163
client/src/routes/player/PlayerRegister.jsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
|
||||||
|
|
||||||
|
export default function PlayerRegister() {
|
||||||
|
const { user, register } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [company, setCompany] = useState('') // honeypot — must stay empty
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
// Which methods are enabled (derived, from /public/settings). null = loading.
|
||||||
|
const [avail, setAvail] = useState(null)
|
||||||
|
const [providers, setProviders] = useState([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && user.role === 'player') navigate('/account', { replace: true })
|
||||||
|
}, [user, navigate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
api
|
||||||
|
.publicSettings()
|
||||||
|
.then((s) => active && setAvail(s?.registration || { password: false, sso: false }))
|
||||||
|
.catch(() => active && setAvail({ password: false, sso: false }))
|
||||||
|
api
|
||||||
|
.authProviders()
|
||||||
|
.then((list) => active && setProviders(Array.isArray(list) ? list : []))
|
||||||
|
.catch(() => active && setProviders([]))
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function startSso(provider) {
|
||||||
|
window.location.assign(provider.loginUrl + `?returnTo=${encodeURIComponent('/account')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
if (username.trim().length < 3) return setError('Username must be at least 3 characters.')
|
||||||
|
if (password.length < 8) return setError('Password must be at least 8 characters.')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await register(username.trim(), password, { email: email.trim() || undefined, company })
|
||||||
|
navigate('/account', { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 409) setError('That username is already taken.')
|
||||||
|
else if (err.status === 403) setError('Registration is not open right now.')
|
||||||
|
else if (err.status === 400) setError(err.message || 'Please check your details and try again.')
|
||||||
|
else setError('Could not create your account right now.')
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closed = avail && !avail.password && !avail.sso
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlayerShell
|
||||||
|
subtitle="Create a player account"
|
||||||
|
footer={
|
||||||
|
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{avail === null ? (
|
||||||
|
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}>
|
||||||
|
<span className="spin" />
|
||||||
|
</div>
|
||||||
|
) : closed ? (
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem', textAlign: 'center', lineHeight: 1.6 }}>
|
||||||
|
Self-registration is currently closed. Please check back later.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{avail.password && (
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||||
|
<span className="field-label">Username</span>
|
||||||
|
<input type="text" autoComplete="username" autoFocus value={username} onChange={(e) => setUsername(e.target.value)} className="input" />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||||
|
<span className="field-label">Password</span>
|
||||||
|
<input type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} className="input" />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||||
|
<span className="field-label">Email (optional)</span>
|
||||||
|
<input type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="player@example.com" />
|
||||||
|
<span className="sans" style={{ display: 'block', marginTop: 6, color: 'var(--dim)', fontSize: '0.74rem' }}>
|
||||||
|
Used only for account recovery help. No password-reset emails yet — a forgotten password
|
||||||
|
is reset by an administrator.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div style={honeypotStyle} aria-hidden="true">
|
||||||
|
<label>
|
||||||
|
Company
|
||||||
|
<input type="text" name="company" tabIndex={-1} autoComplete="off" value={company} onChange={(e) => setCompany(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}>
|
||||||
|
{busy ? 'Creating…' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{avail.sso && providers.length > 0 && (
|
||||||
|
<div style={{ marginTop: avail.password ? 20 : 0 }}>
|
||||||
|
{avail.password && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '0 0 16px', color: 'var(--dim)' }}>
|
||||||
|
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||||
|
<span className="sans" style={{ fontSize: '0.72rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>or</span>
|
||||||
|
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<button key={p.id} type="button" onClick={() => startSso(p)} className="btn" style={ssoBtnStyle}>
|
||||||
|
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
||||||
|
<ProviderIcon icon={p.icon} size={18} />
|
||||||
|
</span>
|
||||||
|
Sign up with {p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PlayerShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssoBtnStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 10,
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 11,
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
}
|
||||||
72
client/src/routes/player/PlayerShell.jsx
Normal file
72
client/src/routes/player/PlayerShell.jsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import MoonDot from '../../components/MoonDot.jsx'
|
||||||
|
|
||||||
|
const BG =
|
||||||
|
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
|
||||||
|
|
||||||
|
// Centered card layout shared by the player login / register pages. `subtitle`
|
||||||
|
// labels the card; `footer` is optional content under the card (e.g. cross-links).
|
||||||
|
export default function PlayerShell({ subtitle, children, footer }) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
padding: '40px 18px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'var(--bg-deep)',
|
||||||
|
backgroundImage: BG,
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 26 }}>
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<MoonDot size={15} glow={0.55} />
|
||||||
|
</div>
|
||||||
|
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
||||||
|
UOMysticmoon
|
||||||
|
</h1>
|
||||||
|
<p className="sans" style={{ margin: '6px 0 0', color: '#9aa6b4', fontSize: '0.8rem', letterSpacing: '0.16em', textTransform: 'uppercase' }}>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 28,
|
||||||
|
background: 'linear-gradient(180deg,rgba(25,34,49,0.92),rgba(20,26,33,0.92))',
|
||||||
|
backdropFilter: 'blur(6px)',
|
||||||
|
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{footer}
|
||||||
|
|
||||||
|
<p style={{ textAlign: 'center', margin: '20px 0 0' }}>
|
||||||
|
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
|
||||||
|
← Back to site
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Off-screen honeypot styling (matches the admin login): present for bots, never
|
||||||
|
// seen or filled by real users. Name must equal the server HONEYPOT_FIELD.
|
||||||
|
export const honeypotStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: '-9999px',
|
||||||
|
top: 'auto',
|
||||||
|
width: '1px',
|
||||||
|
height: '1px',
|
||||||
|
opacity: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}
|
||||||
@@ -618,6 +618,11 @@ button[disabled] {
|
|||||||
color: #e0b070;
|
color: #e0b070;
|
||||||
border: 1px solid rgba(224, 176, 112, 0.4);
|
border: 1px solid rgba(224, 176, 112, 0.4);
|
||||||
}
|
}
|
||||||
|
.badge-player {
|
||||||
|
background: rgba(126, 196, 156, 0.12);
|
||||||
|
color: #7ec49c;
|
||||||
|
border: 1px solid rgba(126, 196, 156, 0.4);
|
||||||
|
}
|
||||||
/* Action-type badges for the moderation dashboard. */
|
/* Action-type badges for the moderation dashboard. */
|
||||||
.badge-ban {
|
.badge-ban {
|
||||||
background: rgba(217, 139, 132, 0.16);
|
background: rgba(217, 139, 132, 0.16);
|
||||||
|
|||||||
@@ -4,16 +4,34 @@
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
username VARCHAR(32) NOT NULL UNIQUE,
|
-- COLLATE is pinned to a case-insensitive (_ci) collation so uniqueness and
|
||||||
password_hash VARCHAR(72) NOT NULL,
|
-- findByUsername lookups both fold case identically ('Foo' == 'foo'). This is
|
||||||
role ENUM('admin','editor','moderator') NOT NULL DEFAULT 'admin',
|
-- the atomic backstop for the username-uniqueness race (see the register /
|
||||||
|
-- change-username duplicate-key handling).
|
||||||
|
username VARCHAR(32) NOT NULL COLLATE utf8mb4_general_ci UNIQUE,
|
||||||
|
-- Nullable: SSO-provisioned players have no password until they choose to set
|
||||||
|
-- one. A NULL hash means password login is impossible for that account
|
||||||
|
-- (validatePassword returns false).
|
||||||
|
password_hash VARCHAR(72) NULL,
|
||||||
|
role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin',
|
||||||
|
-- Optional contact email (players). Not unique — SSO emails may repeat. Used
|
||||||
|
-- only for display + a future self-serve reset. email_verified is wired now so
|
||||||
|
-- an eventual SMTP verification flow needs no schema change.
|
||||||
|
email VARCHAR(255) NULL,
|
||||||
|
email_verified TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
-- Account lifecycle, independent of role: staff can disable/ban a player
|
||||||
|
-- without changing their role. active = normal; disabled = admin-locked;
|
||||||
|
-- banned = moderation ban; pending = reserved for future email-verify gating.
|
||||||
|
-- Enforced in requireAuth + login (non-active is rejected).
|
||||||
|
status ENUM('active','pending','disabled','banned') NOT NULL DEFAULT 'active',
|
||||||
totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA)
|
totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA)
|
||||||
totp_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
totp_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
-- Any session token issued before this instant is rejected (see requireAuth).
|
-- Any session token issued before this instant is rejected (see requireAuth).
|
||||||
-- Bumped on password change / "log out everywhere". NULL = no cutoff yet.
|
-- Bumped on password change / "log out everywhere". NULL = no cutoff yet.
|
||||||
tokens_valid_after DATETIME NULL,
|
tokens_valid_after DATETIME NULL,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_login_at DATETIME NULL
|
last_login_at DATETIME NULL,
|
||||||
|
last_login_ip VARCHAR(45) NULL -- IPv6-capable, set on each login
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS posts (
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
@@ -458,7 +476,22 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS tokens_valid_after DATETIME NULL;
|
|||||||
-- Moderation dashboard (Phase 6): add the 'moderator' role to databases created
|
-- Moderation dashboard (Phase 6): add the 'moderator' role to databases created
|
||||||
-- before it. MODIFY has no IF NOT EXISTS form, but re-declaring the same ENUM is
|
-- before it. MODIFY has no IF NOT EXISTS form, but re-declaring the same ENUM is
|
||||||
-- an idempotent no-op, so it is safe to run on every boot.
|
-- an idempotent no-op, so it is safe to run on every boot.
|
||||||
ALTER TABLE users MODIFY COLUMN role ENUM('admin','editor','moderator') NOT NULL DEFAULT 'admin';
|
-- Player accounts: widen the enum again to include 'player' (self-service public
|
||||||
|
-- accounts). Same idempotent-MODIFY pattern.
|
||||||
|
ALTER TABLE users MODIFY COLUMN role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin';
|
||||||
|
-- Player accounts: make password_hash nullable (SSO-only players), pin the
|
||||||
|
-- username collation (case-insensitive uniqueness backstop), and add the player
|
||||||
|
-- columns to databases created before this. MODIFY is an idempotent no-op when
|
||||||
|
-- the column already matches; ADD COLUMN IF NOT EXISTS is safe to re-run.
|
||||||
|
ALTER TABLE users MODIFY COLUMN password_hash VARCHAR(72) NULL;
|
||||||
|
ALTER TABLE users MODIFY COLUMN username VARCHAR(32) NOT NULL COLLATE utf8mb4_general_ci;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255) NULL;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified TINYINT(1) NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS status ENUM('active','pending','disabled','banned') NOT NULL DEFAULT 'active';
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL;
|
||||||
|
-- Player self-registration mode: disabled | password | sso | both. Default off,
|
||||||
|
-- so the system behaves exactly as today until an admin opts in.
|
||||||
|
INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled');
|
||||||
|
|
||||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
|
||||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ async function requireAuth(req, res, next) {
|
|||||||
const user = await users.getById(session.userId)
|
const user = await users.getById(session.userId)
|
||||||
if (!user) return res.status(401).json({ message: 'Unauthorized' }) // deleted since token issued
|
if (!user) return res.status(401).json({ message: 'Unauthorized' }) // deleted since token issued
|
||||||
|
|
||||||
|
// Status gate, enforced on every request (same immediacy as the cutoff
|
||||||
|
// below): a player disabled/banned by staff loses access on their very next
|
||||||
|
// request, not when their JWT eventually expires.
|
||||||
|
if (user.status && user.status !== 'active') {
|
||||||
|
return res.status(403).json({ message: 'Account disabled' })
|
||||||
|
}
|
||||||
|
|
||||||
// Revocation, enforced here (not in stateless token verification):
|
// Revocation, enforced here (not in stateless token verification):
|
||||||
// 1. per-user cutoff — password change / "log out everywhere" bumps
|
// 1. per-user cutoff — password change / "log out everywhere" bumps
|
||||||
// tokens_valid_after; any token issued before it is dead.
|
// tokens_valid_after; any token issued before it is dead.
|
||||||
|
|||||||
111
server/src/auth/usernamePolicy.js
Normal file
111
server/src/auth/usernamePolicy.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// ── Username policy ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Pure helpers shared by public registration and SSO auto-provisioning:
|
||||||
|
// - a reserved-name blocklist (staff-impersonating / system names),
|
||||||
|
// - normalization (trim; case is preserved for display, uniqueness folds case
|
||||||
|
// at the DB via the column's _ci collation), and
|
||||||
|
// - deriving a valid username from an external SSO profile.
|
||||||
|
//
|
||||||
|
// No I/O — the DB UNIQUE index is the source of truth for collisions; these
|
||||||
|
// helpers only shape/validate candidate names and pick suffixes to retry with.
|
||||||
|
|
||||||
|
// Allowed characters in a stored username: letters, digits, dot, underscore,
|
||||||
|
// dash. Length 3–32 (matches the register validator + the column width).
|
||||||
|
const USERNAME_RE = /^[A-Za-z0-9_.-]{3,32}$/
|
||||||
|
const MIN_LEN = 3
|
||||||
|
const MAX_LEN = 32
|
||||||
|
|
||||||
|
// Names that must never belong to a self-registered account because they imply
|
||||||
|
// staff/system authority or are otherwise confusing. Compared case-insensitively.
|
||||||
|
const RESERVED_USERNAMES = new Set([
|
||||||
|
'admin',
|
||||||
|
'administrator',
|
||||||
|
'root',
|
||||||
|
'system',
|
||||||
|
'staff',
|
||||||
|
'mod',
|
||||||
|
'moderator',
|
||||||
|
'owner',
|
||||||
|
'support',
|
||||||
|
'help',
|
||||||
|
'null',
|
||||||
|
'undefined',
|
||||||
|
'me',
|
||||||
|
'anonymous',
|
||||||
|
'everyone',
|
||||||
|
'here',
|
||||||
|
])
|
||||||
|
|
||||||
|
// Trim surrounding whitespace. Case is preserved (stored as entered); the DB's
|
||||||
|
// _ci collation folds case for uniqueness + lookup.
|
||||||
|
function normalizeUsername(raw) {
|
||||||
|
return typeof raw === 'string' ? raw.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReserved(name) {
|
||||||
|
return RESERVED_USERNAMES.has(String(name || '').trim().toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidFormat(name) {
|
||||||
|
return USERNAME_RE.test(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a user-chosen username for registration. Returns { ok, message }.
|
||||||
|
function validateUsername(raw) {
|
||||||
|
const name = normalizeUsername(raw)
|
||||||
|
if (!isValidFormat(name)) {
|
||||||
|
return { ok: false, message: 'Username must be 3–32 characters (letters, numbers, . _ -).' }
|
||||||
|
}
|
||||||
|
if (isReserved(name)) {
|
||||||
|
return { ok: false, message: 'That username is not available.' }
|
||||||
|
}
|
||||||
|
return { ok: true, name }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reduce an arbitrary string to the allowed charset, clamped to MAX_LEN. Used as
|
||||||
|
// the base for SSO-derived usernames before uniqueness suffixing.
|
||||||
|
function sanitizeToUsername(raw) {
|
||||||
|
let s = String(raw || '')
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[^A-Za-z0-9_.-]/g, '')
|
||||||
|
.replace(/^[._-]+/, '') // don't start with punctuation
|
||||||
|
.slice(0, MAX_LEN)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive a base username from a normalized SSO profile ({ name, email, subject }).
|
||||||
|
// Tries display name, then the email local-part, then a generic 'player' base.
|
||||||
|
// The result is always a valid *base* (>= MIN_LEN, sanitized) but is NOT
|
||||||
|
// guaranteed unique — the caller suffixes + retries against the UNIQUE index.
|
||||||
|
function deriveUsernameBase(profile) {
|
||||||
|
const candidates = [profile && profile.name, profile && (profile.email || '').split('@')[0]]
|
||||||
|
for (const c of candidates) {
|
||||||
|
const s = sanitizeToUsername(c)
|
||||||
|
if (s.length >= MIN_LEN && !isReserved(s)) return s
|
||||||
|
}
|
||||||
|
return 'player'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the Nth candidate username for the dedup retry loop: attempt 0 is the
|
||||||
|
// bare base (padded if short), later attempts append an increasing numeric
|
||||||
|
// suffix, always clamped to MAX_LEN so the suffix survives truncation.
|
||||||
|
function candidateUsername(base, attempt) {
|
||||||
|
const safeBase = base.length >= MIN_LEN ? base : `${base}player`.slice(0, MAX_LEN)
|
||||||
|
if (attempt === 0) return safeBase
|
||||||
|
const suffix = String(attempt + 1) // 2, 3, 4, …
|
||||||
|
return `${safeBase.slice(0, MAX_LEN - suffix.length)}${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
USERNAME_RE,
|
||||||
|
MIN_LEN,
|
||||||
|
MAX_LEN,
|
||||||
|
RESERVED_USERNAMES,
|
||||||
|
normalizeUsername,
|
||||||
|
isReserved,
|
||||||
|
isValidFormat,
|
||||||
|
validateUsername,
|
||||||
|
sanitizeToUsername,
|
||||||
|
deriveUsernameBase,
|
||||||
|
candidateUsername,
|
||||||
|
}
|
||||||
@@ -24,6 +24,26 @@ const loginLimiter = makeLimiter({
|
|||||||
message: 'Too many login attempts. Please try again later.',
|
message: 'Too many login attempts. Please try again later.',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Public self-registration. Mirrors the login cap: a handful of legitimate
|
||||||
|
// attempts per window, a flood is abuse. The global botScore guard + honeypot
|
||||||
|
// cover the rest.
|
||||||
|
const registerLimiter = makeLimiter({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
label: 'register',
|
||||||
|
message: 'Too many registration attempts. Please try again later.',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Authenticated self-service credential changes (username / password). Tighter
|
||||||
|
// than login — a signed-in player rarely changes these, and the wrong-current-
|
||||||
|
// password path also feeds the shared login backoff (see the controller).
|
||||||
|
const accountChangeLimiter = makeLimiter({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
label: 'account-change',
|
||||||
|
message: 'Too many changes. Please try again later.',
|
||||||
|
})
|
||||||
|
|
||||||
// Throttle the public contact form.
|
// Throttle the public contact form.
|
||||||
const contactLimiter = makeLimiter({
|
const contactLimiter = makeLimiter({
|
||||||
windowMs: 60 * 60 * 1000,
|
windowMs: 60 * 60 * 1000,
|
||||||
@@ -51,4 +71,11 @@ const ssoStartLimiter = makeLimiter({
|
|||||||
message: 'Too many sign-in attempts. Please try again later.',
|
message: 'Too many sign-in attempts. Please try again later.',
|
||||||
})
|
})
|
||||||
|
|
||||||
module.exports = { loginLimiter, contactLimiter, mobileRefreshLimiter, ssoStartLimiter }
|
module.exports = {
|
||||||
|
loginLimiter,
|
||||||
|
registerLimiter,
|
||||||
|
accountChangeLimiter,
|
||||||
|
contactLimiter,
|
||||||
|
mobileRefreshLimiter,
|
||||||
|
ssoStartLimiter,
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,27 @@ const PUBLIC_KEYS = [
|
|||||||
'hero_layout', // portal hero composition (JSON). Draft key stays admin-only.
|
'hero_layout', // portal hero composition (JSON). Draft key stays admin-only.
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Player self-registration mode. Stored under the 'player_registration' key.
|
||||||
|
// NOTE: the raw value is never exposed publicly — getPublic() derives boolean
|
||||||
|
// availability flags from it instead (see below).
|
||||||
|
const REGISTRATION_KEY = 'player_registration'
|
||||||
|
const REGISTRATION_MODES = ['disabled', 'password', 'sso', 'both']
|
||||||
|
|
||||||
|
// Resolve the registration mode, defaulting to 'disabled' (and coercing any
|
||||||
|
// unexpected stored value back to 'disabled' so a bad row can't open sign-up).
|
||||||
|
async function getRegistrationMode() {
|
||||||
|
const value = await settingsDb.get(REGISTRATION_KEY)
|
||||||
|
return REGISTRATION_MODES.includes(value) ? value : 'disabled'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derived, public-safe availability flags for the register page.
|
||||||
|
function registrationFlags(mode) {
|
||||||
|
return {
|
||||||
|
password: mode === 'password' || mode === 'both',
|
||||||
|
sso: mode === 'sso' || mode === 'both',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function get(key) {
|
async function get(key) {
|
||||||
return settingsDb.get(key)
|
return settingsDb.get(key)
|
||||||
}
|
}
|
||||||
@@ -35,10 +56,26 @@ async function getAll() {
|
|||||||
|
|
||||||
async function getPublic() {
|
async function getPublic() {
|
||||||
const all = await getAll()
|
const all = await getAll()
|
||||||
return PUBLIC_KEYS.reduce((acc, key) => {
|
const out = PUBLIC_KEYS.reduce((acc, key) => {
|
||||||
if (all[key] !== undefined) acc[key] = all[key]
|
if (all[key] !== undefined) acc[key] = all[key]
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
|
// Derived registration availability (never the raw mode). Lets the register
|
||||||
|
// page show/hide the password form and SSO buttons.
|
||||||
|
const mode = REGISTRATION_MODES.includes(all[REGISTRATION_KEY]) ? all[REGISTRATION_KEY] : 'disabled'
|
||||||
|
out.registration = registrationFlags(mode)
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { get, set, setMany, getAll, getPublic, PUBLIC_KEYS }
|
module.exports = {
|
||||||
|
get,
|
||||||
|
set,
|
||||||
|
setMany,
|
||||||
|
getAll,
|
||||||
|
getPublic,
|
||||||
|
PUBLIC_KEYS,
|
||||||
|
REGISTRATION_KEY,
|
||||||
|
REGISTRATION_MODES,
|
||||||
|
getRegistrationMode,
|
||||||
|
registrationFlags,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
const { query } = require('../../utils/db')
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
const PUBLIC_COLS = 'id, username, role, totp_enabled, created_at, last_login_at'
|
const PUBLIC_COLS =
|
||||||
|
'id, username, role, status, email, email_verified, totp_enabled, created_at, last_login_at'
|
||||||
|
|
||||||
async function insertUser({ username, passwordHash, role = 'admin' }) {
|
// passwordHash may be null (SSO-provisioned players who have not set one yet).
|
||||||
|
// email/status/emailVerified are optional so existing admin-create callers are
|
||||||
|
// unaffected.
|
||||||
|
async function insertUser({
|
||||||
|
username,
|
||||||
|
passwordHash = null,
|
||||||
|
role = 'admin',
|
||||||
|
email = null,
|
||||||
|
status = 'active',
|
||||||
|
emailVerified = false,
|
||||||
|
}) {
|
||||||
const res = await query(
|
const res = await query(
|
||||||
'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)',
|
'INSERT INTO users (username, password_hash, role, email, status, email_verified) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
[username, passwordHash, role],
|
[username, passwordHash, role, email, status, emailVerified ? 1 : 0],
|
||||||
)
|
)
|
||||||
return res.insertId
|
return res.insertId
|
||||||
}
|
}
|
||||||
@@ -50,8 +61,8 @@ async function countAdmins() {
|
|||||||
return Number(rows[0].c)
|
return Number(rows[0].c)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function touchLastLogin(id) {
|
async function touchLastLogin(id, ip = null) {
|
||||||
return query('UPDATE users SET last_login_at = NOW() WHERE id = ?', [id])
|
return query('UPDATE users SET last_login_at = NOW(), last_login_ip = ? WHERE id = ?', [ip, id])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move the "tokens valid after" cutoff to now, invalidating every session token
|
// Move the "tokens valid after" cutoff to now, invalidating every session token
|
||||||
@@ -61,6 +72,16 @@ async function bumpTokensValidAfter(id) {
|
|||||||
return query('UPDATE users SET tokens_valid_after = NOW() WHERE id = ?', [id])
|
return query('UPDATE users SET tokens_valid_after = NOW() WHERE id = ?', [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the cutoff to an explicit instant. Used when re-issuing the caller's own
|
||||||
|
// session right after a password change: the bump above revokes everything at
|
||||||
|
// NOW(), and requireAuth's cutoff test is inclusive (createdAt <= cutoff), so a
|
||||||
|
// freshly-minted token sharing that same wall-clock second would be revoked too.
|
||||||
|
// Rewinding the cutoff a hair below the new token's issued-at lets it survive
|
||||||
|
// while still revoking every older session.
|
||||||
|
async function setTokensValidAfter(id, when) {
|
||||||
|
return query('UPDATE users SET tokens_valid_after = ? WHERE id = ?', [when, id])
|
||||||
|
}
|
||||||
|
|
||||||
// Store a (not-yet-enabled) TOTP secret for a user. Enabling is a separate step
|
// Store a (not-yet-enabled) TOTP secret for a user. Enabling is a separate step
|
||||||
// so a secret is never trusted until the user has confirmed one code.
|
// so a secret is never trusted until the user has confirmed one code.
|
||||||
async function setTotpSecret(id, secret) {
|
async function setTotpSecret(id, secret) {
|
||||||
@@ -86,6 +107,7 @@ module.exports = {
|
|||||||
countAdmins,
|
countAdmins,
|
||||||
touchLastLogin,
|
touchLastLogin,
|
||||||
bumpTokensValidAfter,
|
bumpTokensValidAfter,
|
||||||
|
setTokensValidAfter,
|
||||||
setTotpSecret,
|
setTotpSecret,
|
||||||
enableTotp,
|
enableTotp,
|
||||||
disableTotp,
|
disableTotp,
|
||||||
|
|||||||
@@ -10,12 +10,21 @@ function sanitize(user) {
|
|||||||
return safe
|
return safe
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createUser({ username, password, role = 'admin' }) {
|
// password may be omitted/null — an SSO-provisioned player has no password until
|
||||||
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS)
|
// they set one (a null hash makes password login impossible, see validatePassword).
|
||||||
const id = await usersDb.insertUser({ username, passwordHash, role })
|
async function createUser({ username, password, role = 'admin', email = null, status = 'active', emailVerified = false }) {
|
||||||
|
const passwordHash = password ? await bcrypt.hash(password, SALT_ROUNDS) : null
|
||||||
|
const id = await usersDb.insertUser({ username, passwordHash, role, email, status, emailVerified })
|
||||||
return sanitize(await usersDb.findById(id))
|
return sanitize(await usersDb.findById(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True when a DB error is the unique-index violation on username (the atomic
|
||||||
|
// backstop for the uniqueness race). Callers translate this into a 409 rather
|
||||||
|
// than doing a check-then-write.
|
||||||
|
function isDuplicateUsername(err) {
|
||||||
|
return Boolean(err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062))
|
||||||
|
}
|
||||||
|
|
||||||
// Returns the raw row (incl. hash) — used by login only.
|
// Returns the raw row (incl. hash) — used by login only.
|
||||||
async function getRawByUsername(username) {
|
async function getRawByUsername(username) {
|
||||||
return usersDb.findByUsername(username)
|
return usersDb.findByUsername(username)
|
||||||
@@ -52,10 +61,13 @@ async function list() {
|
|||||||
return usersDb.listUsers()
|
return usersDb.listUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function update(id, { username, password, role }) {
|
async function update(id, { username, password, role, email, status, emailVerified }) {
|
||||||
const fields = {}
|
const fields = {}
|
||||||
if (username !== undefined) fields.username = username
|
if (username !== undefined) fields.username = username
|
||||||
if (role !== undefined) fields.role = role
|
if (role !== undefined) fields.role = role
|
||||||
|
if (email !== undefined) fields.email = email
|
||||||
|
if (status !== undefined) fields.status = status
|
||||||
|
if (emailVerified !== undefined) fields.email_verified = emailVerified ? 1 : 0
|
||||||
if (password) fields.password_hash = await bcrypt.hash(password, SALT_ROUNDS)
|
if (password) fields.password_hash = await bcrypt.hash(password, SALT_ROUNDS)
|
||||||
await usersDb.updateUser(id, fields)
|
await usersDb.updateUser(id, fields)
|
||||||
// A password change must revoke existing sessions ("change password to log
|
// A password change must revoke existing sessions ("change password to log
|
||||||
@@ -70,6 +82,12 @@ async function invalidateSessions(id) {
|
|||||||
return usersDb.bumpTokensValidAfter(id)
|
return usersDb.bumpTokensValidAfter(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the session cutoff to an explicit instant. Used by the self password-change
|
||||||
|
// flow to keep the caller's freshly re-issued session alive (see users.db).
|
||||||
|
async function setSessionCutoff(id, when) {
|
||||||
|
return usersDb.setTokensValidAfter(id, when)
|
||||||
|
}
|
||||||
|
|
||||||
async function remove(id) {
|
async function remove(id) {
|
||||||
return usersDb.deleteUser(id)
|
return usersDb.deleteUser(id)
|
||||||
}
|
}
|
||||||
@@ -82,12 +100,13 @@ async function countAdmins() {
|
|||||||
return usersDb.countAdmins()
|
return usersDb.countAdmins()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recordLogin(id) {
|
async function recordLogin(id, ip = null) {
|
||||||
return usersDb.touchLastLogin(id)
|
return usersDb.touchLastLogin(id, ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createUser,
|
createUser,
|
||||||
|
isDuplicateUsername,
|
||||||
getRawByUsername,
|
getRawByUsername,
|
||||||
getById,
|
getById,
|
||||||
getRawById,
|
getRawById,
|
||||||
@@ -95,6 +114,7 @@ module.exports = {
|
|||||||
list,
|
list,
|
||||||
update,
|
update,
|
||||||
invalidateSessions,
|
invalidateSessions,
|
||||||
|
setSessionCutoff,
|
||||||
remove,
|
remove,
|
||||||
count,
|
count,
|
||||||
countAdmins,
|
countAdmins,
|
||||||
|
|||||||
@@ -5,18 +5,115 @@
|
|||||||
const users = require('../../../model/users/users.model')
|
const users = require('../../../model/users/users.model')
|
||||||
const activity = require('../../../model/activity/activity.model')
|
const activity = require('../../../model/activity/activity.model')
|
||||||
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
||||||
|
const sessionService = require('../../../auth/session.service')
|
||||||
|
const { setAuthCookie } = require('../../../auth/token')
|
||||||
|
const usernamePolicy = require('../../../auth/usernamePolicy')
|
||||||
|
const loginProtection = require('../../../middleware/loginProtection')
|
||||||
|
const botScore = require('../../../middleware/botScore')
|
||||||
const totp = require('../../../utils/totp')
|
const totp = require('../../../utils/totp')
|
||||||
|
|
||||||
const log = require('../../../utils/logger')('account')
|
const log = require('../../../utils/logger')('account')
|
||||||
|
|
||||||
// Current user's security status (does not expose the secret).
|
// Current user's security status (does not expose the secret). has_password lets
|
||||||
|
// the player portal tell an SSO-only account (must *set* a password, no current
|
||||||
|
// one required) apart from one that already has a usable password. req.user is the
|
||||||
|
// sanitized row (password_hash stripped), so read the raw row for that one flag.
|
||||||
async function getAccount(req, res) {
|
async function getAccount(req, res) {
|
||||||
return res.json({
|
try {
|
||||||
id: req.user.id,
|
const raw = await users.getRawById(req.user.id)
|
||||||
username: req.user.username,
|
return res.json({
|
||||||
role: req.user.role,
|
id: req.user.id,
|
||||||
totp_enabled: Boolean(req.user.totp_enabled),
|
username: req.user.username,
|
||||||
})
|
role: req.user.role,
|
||||||
|
email: req.user.email || null,
|
||||||
|
status: req.user.status || 'active',
|
||||||
|
totp_enabled: Boolean(req.user.totp_enabled),
|
||||||
|
has_password: Boolean(raw && raw.password_hash),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getAccount', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-mint this caller's session and refresh their cookie so a self-service change
|
||||||
|
// (username/password) doesn't log them out. Returns the new Session object.
|
||||||
|
function reissueSession(req, res, user) {
|
||||||
|
const { token: sessionToken, session } = sessionService.createSession(user, req.authMethod || 'local')
|
||||||
|
setAuthCookie(req, res, sessionToken)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /account/username — change the caller's own username. The DB UNIQUE index
|
||||||
|
// is the source of truth for collisions (case-insensitive via the column's _ci
|
||||||
|
// collation): attempt the write and translate a duplicate-key error into 409.
|
||||||
|
async function changeUsername(req, res) {
|
||||||
|
const check = usernamePolicy.validateUsername(req.body.username)
|
||||||
|
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||||
|
try {
|
||||||
|
if (check.name === req.user.username) {
|
||||||
|
return res.status(400).json({ message: 'That is already your username.' })
|
||||||
|
}
|
||||||
|
let updated
|
||||||
|
try {
|
||||||
|
updated = await users.update(req.user.id, { username: check.name })
|
||||||
|
} catch (err) {
|
||||||
|
if (users.isDuplicateUsername(err)) {
|
||||||
|
return res.status(409).json({ message: 'That username is already taken.' })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
// The JWT embeds username; authz always uses the fresh DB row, but re-issue
|
||||||
|
// the cookie so nothing downstream renders a stale name. No global revocation
|
||||||
|
// — a username isn't a secret.
|
||||||
|
reissueSession(req, res, updated)
|
||||||
|
await activity.log({ req, action: 'account.username.change', detail: { username: updated.username } })
|
||||||
|
log.info('account username changed', { id: req.user.id, username: updated.username })
|
||||||
|
return res.json({ username: updated.username })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('changeUsername', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /account/password — change (or set) the caller's own password.
|
||||||
|
// • Account already has a password: require currentPassword and verify it.
|
||||||
|
// • SSO-provisioned account with a null hash: allow setting an initial password
|
||||||
|
// with no current password required.
|
||||||
|
// users.update rotates the hash and revokes existing sessions; we then re-issue
|
||||||
|
// this caller's session so their own change doesn't log them out.
|
||||||
|
async function changePassword(req, res) {
|
||||||
|
try {
|
||||||
|
const raw = await users.getRawById(req.user.id)
|
||||||
|
if (!raw) return res.status(401).json({ message: 'Unauthorized' })
|
||||||
|
|
||||||
|
if (raw.password_hash) {
|
||||||
|
const ok = await users.validatePassword(raw, req.body.currentPassword || '')
|
||||||
|
if (!ok) {
|
||||||
|
// A wrong current password is credential-guessing — trip the same
|
||||||
|
// backoff + bot scoring as a failed login.
|
||||||
|
loginProtection.recordFailure(req.ip)
|
||||||
|
botScore.recordLoginFailure(req.ip)
|
||||||
|
log.warn('changePassword wrong current password', { id: req.user.id, ip: req.ip })
|
||||||
|
return res.status(400).json({ message: 'Your current password is incorrect.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate the hash + revoke every existing session (users.update bumps the cutoff).
|
||||||
|
const updated = await users.update(req.user.id, { password: req.body.newPassword })
|
||||||
|
// Re-issue this caller's session, then rewind the cutoff just below the new
|
||||||
|
// token's issued-at so the inclusive cutoff test doesn't catch it (see users.db).
|
||||||
|
const session = reissueSession(req, res, updated)
|
||||||
|
if (session && session.createdAt) {
|
||||||
|
await users.setSessionCutoff(req.user.id, new Date(session.createdAt - 1000))
|
||||||
|
}
|
||||||
|
await activity.log({ req, action: 'account.password.change' })
|
||||||
|
log.info('account password changed', { id: req.user.id })
|
||||||
|
return res.json({ ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('changePassword', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: generate a fresh secret (stored but not yet enabled) and return the
|
// Step 1: generate a fresh secret (stored but not yet enabled) and return the
|
||||||
@@ -110,4 +207,13 @@ async function unlinkIdentity(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getAccount, totpSetup, totpEnable, totpDisable, listIdentities, unlinkIdentity }
|
module.exports = {
|
||||||
|
getAccount,
|
||||||
|
changeUsername,
|
||||||
|
changePassword,
|
||||||
|
totpSetup,
|
||||||
|
totpEnable,
|
||||||
|
totpDisable,
|
||||||
|
listIdentities,
|
||||||
|
unlinkIdentity,
|
||||||
|
}
|
||||||
|
|||||||
@@ -454,6 +454,13 @@ async function updateSettings(req, res) {
|
|||||||
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
||||||
return res.status(400).json({ message: 'Expected an object of key/value settings' })
|
return res.status(400).json({ message: 'Expected an object of key/value settings' })
|
||||||
}
|
}
|
||||||
|
// Enum-constrained keys are validated here (the store itself is schemaless).
|
||||||
|
if (
|
||||||
|
settings.REGISTRATION_KEY in updates &&
|
||||||
|
!settings.REGISTRATION_MODES.includes(updates[settings.REGISTRATION_KEY])
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ message: 'Invalid player_registration value' })
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await settings.setMany(updates, req.user.id)
|
await settings.setMany(updates, req.user.id)
|
||||||
await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } })
|
await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } })
|
||||||
@@ -493,8 +500,14 @@ async function createUser(req, res) {
|
|||||||
username: req.body.username,
|
username: req.body.username,
|
||||||
password: req.body.password,
|
password: req.body.password,
|
||||||
role: req.body.role || 'admin',
|
role: req.body.role || 'admin',
|
||||||
|
email: req.body.email || null,
|
||||||
|
status: req.body.status || 'active',
|
||||||
|
})
|
||||||
|
await activity.log({
|
||||||
|
req,
|
||||||
|
action: 'user.create',
|
||||||
|
detail: { id: user.id, username: user.username, role: user.role },
|
||||||
})
|
})
|
||||||
await activity.log({ req, action: 'user.create', detail: { id: user.id, username: user.username } })
|
|
||||||
return res.status(201).json(user)
|
return res.status(201).json(user)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('createUser', err)
|
log.error('createUser', err)
|
||||||
@@ -525,8 +538,26 @@ async function updateUser(req, res) {
|
|||||||
username: req.body.username,
|
username: req.body.username,
|
||||||
password: req.body.password,
|
password: req.body.password,
|
||||||
role: req.body.role,
|
role: req.body.role,
|
||||||
|
email: req.body.email,
|
||||||
|
status: req.body.status,
|
||||||
})
|
})
|
||||||
await activity.log({ req, action: 'user.update', detail: { id } })
|
await activity.log({ req, action: 'user.update', detail: { id } })
|
||||||
|
// Distinct audit trail for the security-sensitive fields (role & status),
|
||||||
|
// so a promotion/ban is greppable beyond the generic user.update entry.
|
||||||
|
if (req.body.role && req.body.role !== target.role) {
|
||||||
|
await activity.log({
|
||||||
|
req,
|
||||||
|
action: 'admin.user.role_change',
|
||||||
|
detail: { id, from: target.role, to: req.body.role },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (req.body.status && req.body.status !== target.status) {
|
||||||
|
await activity.log({
|
||||||
|
req,
|
||||||
|
action: 'admin.user.status_change',
|
||||||
|
detail: { id, from: target.status, to: req.body.status },
|
||||||
|
})
|
||||||
|
}
|
||||||
return res.json(user)
|
return res.json(user)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('updateUser', err)
|
log.error('updateUser', err)
|
||||||
|
|||||||
@@ -763,7 +763,9 @@ adminRouter.post(
|
|||||||
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
body('username').isString().trim().isLength({ min: 3, max: 32 }),
|
body('username').isString().trim().isLength({ min: 3, max: 32 }),
|
||||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||||
body('role').optional().isIn(['admin', 'editor', 'moderator']),
|
body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']),
|
||||||
|
body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']),
|
||||||
|
body('email').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
|
||||||
validate,
|
validate,
|
||||||
ctrl.createUser,
|
ctrl.createUser,
|
||||||
)
|
)
|
||||||
@@ -783,7 +785,9 @@ adminRouter.put(
|
|||||||
param('id').isInt(),
|
param('id').isInt(),
|
||||||
body('username').optional().isString().trim().isLength({ min: 3, max: 32 }),
|
body('username').optional().isString().trim().isLength({ min: 3, max: 32 }),
|
||||||
body('password').optional().isString().isLength({ min: 8, max: 64 }),
|
body('password').optional().isString().isLength({ min: 8, max: 64 }),
|
||||||
body('role').optional().isIn(['admin', 'editor', 'moderator']),
|
body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']),
|
||||||
|
body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']),
|
||||||
|
body('email').optional({ values: 'null' }).isEmail().isLength({ max: 255 }),
|
||||||
validate,
|
validate,
|
||||||
ctrl.updateUser,
|
ctrl.updateUser,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
const users = require('../../../model/users/users.model')
|
const users = require('../../../model/users/users.model')
|
||||||
const activity = require('../../../model/activity/activity.model')
|
const activity = require('../../../model/activity/activity.model')
|
||||||
|
const settings = require('../../../model/settings/settings.model')
|
||||||
const { setAuthCookie, clearAuthCookie } = require('../../../auth/token')
|
const { setAuthCookie, clearAuthCookie } = require('../../../auth/token')
|
||||||
const sessionService = require('../../../auth/session.service')
|
const sessionService = require('../../../auth/session.service')
|
||||||
const totp = require('../../../utils/totp')
|
const totp = require('../../../utils/totp')
|
||||||
const botScore = require('../../../middleware/botScore')
|
const botScore = require('../../../middleware/botScore')
|
||||||
const loginProtection = require('../../../middleware/loginProtection')
|
const loginProtection = require('../../../middleware/loginProtection')
|
||||||
|
const usernamePolicy = require('../../../auth/usernamePolicy')
|
||||||
|
|
||||||
const log = require('../../../utils/logger')('auth')
|
const log = require('../../../utils/logger')('auth')
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ function needsTotp(user) {
|
|||||||
// the second factor) — carried in the session token for downstream visibility.
|
// the second factor) — carried in the session token for downstream visibility.
|
||||||
async function issueSession(req, res, user, authMethod = 'local') {
|
async function issueSession(req, res, user, authMethod = 'local') {
|
||||||
loginProtection.recordSuccess(req.ip)
|
loginProtection.recordSuccess(req.ip)
|
||||||
await users.recordLogin(user.id)
|
await users.recordLogin(user.id, req.ip)
|
||||||
const { token } = sessionService.createSession(user, authMethod)
|
const { token } = sessionService.createSession(user, authMethod)
|
||||||
setAuthCookie(req, res, token)
|
setAuthCookie(req, res, token)
|
||||||
await activity.log({ req, userId: user.id, action: 'auth.login' })
|
await activity.log({ req, userId: user.id, action: 'auth.login' })
|
||||||
@@ -57,6 +59,14 @@ async function login(req, res) {
|
|||||||
return res.status(401).json(GENERIC_FAIL)
|
return res.status(401).json(GENERIC_FAIL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Correct credentials, but the account is disabled/banned (or pending): do
|
||||||
|
// not issue a session or a TOTP challenge. A distinct, clear message here is
|
||||||
|
// fine — the caller already proved the password, so this leaks nothing.
|
||||||
|
if (user.status && user.status !== 'active') {
|
||||||
|
log.warn('login refused: inactive account', { username, status: user.status, ip: req.ip })
|
||||||
|
return res.status(403).json({ message: 'This account is not active. Contact an administrator.' })
|
||||||
|
}
|
||||||
|
|
||||||
// Password is correct. If this user has TOTP on, do NOT issue a session yet —
|
// Password is correct. If this user has TOTP on, do NOT issue a session yet —
|
||||||
// hand back a short-lived, signed "password verified" challenge and require
|
// hand back a short-lived, signed "password verified" challenge and require
|
||||||
// the code. If TOTP is off, log them straight in.
|
// the code. If TOTP is off, log them straight in.
|
||||||
@@ -73,6 +83,57 @@ async function login(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public self-registration for a `player` account. Gated by the
|
||||||
|
// `player_registration` setting (must allow the password path) and hardened the
|
||||||
|
// same way as login: honeypot + registerLimiter + the global botScore guard.
|
||||||
|
// On success the new player is auto-logged-in (session cookie set).
|
||||||
|
async function register(req, res) {
|
||||||
|
// Honeypot: identical treatment to login — a filled hidden field is a bot.
|
||||||
|
if (req.body[HONEYPOT_FIELD]) {
|
||||||
|
botScore.recordHoneypot(req.ip)
|
||||||
|
loginProtection.recordFailure(req.ip)
|
||||||
|
log.warn('honeypot register hit', { ip: req.ip })
|
||||||
|
return res.status(400).json({ message: 'Registration failed.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mode = await settings.getRegistrationMode()
|
||||||
|
// Password self-registration is only open when the mode includes it.
|
||||||
|
if (mode !== 'password' && mode !== 'both') {
|
||||||
|
return res.status(403).json({ message: 'Registration is not open.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const check = usernamePolicy.validateUsername(req.body.username)
|
||||||
|
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||||
|
const email = req.body.email ? String(req.body.email).trim() : null
|
||||||
|
|
||||||
|
let user
|
||||||
|
try {
|
||||||
|
user = await users.createUser({
|
||||||
|
username: check.name,
|
||||||
|
password: req.body.password,
|
||||||
|
email,
|
||||||
|
role: 'player',
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// The UNIQUE index is the source of truth for the uniqueness race — a
|
||||||
|
// concurrent duplicate loses here and gets a clean 409.
|
||||||
|
if (users.isDuplicateUsername(err)) {
|
||||||
|
return res.status(409).json({ message: 'That username is already taken.' })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
await activity.log({ req, userId: user.id, action: 'auth.register', detail: { username: user.username } })
|
||||||
|
log.info('player registered', { username: user.username, id: user.id, ip: req.ip })
|
||||||
|
// New password accounts never have TOTP yet — log straight in.
|
||||||
|
return issueSession(req, res, user, 'local')
|
||||||
|
} catch (err) {
|
||||||
|
log.error('register error', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Second step for TOTP users: verify the challenge token + code, then issue the
|
// Second step for TOTP users: verify the challenge token + code, then issue the
|
||||||
// session. A wrong code counts as a failed attempt (backoff + bot score).
|
// session. A wrong code counts as a failed attempt (backoff + bot score).
|
||||||
async function loginTotp(req, res) {
|
async function loginTotp(req, res) {
|
||||||
@@ -127,4 +188,4 @@ async function me(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { login, loginTotp, logout, me, needsTotp, HONEYPOT_FIELD }
|
module.exports = { login, register, loginTotp, logout, me, needsTotp, HONEYPOT_FIELD }
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
const express = require('express')
|
const express = require('express')
|
||||||
const { body } = require('express-validator')
|
const { body } = require('express-validator')
|
||||||
|
|
||||||
const { login, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
|
const { login, register, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
|
||||||
const { isLoggedIn } = require('../../../utils/auth')
|
const { isLoggedIn } = require('../../../utils/auth')
|
||||||
const { attachSession } = require('../../../auth/session.middleware')
|
const { attachSession } = require('../../../auth/session.middleware')
|
||||||
const { loginLimiter } = require('../../../middleware/rateLimit')
|
const { loginLimiter, registerLimiter } = require('../../../middleware/rateLimit')
|
||||||
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
|
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
const mobileRouter = require('./mobile.routes')
|
const mobileRouter = require('./mobile.routes')
|
||||||
@@ -45,6 +45,30 @@ authRouter.post(
|
|||||||
login,
|
login,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Public self-registration (player accounts). Gated in the controller by the
|
||||||
|
// player_registration setting; here it reuses the login backoff/limiter stack
|
||||||
|
// plus its own per-IP cap, and accepts the honeypot field.
|
||||||
|
authRouter.post(
|
||||||
|
'/register',
|
||||||
|
// #swagger.tags = ['Auth']
|
||||||
|
// #swagger.summary = 'Register a player account'
|
||||||
|
// #swagger.description = 'Creates a self-service player account and logs it in (sets the session cookie). Available only when an admin has enabled password registration (player_registration = password|both); otherwise returns 403. Rate limited and behind bot/backoff guards; a hidden honeypot field must stay empty.'
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RegisterRequest" } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Account created and session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Validation error or unavailable username', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Registration is not open', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
...loginGuards,
|
||||||
|
registerLimiter,
|
||||||
|
body('username').isString().trim().isLength({ min: 3, max: 32 }),
|
||||||
|
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||||
|
body('email').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
|
||||||
|
body(HONEYPOT_FIELD).optional(),
|
||||||
|
validate,
|
||||||
|
register,
|
||||||
|
)
|
||||||
|
|
||||||
// Second factor: same throttling, since it's a code-guessing surface too.
|
// Second factor: same throttling, since it's a code-guessing surface too.
|
||||||
authRouter.post(
|
authRouter.post(
|
||||||
'/login/totp',
|
'/login/totp',
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ const users = require('../../../model/users/users.model')
|
|||||||
const activity = require('../../../model/activity/activity.model')
|
const activity = require('../../../model/activity/activity.model')
|
||||||
const authProviders = require('../../../model/authProviders/authProviders.model')
|
const authProviders = require('../../../model/authProviders/authProviders.model')
|
||||||
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
||||||
|
const settings = require('../../../model/settings/settings.model')
|
||||||
const registry = require('../../../auth/providers/registry')
|
const registry = require('../../../auth/providers/registry')
|
||||||
const sessionService = require('../../../auth/session.service')
|
const sessionService = require('../../../auth/session.service')
|
||||||
const ssoState = require('../../../auth/ssoState')
|
const ssoState = require('../../../auth/ssoState')
|
||||||
const token = require('../../../auth/token')
|
const token = require('../../../auth/token')
|
||||||
const totp = require('../../../utils/totp')
|
const totp = require('../../../utils/totp')
|
||||||
|
const usernamePolicy = require('../../../auth/usernamePolicy')
|
||||||
const botScore = require('../../../middleware/botScore')
|
const botScore = require('../../../middleware/botScore')
|
||||||
const loginProtection = require('../../../middleware/loginProtection')
|
const loginProtection = require('../../../middleware/loginProtection')
|
||||||
const { needsTotp } = require('./auth.controller')
|
const { needsTotp } = require('./auth.controller')
|
||||||
@@ -27,15 +29,32 @@ const { needsTotp } = require('./auth.controller')
|
|||||||
const log = require('../../../utils/logger')('sso')
|
const log = require('../../../utils/logger')('sso')
|
||||||
|
|
||||||
const PROVIDER_ID_RE = /^[a-z0-9-]+$/
|
const PROVIDER_ID_RE = /^[a-z0-9-]+$/
|
||||||
|
// How many username suffixes to try before giving up on auto-provision.
|
||||||
|
const PROVISION_MAX_TRIES = 25
|
||||||
|
|
||||||
|
// Which front-end area a flow belongs to, derived from its returnTo. Players
|
||||||
|
// drive SSO from /account*, staff from /admin*; defaults to admin. This is what
|
||||||
|
// makes error/TOTP/success redirects land the caller back in their own portal.
|
||||||
|
function portalFor(returnTo) {
|
||||||
|
return typeof returnTo === 'string' && /^\/account(?:[/?]|$)/.test(returnTo) ? 'account' : 'admin'
|
||||||
|
}
|
||||||
|
const loginPath = (portal) => (portal === 'account' ? '/account/login' : '/admin/login')
|
||||||
|
const accountPath = (portal) => (portal === 'account' ? '/account' : '/admin/account')
|
||||||
|
const homePath = (portal) => (portal === 'account' ? '/account' : '/admin')
|
||||||
|
|
||||||
// Redirect targets (front-end routes). Errors surface as a query param the login
|
// Redirect targets (front-end routes). Errors surface as a query param the login
|
||||||
// / account pages can render.
|
// / account pages can render. Portal-aware so a player flow stays in /account*.
|
||||||
const loginError = (code) => `/admin/login?sso_error=${code}`
|
const loginError = (code, portal = 'admin') => `${loginPath(portal)}?sso_error=${code}`
|
||||||
const accountError = (code) => `/admin/account?link_error=${code}`
|
const accountError = (code, portal = 'admin') => `${accountPath(portal)}?link_error=${code}`
|
||||||
|
|
||||||
// Only allow returning to an internal /admin path (prevents open redirect).
|
// Only allow returning to an internal /admin or /account path (prevents open
|
||||||
|
// redirect). Both areas are first-party SPA routes.
|
||||||
function sanitizeReturn(returnTo) {
|
function sanitizeReturn(returnTo) {
|
||||||
if (typeof returnTo === 'string' && /^\/admin(?:[/?]|$)/.test(returnTo) && !returnTo.startsWith('//')) {
|
if (
|
||||||
|
typeof returnTo === 'string' &&
|
||||||
|
/^\/(admin|account)(?:[/?]|$)/.test(returnTo) &&
|
||||||
|
!returnTo.startsWith('//')
|
||||||
|
) {
|
||||||
return returnTo
|
return returnTo
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -81,20 +100,24 @@ async function listProviders(req, res) {
|
|||||||
// requireAuth has already run so req.user is the account to attach the identity to.
|
// requireAuth has already run so req.user is the account to attach the identity to.
|
||||||
async function beginFlow(req, res, mode) {
|
async function beginFlow(req, res, mode) {
|
||||||
const providerId = req.params.provider
|
const providerId = req.params.provider
|
||||||
const failUrl = mode === 'link' ? accountError('error') : loginError('error')
|
const returnTo = sanitizeReturn(req.query.returnTo)
|
||||||
|
const portal = portalFor(returnTo)
|
||||||
|
const failUrl = mode === 'link' ? accountError('error', portal) : loginError('error', portal)
|
||||||
try {
|
try {
|
||||||
if (!PROVIDER_ID_RE.test(providerId)) return res.redirect(failUrl)
|
if (!PROVIDER_ID_RE.test(providerId)) return res.redirect(failUrl)
|
||||||
const row = await authProviders.getWithSecret(providerId)
|
const row = await authProviders.getWithSecret(providerId)
|
||||||
if (!row || !row.enabled || !registry.validateConfig(row).valid) {
|
if (!row || !row.enabled || !registry.validateConfig(row).valid) {
|
||||||
log.warn('sso start: provider unavailable', { provider: providerId, mode })
|
log.warn('sso start: provider unavailable', { provider: providerId, mode })
|
||||||
return res.redirect(mode === 'link' ? accountError('unavailable') : loginError('unavailable'))
|
return res.redirect(
|
||||||
|
mode === 'link' ? accountError('unavailable', portal) : loginError('unavailable', portal),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
const provider = registry.instantiate(row)
|
const provider = registry.instantiate(row)
|
||||||
const tx = ssoState.createTx({
|
const tx = ssoState.createTx({
|
||||||
provider: providerId,
|
provider: providerId,
|
||||||
mode,
|
mode,
|
||||||
linkUserId: mode === 'link' ? req.user.id : undefined,
|
linkUserId: mode === 'link' ? req.user.id : undefined,
|
||||||
returnTo: sanitizeReturn(req.query.returnTo) || undefined,
|
returnTo: returnTo || undefined,
|
||||||
})
|
})
|
||||||
res.cookie(ssoState.TX_COOKIE, tx.txToken, txCookieOptions(req))
|
res.cookie(ssoState.TX_COOKIE, tx.txToken, txCookieOptions(req))
|
||||||
const url = provider.getAuthorizationUrl(tx.nonce, {
|
const url = provider.getAuthorizationUrl(tx.nonce, {
|
||||||
@@ -129,10 +152,13 @@ async function callback(req, res) {
|
|||||||
return res.redirect(loginError('bad_state'))
|
return res.redirect(loginError('bad_state'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tx is verified — steer failures back to the portal (and page) the flow began in.
|
||||||
|
const portal = portalFor(tx.returnTo)
|
||||||
|
const failFor = (code) => (tx.mode === 'link' ? accountError(code, portal) : loginError(code, portal))
|
||||||
try {
|
try {
|
||||||
const row = await authProviders.getWithSecret(providerId)
|
const row = await authProviders.getWithSecret(providerId)
|
||||||
if (!row || !row.enabled || !registry.validateConfig(row).valid) {
|
if (!row || !row.enabled || !registry.validateConfig(row).valid) {
|
||||||
return res.redirect(loginError('unavailable'))
|
return res.redirect(failFor('unavailable'))
|
||||||
}
|
}
|
||||||
const provider = registry.instantiate(row)
|
const provider = registry.instantiate(row)
|
||||||
const profile = await provider.handleCallback({
|
const profile = await provider.handleCallback({
|
||||||
@@ -144,19 +170,75 @@ async function callback(req, res) {
|
|||||||
return finishLogin(req, res, providerId, row.kind, tx, profile)
|
return finishLogin(req, res, providerId, row.kind, tx, profile)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('sso callback', err)
|
log.error('sso callback', err)
|
||||||
return res.redirect(loginError('error'))
|
return res.redirect(failFor('error'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Link-only login: require an existing (provider, subject) identity → session.
|
// Auto-provision a `player` from an SSO profile when no identity is linked yet
|
||||||
async function finishLogin(req, res, providerId, kind, tx, profile) {
|
// and registration allows SSO sign-up. Derives a unique username (reserved-name
|
||||||
const identity = await userIdentities.findByProviderSubject(providerId, profile.subject)
|
// safe) with a bounded retry against the UNIQUE index, captures the provider
|
||||||
if (!identity) {
|
// email, links the identity, and audit-logs the provision. Returns the new user,
|
||||||
log.warn('sso login refused: no linked account', { provider: providerId })
|
// or null if a unique username couldn't be found.
|
||||||
return res.redirect(loginError('not_linked'))
|
async function provisionSsoPlayer(req, providerId, profile) {
|
||||||
|
const base = usernamePolicy.deriveUsernameBase(profile)
|
||||||
|
for (let attempt = 0; attempt < PROVISION_MAX_TRIES; attempt++) {
|
||||||
|
const candidate = usernamePolicy.candidateUsername(base, attempt)
|
||||||
|
try {
|
||||||
|
const user = await users.createUser({
|
||||||
|
username: candidate,
|
||||||
|
role: 'player',
|
||||||
|
email: profile.email || null,
|
||||||
|
// The built-in providers only return an email the IdP has verified, so
|
||||||
|
// treat a supplied address as verified (skips the eventual re-verify).
|
||||||
|
emailVerified: Boolean(profile.email),
|
||||||
|
})
|
||||||
|
await userIdentities.link({
|
||||||
|
userId: user.id,
|
||||||
|
provider: providerId,
|
||||||
|
subject: profile.subject,
|
||||||
|
email: profile.email,
|
||||||
|
})
|
||||||
|
await activity.log({ req, userId: user.id, action: 'auth.sso.provision', detail: { provider: providerId } })
|
||||||
|
log.info('sso player provisioned', { provider: providerId, id: user.id, username: user.username })
|
||||||
|
return user
|
||||||
|
} catch (err) {
|
||||||
|
// Username collided with a concurrent/existing account — try the next
|
||||||
|
// suffix. Any other error is real; propagate it.
|
||||||
|
if (users.isDuplicateUsername(err)) continue
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.error('sso provision: exhausted username candidates', { provider: providerId, base })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSO login. Normally link-only: a login succeeds only if the external identity
|
||||||
|
// is already linked. The one setting-gated relaxation is auto-provisioning a
|
||||||
|
// player when player_registration ∈ {sso, both} (see provisionSsoPlayer).
|
||||||
|
async function finishLogin(req, res, providerId, kind, tx, profile) {
|
||||||
|
const portal = portalFor(tx.returnTo)
|
||||||
|
let user
|
||||||
|
const identity = await userIdentities.findByProviderSubject(providerId, profile.subject)
|
||||||
|
if (identity) {
|
||||||
|
user = await users.getById(identity.user_id)
|
||||||
|
if (!user) return res.redirect(loginError('not_linked', portal))
|
||||||
|
} else {
|
||||||
|
// Unknown identity: auto-provision only if registration opts into SSO sign-up.
|
||||||
|
const mode = await settings.getRegistrationMode()
|
||||||
|
if (mode !== 'sso' && mode !== 'both') {
|
||||||
|
log.warn('sso login refused: no linked account', { provider: providerId })
|
||||||
|
return res.redirect(loginError('not_linked', portal))
|
||||||
|
}
|
||||||
|
user = await provisionSsoPlayer(req, providerId, profile)
|
||||||
|
if (!user) return res.redirect(loginError('error', portal))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status gate (parity with local login): a disabled/banned account can't
|
||||||
|
// complete SSO login either.
|
||||||
|
if (user.status && user.status !== 'active') {
|
||||||
|
log.warn('sso login refused: inactive account', { provider: providerId, id: user.id, status: user.status })
|
||||||
|
return res.redirect(loginError('disabled', portal))
|
||||||
}
|
}
|
||||||
const user = await users.getById(identity.user_id)
|
|
||||||
if (!user) return res.redirect(loginError('not_linked'))
|
|
||||||
|
|
||||||
const authMethod = sessionService.AUTH_METHODS.includes(kind) ? kind : 'sso'
|
const authMethod = sessionService.AUTH_METHODS.includes(kind) ? kind : 'sso'
|
||||||
|
|
||||||
@@ -173,15 +255,15 @@ async function finishLogin(req, res, providerId, kind, tx, profile) {
|
|||||||
})
|
})
|
||||||
res.cookie(ssoState.TOTP_COOKIE, pending, totpCookieOptions(req))
|
res.cookie(ssoState.TOTP_COOKIE, pending, totpCookieOptions(req))
|
||||||
log.info('sso login: awaiting TOTP', { provider: providerId, id: user.id, ip: req.ip })
|
log.info('sso login: awaiting TOTP', { provider: providerId, id: user.id, ip: req.ip })
|
||||||
return res.redirect('/admin/login?sso_totp=1')
|
return res.redirect(`${loginPath(portal)}?sso_totp=1`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { token: sessionToken } = sessionService.createSession(user, authMethod)
|
const { token: sessionToken } = sessionService.createSession(user, authMethod)
|
||||||
token.setAuthCookie(req, res, sessionToken)
|
token.setAuthCookie(req, res, sessionToken)
|
||||||
await users.recordLogin(user.id)
|
await users.recordLogin(user.id, req.ip)
|
||||||
await activity.log({ req, userId: user.id, action: 'auth.sso.login', detail: { provider: providerId } })
|
await activity.log({ req, userId: user.id, action: 'auth.sso.login', detail: { provider: providerId } })
|
||||||
log.info('sso login success', { provider: providerId, id: user.id, ip: req.ip })
|
log.info('sso login success', { provider: providerId, id: user.id, ip: req.ip })
|
||||||
return res.redirect(sanitizeReturn(tx.returnTo) || '/admin')
|
return res.redirect(sanitizeReturn(tx.returnTo) || homePath(portal))
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /auth/sso/totp — second factor for an SSO login whose account has TOTP on.
|
// POST /auth/sso/totp — second factor for an SSO login whose account has TOTP on.
|
||||||
@@ -203,18 +285,26 @@ async function finishSsoTotp(req, res) {
|
|||||||
return res.status(401).json({ message: 'Invalid verification code.' })
|
return res.status(401).json({ message: 'Invalid verification code.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Correct second factor, but the account is disabled/banned since the flow
|
||||||
|
// started — refuse and clear the staged cookie.
|
||||||
|
if (user.status && user.status !== 'active') {
|
||||||
|
res.clearCookie(ssoState.TOTP_COOKIE, token.cookieOptions(req))
|
||||||
|
log.warn('sso TOTP refused: inactive account', { id: user.id, status: user.status })
|
||||||
|
return res.status(403).json({ message: 'This account is not active. Contact an administrator.' })
|
||||||
|
}
|
||||||
|
|
||||||
// Second factor satisfied — clear the staged cookie and issue the real session.
|
// Second factor satisfied — clear the staged cookie and issue the real session.
|
||||||
res.clearCookie(ssoState.TOTP_COOKIE, token.cookieOptions(req))
|
res.clearCookie(ssoState.TOTP_COOKIE, token.cookieOptions(req))
|
||||||
loginProtection.recordSuccess(req.ip)
|
loginProtection.recordSuccess(req.ip)
|
||||||
const authMethod = sessionService.AUTH_METHODS.includes(pending.authMethod) ? pending.authMethod : 'sso'
|
const authMethod = sessionService.AUTH_METHODS.includes(pending.authMethod) ? pending.authMethod : 'sso'
|
||||||
const { token: sessionToken } = sessionService.createSession(user, authMethod)
|
const { token: sessionToken } = sessionService.createSession(user, authMethod)
|
||||||
token.setAuthCookie(req, res, sessionToken)
|
token.setAuthCookie(req, res, sessionToken)
|
||||||
await users.recordLogin(user.id)
|
await users.recordLogin(user.id, req.ip)
|
||||||
await activity.log({ req, userId: user.id, action: 'auth.sso.login', detail: { provider: pending.provider, totp: true } })
|
await activity.log({ req, userId: user.id, action: 'auth.sso.login', detail: { provider: pending.provider, totp: true } })
|
||||||
log.info('sso login success (2fa)', { provider: pending.provider, id: user.id, ip: req.ip })
|
log.info('sso login success (2fa)', { provider: pending.provider, id: user.id, ip: req.ip })
|
||||||
return res.json({
|
return res.json({
|
||||||
user: { id: user.id, username: user.username, role: user.role },
|
user: { id: user.id, username: user.username, role: user.role },
|
||||||
returnTo: sanitizeReturn(pending.returnTo) || '/admin',
|
returnTo: sanitizeReturn(pending.returnTo) || homePath(portalFor(pending.returnTo)),
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('sso totp error', err)
|
log.error('sso totp error', err)
|
||||||
@@ -225,18 +315,19 @@ async function finishSsoTotp(req, res) {
|
|||||||
// Attach the external identity to the account that initiated linking (tx.linkUserId
|
// Attach the external identity to the account that initiated linking (tx.linkUserId
|
||||||
// was captured behind requireAuth at /link start, so the signed tx authorizes it).
|
// was captured behind requireAuth at /link start, so the signed tx authorizes it).
|
||||||
async function finishLink(req, res, providerId, tx, profile) {
|
async function finishLink(req, res, providerId, tx, profile) {
|
||||||
|
const portal = portalFor(tx.returnTo)
|
||||||
const userId = tx.linkUserId
|
const userId = tx.linkUserId
|
||||||
if (!userId) return res.redirect(loginError('error'))
|
if (!userId) return res.redirect(loginError('error', portal))
|
||||||
const existing = await userIdentities.findByProviderSubject(providerId, profile.subject)
|
const existing = await userIdentities.findByProviderSubject(providerId, profile.subject)
|
||||||
if (existing && existing.user_id !== userId) {
|
if (existing && existing.user_id !== userId) {
|
||||||
return res.redirect(accountError('in_use')) // that external identity belongs to another account
|
return res.redirect(accountError('in_use', portal)) // external identity belongs to another account
|
||||||
}
|
}
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
await userIdentities.link({ userId, provider: providerId, subject: profile.subject, email: profile.email })
|
await userIdentities.link({ userId, provider: providerId, subject: profile.subject, email: profile.email })
|
||||||
await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } })
|
await activity.log({ req, userId, action: 'auth.sso.link', detail: { provider: providerId } })
|
||||||
log.info('sso account linked', { provider: providerId, userId })
|
log.info('sso account linked', { provider: providerId, userId })
|
||||||
}
|
}
|
||||||
return res.redirect(`/admin/account?linked=${providerId}`)
|
return res.redirect(`${accountPath(portal)}?linked=${providerId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { listProviders, start, linkStart, callback, beginFlow, finishLogin, finishSsoTotp, finishLink }
|
module.exports = { listProviders, start, linkStart, callback, beginFlow, finishLogin, finishSsoTotp, finishLink }
|
||||||
|
|||||||
132
server/src/router/v1/player/player.routes.js
Normal file
132
server/src/router/v1/player/player.routes.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// ── Player self-service (role: 'player') ───────────────────────────────────
|
||||||
|
//
|
||||||
|
// The player-gated surface. Every route here requires an authenticated session
|
||||||
|
// whose fresh DB role is 'player' (staff use /admin/account for the same self-
|
||||||
|
// service). Handlers are shared with the admin account view (account.controller)
|
||||||
|
// — the same TOTP / identity logic, plus the net-new self-scoped credential
|
||||||
|
// changes. Future player-only endpoints (profile, etc.) hang off this group.
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const { body, param } = require('express-validator')
|
||||||
|
|
||||||
|
const account = require('../admin/account.controller')
|
||||||
|
const { requireAuth, requireRole } = require('../../../auth/session.middleware')
|
||||||
|
const noindex = require('../../../middleware/noindex')
|
||||||
|
const validate = require('../../../middleware/validate')
|
||||||
|
const { accountChangeLimiter } = require('../../../middleware/rateLimit')
|
||||||
|
|
||||||
|
const playerRouter = express.Router()
|
||||||
|
|
||||||
|
// Group gate: authenticated + fresh role must be 'player', and keep it out of
|
||||||
|
// search indexes. requireAuth also enforces the account status check (a
|
||||||
|
// disabled/banned player is rejected here with 403 before any handler runs).
|
||||||
|
playerRouter.use(noindex, requireAuth, requireRole('player'))
|
||||||
|
|
||||||
|
playerRouter.get(
|
||||||
|
'/account',
|
||||||
|
// #swagger.tags = ['Player']
|
||||||
|
// #swagger.summary = 'Get the current player account (self)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'The player account', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerAccount" } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Player role required, or account not active', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
account.getAccount,
|
||||||
|
)
|
||||||
|
|
||||||
|
playerRouter.patch(
|
||||||
|
'/account/username',
|
||||||
|
// #swagger.tags = ['Player']
|
||||||
|
// #swagger.summary = 'Change the current player’s username'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangeUsernameRequest" } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Updated username (session cookie re-issued)', content: { "application/json": { schema: { type: "object", properties: { username: { type: "string" } } } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Validation error or unavailable username', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Player role required, or account not active', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
accountChangeLimiter,
|
||||||
|
body('username').isString().trim().isLength({ min: 3, max: 32 }),
|
||||||
|
validate,
|
||||||
|
account.changeUsername,
|
||||||
|
)
|
||||||
|
|
||||||
|
playerRouter.patch(
|
||||||
|
'/account/password',
|
||||||
|
// #swagger.tags = ['Player']
|
||||||
|
// #swagger.summary = 'Change or set the current player’s password'
|
||||||
|
// #swagger.description = 'If the account already has a password, currentPassword is required and verified. SSO-provisioned accounts with no password may set an initial one without a current password. On success the caller’s session is re-issued (they stay logged in) while all other sessions are revoked.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangePasswordRequest" } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Password changed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Validation error or wrong current password', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Player role required, or account not active', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
accountChangeLimiter,
|
||||||
|
body('newPassword').isString().isLength({ min: 8, max: 64 }),
|
||||||
|
body('currentPassword').optional({ values: 'falsy' }).isString(),
|
||||||
|
validate,
|
||||||
|
account.changePassword,
|
||||||
|
)
|
||||||
|
|
||||||
|
// TOTP self-enrollment — identical to the admin account flow (disable requires a
|
||||||
|
// valid current code; it does not take a password).
|
||||||
|
playerRouter.post(
|
||||||
|
'/account/totp/setup',
|
||||||
|
// #swagger.tags = ['Player']
|
||||||
|
// #swagger.summary = 'Begin 2FA enrollment (returns secret + QR)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpSetup" } } } } */
|
||||||
|
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
account.totpSetup,
|
||||||
|
)
|
||||||
|
playerRouter.post(
|
||||||
|
'/account/totp/enable',
|
||||||
|
// #swagger.tags = ['Player']
|
||||||
|
// #swagger.summary = 'Enable 2FA by confirming a code'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Setup not started, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
body('code').isString().trim().isLength({ min: 6, max: 8 }),
|
||||||
|
validate,
|
||||||
|
account.totpEnable,
|
||||||
|
)
|
||||||
|
playerRouter.post(
|
||||||
|
'/account/totp/disable',
|
||||||
|
// #swagger.tags = ['Player']
|
||||||
|
// #swagger.summary = 'Disable 2FA by confirming a code'
|
||||||
|
// #swagger.description = 'Requires a valid current authenticator code (proves control of the authenticator); it does not take a password.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Not enabled, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
body('code').isString().trim().isLength({ min: 6, max: 8 }),
|
||||||
|
validate,
|
||||||
|
account.totpDisable,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Linked SSO identities (self-service). Linking itself starts at
|
||||||
|
// GET /auth/sso/:provider/link (already behind requireAuth; works for players).
|
||||||
|
playerRouter.get(
|
||||||
|
'/account/identities',
|
||||||
|
// #swagger.tags = ['Player']
|
||||||
|
// #swagger.summary = 'List linked SSO identities (self)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/LinkedIdentity" } } } } } */
|
||||||
|
account.listIdentities,
|
||||||
|
)
|
||||||
|
playerRouter.delete(
|
||||||
|
'/account/identities/:provider',
|
||||||
|
// #swagger.tags = ['Player']
|
||||||
|
// #swagger.summary = 'Unlink an SSO identity (self)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('provider').matches(/^[a-z0-9-]+$/),
|
||||||
|
validate,
|
||||||
|
account.unlinkIdentity,
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = playerRouter
|
||||||
@@ -5,10 +5,12 @@ const v1Router = express.Router()
|
|||||||
const authRouter = require('./auth/auth.routes')
|
const authRouter = require('./auth/auth.routes')
|
||||||
const publicRouter = require('./public/public.routes')
|
const publicRouter = require('./public/public.routes')
|
||||||
const adminRouter = require('./admin/admin.routes')
|
const adminRouter = require('./admin/admin.routes')
|
||||||
|
const playerRouter = require('./player/player.routes')
|
||||||
|
|
||||||
v1Router.use('/auth', authRouter)
|
v1Router.use('/auth', authRouter)
|
||||||
v1Router.use('/public', publicRouter)
|
v1Router.use('/public', publicRouter)
|
||||||
v1Router.use('/admin', adminRouter)
|
v1Router.use('/admin', adminRouter)
|
||||||
|
v1Router.use('/player', playerRouter)
|
||||||
// NOTE: /internal is intentionally NOT mounted here. Those routes return the
|
// NOTE: /internal is intentionally NOT mounted here. Those routes return the
|
||||||
// decrypted Discord bot token and must never share the public listener that
|
// decrypted Discord bot token and must never share the public listener that
|
||||||
// Pangolin proxies. They live on a separate, unpublished port via
|
// Pangolin proxies. They live on a separate, unpublished port via
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,7 @@ const doc = {
|
|||||||
{ name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' },
|
{ name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' },
|
||||||
{ name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' },
|
{ name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' },
|
||||||
{ name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' },
|
{ name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' },
|
||||||
|
{ name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' },
|
||||||
{ name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' },
|
{ name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' },
|
||||||
{ name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
|
{ name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
|
||||||
{ name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' },
|
{ name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' },
|
||||||
@@ -113,6 +114,16 @@ const doc = {
|
|||||||
company: { type: 'string', description: 'Honeypot — must be empty for humans.', example: '' },
|
company: { type: 'string', description: 'Honeypot — must be empty for humans.', example: '' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
RegisterRequest: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['username', 'password'],
|
||||||
|
properties: {
|
||||||
|
username: { type: 'string', minLength: 3, maxLength: 32, example: 'newplayer' },
|
||||||
|
password: { type: 'string', format: 'password', minLength: 8, maxLength: 64 },
|
||||||
|
email: { type: 'string', format: 'email', nullable: true, example: 'player@example.com' },
|
||||||
|
company: { type: 'string', description: 'Honeypot — must be empty for humans.', example: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
LoginResponse: {
|
LoginResponse: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description:
|
description:
|
||||||
@@ -340,7 +351,10 @@ const doc = {
|
|||||||
properties: {
|
properties: {
|
||||||
id: { type: 'integer', example: 1 },
|
id: { type: 'integer', example: 1 },
|
||||||
username: { type: 'string', example: 'admin' },
|
username: { type: 'string', example: 'admin' },
|
||||||
role: { type: 'string', enum: ['admin', 'editor'], example: 'admin' },
|
role: { type: 'string', enum: ['admin', 'editor', 'moderator', 'player'], example: 'admin' },
|
||||||
|
status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' },
|
||||||
|
email: { type: 'string', format: 'email', nullable: true },
|
||||||
|
email_verified: { type: 'boolean', example: false },
|
||||||
totp_enabled: { type: 'boolean', example: true },
|
totp_enabled: { type: 'boolean', example: true },
|
||||||
last_login_at: { type: 'string', format: 'date-time', nullable: true },
|
last_login_at: { type: 'string', format: 'date-time', nullable: true },
|
||||||
created_at: { type: 'string', format: 'date-time' },
|
created_at: { type: 'string', format: 'date-time' },
|
||||||
@@ -352,9 +366,53 @@ const doc = {
|
|||||||
properties: {
|
properties: {
|
||||||
username: { type: 'string', minLength: 3, maxLength: 32, example: 'editor1' },
|
username: { type: 'string', minLength: 3, maxLength: 32, example: 'editor1' },
|
||||||
password: { type: 'string', format: 'password', minLength: 8, maxLength: 64 },
|
password: { type: 'string', format: 'password', minLength: 8, maxLength: 64 },
|
||||||
role: { type: 'string', enum: ['admin', 'editor'], example: 'editor' },
|
role: { type: 'string', enum: ['admin', 'editor', 'moderator', 'player'], example: 'editor' },
|
||||||
|
status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' },
|
||||||
|
email: { type: 'string', format: 'email', nullable: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Player self-service credential changes (/api/v1/player/account/*).
|
||||||
|
ChangeUsernameRequest: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['username'],
|
||||||
|
properties: {
|
||||||
|
username: { type: 'string', minLength: 3, maxLength: 32, example: 'newname' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ChangePasswordRequest: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['newPassword'],
|
||||||
|
properties: {
|
||||||
|
newPassword: { type: 'string', format: 'password', minLength: 8, maxLength: 64 },
|
||||||
|
currentPassword: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'password',
|
||||||
|
description:
|
||||||
|
'Required when the account already has a password. Omit only for an SSO-provisioned account setting its first password.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PlayerAccount: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Self-service player account (GET /player/account).',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'integer', example: 42 },
|
||||||
|
username: { type: 'string', example: 'newplayer' },
|
||||||
|
role: { type: 'string', enum: ['player'], example: 'player' },
|
||||||
|
email: { type: 'string', format: 'email', nullable: true, example: 'player@example.com' },
|
||||||
|
status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' },
|
||||||
|
totp_enabled: { type: 'boolean', example: false },
|
||||||
|
has_password: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'False for an SSO-provisioned account that has not set a password yet.',
|
||||||
|
example: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OkFlag: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { ok: { type: 'boolean', example: true } },
|
||||||
|
},
|
||||||
TotpCodeRequest: {
|
TotpCodeRequest: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['code'],
|
required: ['code'],
|
||||||
|
|||||||
112
server/test/playerAccounts.test.js
Normal file
112
server/test/playerAccounts.test.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// Point the DB at a closed port BEFORE requiring anything that builds the pool,
|
||||||
|
// so the one branch that reaches the DB fails fast instead of hanging the runner.
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const bcrypt = require('bcryptjs')
|
||||||
|
|
||||||
|
const authCtrl = require('../src/router/v1/auth/auth.controller')
|
||||||
|
const account = require('../src/router/v1/admin/account.controller')
|
||||||
|
const users = require('../src/model/users/users.model')
|
||||||
|
const settings = require('../src/model/settings/settings.model')
|
||||||
|
const botScore = require('../src/middleware/botScore')
|
||||||
|
const lp = require('../src/middleware/loginProtection')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
function mockRes() {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
status(c) {
|
||||||
|
this.statusCode = c
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
json(b) {
|
||||||
|
this.body = b
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
set() {
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
cookie() {
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
botScore._reset()
|
||||||
|
lp._reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Derived public registration flags ─────────────────────────────────────
|
||||||
|
test('registrationFlags maps each mode to password/sso booleans', () => {
|
||||||
|
assert.deepEqual(settings.registrationFlags('disabled'), { password: false, sso: false })
|
||||||
|
assert.deepEqual(settings.registrationFlags('password'), { password: true, sso: false })
|
||||||
|
assert.deepEqual(settings.registrationFlags('sso'), { password: false, sso: true })
|
||||||
|
assert.deepEqual(settings.registrationFlags('both'), { password: true, sso: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('REGISTRATION_MODES is the closed set of allowed values', () => {
|
||||||
|
assert.deepEqual(settings.REGISTRATION_MODES, ['disabled', 'password', 'sso', 'both'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Null-hash password rule ────────────────────────────────────────────────
|
||||||
|
test('validatePassword rejects an SSO-only account with a null hash', async () => {
|
||||||
|
assert.equal(await users.validatePassword({ password_hash: null }, 'anything'), false)
|
||||||
|
assert.equal(await users.validatePassword(null, 'anything'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validatePassword accepts a correct password against a real hash', async () => {
|
||||||
|
const password_hash = await bcrypt.hash('correct horse', 10)
|
||||||
|
assert.equal(await users.validatePassword({ password_hash }, 'correct horse'), true)
|
||||||
|
assert.equal(await users.validatePassword({ password_hash }, 'wrong'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isDuplicateUsername recognizes the driver duplicate-key error', () => {
|
||||||
|
assert.equal(users.isDuplicateUsername({ code: 'ER_DUP_ENTRY' }), true)
|
||||||
|
assert.equal(users.isDuplicateUsername({ errno: 1062 }), true)
|
||||||
|
assert.equal(users.isDuplicateUsername({ code: 'ER_NO_SUCH_TABLE' }), false)
|
||||||
|
assert.equal(users.isDuplicateUsername(null), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── getAccount.has_password reads the RAW row ─────────────────────────────
|
||||||
|
// Regression: req.user is the sanitized row (password_hash stripped), so
|
||||||
|
// has_password must come from users.getRawById, not req.user.password_hash —
|
||||||
|
// otherwise a real password account is mis-rendered as "set a password".
|
||||||
|
test('getAccount reports has_password from the raw row, not the sanitized req.user', async () => {
|
||||||
|
const origGetRaw = users.getRawById
|
||||||
|
try {
|
||||||
|
users.getRawById = async () => ({ id: 1, password_hash: '$2a$hash' }) // has a password
|
||||||
|
const req = { user: { id: 1, username: 'p', role: 'player', status: 'active', totp_enabled: 0 } } // sanitized: no hash
|
||||||
|
const res = mockRes()
|
||||||
|
await account.getAccount(req, res)
|
||||||
|
assert.equal(res.body.has_password, true)
|
||||||
|
|
||||||
|
users.getRawById = async () => ({ id: 1, password_hash: null }) // SSO-only, no password
|
||||||
|
const res2 = mockRes()
|
||||||
|
await account.getAccount(req, res2)
|
||||||
|
assert.equal(res2.body.has_password, false)
|
||||||
|
} finally {
|
||||||
|
users.getRawById = origGetRaw
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Registration honeypot (does not need the DB) ──────────────────────────
|
||||||
|
test('register with a filled honeypot fails and bans the IP before any DB hit', async () => {
|
||||||
|
const ip = '203.0.113.90'
|
||||||
|
const req = {
|
||||||
|
ip,
|
||||||
|
body: { username: 'newplayer', password: 'password123', [authCtrl.HONEYPOT_FIELD]: 'Acme' },
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await authCtrl.register(req, res)
|
||||||
|
|
||||||
|
assert.equal(res.statusCode, 400)
|
||||||
|
assert.doesNotMatch(res.body.message, /honeypot|bot|company/i)
|
||||||
|
assert.equal(botScore.isBanned(ip), true)
|
||||||
|
})
|
||||||
@@ -14,6 +14,7 @@ const users = require('../src/model/users/users.model')
|
|||||||
const activity = require('../src/model/activity/activity.model')
|
const activity = require('../src/model/activity/activity.model')
|
||||||
const authProviders = require('../src/model/authProviders/authProviders.model')
|
const authProviders = require('../src/model/authProviders/authProviders.model')
|
||||||
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
|
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
|
||||||
|
const settings = require('../src/model/settings/settings.model')
|
||||||
const registry = require('../src/auth/providers/registry')
|
const registry = require('../src/auth/providers/registry')
|
||||||
const totp = require('../src/utils/totp')
|
const totp = require('../src/utils/totp')
|
||||||
const db = require('../src/utils/db')
|
const db = require('../src/utils/db')
|
||||||
@@ -34,6 +35,9 @@ beforeEach(() => {
|
|||||||
userIdentities.link = async () => 1
|
userIdentities.link = async () => 1
|
||||||
users.getById = async (id) => ({ id, username: 'alice', role: 'admin' })
|
users.getById = async (id) => ({ id, username: 'alice', role: 'admin' })
|
||||||
users.recordLogin = async () => {} // avoid the real DB on the success path
|
users.recordLogin = async () => {} // avoid the real DB on the success path
|
||||||
|
// Default: registration closed, so login stays strictly link-only unless a
|
||||||
|
// test opts into SSO sign-up.
|
||||||
|
settings.getRegistrationMode = async () => 'disabled'
|
||||||
})
|
})
|
||||||
|
|
||||||
function mockRes() {
|
function mockRes() {
|
||||||
@@ -87,6 +91,39 @@ test('UNLINKED identity → no session, redirect to not_linked (link-only policy
|
|||||||
assert.equal(logged.length, 0)
|
assert.equal(logged.length, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('UNLINKED identity + SSO sign-up enabled → auto-provisions a player and logs in', async () => {
|
||||||
|
settings.getRegistrationMode = async () => 'both'
|
||||||
|
userIdentities.findByProviderSubject = async () => null
|
||||||
|
let created = null
|
||||||
|
users.createUser = async (args) => {
|
||||||
|
created = args
|
||||||
|
return { id: 42, username: args.username, role: 'player', status: 'active' }
|
||||||
|
}
|
||||||
|
let linkArgs = null
|
||||||
|
userIdentities.link = async (args) => { linkArgs = args; return 1 }
|
||||||
|
const tx = ssoState.createTx({ provider: 'google', mode: 'login' })
|
||||||
|
const res = mockRes()
|
||||||
|
await ssoCtrl.callback(makeReq(tx), res)
|
||||||
|
|
||||||
|
assert.equal(created.role, 'player')
|
||||||
|
assert.equal(created.email, 'alice@example.com')
|
||||||
|
assert.equal(linkArgs.userId, 42)
|
||||||
|
assert.ok(res.cookies[token.COOKIE_NAME], 'session cookie set for the new player')
|
||||||
|
assert.equal(res.redirectedTo, '/admin')
|
||||||
|
// Both the provision and the login are audited.
|
||||||
|
assert.deepEqual(logged.map((e) => e.action), ['auth.sso.provision', 'auth.sso.login'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('UNLINKED identity from the player portal lands back in /account', async () => {
|
||||||
|
settings.getRegistrationMode = async () => 'both'
|
||||||
|
userIdentities.findByProviderSubject = async () => null
|
||||||
|
users.createUser = async (args) => ({ id: 43, username: args.username, role: 'player', status: 'active' })
|
||||||
|
const tx = ssoState.createTx({ provider: 'google', mode: 'login', returnTo: '/account' })
|
||||||
|
const res = mockRes()
|
||||||
|
await ssoCtrl.callback(makeReq(tx), res)
|
||||||
|
assert.equal(res.redirectedTo, '/account')
|
||||||
|
})
|
||||||
|
|
||||||
test('link mode → identity linked to the acting user, redirect to account', async () => {
|
test('link mode → identity linked to the acting user, redirect to account', async () => {
|
||||||
let linkArgs = null
|
let linkArgs = null
|
||||||
userIdentities.link = async (args) => { linkArgs = args; return 1 }
|
userIdentities.link = async (args) => { linkArgs = args; return 1 }
|
||||||
|
|||||||
52
server/test/usernamePolicy.test.js
Normal file
52
server/test/usernamePolicy.test.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Unit tests for the pure username policy (no DB): validation, reserved-name
|
||||||
|
// blocklist, case normalization, SSO derivation + the dedup suffix loop.
|
||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const policy = require('../src/auth/usernamePolicy')
|
||||||
|
|
||||||
|
test('validateUsername accepts a normal name and trims whitespace', () => {
|
||||||
|
const r = policy.validateUsername(' Frodo_99 ')
|
||||||
|
assert.equal(r.ok, true)
|
||||||
|
assert.equal(r.name, 'Frodo_99') // trimmed, case preserved
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validateUsername rejects too-short / too-long / bad-charset names', () => {
|
||||||
|
assert.equal(policy.validateUsername('ab').ok, false) // < 3
|
||||||
|
assert.equal(policy.validateUsername('x'.repeat(33)).ok, false) // > 32
|
||||||
|
assert.equal(policy.validateUsername('has space').ok, false)
|
||||||
|
assert.equal(policy.validateUsername('emoji😀here').ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reserved names are rejected case-insensitively', () => {
|
||||||
|
for (const name of ['admin', 'ADMIN', 'Administrator', 'root', 'moderator', 'support', 'me']) {
|
||||||
|
assert.equal(policy.isReserved(name), true, `${name} should be reserved`)
|
||||||
|
assert.equal(policy.validateUsername(name).ok, false, `${name} should be rejected`)
|
||||||
|
}
|
||||||
|
assert.equal(policy.isReserved('frodo'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sanitizeToUsername strips disallowed chars and leading punctuation', () => {
|
||||||
|
assert.equal(policy.sanitizeToUsername('Fró.do Baggins!'), 'Fro.doBaggins')
|
||||||
|
assert.equal(policy.sanitizeToUsername('...weird'), 'weird')
|
||||||
|
assert.equal(policy.sanitizeToUsername('a'.repeat(50)).length, policy.MAX_LEN)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deriveUsernameBase prefers display name, then email local-part, then player', () => {
|
||||||
|
assert.equal(policy.deriveUsernameBase({ name: 'Gandalf', email: 'g@x.com' }), 'Gandalf')
|
||||||
|
assert.equal(policy.deriveUsernameBase({ name: '💥', email: 'samwise@shire.net' }), 'samwise')
|
||||||
|
assert.equal(policy.deriveUsernameBase({ name: '', email: '' }), 'player')
|
||||||
|
// A reserved derived base is skipped in favor of the next candidate.
|
||||||
|
assert.equal(policy.deriveUsernameBase({ name: 'admin', email: 'realuser@x.com' }), 'realuser')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('candidateUsername yields the base then increasing suffixes, clamped to length', () => {
|
||||||
|
assert.equal(policy.candidateUsername('bilbo', 0), 'bilbo')
|
||||||
|
assert.equal(policy.candidateUsername('bilbo', 1), 'bilbo2')
|
||||||
|
assert.equal(policy.candidateUsername('bilbo', 2), 'bilbo3')
|
||||||
|
// Long base: the numeric suffix must survive the MAX_LEN clamp.
|
||||||
|
const long = 'a'.repeat(policy.MAX_LEN)
|
||||||
|
const c = policy.candidateUsername(long, 10)
|
||||||
|
assert.ok(c.length <= policy.MAX_LEN)
|
||||||
|
assert.ok(c.endsWith('11'))
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user