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:
@@ -3,16 +3,41 @@ const { query } = require('../../utils/db')
|
||||
// SQL for the mobile_refresh_tokens table. Tokens are stored only as sha256
|
||||
// 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).
|
||||
async function insert({ userId, tokenHash, deviceHash = null, userAgent = null, expiresAt }) {
|
||||
// Insert a new refresh-token row. expiresAt is a JS Date (or ms epoch). last_used_at
|
||||
// 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(
|
||||
`INSERT INTO mobile_refresh_tokens (user_id, token_hash, device_hash, user_agent, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[userId, tokenHash, deviceHash, userAgent, new Date(expiresAt)],
|
||||
`INSERT INTO mobile_refresh_tokens (user_id, token_hash, device_hash, device_name, user_agent, expires_at, last_used_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NOW())`,
|
||||
[userId, tokenHash, deviceHash, deviceName, userAgent, new Date(expiresAt)],
|
||||
)
|
||||
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
|
||||
// its expiry. Returns the row (incl. user_id) or null.
|
||||
async function findValidByHash(tokenHash) {
|
||||
@@ -55,6 +80,8 @@ async function pruneExpired() {
|
||||
|
||||
module.exports = {
|
||||
insert,
|
||||
listActiveForUser,
|
||||
revokeByIdForUser,
|
||||
findValidByHash,
|
||||
revokeByHash,
|
||||
revokeAllForUser,
|
||||
|
||||
@@ -6,8 +6,19 @@
|
||||
const db = require('./mobileSessions.db')
|
||||
|
||||
// Persist a newly issued refresh token (by hash). Returns the row id.
|
||||
async function store({ userId, tokenHash, deviceHash, userAgent, expiresAt }) {
|
||||
return db.insert({ userId, tokenHash, deviceHash, userAgent, expiresAt })
|
||||
async function store({ userId, tokenHash, deviceHash, deviceName, 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.
|
||||
@@ -33,6 +44,8 @@ async function pruneExpired() {
|
||||
|
||||
module.exports = {
|
||||
store,
|
||||
listActiveForUser,
|
||||
revokeByIdForUser,
|
||||
findValidByHash,
|
||||
revokeByHash,
|
||||
revokeAllForUser,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
const users = require('../../../model/users/users.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const userIdentities = require('../../../model/userIdentities/userIdentities.model')
|
||||
const mobileSessions = require('../../../model/mobileSessions/mobileSessions.model')
|
||||
const sessionService = require('../../../auth/session.service')
|
||||
const { setAuthCookie } = require('../../../auth/token')
|
||||
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 = {
|
||||
getAccount,
|
||||
changeUsername,
|
||||
@@ -216,4 +255,6 @@ module.exports = {
|
||||
totpDisable,
|
||||
listIdentities,
|
||||
unlinkIdentity,
|
||||
listSessions,
|
||||
revokeSession,
|
||||
}
|
||||
|
||||
@@ -137,4 +137,31 @@ meRouter.delete(
|
||||
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
|
||||
|
||||
@@ -37,11 +37,14 @@ function tokenResponse(out, user) {
|
||||
|
||||
// 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.
|
||||
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({
|
||||
userId: user.id,
|
||||
tokenHash: out.refreshHash,
|
||||
deviceHash: out.deviceHash,
|
||||
deviceName,
|
||||
userAgent: out.userAgent,
|
||||
expiresAt: out.refreshExpiresAt,
|
||||
})
|
||||
@@ -79,7 +82,7 @@ async function login(req, res) {
|
||||
loginProtection.recordSuccess(req.ip)
|
||||
const meta = sessionService.sessionMeta(req)
|
||||
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 })
|
||||
return res.json(tokenResponse(out, user))
|
||||
} catch (err) {
|
||||
@@ -111,7 +114,9 @@ async function refresh(req, res) {
|
||||
await mobileSessions.revokeByHash(hash) // rotate: old token is now dead
|
||||
const meta = sessionService.sessionMeta(req)
|
||||
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 })
|
||||
return res.json(tokenResponse(out, user))
|
||||
} catch (err) {
|
||||
|
||||
@@ -36,6 +36,8 @@ mobileRouter.post(
|
||||
body('password').isString().notEmpty(),
|
||||
// Optional TOTP code (single-request 2FA); only checked when the account has 2FA on.
|
||||
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,
|
||||
login,
|
||||
)
|
||||
|
||||
@@ -131,6 +131,7 @@ async function exchange(req, res) {
|
||||
userId: user.id,
|
||||
tokenHash: out.refreshHash,
|
||||
deviceHash: out.deviceHash,
|
||||
deviceName: req.body.device_name || null,
|
||||
userAgent: out.userAgent,
|
||||
expiresAt: out.refreshExpiresAt,
|
||||
})
|
||||
|
||||
@@ -48,6 +48,7 @@ mobileSsoRouter.post(
|
||||
mobileSsoExchangeLimiter,
|
||||
body('code').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,
|
||||
exchange,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user