跳到主要内容

SSO 单点登录使用文档

概述

FreeKit.Identity 使用 OpenIddict 实现标准 OAuth 2.0 / OpenID Connect 单点登录。支持授权码流程(Authorization Code Flow)+ PKCE,适用于 Web 应用、SPA、移动端等场景。

端点说明

端点方法说明
/connect/authorizeGET授权端点,发起授权请求
/connect/tokenPOST令牌端点,交换授权码/刷新令牌
/connect/userinfoGET用户信息端点,获取当前用户信息
/connect/logoutPOST登出端点

配置说明

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_challengePKCE 代码挑战
code_challenge_methodPKCE 方法,固定值 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:

  1. 注册客户端:将配置文件中的 AllowedClientIds 迁移到 OpenIddictApplication
  2. 更新前端:将 /api/identity/sso/authorize 改为 /connect/authorize
  3. 更新 Token 交换:将 /api/identity/sso/callback 改为 POST /connect/token
  4. 添加 PKCE:前端实现 PKCE 代码挑战(推荐)
  5. 更新 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 中的配置。