Skip to content

客户端与资源

涵盖 Identity Server 的客户端、身份资源、API 作用域、API 资源的配置模型,以及签名凭据注册和配置文件加载。

适用场景

  • 需要为应用注册 OAuth 客户端,配置授权类型和回调地址
  • 需要定义身份资源,控制令牌中包含哪些用户声明
  • 需要定义 API 作用域,控制客户端可申请的权限范围
  • 需要配置签名凭据和密钥管理

前置条件

客户端与资源配置依赖 Identity Server 核心注册,详见 快速开始授权类型

快速开始

Identity Server 用三层模型定义"谁能访问什么":

Client 定义"谁在请求",指定授权类型、可访问的作用域和客户端密钥。ApiResource 定义"保护什么",指定哪些作用域可以访问该 API。中间的 ApiScope 是桥梁——客户端申请作用域,API 资源声明接受哪些作用域。

cangjie
// 客户端:谁在请求令牌
let clients = [
    Client("spa", []).also { c =>
        c.requireSecret = false
        c.allowedGrantTypes = [GrantTypes.AuthorizationCode]
        c.allowedScopes = ["openid", "profile", "api"]
        c.allowedRedirectUris = ["http://localhost:3000/callback"]
    }
]

// 身份资源:令牌中包含的用户声明
let identityResources = [
    IdentityResource.OpenId,
    IdentityResource.Profile
]

// API 作用域:客户端可以申请的权限
let apiScopes = [ApiScope("api", Some("API 访问权限"))]

// API 资源:受保护的 API,声明接受哪些作用域
let apiResources = [ApiResource("order-api", ["api"])]

// 签名凭据:用什么密钥签名 JWT
let signingCredentials = [
    SigningCredentials(rsaKey, SecurityAlgorithms.RsaSha256)
]

核心概念

三层资源模型

text
Client(客户端)           IdentityResource(身份资源)     ApiResource(API 资源)
  ├── clientId                ├── name(如 openid)          ├── name(如 order-api)
  ├── allowedGrantTypes       ├── claimTypes                 ├── allowedScopes
  ├── allowedScopes           └── required                   ├── apiSecrets
  ├── clientSecrets                                           └── claimTypes
  └── allowedRedirectUris

                                ApiScope(作用域)
                                  ├── name(如 api)
                                  └── required
  • Client → 定义谁能请求令牌、用什么方式、能申请哪些作用域
  • ApiScope → 定义有哪些权限可以申请
  • ApiResource → 定义哪些 API 接受哪些作用域
  • IdentityResource → 定义令牌中包含哪些用户身份声明

Client 配置

Client 是客户端的完整配置,关键字段:

字段类型默认值说明
clientIdString""客户端唯一标识,必填
clientNameString""显示名称,用于同意页面
requireSecretBooltrue是否要求客户端密钥——公开客户端设为 false
clientSecretsArray<Secret>[]客户端密钥列表,支持 plain/sha256/sha384/sha512
allowedGrantTypesArray<String>[]允许的授权类型,如 ["authorization_code", "password"]
allowedScopesArray<String>[]允许申请的作用域,如 ["openid", "profile", "api"]
allowedRedirectUrisArray<String>[]允许的回调地址白名单(授权码模式必填)
postLogoutRedirectUrisArray<String>[]登出后允许跳转的地址白名单
requirePkceBoolfalse是否强制 PKCE——公开客户端建议 true
allowPlainTextPkceBooltrue是否允许 plain 挑战方法(建议仅允许 S256
requireConsentBoolfalse是否需要用户同意授权
allowOfflineAccessBoolfalse是否允许刷新令牌(离线访问)
accessTokenLifetimeInt643600访问令牌有效期(秒),默认 1 小时
identityTokenLifetimeInt64300身份令牌有效期(秒),默认 5 分钟
authorizationCodeLifetimeInt64300授权码有效期(秒),默认 5 分钟
absoluteRefreshTokenLifetimeInt642592000刷新令牌绝对过期(秒),默认 30 天
slidingRefreshTokenLifetimeInt641296000滑动刷新生命周期(秒),默认 15 天
refreshTokenExpirationRefreshTokenExpirationAbsolute过期策略:AbsoluteSliding
refreshTokenUsageRefreshTokenUsageReUse使用策略:ReUseOnce

密钥类型——clientSecrets 中的 Secret 的值会根据 type 字段做哈希处理:

cangjie
// 明文存储
Secret("my-secret")

// SHA256 哈希
Secret("my-secret".sha256())

配置文件中通过 type 字段指定:"plain""sha256""sha384""sha512"ConfigurationLoader 在加载时自动计算哈希。

IdentityResource

IdentityResource 定义令牌中应包含哪些用户声明。框架内置了 4 个标准身份资源:

静态常量名称默认声明
IdentityResource.OpenIdopenidsub
IdentityResource.Profileprofileprofile(业务自定义)
IdentityResource.Emailemailemail
IdentityResource.Addressaddressaddress

自定义身份资源:

cangjie
// 方式一:直接构造
let customResource = IdentityResource("custom", ["tenant_id", "department"])

// 方式二:从配置文件加载
builder.services.addIdentityServer()
    .addInMemoryIdentityResources(builder.configuration.getSection("identityserver:identityResources"))

关键字段:

字段说明
name资源名称(也是作用域名)
claimTypes该资源关联的用户声明类型列表
required是否必须在请求中显式指定——true 时不能省略
enabled是否启用
discoveryDocument是否出现在 OIDC 发现文档中

ApiScope

ApiScope 定义一个可被客户端申请的权限。一个作用域可以被多个 API 资源引用:

cangjie
// 单个作用域
ApiScope("read", Some("读取权限"))

// 多个作用域
let scopes = [
    ApiScope("read", Some("读取权限")),
    ApiScope("write", Some("写入权限")),
    ApiScope("admin", Some("管理权限"))
]

required 字段——当 required = true 时,客户端必须在请求中显式指定这个作用域,不能被默认授予。这用于防止权限被意外扩大。

ApiResource

ApiResource 定义一个受保护的 API 资源——它是一个或多个作用域的容器:

cangjie
ApiResource("order-api", ["read", "write", "admin"])

关键字段:

字段说明
nameAPI 资源名称
allowedScopes该 API 接受的作用域列表
apiSecretsAPI 自身的密钥(用于自省端点等场景)
claimTypes访问该 API 时令牌应附带的自定义声明

配置文件加载

客户端和资源的定义可从 appsettings.json 加载,避免硬编码大量配置对象:

json
{
  "identityserver": {
    "clients": [
      {
        "clientId": "interactive.public",
        "clientName": "交互式公开客户端",
        "requireSecret": false,
        "requireConsent": true,
        "allowedScopes": ["profile", "openid", "api"],
        "allowedRedirectUris": ["http://127.0.0.1:3000/callback.html"],
        "allowedGrantTypes": ["authorization_code", "refresh_token"],
        "accessTokenLifetime": 3600,
        "requirePkce": true
      }
    ],
    "identityResources": [
      { "name": "openid", "required": true, "claimTypes": ["sub"] },
      { "name": "profile", "claimTypes": ["name", "email", "picture"] }
    ],
    "apiScopes": [
      { "name": "api", "displayName": "API 访问", "description": "允许访问 API" }
    ],
    "apiResources": [
      { "name": "order", "displayName": "Order API", "allowedScopes": ["api"] }
    ]
  }
}

代码侧用 IConfigurationgetSection 加载:

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"))

ConfigurationLoader 自动将 JSON 字段映射到对应的模型属性,包括枚举值(如 refreshTokenExpiration"Absolute" / "Sliding")和密钥类型的自动哈希。

签名凭据

签名凭据决定 JWT 令牌的签名算法和密钥:

cangjie
// 对称密钥(仅开发环境)
let symmetricKey = SymmetricSecurityKey("your-256-bit-secret...".toArray())
let hmacCreds = SigningCredentials(symmetricKey, SecurityAlgorithms.HmacSha256)

// RSA 非对称密钥(生产环境推荐)
let rsaKey = RsaSecurityKey.fromPemFiles("public.pem", "private.pem")
let rsaCreds = SigningCredentials(rsaKey, SecurityAlgorithms.RsaSha256)

// ECDSA 非对称密钥(更小的密钥 + 同等安全性)
let ecKey = ECDsaSecurityKey.fromPemFiles("ec_public.pem", "ec_private.pem")
let ecCreds = SigningCredentials(ecKey, SecurityAlgorithms.EcdsaSha256)

注册多组凭据后,JWKS 端点(/.well-known/openid-configuration/jwks)会暴露所有公钥,JWT 头部通过 kid 字段标识使用了哪组密钥。

常见用法

公开客户端 vs 机密客户端

cangjie
// 公开客户端:SPA、移动端——不要求密钥,必须 PKCE
let publicClient = Client("spa-app", []).also { c =>
    c.requireSecret = false
    c.requirePkce = true
    c.allowedGrantTypes = [GrantTypes.AuthorizationCode]
    c.allowedRedirectUris = ["http://localhost:3000/callback"]
    c.allowedScopes = ["openid", "profile", "api"]
}

// 机密客户端:后端服务——要求密钥,可使用密码模式
let confidentialClient = Client("backend", [Secret("secret".sha256())]).also { c =>
    c.requireSecret = true
    c.allowedGrantTypes = [GrantTypes.Password, GrantTypes.ClientCredentials]
    c.allowedScopes = ["api"]
}

存储替换入口

所有内存存储都支持替换。以客户端存储为例:

cangjie
// 默认:内存存储
builder.services.addIdentityServer()
    .addInMemoryClients([...])

// 替换:自定义数据库存储
builder.services.addIdentityServer()
    .addClientStore<MyDatabaseClientStore>()

其他存储接口(IApiResourceStoreIApiScopeStoreIIdentityResourceStoreISigningCredentialStoreIConsentStoreIRefreshTokenStoreIAuthorizationCodeStore)都遵循相同的模式——默认用 addInMemoryXxx(),替换用 addXxxStore<T>()

生产建议

  • 公开客户端必须设置 requirePkce = true PKCE 是防止授权码拦截攻击的业界标准,OAuth 2.1 将其列为公开客户端的强制要求。如果客户端无法安全保管 client_secret(如 SPA、移动端),PKCE 是唯一的安全保障。
  • 客户端密钥使用 sha256 而非 plain ConfigurationLoader 支持自动哈希,配置文件中写 "type": "sha256" 即可。即使配置文件泄露,攻击者拿到的也只是哈希值而非原始密钥。
  • allowedRedirectUris 必须精确匹配。 不要使用通配符或模糊匹配——回调地址白名单是防止授权码被重定向到恶意地址的最后防线。
  • 生产环境签名凭据替换为 ISigningCredentialStore 实现。 默认的 SigningCredentialMemoryStore 将密钥存于内存,无法轮换。替换为数据库或密钥管理服务的实现后,可以在不重启服务的情况下轮换密钥。

常见问题

ApiScope 和 ApiResource 为什么分开

ApiScope 是"权限"(如 readwrite),ApiResource 是"需要该权限的服务"(如 order-apiuser-api)。一个 read 作用域可以被多个 API 资源引用,实现权限的跨 API 复用。客户端申请的是作用域,API 资源声明接受哪些作用域——两者解耦后,可以按需组合而无需修改客户端配置。

offline_access 作用域和 allowOfflineAccess 是什么关系

allowOfflineAccess = true(Client 级别)是前提——开启后该客户端才能请求刷新令牌。offline_access 作用域(ApiScope 级别)是可选的显式声明——如果定义了该作用域,客户端必须在请求中包含它才能获得 refresh_token

什么情况下需要 claimTypes

在 IdentityResource 和 ApiResource 上都可以指定 claimTypesIdentityResourceclaimTypes 控制该身份资源激活时令牌中会包含哪些用户声明。ApiResourceclaimTypes 控制访问该 API 时令牌应附带的额外声明(如租户 ID、API 版本等)。

相关专题