admin 子站密码登录
要点
- 接下来我们学习一下,在逻辑拆分之后,admin 的登录接口在服务端应该怎么写
- 函数开头先做了两件小事
- 接下来第一步不是查用户,也不是验密码,而是先看 admin 这条线现在还让不让用 password 登录
- 策略通过之后,才轮到真正去碰用户数据
内容
1. 登录主线
接下来我们学习一下,在逻辑拆分之后,admin 的登录接口在服务端应该怎么写
用户在后台登录页输入邮箱和密码之后,后端真正要做的事,其实就是顺着这条线一段一段往下走:
先看 admin 现在还允不允许密码登录,再去查这个邮箱对应的是谁,再校验密码,再确认这个人到底能不能进后台。前面这些都通过了,后面才轮到创建 session、签 token、把 refresh token 的状态写进数据库,最后把响应结构整理好返回给前端。
2. 完整代码
// admin-password-login.ts
import { AdminPasswordLoginResponseSchema } from '@repo/contracts'
import type { ApiBindings } from '@/bindings'
import { getDb } from '@/db/client'
import { getApiEnv } from '@/env'
import {
adminRoleRequiredError,
authMethodDisabledError,
invalidCredentialsError,
} from '@/auth/errors'
import { issueAdminTokenPair } from '@/auth/jwt'
import { verifyPasswordHash } from '@/auth/password'
import { getIp, getUserAgent, normalizeEmail } from '@/auth/request-context'
import {
createAdminSession,
findLoginUserByNormalizedEmail,
getAdminApplicationId,
getAdminRolesForUser,
insertRefreshToken,
isPasswordLoginEnabledForAdmin,
} from '@/auth/repository'
import { hashTokenJti } from '@/auth/token-hash'
import type { Context } from 'hono'
import type { AdminPasswordLoginRequest } from '@repo/contracts'
export async function handleAdminPasswordLogin(params: {
c: Context<{ Bindings: ApiBindings }>
payload: AdminPasswordLoginRequest
}) {
const { c, payload } = params
const db = getDb(c.env.DB)
const env = getApiEnv(c.env)
if (!(await isPasswordLoginEnabledForAdmin(db))) {
throw authMethodDisabledError()
}
const normalizedEmail = normalizeEmail(payload.email)
const loginUser = await findLoginUserByNormalizedEmail(db, normalizedEmail)
if (!loginUser) {
throw invalidCredentialsError()
}
const isPasswordValid = await verifyPasswordHash({
password: payload.password,
passwordHash: loginUser.passwordHash,
passwordAlgo: loginUser.passwordAlgo,
})
if (!isPasswordValid || loginUser.userStatus !== 'active') {
throw invalidCredentialsError()
}
const roles = await getAdminRolesForUser(db, loginUser.userId)
if (roles.length === 0) {
throw adminRoleRequiredError()
}
const applicationId = await getAdminApplicationId(db)
const nowMs = Date.now()
const refreshExpiresAtMs = nowMs + env.REFRESH_TOKEN_TTL_SEC * 1000
const session = await createAdminSession({
db: db,
userId: loginUser.userId,
applicationId,
userAgent: getUserAgent(c),
ip: getIp(c),
nowMs,
expiresAtMs: refreshExpiresAtMs,
roles,
})
const tokenPair = await issueAdminTokenPair({
session,
accessSecret: env.JWT_ACCESS_SECRET,
refreshSecret: env.JWT_REFRESH_SECRET,
accessTtlSec: env.ACCESS_TOKEN_TTL_SEC,
refreshTtlSec: env.REFRESH_TOKEN_TTL_SEC,
})
await insertRefreshToken({
db: db,
tokenId: tokenPair.refreshJti,
sessionId: session.sessionId,
jtiHash: await hashTokenJti(tokenPair.refreshJti),
parentTokenId: null,
issuedAtMs: nowMs,
expiresAtMs: refreshExpiresAtMs,
})
return AdminPasswordLoginResponseSchema.parse({
accessToken: tokenPair.accessToken,
refreshToken: tokenPair.refreshToken,
tokenType: 'Bearer',
expiresInSec: env.ACCESS_TOKEN_TTL_SEC,
refreshExpiresInSec: env.REFRESH_TOKEN_TTL_SEC,
session,
})
}3. 先准备什么
函数开头先做了两件小事:
// admin-password-login.ts
const { c, payload } = params
const db = getDb(c.env.DB)
const env = getApiEnv(c.env)第一件事是把参数拆开。
payload 就是前端传上来的登录数据,也就是邮箱和密码。c 则是 Hono 的上下文对象,后面拿环境变量、拿 IP、拿 User-Agent 都要从它身上走。
第二件事是把两个后面一定会反复用到的东西先准备好:
dbenv
db 代表这次请求要用的数据库连接。后面所有 repository 方法都会用到它。
env 则把 JWT secret、token 过期时间这些配置先拿出来。这样后面签 token 时就不用再到处从 c.env 里零碎地取。
这一步很轻,但很有 service 的味道:先把这条业务线要用的上下文准备好,后面就能一直顺着往下走。
4. 先查登录策略
接下来第一步不是查用户,也不是验密码,而是先看 admin 这条线现在还让不让用 password 登录:
// admin-password-login.ts
if (!(await isPasswordLoginEnabledForAdmin(db))) {
throw authMethodDisabledError()
}这个顺序是对的。
因为如果后台当前压根没开密码登录,那后面那些查邮箱、验密码的动作就都没有意义了。早点挡掉,既省事,也更符合这条业务线本身的边界。
这里还有一个很明显的分层感:
- 这个 service 只表达“去查一下当前策略”
- 至于具体查哪些表、怎么写条件,不在这里展开
所以你读到这里时,脑子里只需要记住一句话:先看 admin 当前策略。
5. 查登录用户
策略通过之后,才轮到真正去碰用户数据。
// admin-password-login.ts
const normalizedEmail = normalizeEmail(payload.email)
const loginUser = await findLoginUserByNormalizedEmail(db, normalizedEmail)
if (!loginUser) {
throw invalidCredentialsError()
}这里先做了一步很小但很关键的动作:
// admin-password-login.ts
const normalizedEmail = normalizeEmail(payload.email)也就是说,系统不会直接拿用户原样输入的邮箱去查库,而是先标准化一遍。
这一步做完之后,才会真正去数据库里找:
// admin-password-login.ts
const loginUser = await findLoginUserByNormalizedEmail(db, normalizedEmail)这个 repository 方法会把登录所需的用户信息一次查出来。service 到这里并不关心它 join 了哪些表,它只关心结果有没有拿到。
如果拿不到,直接抛:
// admin-password-login.ts
throw invalidCredentialsError()到这里为止,系统只是在回答一个问题:这个邮箱,能不能在当前系统里对应到一个可登录用户。
6. 验密码
找到用户之后,下一步才轮到真正的密码校验。
// admin-password-login.ts
const isPasswordValid = await verifyPasswordHash({
password: payload.password,
passwordHash: loginUser.passwordHash,
passwordAlgo: loginUser.passwordAlgo,
})这里的写法很清楚。
用户输入的明文密码在 payload.password 里,数据库里存的 hash 和算法在 loginUser 里,service 把这三样东西交给 verifyPasswordHash(...),剩下的不再展开。
也就是说,这里完全没有自己手动比对密码。密码怎么验,是 password 模块的事;service 只负责把验密码需要的材料准备好。
后面的判断也放得很集中:
// admin-password-login.ts
if (!isPasswordValid || loginUser.userStatus !== 'active') {
throw invalidCredentialsError()
}密码不对,或者用户状态不是 active,都统一落到 invalidCredentialsError()。
这个收口很重要。因为登录接口对外不需要把账号状态暴露得太细。对外统一成“凭证无效”,边界会更干净。
7. 查 admin 角色
很多系统做到密码校验通过就差不多结束了,但后台登录还要多过一道门。
// admin-password-login.ts
const roles = await getAdminRolesForUser(db, loginUser.userId)
if (roles.length === 0) {
throw adminRoleRequiredError()
}这一段其实很好理解。
前面那几步证明的是“你是谁”,这里证明的是“你能不能进后台”。
也就是说,后台登录不只是身份验证,还带着一层权限门禁。
如果这个用户根本没有 admin 角色,那即便邮箱是真的、密码也对,还是不能进入后台。这就是 adminRoleRequiredError() 出现的位置。
这个顺序放在这里也很顺:先把身份问题搞清楚,再看后台权限问题。
8. 准备 session 数据
前面几关都过了,说明这个请求终于从“尝试登录”进入了“允许登录”的阶段。后面要做的,就不再是拦截,而是开始真正创建登录状态。
先准备 3 个值:
// admin-password-login.ts
const applicationId = await getAdminApplicationId(db)
const nowMs = Date.now()
const refreshExpiresAtMs = nowMs + env.REFRESH_TOKEN_TTL_SEC * 1000applicationId 后面写 session 要用。
nowMs 是当前毫秒时间戳。
refreshExpiresAtMs 则是按 refresh token 的 TTL 算出来的绝对过期时间。这里要乘 1000,因为环境变量给的是秒,但数据库里存的是毫秒。
这种时间换算放在 service 层很合理,因为它紧贴当前业务动作:马上就要创建 session,也马上就要落 refresh token 记录了。
9. 创建 session
// admin-password-login.ts
const session = await createAdminSession({
db: db,
userId: loginUser.userId,
applicationId,
userAgent: getUserAgent(c),
ip: getIp(c),
nowMs,
expiresAtMs: refreshExpiresAtMs,
roles,
})这一步非常像“把已经确认无误的登录结果落库”。
先看传进去的东西。
userIdapplicationIdrolesnowMsexpiresAtMs
这些都很好理解。
更值得注意的是这两项:
userAgent: getUserAgent(c)ip: getIp(c)
说明 service 在这里还顺手把请求上下文里的设备和来源信息提了出来,一起交给 session 创建逻辑。也就是说,这次 session 不是一个抽象状态,而是一条带着具体上下文的登录记录。
方法返回的 session 也不是数据库原始结果,而是一份后面还能继续往下传的业务对象。下一步签 token 就会直接吃它。
10. 发 token
// admin-password-login.ts
const tokenPair = await issueAdminTokenPair({
session,
accessSecret: env.JWT_ACCESS_SECRET,
refreshSecret: env.JWT_REFRESH_SECRET,
accessTtlSec: env.ACCESS_TOKEN_TTL_SEC,
refreshTtlSec: env.REFRESH_TOKEN_TTL_SEC,
})这里没有分别去签 access token 和 refresh token,而是直接调了一个更高层的方法:
issueAdminTokenPair(...)
这说明 JWT 那一层已经把“给 admin session 发一对 token”这件事封好了。service 这里只需要把 session 和配置交进去,拿回完整的 token pair 就行。
这种写法很好,因为它让当前这条主线继续保持干净。
你读到这里,不会被 JWT payload 细节打断,脑子里只需要记住:session 创建完了,现在开始发 token。
11. 写 refresh token
很多人第一次看登录代码,看到 token 签完就会下意识觉得差不多结束了。但这里还差一步:
// admin-password-login.ts
await insertRefreshToken({
db: db,
tokenId: tokenPair.refreshJti,
sessionId: session.sessionId,
jtiHash: await hashTokenJti(tokenPair.refreshJti),
parentTokenId: null,
issuedAtMs: nowMs,
expiresAtMs: refreshExpiresAtMs,
})这一段是在给 refresh token 建状态记录。
重点可以看两个地方。
第一个是:
// admin-password-login.ts
tokenId: tokenPair.refreshJti这里拿的是 refresh token 的 jti,也就是这张 refresh token 自己的唯一编号。
第二个是:
// admin-password-login.ts
jtiHash: await hashTokenJti(tokenPair.refreshJti)这里没有直接把原始 jti 拿去存,而是先算了一次 hash,再把 hash 存进去。
parentTokenId: null 也很好理解,它说明当前这张 refresh token 还不是从上一张轮换出来的,所以没有父节点。
这一步做完之后,这次登录不只是“前端手里拿到 token”了,服务端自己也把 refresh token 的状态线索落到了数据库里。
12. 返回响应
// admin-password-login.ts
return AdminPasswordLoginResponseSchema.parse({
accessToken: tokenPair.accessToken,
refreshToken: tokenPair.refreshToken,
tokenType: 'Bearer',
expiresInSec: env.ACCESS_TOKEN_TTL_SEC,
refreshExpiresInSec: env.REFRESH_TOKEN_TTL_SEC,
session,
})这一步很像收尾。
access token、refresh token、token type、过期时间、session,这些值前面都已经有了。这里做的事,是把它们重新整理成响应对象,然后再交给 AdminPasswordLoginResponseSchema 过一遍。
也就是说,service 最后不是“随手拼个对象就返回”,而是让返回结构再和契约层对齐一次。
这样一来,请求和响应这条线就闭合了:
- 请求进来,吃的是
AdminPasswordLoginRequest - 响应出去,走的是
AdminPasswordLoginResponseSchema
前后结构都是有约束的。
13. 这段 service 串起了什么
整段代码顺下来之后,你会发现它其实像个调度中心,把几层东西串在了一起。
请求上下文这一层,提供的是:
cgetUserAgent(c)getIp(c)getApiEnv(c.env)
repository 这一层,负责的是数据库读写:
- 查策略
- 查用户
- 查角色
- 查 application
- 建 session
- 插 refresh token
密码模块负责验密码。
JWT 模块负责发 token。
token-hash 模块负责把 refresh token 的 jti 变成 hash。
错误模块负责把各种失败情况收成统一错误对象。
契约层负责把输入输出结构兜住。
而这个 service 自己做的,是把这些现成能力排成一条有顺序的登录主线。
14. 总结
如果把这段 handleAdminPasswordLogin 压成一句话,它做的就是:
先确认 admin 现在还允不允许密码登录,再确认这个邮箱和密码对应的是不是一个可进入后台的人;确认无误之后,创建 session、签一对 token、把 refresh token 状态记下来,最后按约定好的响应格式回给前端。
所以读这类 service 文件时,最好的方式不是盯着某个底层方法不放,而是先把整条主线顺下来。主线顺了,每个模块为什么会在这里出现,自然就都明白了。