feat(auth): Active Devices — view/revoke mobile sessions
Adds the self-service device-session surface the mobile-SSO spec requires, on
top of the existing mobile_refresh_tokens store.
- Schema: device_name + last_used_at columns on mobile_refresh_tokens (nullable,
additive via the ALTER section; seeded to now on insert). With single-use
rotation each login/refresh inserts a fresh row, so the active row's timestamp
is the session's last activity, and the label is carried forward on refresh.
- Model: listActiveForUser (one row per live device, no token hash) +
revokeByIdForUser (ownership-scoped, idempotent).
- GET /auth/me/sessions + DELETE /auth/me/sessions/:id (role-agnostic, behind
requireAuth). Named distinctly from /auth/me/devices (push endpoints).
- device_name is an optional field on /auth/mobile/login and
/auth/mobile/sso/exchange so the app can label a device.
- Client: an "Active Devices" panel on the player account page (list + sign a
device out), plus the PlayerLogin change to honor the mobile SSO bridge's
{ redirect } deep link on a 2FA completion.
- Swagger DeviceSession schema + regenerated spec; 3 controller tests. Full
server suite green (274); client builds.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -68,6 +68,10 @@ export const api = {
|
|||||||
logout: () => req('/auth/logout', { method: 'POST' }),
|
logout: () => req('/auth/logout', { method: 'POST' }),
|
||||||
// Public SSO provider discovery — drives the login-page provider buttons.
|
// Public SSO provider discovery — drives the login-page provider buttons.
|
||||||
authProviders: () => req('/auth/providers'),
|
authProviders: () => req('/auth/providers'),
|
||||||
|
// Active mobile device sessions (role-agnostic self-service under /auth/me).
|
||||||
|
// List the active ones and revoke a single device by its session id.
|
||||||
|
mySessions: () => req('/auth/me/sessions'),
|
||||||
|
revokeMySession: (id) => req(`/auth/me/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
// ----- public -----
|
// ----- public -----
|
||||||
publicSettings: () => req('/public/settings'),
|
publicSettings: () => req('/public/settings'),
|
||||||
|
|||||||
@@ -295,6 +295,73 @@ function LinkedAccounts() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Active mobile device sessions ──────────────────────────────────────────
|
||||||
|
function ActiveDevices() {
|
||||||
|
const [sessions, setSessions] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busyId, setBusyId] = useState(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setSessions(await api.mySessions())
|
||||||
|
} catch {
|
||||||
|
setError('Could not load your devices.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
async function revoke(id) {
|
||||||
|
if (!window.confirm('Sign this device out? It will need to sign in again.')) return
|
||||||
|
setBusyId(id)
|
||||||
|
try {
|
||||||
|
await api.revokeMySession(id)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not sign that device out.')
|
||||||
|
} finally {
|
||||||
|
setBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (d) => {
|
||||||
|
const t = d ? new Date(d) : null
|
||||||
|
return t && !Number.isNaN(t.getTime()) ? t.toLocaleString() : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return (
|
||||||
|
<Section title="Active devices"><ErrorState message={error} /></Section>
|
||||||
|
)
|
||||||
|
if (!sessions) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title="Active devices">
|
||||||
|
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
Devices signed in to the mobile app. Sign one out to revoke its access — it may keep working for
|
||||||
|
a few minutes until its current token expires.
|
||||||
|
</p>
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.86rem' }}>No mobile devices are signed in.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||||
|
{sessions.map((s) => (
|
||||||
|
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
|
||||||
|
{s.deviceName || s.userAgent || 'Mobile device'}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.78rem' }}>Last active {fmt(s.lastUsedAt)}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => revoke(s.id)} disabled={busyId === s.id} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||||
|
{busyId === s.id ? 'Signing out…' : 'Sign out'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Shared bits ────────────────────────────────────────────────────────────
|
// ── Shared bits ────────────────────────────────────────────────────────────
|
||||||
function Section({ title, children }) {
|
function Section({ title, children }) {
|
||||||
return (
|
return (
|
||||||
@@ -347,6 +414,7 @@ export default function PlayerAccount() {
|
|||||||
<ChangePassword account={account} />
|
<ChangePassword account={account} />
|
||||||
<TwoFactor account={account} reload={load} />
|
<TwoFactor account={account} reload={load} />
|
||||||
<LinkedAccounts />
|
<LinkedAccounts />
|
||||||
|
<ActiveDevices />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,7 +101,14 @@ export default function PlayerLogin() {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
if (ssoTotp) {
|
if (ssoTotp) {
|
||||||
const { returnTo } = await ssoLoginTotp(code)
|
const { returnTo, redirect } = await ssoLoginTotp(code)
|
||||||
|
// Native SSO bridge (M9): a mobile 2FA completion returns an absolute
|
||||||
|
// deep link (e.g. runicgateway://…) to hand the app its one-time code.
|
||||||
|
// React Router can't navigate a custom scheme, so leave the SPA for it.
|
||||||
|
if (redirect) {
|
||||||
|
window.location.href = redirect
|
||||||
|
return
|
||||||
|
}
|
||||||
navigate(returnTo || '/account', { replace: true })
|
navigate(returnTo || '/account', { replace: true })
|
||||||
} else {
|
} else {
|
||||||
const u = await loginTotp(challenge, code)
|
const u = await loginTotp(challenge, code)
|
||||||
|
|||||||
@@ -188,8 +188,10 @@ CREATE TABLE IF NOT EXISTS mobile_refresh_tokens (
|
|||||||
user_id INT NOT NULL,
|
user_id INT NOT NULL,
|
||||||
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque refresh token
|
token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque refresh token
|
||||||
device_hash VARCHAR(32) NULL, -- from sessionService.sessionMeta (best-effort)
|
device_hash VARCHAR(32) NULL, -- from sessionService.sessionMeta (best-effort)
|
||||||
|
device_name VARCHAR(100) NULL, -- friendly label the app may send (M9)
|
||||||
user_agent VARCHAR(255) NULL,
|
user_agent VARCHAR(255) NULL,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at DATETIME NULL, -- last time this session token was issued/used (M9)
|
||||||
expires_at DATETIME NOT NULL,
|
expires_at DATETIME NOT NULL,
|
||||||
revoked_at DATETIME NULL,
|
revoked_at DATETIME NULL,
|
||||||
CONSTRAINT fk_mrt_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
CONSTRAINT fk_mrt_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -1016,3 +1018,9 @@ ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay VARCHAR(24) NULL;
|
|||||||
-- so the public Houses browser can list registered houses without pulling in rows
|
-- so the public Houses browser can list registered houses without pulling in rows
|
||||||
-- we only ever saw an IDOC transition for.
|
-- we only ever saw an IDOC transition for.
|
||||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NULL DEFAULT 0;
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Mobile device sessions (M9): a friendly label the app may send at login, and
|
||||||
|
-- the last time this session token was issued/used, for the "Active Devices"
|
||||||
|
-- self-service list. Both nullable and additive; existing rows get them here.
|
||||||
|
ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS device_name VARCHAR(100) NULL;
|
||||||
|
ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME NULL;
|
||||||
|
|||||||
@@ -3,16 +3,41 @@ const { query } = require('../../utils/db')
|
|||||||
// SQL for the mobile_refresh_tokens table. Tokens are stored only as sha256
|
// SQL for the mobile_refresh_tokens table. Tokens are stored only as sha256
|
||||||
// hashes (token_hash); the raw refresh token never touches the database.
|
// hashes (token_hash); the raw refresh token never touches the database.
|
||||||
|
|
||||||
// Insert a new refresh-token row. expiresAt is a JS Date (or ms epoch).
|
// Insert a new refresh-token row. expiresAt is a JS Date (or ms epoch). last_used_at
|
||||||
async function insert({ userId, tokenHash, deviceHash = null, userAgent = null, expiresAt }) {
|
// is seeded to now: with single-use rotation each login/refresh inserts a fresh
|
||||||
|
// row, so the current active row's timestamp IS the session's last activity.
|
||||||
|
async function insert({ userId, tokenHash, deviceHash = null, deviceName = null, userAgent = null, expiresAt }) {
|
||||||
const res = await query(
|
const res = await query(
|
||||||
`INSERT INTO mobile_refresh_tokens (user_id, token_hash, device_hash, user_agent, expires_at)
|
`INSERT INTO mobile_refresh_tokens (user_id, token_hash, device_hash, device_name, user_agent, expires_at, last_used_at)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, NOW())`,
|
||||||
[userId, tokenHash, deviceHash, userAgent, new Date(expiresAt)],
|
[userId, tokenHash, deviceHash, deviceName, userAgent, new Date(expiresAt)],
|
||||||
)
|
)
|
||||||
return res.insertId
|
return res.insertId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List a user's currently-active (unrevoked, unexpired) sessions — one row per
|
||||||
|
// live device, newest first. Never returns the token hash. For the "Active
|
||||||
|
// Devices" self-service surface.
|
||||||
|
async function listActiveForUser(userId) {
|
||||||
|
return query(
|
||||||
|
`SELECT id, device_name, device_hash, user_agent, created_at, last_used_at, expires_at
|
||||||
|
FROM mobile_refresh_tokens
|
||||||
|
WHERE user_id = ? AND revoked_at IS NULL AND expires_at > NOW()
|
||||||
|
ORDER BY last_used_at DESC, created_at DESC`,
|
||||||
|
[userId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke one of a user's sessions by row id (ownership-scoped so a user can only
|
||||||
|
// revoke their own). Idempotent; returns rows changed.
|
||||||
|
async function revokeByIdForUser(id, userId) {
|
||||||
|
const res = await query(
|
||||||
|
'UPDATE mobile_refresh_tokens SET revoked_at = NOW() WHERE id = ? AND user_id = ? AND revoked_at IS NULL',
|
||||||
|
[id, userId],
|
||||||
|
)
|
||||||
|
return Number(res.affectedRows || 0)
|
||||||
|
}
|
||||||
|
|
||||||
// Look up a token by hash only if it is still usable: not revoked and not past
|
// Look up a token by hash only if it is still usable: not revoked and not past
|
||||||
// its expiry. Returns the row (incl. user_id) or null.
|
// its expiry. Returns the row (incl. user_id) or null.
|
||||||
async function findValidByHash(tokenHash) {
|
async function findValidByHash(tokenHash) {
|
||||||
@@ -55,6 +80,8 @@ async function pruneExpired() {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
insert,
|
insert,
|
||||||
|
listActiveForUser,
|
||||||
|
revokeByIdForUser,
|
||||||
findValidByHash,
|
findValidByHash,
|
||||||
revokeByHash,
|
revokeByHash,
|
||||||
revokeAllForUser,
|
revokeAllForUser,
|
||||||
|
|||||||
@@ -6,8 +6,19 @@
|
|||||||
const db = require('./mobileSessions.db')
|
const db = require('./mobileSessions.db')
|
||||||
|
|
||||||
// Persist a newly issued refresh token (by hash). Returns the row id.
|
// Persist a newly issued refresh token (by hash). Returns the row id.
|
||||||
async function store({ userId, tokenHash, deviceHash, userAgent, expiresAt }) {
|
async function store({ userId, tokenHash, deviceHash, deviceName, userAgent, expiresAt }) {
|
||||||
return db.insert({ userId, tokenHash, deviceHash, userAgent, expiresAt })
|
return db.insert({ userId, tokenHash, deviceHash, deviceName, userAgent, expiresAt })
|
||||||
|
}
|
||||||
|
|
||||||
|
// List a user's active sessions (one per live device) for the Active Devices UI.
|
||||||
|
async function listActiveForUser(userId) {
|
||||||
|
return db.listActiveForUser(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke one of a user's own sessions by row id. Returns rows changed (0 if it
|
||||||
|
// wasn't theirs / already gone — callers treat this idempotently).
|
||||||
|
async function revokeByIdForUser(id, userId) {
|
||||||
|
return db.revokeByIdForUser(id, userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the stored row for a still-valid (unrevoked, unexpired) token, else null.
|
// Return the stored row for a still-valid (unrevoked, unexpired) token, else null.
|
||||||
@@ -33,6 +44,8 @@ async function pruneExpired() {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
store,
|
store,
|
||||||
|
listActiveForUser,
|
||||||
|
revokeByIdForUser,
|
||||||
findValidByHash,
|
findValidByHash,
|
||||||
revokeByHash,
|
revokeByHash,
|
||||||
revokeAllForUser,
|
revokeAllForUser,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
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 mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
|
||||||
const sessionService = require('../../../auth/session.service')
|
const sessionService = require('../../../auth/session.service')
|
||||||
const { setAuthCookie } = require('../../../auth/token')
|
const { setAuthCookie } = require('../../../auth/token')
|
||||||
const usernamePolicy = require('../../../auth/usernamePolicy')
|
const usernamePolicy = require('../../../auth/usernamePolicy')
|
||||||
@@ -207,6 +208,44 @@ async function unlinkIdentity(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List the current user's active mobile device sessions (the "Active Devices"
|
||||||
|
// surface). Never exposes the token hash — only labels + timestamps.
|
||||||
|
async function listSessions(req, res) {
|
||||||
|
try {
|
||||||
|
const rows = await mobileSessions.listActiveForUser(req.user.id)
|
||||||
|
return res.json(
|
||||||
|
rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
deviceName: r.device_name || null,
|
||||||
|
userAgent: r.user_agent || null,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
lastUsedAt: r.last_used_at || r.created_at,
|
||||||
|
expiresAt: r.expires_at,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('listSessions', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke one of the current user's mobile device sessions by id (ownership-scoped
|
||||||
|
// in the query so a user can only revoke their own). Idempotent.
|
||||||
|
async function revokeSession(req, res) {
|
||||||
|
const id = Number(req.params.id)
|
||||||
|
try {
|
||||||
|
const n = await mobileSessions.revokeByIdForUser(id, req.user.id)
|
||||||
|
if (n) {
|
||||||
|
await activity.log({ req, action: 'auth.mobile.session.revoke', detail: { sessionRowId: id } })
|
||||||
|
log.info('mobile session revoked (self)', { id, userId: req.user.id })
|
||||||
|
}
|
||||||
|
return res.json({ revoked: n > 0 })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('revokeSession', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getAccount,
|
getAccount,
|
||||||
changeUsername,
|
changeUsername,
|
||||||
@@ -216,4 +255,6 @@ module.exports = {
|
|||||||
totpDisable,
|
totpDisable,
|
||||||
listIdentities,
|
listIdentities,
|
||||||
unlinkIdentity,
|
unlinkIdentity,
|
||||||
|
listSessions,
|
||||||
|
revokeSession,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,4 +137,31 @@ meRouter.delete(
|
|||||||
account.unlinkIdentity,
|
account.unlinkIdentity,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Active mobile device sessions (self-service). Distinct from /auth/me/devices,
|
||||||
|
// which is push-notification endpoints — these are login sessions (M9). List the
|
||||||
|
// active ones and revoke a single device without "log out everywhere".
|
||||||
|
meRouter.get(
|
||||||
|
'/sessions',
|
||||||
|
// #swagger.tags = ['Auth · Me']
|
||||||
|
// #swagger.summary = 'List active mobile device sessions (self)'
|
||||||
|
// #swagger.description = 'Active (unrevoked, unexpired) mobile bearer sessions — one per live device — for the Active Devices screen. Never returns tokens.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Active device sessions', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/DeviceSession" } } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
account.listSessions,
|
||||||
|
)
|
||||||
|
meRouter.delete(
|
||||||
|
'/sessions/:id',
|
||||||
|
// #swagger.tags = ['Auth · Me']
|
||||||
|
// #swagger.summary = 'Revoke one mobile device session (self)'
|
||||||
|
// #swagger.description = 'Revokes a single device by its session id (ownership-scoped). Revoking stops future token renewals; an already-issued access token remains valid until it expires (see the documented revocation-latency window).'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The session row id from GET /auth/me/sessions.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Revoked (idempotent)', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "boolean" } } } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt({ min: 1 }),
|
||||||
|
validate,
|
||||||
|
account.revokeSession,
|
||||||
|
)
|
||||||
|
|
||||||
module.exports = meRouter
|
module.exports = meRouter
|
||||||
|
|||||||
@@ -37,11 +37,14 @@ function tokenResponse(out, user) {
|
|||||||
|
|
||||||
// Persist a freshly minted refresh token (by hash) and record the login. Shared
|
// Persist a freshly minted refresh token (by hash) and record the login. Shared
|
||||||
// by login and refresh so the storage/side-effect logic lives in one place.
|
// by login and refresh so the storage/side-effect logic lives in one place.
|
||||||
async function persistAndFinish(req, user, out, action) {
|
// `deviceName` is the friendly label for the Active Devices list — supplied by
|
||||||
|
// the client at login and carried forward across rotations on refresh.
|
||||||
|
async function persistAndFinish(req, user, out, action, deviceName = null) {
|
||||||
await mobileSessions.store({
|
await mobileSessions.store({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
tokenHash: out.refreshHash,
|
tokenHash: out.refreshHash,
|
||||||
deviceHash: out.deviceHash,
|
deviceHash: out.deviceHash,
|
||||||
|
deviceName,
|
||||||
userAgent: out.userAgent,
|
userAgent: out.userAgent,
|
||||||
expiresAt: out.refreshExpiresAt,
|
expiresAt: out.refreshExpiresAt,
|
||||||
})
|
})
|
||||||
@@ -79,7 +82,7 @@ async function login(req, res) {
|
|||||||
loginProtection.recordSuccess(req.ip)
|
loginProtection.recordSuccess(req.ip)
|
||||||
const meta = sessionService.sessionMeta(req)
|
const meta = sessionService.sessionMeta(req)
|
||||||
const out = sessionService.createMobileSession(user, meta)
|
const out = sessionService.createMobileSession(user, meta)
|
||||||
await persistAndFinish(req, user, out, 'auth.mobile.login')
|
await persistAndFinish(req, user, out, 'auth.mobile.login', req.body.device_name || null)
|
||||||
log.info('mobile login success', { username: user.username, id: user.id, ip: req.ip })
|
log.info('mobile login success', { username: user.username, id: user.id, ip: req.ip })
|
||||||
return res.json(tokenResponse(out, user))
|
return res.json(tokenResponse(out, user))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -111,7 +114,9 @@ async function refresh(req, res) {
|
|||||||
await mobileSessions.revokeByHash(hash) // rotate: old token is now dead
|
await mobileSessions.revokeByHash(hash) // rotate: old token is now dead
|
||||||
const meta = sessionService.sessionMeta(req)
|
const meta = sessionService.sessionMeta(req)
|
||||||
const out = sessionService.refreshMobileSession(user, meta)
|
const out = sessionService.refreshMobileSession(user, meta)
|
||||||
await persistAndFinish(req, user, out, 'auth.mobile.refresh')
|
// Carry the device label forward across rotation so the Active Devices list
|
||||||
|
// stays labeled for the life of the session.
|
||||||
|
await persistAndFinish(req, user, out, 'auth.mobile.refresh', row.device_name || null)
|
||||||
log.info('mobile session refreshed', { id: user.id, ip: req.ip })
|
log.info('mobile session refreshed', { id: user.id, ip: req.ip })
|
||||||
return res.json(tokenResponse(out, user))
|
return res.json(tokenResponse(out, user))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ mobileRouter.post(
|
|||||||
body('password').isString().notEmpty(),
|
body('password').isString().notEmpty(),
|
||||||
// Optional TOTP code (single-request 2FA); only checked when the account has 2FA on.
|
// Optional TOTP code (single-request 2FA); only checked when the account has 2FA on.
|
||||||
body('code').optional().isString().trim().isLength({ min: 6, max: 8 }),
|
body('code').optional().isString().trim().isLength({ min: 6, max: 8 }),
|
||||||
|
// Optional friendly device label for the Active Devices list.
|
||||||
|
body('device_name').optional({ values: 'falsy' }).isString().trim().isLength({ max: 100 }),
|
||||||
validate,
|
validate,
|
||||||
login,
|
login,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ async function exchange(req, res) {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
tokenHash: out.refreshHash,
|
tokenHash: out.refreshHash,
|
||||||
deviceHash: out.deviceHash,
|
deviceHash: out.deviceHash,
|
||||||
|
deviceName: req.body.device_name || null,
|
||||||
userAgent: out.userAgent,
|
userAgent: out.userAgent,
|
||||||
expiresAt: out.refreshExpiresAt,
|
expiresAt: out.refreshExpiresAt,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ mobileSsoRouter.post(
|
|||||||
mobileSsoExchangeLimiter,
|
mobileSsoExchangeLimiter,
|
||||||
body('code').isString().trim().isLength({ min: 20, max: 255 }),
|
body('code').isString().trim().isLength({ min: 20, max: 255 }),
|
||||||
body('code_verifier').isString().trim().isLength({ min: 20, max: 255 }),
|
body('code_verifier').isString().trim().isLength({ min: 20, max: 255 }),
|
||||||
|
body('device_name').optional({ values: 'falsy' }).isString().trim().isLength({ max: 100 }),
|
||||||
validate,
|
validate,
|
||||||
exchange,
|
exchange,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1770,6 +1770,118 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/auth/me/sessions": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Auth · Me"
|
||||||
|
],
|
||||||
|
"summary": "List active mobile device sessions (self)",
|
||||||
|
"description": "Active (unrevoked, unexpired) mobile bearer sessions — one per live device — for the Active Devices screen. Never returns tokens.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Active device sessions",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/DeviceSession"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Not authenticated",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/auth/me/sessions/{id}": {
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"Auth · Me"
|
||||||
|
],
|
||||||
|
"summary": "Revoke one mobile device session (self)",
|
||||||
|
"description": "Revokes a single device by its session id (ownership-scoped). Revoking stops future token renewals; an already-issued access token remains valid until it expires (see the documented revocation-latency window).",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"description": "The session row id from GET /auth/me/sessions."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Revoked (idempotent)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"revoked": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Not authenticated",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/auth/me/devices": {
|
"/api/v1/auth/me/devices": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -11113,6 +11225,23 @@
|
|||||||
"example": "123456"
|
"example": "123456"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"device_name": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Optional friendly device label for Active Devices."
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Pixel 8"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11299,6 +11428,119 @@
|
|||||||
"example": "The PKCE verifier for the challenge sent to /auth/mobile/sso/start."
|
"example": "The PKCE verifier for the challenge sent to /auth/mobile/sso/start."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"device_name": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Optional friendly device label for Active Devices."
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Pixel 8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DeviceSession": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "object"
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "integer"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Session row id (pass to DELETE /auth/me/sessions/:id)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deviceName": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"nullable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Pixel 8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"userAgent": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"nullable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lastUsedAt": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"expiresAt": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ const doc = {
|
|||||||
username: { type: 'string', example: 'admin' },
|
username: { type: 'string', example: 'admin' },
|
||||||
password: { type: 'string', format: 'password', example: 'super-secret' },
|
password: { type: 'string', format: 'password', example: 'super-secret' },
|
||||||
code: { type: 'string', description: 'TOTP code (only when 2FA is enabled).', example: '123456' },
|
code: { type: 'string', description: 'TOTP code (only when 2FA is enabled).', example: '123456' },
|
||||||
|
device_name: { type: 'string', description: 'Optional friendly device label for Active Devices.', example: 'Pixel 8' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
MobileTokenResponse: {
|
MobileTokenResponse: {
|
||||||
@@ -197,6 +198,18 @@ const doc = {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'The PKCE verifier for the challenge sent to /auth/mobile/sso/start.',
|
description: 'The PKCE verifier for the challenge sent to /auth/mobile/sso/start.',
|
||||||
},
|
},
|
||||||
|
device_name: { type: 'string', description: 'Optional friendly device label for Active Devices.', example: 'Pixel 8' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DeviceSession: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'integer', description: 'Session row id (pass to DELETE /auth/me/sessions/:id).' },
|
||||||
|
deviceName: { type: 'string', nullable: true, example: 'Pixel 8' },
|
||||||
|
userAgent: { type: 'string', nullable: true },
|
||||||
|
createdAt: { type: 'string', format: 'date-time' },
|
||||||
|
lastUsedAt: { type: 'string', format: 'date-time' },
|
||||||
|
expiresAt: { type: 'string', format: 'date-time' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Message: {
|
Message: {
|
||||||
|
|||||||
65
server/test/mobileDeviceSessions.test.js
Normal file
65
server/test/mobileDeviceSessions.test.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Active Devices (mobile session view/revoke) — controller tests. The
|
||||||
|
// mobileSessions model is stubbed, so these are DB-free.
|
||||||
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
|
||||||
|
process.env.SECRET_ENC_KEY = process.env.SECRET_ENC_KEY || 'unit-test-enc-key'
|
||||||
|
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 account = require('../src/router/v1/admin/account.controller')
|
||||||
|
const mobileSessions = require('../src/model/mobileSessions/mobileSessions.model')
|
||||||
|
const activity = require('../src/model/activity/activity.model')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
let logged
|
||||||
|
beforeEach(() => {
|
||||||
|
logged = []
|
||||||
|
activity.log = async (e) => { logged.push(e) }
|
||||||
|
})
|
||||||
|
|
||||||
|
function res() {
|
||||||
|
return {
|
||||||
|
statusCode: 200, body: null,
|
||||||
|
status(c) { this.statusCode = c; return this },
|
||||||
|
json(b) { this.body = b; return this },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('listSessions returns active devices without any token material', async () => {
|
||||||
|
mobileSessions.listActiveForUser = async (uid) => {
|
||||||
|
assert.equal(uid, 7)
|
||||||
|
return [
|
||||||
|
{ id: 3, device_name: 'Pixel 8', user_agent: 'okhttp', created_at: 'c3', last_used_at: 'u3', expires_at: 'e3', token_hash: 'SECRET' },
|
||||||
|
{ id: 1, device_name: null, user_agent: null, created_at: 'c1', last_used_at: null, expires_at: 'e1' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const r = res()
|
||||||
|
await account.listSessions({ user: { id: 7 } }, r)
|
||||||
|
assert.equal(r.body.length, 2)
|
||||||
|
assert.deepEqual(r.body[0], { id: 3, deviceName: 'Pixel 8', userAgent: 'okhttp', createdAt: 'c3', lastUsedAt: 'u3', expiresAt: 'e3' })
|
||||||
|
// last_used_at falls back to created_at when null; no token/hash leaks.
|
||||||
|
assert.equal(r.body[1].lastUsedAt, 'c1')
|
||||||
|
assert.equal(JSON.stringify(r.body).includes('SECRET'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('revokeSession is ownership-scoped and audits a real revoke', async () => {
|
||||||
|
let args = null
|
||||||
|
mobileSessions.revokeByIdForUser = async (id, uid) => { args = [id, uid]; return 1 }
|
||||||
|
const r = res()
|
||||||
|
await account.revokeSession({ user: { id: 7 }, params: { id: '3' } }, r)
|
||||||
|
assert.deepEqual(args, [3, 7], 'revokes by id scoped to the caller')
|
||||||
|
assert.deepEqual(r.body, { revoked: true })
|
||||||
|
assert.equal(logged.at(-1).action, 'auth.mobile.session.revoke')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('revokeSession on a non-owned/absent id is idempotent and not audited', async () => {
|
||||||
|
mobileSessions.revokeByIdForUser = async () => 0
|
||||||
|
const r = res()
|
||||||
|
await account.revokeSession({ user: { id: 7 }, params: { id: '999' } }, r)
|
||||||
|
assert.deepEqual(r.body, { revoked: false })
|
||||||
|
assert.equal(logged.length, 0, 'nothing revoked → nothing logged')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user