Skip to content

认证与用户体系

上一篇你理解了内容模块。这一篇回答一个问题:用户是怎么登录的,登录后后端怎么知道"你是谁"。

整体认证流程

项目的认证链路只有两步:

text
第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

cangjie
// 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 负责校验密码

cangjie
// 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

cangjie
// 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 封装了这个逻辑:

cangjie
// 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。

业务代码里用它非常简单:

cangjie
// 任何 CommandHandler 里:
let loginUser = userAccessor.getLoginUser()
// 现在可以拿 loginUser.id、loginUser.username 了

中间件是怎么串起来的

认证和授权不是"自动生效"的,需要在 main.cj 里按正确顺序注册中间件:

cangjie
// main.cj — 中间件顺序
host.useRouting()           // ① 先匹配路由
host.useAuthentication()    // ② 解析 JWT,还原用户身份
host.useAuthorization()     // ③ 检查用户有没有权限访问这个接口
host.mapControllers()       // ④ 执行 Action
    .requireAuthorization("default")  // 默认所有接口都要登录

顺序是关键的:认证在授权之前,授权在控制器之前。如果顺序反了,授权检查时用户身份还没解析出来。

requireAuthorization("default") 对应前面注册的授权策略:

cangjie
builder.services.addAuthorizationBuilder().addPolicy("default") {
    options =>
        options.requireAuthenticatedUser()     // 必须登录
        options.requireClaim(ClaimTypes.Sub)   // 必须有 sub claim
}

如果某个接口不需要登录(如首页、注册),加 @AllowAnonymous

cangjie
@AllowAnonymous
@HttpPost
public func getBlogPostList(model: BlogPostSearchModel) { ... }

用户模块

跟认证相关的用户能力在 UserController 里:

接口需要登录说明
register注册新用户
getUserProfile查看用户资料
getUserPosts查看用户的文章
updateProfile编辑自己的资料
deleteAccount注销自己的账号

注册时的校验逻辑在 UserService.register

cangjie
// 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 要谨慎加:列表和详情可以公开,创建/更新/删除不能公开