快速开始
从零启动一个最小可运行的 Identity Server——配置内存存储、实现用户资料服务和密码验证器,通过
POST /connect/token签发 JWT 令牌。
适用场景
- 第一次接入 Identity Server,需要跑通最小可用链路
- 需要理解 Identity Server 的注册顺序和必须实现的自定义服务
- 需要快速搭建开发环境的认证中心
前置条件
Identity Server 依赖以下包:
| 包 | 用途 |
|---|---|
soulsoft_identity_server | 认证中心核心:端点、中间件、服务注册 |
soulsoft_identity_tokens | 签名凭据和密钥管理 |
soulsoft_identity_tokens_jwt | JWT 令牌的序列化与签名 |
soulsoft_identity_claims | 身份数据模型 |
soulsoft_web_authentication | 认证框架(Cookie 方案用于登录态) |
soulsoft_web_authentication_cookies | Cookie 认证处理器 |
soulsoft_web_hosting | Web 主机和中间件管道 |
最小示例
下面是一个完整可运行的最小示例:注册 Identity Server、配置内存存储、实现用户资料服务、通过密码模式签发令牌。
addIdentityServer() 注册核心服务、指定 issuer 和登录页地址;addInMemoryClients、addInMemorySigningCredentials 等链式调用注入内存存储数据;addProfileService、addPasswordGrantValidator 是必须由你实现的扩展点。
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——提供用户身份声明,在每次签发令牌时被调用:
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——验证密码授权请求:
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 令牌。
核心概念
请求流程
密码授权模式的完整请求链路:
客户端 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 的中间件顺序是固定的,必须严格遵守:
useIdentityServer() ← 建议在 useRouting() 之前
useFileServer() ← 可选,用于提供登录页等静态文件
useRouting() ← 路由中间件
useAuthentication() ← 认证中间件(Cookie 登录态)
useAuthorization() ← 授权中间件建议将 useIdentityServer() 放在路由之前。IdentityServerMiddleware 通过自身路径匹配(按 context.request.path.value 匹配端点表)拦截 /connect/* 和 /.well-known/* 请求,命中后直接短路返回,不依赖 useRouting() 是否已执行。放在前面可以避免与普通路由端点产生歧义。
客户端密钥验证
Identity Server 支持两种客户端密钥提交方式:
| 方式 | 常量 | 密钥位置 |
|---|---|---|
| HTTP Basic | client_secret_basic | Authorization: Basic base64(clientId:secret) |
| POST Body | client_secret_post | 请求体 client_id + client_secret 参数 |
两种方式都已注册(BasicClientSecretParser 和 PostBodyClientSecretParser),框架自动检测并选择合适的解析器。
常见用法
从配置文件加载
随着客户端和资源定义增多,硬编码会变得难以维护。Identity Server 支持从 appsettings.json 直接加载配置:
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 标识使用了哪个密钥:
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 端点会自动分发公钥,无需手动同步。
- 存储实现替换为数据库。 默认的内存存储重启后丢失所有数据(授权码、刷新令牌、同意记录)。生产环境中应将
IClientStore、IRefreshTokenStore、IAuthorizationCodeStore、IConsentStore、ISigningCredentialStore替换为持久化实现。 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)提供更小的密钥尺寸和同等安全性。
相关专题
- 需要了解授权类型 → 授权类型
- 需要了解客户端与资源配置 → 客户端与资源
- 需要了解 Identity Server 整体架构 → Identity Server 总览
- 需要了解 JWT 令牌的底层签名机制 → JWT 令牌