SSO 单点登录使用文档
概述
FreeKit.Identity 使用 OpenIddict 实现标准 OAuth 2.0 / OpenID Connect 单点登录。支持授权码流程(Authorization Code Flow)+ PKCE,适用于 Web 应用、SPA、移动端等场景。
端点说明
| 端点 | 方法 | 说明 |
|---|---|---|
/connect/authorize | GET | 授权端点,发起授权请求 |
/connect/token | POST | 令牌端点,交换授权码/刷新令牌 |
/connect/userinfo | GET | 用户信息端点,获取当前用户信息 |
/connect/logout | POST | 登出端点 |
配置说明
OpenIddict 的客户端应用通过数据库管理(OpenIddictApplication 表),不再通过配置文件管理。
注册客户端应用
通过 IOpenIddictApplicationManager 注册客户端:
var manager = serviceProvider.GetRequiredService<IOpenIddictApplicationManager>();
await manager.CreateAsync(new OpenIddictApplicationDescriptor
{
ClientId = "my-app",
ClientSecret = "my-secret", // confidential 客户端需要
DisplayName = "My Application",
RedirectUris =
{
new Uri("https://my-app.example.com/callback")
},
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.GrantTypes.RefreshToken,
OpenIddictConstants.Permissions.Scopes.Profile,
OpenIddictConstants.Permissions.Scopes.Email,
OpenIddictConstants.Permissions.Scopes.Roles
}
});
授权码流程(Authorization Code Flow + PKCE)
流程图
客户端 Identity 服务 用户
| | |
| 1. 重定向到 /connect/authorize |
|---------------------->| |
| | 2. 检查是否已登录 |
| |---- (未登录) ---------->|
| | 3. 重定向到登录页 |
|<----------------------| |
| 4. 用户登录 (POST /api/identity/account/login) |
|------------------------------------------------>|
|<------------------------------------------------|
| 5. 登录成功,重定向回 /connect/authorize |
|---------------------->| |
| | 6. 生成授权码 |
|<----------------------| |
| 7. 用授权码换 token (POST /connect/token) |
|---------------------->| |
|<----------------------| |
| 8. 收到 access_token + refresh_token |
详细步骤
1. 发起授权请求
客户端将用户重定向到授权端点:
GET /connect/authorize?
response_type=code&
client_id=my-app&
redirect_uri=https://my-app.example.com/callback&
scope=openid profile email&
state=abc123&
code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
code_challenge_method=S256
| 参数 | 必填 | 说明 |
|---|---|---|
| response_type | 是 | 固定值 code |
| client_id | 是 | 客户端 ID |
| redirect_uri | 是 | 回调地址,必须与注册时一致 |
| scope | 否 | 请求的权限范围(空格分隔) |
| state | 否 | 状态参数,防止 CSRF |
| code_challenge | 否 | PKCE 代码挑战 |
| code_challenge_method | 否 | PKCE 方法,固定值 S256 |
2. 用户登录
如果用户未登录,系统会返回 401。前端需要先调用登录接口:
POST /api/identity/account/login
Content-Type: application/json
{
"userName": "admin",
"password": "123qwe"
}
登录成功后,JWT token 会设置到 HTTP-only cookie 中。
3. 获取授权码
登录后重新访问 /connect/authorize,系统会:
- 验证客户端和 redirect_uri
- 生成授权码
- 重定向到
redirect_uri?code=AUTH_CODE&state=abc123
4. 交换令牌
POST /connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://my-app.example.com/callback&
client_id=my-app&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
响应:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "eyJhbGciOiJSUzI1NiIs...",
"id_token": "eyJhbGciOiJSUzI1NiIs..."
}
5. 刷新令牌
POST /connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&
refresh_token=eyJhbGciOiJSUzI1NiIs...&
client_id=my-app
6. 获取用户信息
GET /connect/userinfo
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
响应:
{
"sub": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "admin",
"email": "admin@example.com",
"role": ["Admin"]
}
JavaScript 集成示例
使用原生 Fetch API
// 1. 生成 PKCE 参数
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
async function generateCodeChallenge(verifier) {
const data = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
// 2. 发起授权
async function login() {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = Math.random().toString(36).substring(7);
sessionStorage.setItem('code_verifier', codeVerifier);
sessionStorage.setItem('state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: 'my-app',
redirect_uri: 'https://my-app.example.com/callback',
scope: 'openid profile email',
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
window.location.href = `/connect/authorize?${params.toString()}`;
}
// 3. 回调页面处理
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (state !== sessionStorage.getItem('state')) {
throw new Error('State mismatch');
}
const codeVerifier = sessionStorage.getItem('code_verifier');
const response = await fetch('/connect/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: 'https://my-app.example.com/callback',
client_id: 'my-app',
code_verifier: codeVerifier
})
});
const tokens = await response.json();
localStorage.setItem('access_token', tokens.access_token);
localStorage.setItem('refresh_token', tokens.refresh_token);
}
使用 oidc-client-ts
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
const userManager = new UserManager({
authority: 'https://identity.example.com',
client_id: 'my-app',
redirect_uri: 'https://my-app.example.com/callback',
response_type: 'code',
scope: 'openid profile email',
automaticSilentRenew: true,
userStore: new WebStorageStateStore({ store: localStorage })
});
// 登录
await userManager.signinRedirect();
// 回调处理
const user = await userManager.signinRedirectCallback();
console.log(user.access_token);
// 获取用户信息
const userInfo = await userManager.getUser();
C# 集成示例
public class SsoClient
{
private readonly HttpClient _httpClient;
public SsoClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public string GetAuthorizationUrl(string clientId, string redirectUri,
string scope = "openid profile email", string? state = null)
{
var parameters = new Dictionary<string, string>
{
["response_type"] = "code",
["client_id"] = clientId,
["redirect_uri"] = redirectUri,
["scope"] = scope
};
if (!string.IsNullOrEmpty(state))
parameters["state"] = state;
var query = string.Join("&", parameters.Select(kvp =>
$"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}"));
return $"/connect/authorize?{query}";
}
public async Task<TokenResponse> ExchangeCodeAsync(string code,
string redirectUri, string clientId, string? codeVerifier = null)
{
var parameters = new Dictionary<string, string>
{
["grant_type"] = "authorization_code",
["code"] = code,
["redirect_uri"] = redirectUri,
["client_id"] = clientId
};
if (!string.IsNullOrEmpty(codeVerifier))
parameters["code_verifier"] = codeVerifier;
var response = await _httpClient.PostAsync("/connect/token",
new FormUrlEncodedContent(parameters));
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<TokenResponse>();
}
public async Task<UserInfoResponse> GetUserInfoAsync(string accessToken)
{
var request = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<UserInfoResponse>();
}
}
与旧版 SSO 的区别
| 对比项 | 旧版(自定义 SSO) | 新版(OpenIddict) |
|---|---|---|
| 标准 | 自定义实现 | OAuth 2.0 / OpenID Connect |
| 端点 | /api/identity/sso/* | /connect/* |
| 授权码 | 自定义生成 | OpenIddict 管理 |
| PKCE | 不支持 | 支持 |
| 客户端管理 | 配置文件 | 数据库(OpenIddictApplication 表) |
| Token 格式 | 自定义 JWT | 标准 OIDC JWT |
| 刷新令牌 | 通过 AccountController | 通过 /connect/token |
迁移指南
从旧版 SSO 迁移到 OpenIddict:
- 注册客户端:将配置文件中的
AllowedClientIds迁移到OpenIddictApplication表 - 更新前端:将
/api/identity/sso/authorize改为/connect/authorize - 更新 Token 交换:将
/api/identity/sso/callback改为POST /connect/token - 添加 PKCE:前端实现 PKCE 代码挑战(推荐)
- 更新 Token 验证:使用
/connect/userinfo验证 token
常见问题
Q: 如何添加新的客户端应用?
A: 通过 IOpenIddictApplicationManager.CreateAsync() 注册,或直接向 OpenIddictApplication 表插入数据。
Q: redirect_uri 验证失败怎么办?
A: 确保 redirect_uri 与注册时完全一致(包括协议、端口、路径)。
Q: 授权码过期了怎么办?
A: 授权码默认有效期较短,过期后需要重新发起授权请求。
Q: 如何刷新访问令牌?
A: 使用 refresh_token 调用 POST /connect/token,设置 grant_type=refresh_token。
Q: 支持哪些授权模式?
A: 当前支持 Authorization Code(推荐)+ Refresh Token。如需其他模式,需修改 IdentityModuleStartup 中的配置。