177 lines
7.0 KiB
TypeScript
177 lines
7.0 KiB
TypeScript
/**
|
||
* Auth routes: self-registration (→ pending), login, logout, and the current
|
||
* identity. Registration always yields a `pending` user; an admin approves it.
|
||
* @module dshs/web/routes/auth
|
||
*/
|
||
|
||
import type { FastifyPluginAsync } from 'fastify'
|
||
import { randomUUID } from 'node:crypto'
|
||
import { requireAdmin, requireAuth } from '../middleware/authn.js'
|
||
import { homeRoot, userRoot } from '../../fs/workspace.js'
|
||
import { deriveKey, encrypt } from '../../crypto.js'
|
||
import { toPublicUser } from '../../db/types.js'
|
||
import {
|
||
clearSessionCookie,
|
||
hashPassword,
|
||
hashSessionToken,
|
||
newSessionToken,
|
||
parseCookie,
|
||
sessionCookie,
|
||
verifyPassword,
|
||
} from '../auth.js'
|
||
|
||
const registerSchema = {
|
||
body: {
|
||
type: 'object',
|
||
required: ['username', 'password'],
|
||
additionalProperties: false,
|
||
properties: {
|
||
username: { type: 'string', minLength: 3, maxLength: 32, pattern: '^[a-zA-Z0-9_-]+$' },
|
||
password: { type: 'string', minLength: 8, maxLength: 128 },
|
||
},
|
||
},
|
||
} as const
|
||
|
||
const loginSchema = {
|
||
body: {
|
||
type: 'object',
|
||
required: ['username', 'password'],
|
||
additionalProperties: false,
|
||
properties: {
|
||
username: { type: 'string', maxLength: 64 },
|
||
password: { type: 'string', maxLength: 128 },
|
||
},
|
||
},
|
||
} as const
|
||
|
||
interface Credentials {
|
||
username: string
|
||
password: string
|
||
}
|
||
|
||
export const authRoutes: FastifyPluginAsync = async (app) => {
|
||
app.post(
|
||
'/api/auth/register',
|
||
{ schema: registerSchema, config: { rateLimit: { max: 5, timeWindow: '1 minute' } } },
|
||
async (request, reply) => {
|
||
const { username, password } = request.body as Credentials
|
||
if ((await app.db.findUserByUsername(username)) !== undefined) {
|
||
return reply.code(409).send({ error: 'username_taken' })
|
||
}
|
||
const id = randomUUID()
|
||
const homeDir = homeRoot(userRoot(app.config.dataRoot, id))
|
||
const passHash = await hashPassword(password)
|
||
// Create the user first so `initUserRoot` resolves the DB-assigned uid
|
||
// (baseUid + row_id) instead of the hash fallback — the per-user Pods and
|
||
// the DSH Pod must run as the *same* uid or the DSH cannot write its dirs.
|
||
const user = await app.db.createUser({ id, username, passHash, role: 'pending', homeDir })
|
||
await app.userFs.initUserRoot(id, user.uid ?? undefined)
|
||
await app.db.audit(id, 'register', JSON.stringify({ username }))
|
||
return reply.code(201).send({ user: { id, username, role: 'pending' } })
|
||
},
|
||
)
|
||
|
||
app.post(
|
||
'/api/auth/login',
|
||
{ schema: loginSchema, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
||
async (request, reply) => {
|
||
const { username, password } = request.body as Credentials
|
||
const user = await app.db.findUserByUsername(username)
|
||
if (user === undefined || !(await verifyPassword(password, user.pass_hash))) {
|
||
return reply.code(401).send({ error: 'invalid_credentials' })
|
||
}
|
||
if (user.role === 'pending') return reply.code(403).send({ error: 'pending_review' })
|
||
if (user.role === 'disabled') return reply.code(403).send({ error: 'disabled' })
|
||
|
||
const token = newSessionToken()
|
||
// 单活跃会话(last-wins):新登录顶掉该账号此前所有会话——旧浏览器的
|
||
// sid 立即失效,proxy/API 对其返回 401,需重新登录后才能继续访问。
|
||
await app.db.deleteUserSessions(user.id)
|
||
await app.db.createSession({
|
||
tokenHash: hashSessionToken(token),
|
||
userId: user.id,
|
||
expiresAt: Date.now() + app.config.sessionTtlSeconds * 1000,
|
||
ip: request.ip,
|
||
userAgent: request.headers['user-agent'],
|
||
})
|
||
await app.db.audit(user.id, 'login', null)
|
||
reply.header(
|
||
'set-cookie',
|
||
sessionCookie(token, app.config.sessionTtlSeconds, app.config.secureCookies, app.config.cookieDomain),
|
||
)
|
||
return { user: toPublicUser(user) }
|
||
},
|
||
)
|
||
|
||
app.post('/api/auth/logout', async (request, reply) => {
|
||
const token = parseCookie(request.headers.cookie, 'sid')
|
||
if (token !== undefined) await app.db.deleteSession(hashSessionToken(token))
|
||
reply.header('set-cookie', clearSessionCookie(app.config.secureCookies, app.config.cookieDomain))
|
||
return { ok: true }
|
||
})
|
||
|
||
// ---- 同源退出页:会话内「退出登录」整页跳转(清 sid + 删 session → 回登录页) ----
|
||
app.get('/logout', async (request, reply) => {
|
||
const token = parseCookie(request.headers.cookie, 'sid')
|
||
if (token !== undefined) await app.db.deleteSession(hashSessionToken(token))
|
||
reply.header('set-cookie', clearSessionCookie(app.config.secureCookies, app.config.cookieDomain))
|
||
return reply.redirect('/')
|
||
})
|
||
|
||
app.get('/api/auth/me', { preHandler: requireAuth }, async (request) => ({ user: request.user }))
|
||
|
||
const keyAddSchema = {
|
||
body: {
|
||
type: 'object',
|
||
required: ['name', 'apiKey'],
|
||
additionalProperties: false,
|
||
properties: {
|
||
name: { type: 'string', minLength: 1, maxLength: 32 },
|
||
apiKey: { type: 'string', minLength: 1, maxLength: 256 },
|
||
},
|
||
},
|
||
} as const
|
||
|
||
// ---- 统一 KEY:仅管理员可管理。全局只认 admin 的启用 key(见 server.ts resolveApiKey),
|
||
// 普通用户不再有自配 key 的入口/能力;这些路由对非 admin 一律 403。 ----
|
||
app.get('/api/me/keys', { preHandler: requireAdmin }, async (request) => ({
|
||
keys: await app.db.listCredentialKeys(request.user!.id),
|
||
}))
|
||
|
||
app.post('/api/me/keys', { preHandler: requireAdmin, schema: keyAddSchema }, async (request, reply) => {
|
||
const { name, apiKey } = request.body as { name: string; apiKey: string }
|
||
const cleanName = name.trim()
|
||
if (!/^[A-Za-z0-9\-_ .]{1,32}$/.test(cleanName)) {
|
||
return reply.code(400).send({ error: 'invalid_name' })
|
||
}
|
||
// Header-safe charset only: reject spaces, quotes, non-ASCII, etc.
|
||
if (!/^[A-Za-z0-9\-_.]{1,256}$/.test(apiKey)) {
|
||
return reply.code(400).send({ error: 'invalid_api_key' })
|
||
}
|
||
const key = await app.db.setCredentialKey(
|
||
request.user!.id,
|
||
cleanName,
|
||
encrypt(apiKey, deriveKey(app.config.encryptionSecret)),
|
||
)
|
||
await app.db.audit(request.user!.id, 'set_api_key', JSON.stringify({ name: cleanName }))
|
||
// 统一 KEY:admin 换 key 后广播重启所有用户的实例(DEEPSEEK_API_KEY 是 spawn 时
|
||
// 注入 env 的快照,不重启运行中的实例不会刷新)。
|
||
await app.supervisor.restartAllMains()
|
||
return { key }
|
||
})
|
||
|
||
app.post('/api/me/keys/:id/select', { preHandler: requireAdmin }, async (request, reply) => {
|
||
const { id } = request.params as { id: string }
|
||
if (!(await app.db.selectCredentialKey(request.user!.id, id))) return reply.code(404).send({ error: 'not_found' })
|
||
// 切换启用的全局 key 后同样广播刷新
|
||
await app.supervisor.restartAllMains()
|
||
return { ok: true }
|
||
})
|
||
|
||
app.delete('/api/me/keys/:id', { preHandler: requireAdmin }, async (request, reply) => {
|
||
const { id } = request.params as { id: string }
|
||
if (!(await app.db.deleteCredentialKey(request.user!.id, id))) return reply.code(404).send({ error: 'not_found' })
|
||
return { ok: true }
|
||
})
|
||
}
|