认证与用户体系
上一篇你理解了内容模块。这一篇回答一个问题:用户是怎么登录的,登录后后端怎么知道"你是谁"。
整体认证流程
项目的认证链路只有两步:
第1步:POST /api/Connect/token { username, password }
│
▼
LoginService 查数据库校验用户名密码
│
▼
TokenService 生成 JWT(含 sub=用户ID)
│
▼
返回 { accessToken, expiresIn }
│
第2步:客户端后续请求都带 Authorization: Bearer <token>
│
▼
JWT Bearer 中间件解析 token → 还原出 ClaimsPrincipal
│
▼
UserAccessor 从 ClaimsPrincipal 取出 userId → 查到 User 实体下面逐步拆解。
登录:从表单到 Token
登录接口在 ConnectController 里。它有三个特别之处:
1. 参数来自表单,不是 JSON Body
// ConnectController.cj
@AllowAnonymous // 登录不需要认证
@HttpPost["token"]
public func token(
@FromServices tokenService: TokenService, // 从 DI 容器拿
@FromForm model: TokenModel // 从表单拿
) {
let user = loginService.findByPassword(model.username, model.password)
let (token, expiresIn) = tokenService.createToken(user)
return TokenResult(expiresIn: expiresIn, accessToken: token)
}@FromForm 表示参数从 application/x-www-form-urlencoded 表单取值。这是 OAuth 2.0 的惯例,不是随意选的。
@FromServices 用在 Action 参数上,直接从 DI 容器解析,不需要在构造函数里声明。
2. LoginService 负责校验密码
// LoginService.cj
public class LoginService {
public LoginService(let context: AdminDbContext, let userAccessor: UserAccessor) {}
public func findByPassword(username: String, password: String): User {
let param = DbParameters()
param.add("username", username)
let user = context.users
.params(param)
.whereIf("username = @username")
.firstOrDefault()
if (let Some(user) <- user && user.passwordHash != password) {
throw KnowException("用户名或密码错误")
}
return user.getOrThrow { KnowException("用户名或密码错误") }
}
}注意:错误信息故意不区分"用户名不存在"和"密码错误"。这是安全最佳实践——不让攻击者通过错误信息猜测有效用户名。
3. TokenService 负责生成 JWT
// TokenService.cj
public func createToken(user: User): (String, Int64) {
let expiresIn = 3600 // 1 小时过期
let securityKey = SymmetricSecurityKey("fromPublicKeyPemFile")
let descriptor = SecurityTokenDescriptor(
SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256)
)
descriptor.issuer = "soulsoft"
descriptor.notBefore = DateTime.nowUTC()
descriptor.expires = DateTime.nowUTC().addSeconds(expiresIn)
descriptor.claims = [Claim(ClaimTypes.Sub, user.id.toString())]
let handler = JwtSecurityTokenHandler()
let token = handler.createEncodedJwt(descriptor)
return (token, expiresIn)
}生成的 JWT 里只有一个核心 claim:sub = 用户ID。所有后续的"当前用户是谁"都从这来。
获取当前用户:UserAccessor
每个需要"当前用户"的地方,都不应该自己去解析 HTTP Header。项目里用 UserAccessor 封装了这个逻辑:
// UserAccessor.cj
public class UserAccessor {
private let context: AdminDbContext
private let httpContext: HttpContext
public init(context: AdminDbContext, contextAccessor: IHttpContextAccessor) {
this.context = context
httpContext = contextAccessor.context.getOrThrow()
}
// 获取完整 User 实体
public func getLoginUser(): User {
let userId = getUserId()
return context.users.find(userId).getOrThrow()
}
// 只拿 userId
public func getUserId(): Int64 {
let sub = httpContext.user.findFirstValue(ClaimTypes.Sub).getOrThrow()
return Int64.parse(sub)
}
}它依赖 IHttpContextAccessor 来拿当前请求上下文,然后从 JWT 的 sub claim 里解析出用户 ID。
业务代码里用它非常简单:
// 任何 CommandHandler 里:
let loginUser = userAccessor.getLoginUser()
// 现在可以拿 loginUser.id、loginUser.username 了中间件是怎么串起来的
认证和授权不是"自动生效"的,需要在 main.cj 里按正确顺序注册中间件:
// main.cj — 中间件顺序
host.useRouting() // ① 先匹配路由
host.useAuthentication() // ② 解析 JWT,还原用户身份
host.useAuthorization() // ③ 检查用户有没有权限访问这个接口
host.mapControllers() // ④ 执行 Action
.requireAuthorization("default") // 默认所有接口都要登录顺序是关键的:认证在授权之前,授权在控制器之前。如果顺序反了,授权检查时用户身份还没解析出来。
requireAuthorization("default") 对应前面注册的授权策略:
builder.services.addAuthorizationBuilder().addPolicy("default") {
options =>
options.requireAuthenticatedUser() // 必须登录
options.requireClaim(ClaimTypes.Sub) // 必须有 sub claim
}如果某个接口不需要登录(如首页、注册),加 @AllowAnonymous:
@AllowAnonymous
@HttpPost
public func getBlogPostList(model: BlogPostSearchModel) { ... }用户模块
跟认证相关的用户能力在 UserController 里:
| 接口 | 需要登录 | 说明 |
|---|---|---|
register | 否 | 注册新用户 |
getUserProfile | 否 | 查看用户资料 |
getUserPosts | 否 | 查看用户的文章 |
updateProfile | 是 | 编辑自己的资料 |
deleteAccount | 是 | 注销自己的账号 |
注册时的校验逻辑在 UserService.register:
// UserService.cj — 注册
public func register(username: String, email: String, password: String): User {
// ① 检查用户名是否已存在
let param1 = DbParameters()
param1.add("username", username)
if (context.users.params(param1).whereIf("username = @username").any()) {
throw KnowException("用户名已存在")
}
// ② 检查邮箱是否已存在
let param2 = DbParameters()
param2.add("email", email)
if (context.users.params(param2).whereIf("email = @email").any()) {
throw KnowException("邮箱已存在")
}
// ③ 创建用户
let user = User()
user.username = username
user.email = email
user.passwordHash = password
user.joinedAt = DateTime.now()
context.users.add(user)
context.saveChanges()
return user
}认证 vs 用户资料 — 为什么分开两个 Controller
项目里认证相关接口在 ConnectController,用户资料在 UserController,没有混在一起:
| Controller | 管什么 | 原因 |
|---|---|---|
ConnectController | 登录、获取当前用户信息 | 跟 Token 相关,属于认证层 |
UserController | 注册、资料、文章 | 跟用户数据相关,属于业务层 |
这样当你想换一种认证方式(比如加 GitHub OAuth),UserController 完全不受影响。
生产注意事项
- 当前密码是明文存储的(
passwordHash字段实为明文)。上线前应改为真正的哈希(如 bcrypt) - 当前签名密钥是硬编码的字符串。上线前应使用 RSA 密钥对或从配置读取
- JWT 过期时间是 1 小时,客户端需要在过期前刷新 Token 或引导重新登录
@AllowAnonymous要谨慎加:列表和详情可以公开,创建/更新/删除不能公开