auth.js
4.24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
import * as v from 'valibot'
import { HANDLE_PATTERN } from '@voko/access'
const SendCodeBody = v.object({
email: v.pipe(v.string(), v.email()),
locale: v.optional(v.string()),
})
const RegisterBody = v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
verification_code: v.pipe(v.string(), v.minLength(1)),
locale: v.picklist(['en', 'zh', 'ja', 'ko', 'es', 'pt']),
// 主人 id:Agent 的全名是「名字@主人 id」。
handle: v.pipe(v.string(), v.regex(HANDLE_PATTERN, 'handle must be 2-32 characters: lowercase letters, digits, _ or -, starting with a letter or digit')),
})
const LoginBody = v.variant('method', [
v.object({ method: v.literal('password'), email: v.pipe(v.string(), v.email()), password: v.pipe(v.string(), v.minLength(1)) }),
v.object({
method: v.literal('google'),
credential: v.pipe(v.string(), v.minLength(1)),
oauth_flow: v.picklist(['access_token', 'authorization_code']),
redirect_uri: v.optional(v.string()),
}),
])
const RefreshBody = v.object({ refresh_token: v.pipe(v.string(), v.minLength(1)) })
/**
* 账号:邮箱验证码注册 + 密码登录,或 Google 登录(配置了 GOOGLE_OAUTH_CLIENT_ID 时)。
* @param {import('fastify').FastifyInstance} app
* @param {Record<string, any>} services
*/
export function registerAuthRoutes(app, services) {
const { auth } = services
app.post('/api/voko/auth/send-code', async (request) => {
const body = v.parse(SendCodeBody, request.body)
await auth.sendCode({ email: body.email, purpose: 'register', locale: body.locale, ip: request.ip })
return { ok: true }
})
app.post('/api/voko/auth/register', async (request) => {
const body = v.parse(RegisterBody, request.body)
if (await services.ownerHandles.isTaken(request.ctx, { handle: body.handle })) throw codedError(`The id ${body.handle} is already taken`, 'CONFLICT')
return sessionResponse(await services.register(body))
})
app.post('/api/voko/auth/login', async (request) => {
const body = v.parse(LoginBody, request.body)
switch (body.method) {
case 'password':
return sessionResponse(await auth.login({ email: body.email, password: body.password, ip: request.ip }))
case 'google':
if (!services.oauthEnabled) throw codedError('Google login is not configured', 'BAD_REQUEST')
return sessionResponse(await auth.oauthLogin({ provider: 'google', code: body.credential, oauth_flow: body.oauth_flow, redirect_uri: body.redirect_uri }))
default:
throw new Error(`Unhandled login method: ${/** @type {any} */ (body).method}`)
}
})
app.post('/api/voko/auth/refresh', async (request) => {
const body = v.parse(RefreshBody, request.body)
return sessionResponse(await auth.refresh(body))
})
app.post('/api/voko/auth/logout', async (request) => {
const body = v.parse(RefreshBody, request.body)
return auth.logout(body)
})
}
/**
* @param {{ access_token: string, refresh_token: string, user: { id: string, email: string } }} result
*/
function sessionResponse(result) {
return {
access_token: result.access_token,
refresh_token: result.refresh_token,
user: { id: result.user.id, email: result.user.email },
}
}
/**
* @param {string} message
* @param {string} code
*/
export function codedError(message, code) {
const err = /** @type {Error & { code: string }} */ (new Error(message))
err.code = code
return err
}
/**
* 从 Bearer access token 解析当前用户;缺失或无效即 401。
* @param {import('fastify').FastifyRequest} request
* @param {Record<string, any>} services
* @returns {Promise<{ id: string, email: string, role: string|null }>}
*/
export async function requireUser(request, services) {
const header = request.headers.authorization
const match = typeof header === 'string' ? /^Bearer\s+(.+)$/i.exec(header.trim()) : null
if (match === null) throw codedError('Authorization bearer token is required', 'TOKEN_INVALID')
const claims = await services.auth.verifyAccessToken(match[1])
const user = await services.userRepo.findById(String(claims.sub))
if (!user) throw codedError(`User not found: ${claims.sub}`, 'TOKEN_INVALID')
return { id: user.id, email: user.email, role: user.role === undefined ? null : user.role }
}