Skip to content

快速开始

从零启动一个最小可运行的 Identity Server——配置内存存储、实现用户资料服务和密码验证器,通过 POST /connect/token 签发 JWT 令牌。

适用场景

  • 第一次接入 Identity Server,需要跑通最小可用链路
  • 需要理解 Identity Server 的注册顺序和必须实现的自定义服务
  • 需要快速搭建开发环境的认证中心

前置条件

Identity Server 依赖以下包:

用途
soulsoft_identity_server认证中心核心:端点、中间件、服务注册
soulsoft_identity_tokens签名凭据和密钥管理
soulsoft_identity_tokens_jwtJWT 令牌的序列化与签名
soulsoft_identity_claims身份数据模型
soulsoft_web_authentication认证框架(Cookie 方案用于登录态)
soulsoft_web_authentication_cookiesCookie 认证处理器
soulsoft_web_hostingWeb 主机和中间件管道

最小示例

下面是一个完整可运行的最小示例:注册 Identity Server、配置内存存储、实现用户资料服务、通过密码模式签发令牌。

addIdentityServer() 注册核心服务、指定 issuer 和登录页地址;addInMemoryClientsaddInMemorySigningCredentials 等链式调用注入内存存储数据;addProfileServiceaddPasswordGrantValidator 是必须由你实现的扩展点。

cangjie
import soulsoft_web_http.*
import soulsoft_web_hosting.*
import soulsoft_web_routing.*
import soulsoft_identity_tokens.*
import soulsoft_identity_server.*
import soulsoft_web_authentication.*
import soulsoft_identity_server.models.*
import soulsoft_identity_server.services.*
import soulsoft_extensions_injection.*
import soulsoft_web_authentication_cookies.*
import soulsoft_identity_server.authentication.*

main(): Int64 {
    let builder = WebHost.createBuilder()

    let signingKey = SymmetricSecurityKey(
        "your-256-bit-secret-your-256-bit-secret".toArray())

    // 注册 Identity Server 核心服务
    builder.services.addIdentityServer() { options =>
        options.issuer = "spire-idp"
        options.issuerUri = "http://localhost:5000"
        options.interaction.loginUrl = "/account/login"
    }
        .addInMemoryClients([
            Client("client", [Secret("secret".sha256())]).also { c =>
                c.clientName = "默认客户端"
                c.allowedScopes = ["openid", "profile", "api"]
                c.allowedGrantTypes = [GrantTypes.Password]
            }
        ])
        .addInMemoryApiScopes([
            ApiScope("api", Some("API 访问权限"))
        ])
        .addInMemoryApiResources([
            ApiResource("api", ["api"])
        ])
        .addInMemoryIdentityResources([
            IdentityResource.OpenId,
            IdentityResource.Profile
        ])
        .addInMemorySigningCredentials([
            SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256)
        ])
        .addProfileService<MyProfileService>()
        .addPasswordGrantValidator<MyPasswordValidator>()

    builder.services.addRouting()
    builder.services.addAuthentication(LocalApiAuthenticationDefaults.Scheme)
        .addCookie()
        .addLocalApi()

    let app = builder.build()
    app.useIdentityServer()
    app.useRouting()
    app.useAuthentication()
    app.useAuthorization()

    // 登录端点——验证用户名密码,签发 Cookie 登录态
    app.mapPost("/account/login") { ctx =>
        let username = ctx.request.form.get("username").getOrThrow()
        let password = ctx.request.form.get("password").getOrThrow()
        if (username == "admin" && password == "1024") {
            ctx.signIn(IdentityUser("1024"))
        }
        ctx.response.redirect("/")
    }

    app.run("http://127.0.0.1:5000")
    return 0
}

这段代码启动了一个完整的认证中心。现在可以用 POST /connect/token 获取令牌:

POST http://localhost:5000/connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=password&username=admin&password=1024&client_id=client&client_secret=secret

必须实现的两个服务

MyProfileService——提供用户身份声明,在每次签发令牌时被调用:

cangjie
import std.collection.*
import soulsoft_identity_claims.*
import soulsoft_identity_server.services.*
import soulsoft_identity_server.models.*

class MyProfileService <: IProfileService {
    func getProfileData(request: ProfileDataRequest): Collection<Claim> {
        [Claim(ClaimTypes.Sub, "1024"),
         Claim(ClaimTypes.Name, "spire"),
         Claim(ClaimTypes.Email, "spire@example.com")]
    }
}

request.subject 是当前用户的 ClaimsPrincipal(来自 Cookie 登录态),request.caller 区分调用场景:UserInfo(UserInfo 端点请求)、AccessToken(签发 access_token 时)、IdentityToken(签发 identity_token 时)。你可以按场景返回不同的声明集。

MyPasswordValidator——验证密码授权请求:

cangjie
import soulsoft_identity_server.validation.*

class MyPasswordValidator <: IResourceOwnerPasswordValidator {
    func validate(context: ResourceOwnerPasswordValidationContext): Unit {
        if (context.username != "admin" || context.password != "1024") {
            throw ValidationException("invalid_grant", "用户名或密码错误")
        }
    }
}

校验通过后,Identity Server 自动调用 IProfileService.getProfileData() 获取 claims 并签发包含这些声明的 JWT 令牌。

核心概念

请求流程

密码授权模式的完整请求链路:

text
客户端                    Identity Server                  你的代码
  │                            │                              │
  │  POST /connect/token       │                              │
  │  grant_type=password       │                              │
  │  username+password         │                              │
  │  client_id+client_secret   │                              │
  │ ─────────────────────────→ │                              │
  │                            │  验证 client_secret          │
  │                            │  调用 IResourceOwnerPasswordValidator
  │                            │ ───────────────────────────→ │
  │                            │                              │ 验证密码
  │                            │ ←─────────────────────────── │
  │                            │  调用 IProfileService.getProfileData()
  │                            │ ───────────────────────────→ │
  │                            │                              │ 返回 claims
  │                            │ ←─────────────────────────── │
  │                            │  生成 access_token           │
  │  ←─────────────────────────│                              │
  │  { access_token, expires } │                              │

关键点:Identity Server 不负责验证用户密码——那是你的 IResourceOwnerPasswordValidator 的职责。它也不负责提供用户信息——那是你的 IProfileService 的职责。Identity Server 只负责编排流程和签发令牌

注册顺序

Identity Server 的中间件顺序是固定的,必须严格遵守:

text
useIdentityServer()  ← 建议在 useRouting() 之前
useFileServer()      ← 可选,用于提供登录页等静态文件
useRouting()         ← 路由中间件
useAuthentication()  ← 认证中间件(Cookie 登录态)
useAuthorization()   ← 授权中间件

建议将 useIdentityServer() 放在路由之前。IdentityServerMiddleware 通过自身路径匹配(按 context.request.path.value 匹配端点表)拦截 /connect/*/.well-known/* 请求,命中后直接短路返回,不依赖 useRouting() 是否已执行。放在前面可以避免与普通路由端点产生歧义。

客户端密钥验证

Identity Server 支持两种客户端密钥提交方式:

方式常量密钥位置
HTTP Basicclient_secret_basicAuthorization: Basic base64(clientId:secret)
POST Bodyclient_secret_post请求体 client_id + client_secret 参数

两种方式都已注册(BasicClientSecretParserPostBodyClientSecretParser),框架自动检测并选择合适的解析器。

常见用法

从配置文件加载

随着客户端和资源定义增多,硬编码会变得难以维护。Identity Server 支持从 appsettings.json 直接加载配置:

cangjie
builder.services.addIdentityServer() { options =>
    options.issuer = "spire-idp"
    options.issuerUri = "http://localhost:5000"
}
    .addInMemoryClients(builder.configuration.getSection("identityserver:clients"))
    .addInMemoryApiScopes(builder.configuration.getSection("identityserver:apiScopes"))
    .addInMemoryApiResources(builder.configuration.getSection("identityserver:apiResources"))
    .addInMemoryIdentityResources(builder.configuration.getSection("identityserver:identityResources"))
    .addInMemorySigningCredentials([
        SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256)
    ])
    .addProfileService<MyProfileService>()

配置文件中秘钥的 type 字段支持 "plain""sha256""sha384""sha512" 四种格式,ConfigurationLoader 会自动做哈希处理。

多签名凭据

注册多个签名凭据后,JWT 头部通过 kid 标识使用了哪个密钥:

cangjie
let rsaKey = RsaSecurityKey.fromPemFiles("public.pem", "private.pem")
let ecKey = ECDsaSecurityKey.fromPemFiles("ec_public.pem", "ec_private.pem")

builder.services.addIdentityServer()
    .addInMemorySigningCredentials([
        SigningCredentials(rsaKey, SecurityAlgorithms.RsaSha256),
        SigningCredentials(ecKey, SecurityAlgorithms.EcdsaSha256)
    ])

资源服务器可通过 /.well-known/openid-configuration/jwks 获取所有公钥。

生产建议

  • 生产环境使用非对称密钥(RSA/ECDSA)而非对称密钥。 对称密钥要求 Identity Server 和资源服务器共享同一密钥,泄露面大;非对称密钥只需在资源服务器部署公钥,私钥仅 Identity Server 持有。JWKS 端点会自动分发公钥,无需手动同步。
  • 存储实现替换为数据库。 默认的内存存储重启后丢失所有数据(授权码、刷新令牌、同意记录)。生产环境中应将 IClientStoreIRefreshTokenStoreIAuthorizationCodeStoreIConsentStoreISigningCredentialStore 替换为持久化实现。
  • issuerUri 必须是资源服务器可访问的地址。 资源服务器通过 OIDC 发现文档获取 JWKS 公钥和 Token 校验端点,issuerUri 不可达意味着 Token 校验失败。
  • IProfileService.getProfileData() 会被频繁调用。 每次签发令牌都会触发,应缓存用户信息或限制数据库查询,避免成为性能瓶颈。

常见问题

为什么 POST /connect/token 返回 400 "Client secret is invalid"

检查三件事:① 客户端 requireSecret 是否为 true(默认是),是否提供了 client_secret;② client_secret 的值是否与注册时的 Secret 匹配——如果注册时用了 Secret("secret".sha256()),提交时也必须用 "sha256" 类型的密钥;③ 密钥提交方式(client_secret_basic vs client_secret_post)是否正确——框架两种都支持,但密钥格式要对应。

登录端点应该怎么写

Identity Server 不提供登录页面——你需要自己实现。登录端点的职责是:验证用户凭证 → 调用 context.signIn(IdentityUser(userId)) 签发 Cookie 登录态 → 重定向。IdentityUser 的构造参数是用户 ID,后续 IProfileService.getProfileData() 通过 request.subject 获取这个 ID 来查询用户信息。

令牌用 HS256 还是 RS256

开发环境用 HS256(对称密钥)方便快速启动。生产环境用 RS256(RSA 非对称密钥)——私钥仅 Identity Server 持有用于签名,公钥通过 JWKS 端点分发,资源服务器无需持有私钥。ECDSA(ES256/ES384/ES512)提供更小的密钥尺寸和同等安全性。

相关专题