添加sso
This commit is contained in:
1 parent
fb812268d7
commit
6bee29a351
16 files changed
+917
-5
No files matched your search
@@ -69,6 +69,21 @@ Jwt__Audience=Jiaowu.Web
|
|||||||
Jwt__Key=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
|
Jwt__Key=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
|
||||||
Jwt__ExpireMinutes=60
|
Jwt__ExpireMinutes=60
|
||||||
|
|
||||||
|
# Keycloak SSO(可选)。Authority 必须指向 realm,例如:
|
||||||
|
# https://sso.example.edu.cn/realms/mingxu
|
||||||
|
Sso__Enabled=false
|
||||||
|
# Sso__DisplayName=学校统一身份认证
|
||||||
|
# Sso__Authority=https://sso.example.edu.cn/realms/mingxu
|
||||||
|
# Sso__ClientId=jiaowu-web
|
||||||
|
# Sso__ClientSecret=REPLACE_WITH_KEYCLOAK_CLIENT_SECRET
|
||||||
|
# Sso__UserNameClaim=preferred_username
|
||||||
|
# Sso__RequireHttpsMetadata=true
|
||||||
|
# 首次 SSO 登录优先绑定同名本地账号;用户名不同时由用户输入现有账号密码完成绑定。
|
||||||
|
# 不会自动创建账号或授予角色。
|
||||||
|
# Sso__LinkExistingUsersByUserName=true
|
||||||
|
# 前后端同域部署时留空;开发或分离部署时填写前端公开根地址。
|
||||||
|
# Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
|
||||||
|
|
||||||
AllowedHosts=jiaowu.example.edu.cn
|
AllowedHosts=jiaowu.example.edu.cn
|
||||||
Cors__Origins__0=https://jiaowu.example.edu.cn
|
Cors__Origins__0=https://jiaowu.example.edu.cn
|
||||||
Cors__Origins__1=capacitor://localhost
|
Cors__Origins__1=capacitor://localhost
|
||||||
|
|||||||
@@ -144,6 +144,44 @@ Kubernetes 或密钥管理系统仍可覆盖文件中的值。
|
|||||||
`chmod 600 .env`,Windows 应通过 ACL 只允许服务账号和管理员读取。连接串中的证书
|
`chmod 600 .env`,Windows 应通过 ACL 只允许服务账号和管理员读取。连接串中的证书
|
||||||
路径必须是运行服务器上的实际路径。
|
路径必须是运行服务器上的实际路径。
|
||||||
|
|
||||||
|
### Keycloak 单点登录(可选)
|
||||||
|
|
||||||
|
系统支持 Keycloak 的 OpenID Connect 授权码流程。Keycloak 只负责验证身份;账号是否
|
||||||
|
启用、角色和学院数据范围仍以本系统 Identity 数据为准。首次 SSO 登录会用
|
||||||
|
`preferred_username`(可通过 `Sso__UserNameClaim` 修改)优先匹配已有登录账号并记录
|
||||||
|
外部账号绑定。如果 Keycloak 用户名与教务系统账号不同,认证后会进入账户绑定页,用户
|
||||||
|
需要再输入一次现有教务系统账号和密码;验证成功后建立永久绑定并直接登录。绑定不会
|
||||||
|
自动创建本地账号、修改人员档案或从 Keycloak 导入高权限角色。同一 Keycloak 身份不能
|
||||||
|
绑定多个本地账号,同一本地账号也不能绑定多个 Keycloak 身份。原账号密码登录和学生
|
||||||
|
自助激活入口不受影响。
|
||||||
|
|
||||||
|
在 Keycloak 中创建 OpenID Connect 客户端,并至少配置:
|
||||||
|
|
||||||
|
- Valid redirect URI:`https://jiaowu.example.edu.cn/signin-keycloak`
|
||||||
|
- Valid post logout redirect URI:`https://jiaowu.example.edu.cn/*`(若后续启用 Keycloak 全局退出)
|
||||||
|
- Standard flow:开启;Implicit flow:关闭;PKCE:`S256`
|
||||||
|
|
||||||
|
然后在 `.env` 中配置:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
Sso__Enabled=true
|
||||||
|
Sso__DisplayName=学校统一身份认证
|
||||||
|
Sso__Authority=https://sso.example.edu.cn/realms/mingxu
|
||||||
|
Sso__ClientId=jiaowu-web
|
||||||
|
Sso__ClientSecret=REPLACE_WITH_KEYCLOAK_CLIENT_SECRET
|
||||||
|
Sso__UserNameClaim=preferred_username
|
||||||
|
Sso__RequireHttpsMetadata=true
|
||||||
|
Sso__LinkExistingUsersByUserName=true
|
||||||
|
Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
|
||||||
|
```
|
||||||
|
|
||||||
|
前后端同域时 `Sso__FrontendBaseUrl` 可以留空。本地 Vite 开发默认回到
|
||||||
|
`http://localhost:5173`,Keycloak 测试客户端需同时允许
|
||||||
|
`http://localhost:5255/signin-keycloak`。生产环境位于反向代理之后时,应确保应用收到
|
||||||
|
正确的公开 HTTPS scheme(例如设置 `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true`),
|
||||||
|
否则生成的 Keycloak 回调地址可能错误。多实例部署应配置 Redis,以便任意实例都能兑换
|
||||||
|
两分钟内有效、使用后即删除的 SSO 登录码。
|
||||||
|
|
||||||
### Linux systemd 服务
|
### Linux systemd 服务
|
||||||
|
|
||||||
仓库提供 [`deploy/systemd/jiaowu.service`](deploy/systemd/jiaowu.service),适用于
|
仓库提供 [`deploy/systemd/jiaowu.service`](deploy/systemd/jiaowu.service),适用于
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
using Microsoft.Extensions.Caching.Distributed;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[Route("api/auth/sso")]
|
||||||
|
public sealed class SsoController(
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
ITokenService tokenService,
|
||||||
|
IDistributedCache cache,
|
||||||
|
IOptions<SsoOptions> options) : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly SsoOptions _options = options.Value;
|
||||||
|
|
||||||
|
[HttpGet("settings")]
|
||||||
|
public ActionResult<SsoSettingsResponse> Settings() =>
|
||||||
|
new SsoSettingsResponse(_options.Enabled, _options.DisplayName);
|
||||||
|
|
||||||
|
[EnableRateLimiting("public-auth")]
|
||||||
|
[HttpGet("login")]
|
||||||
|
public ActionResult Login([FromQuery] string? returnUrl = null)
|
||||||
|
{
|
||||||
|
if (!_options.Enabled)
|
||||||
|
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
|
||||||
|
var completeUrl = Url.Action(
|
||||||
|
nameof(Complete),
|
||||||
|
values: new { returnUrl = safeReturnUrl })!;
|
||||||
|
return Challenge(
|
||||||
|
new AuthenticationProperties { RedirectUri = completeUrl },
|
||||||
|
SsoAuthSchemes.Keycloak);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
|
[HttpGet("complete")]
|
||||||
|
public async Task<ActionResult> Complete(
|
||||||
|
[FromQuery] string? returnUrl,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_options.Enabled)
|
||||||
|
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
var authentication = await HttpContext.AuthenticateAsync(
|
||||||
|
SsoAuthSchemes.ExternalCookie);
|
||||||
|
if (!authentication.Succeeded || authentication.Principal is null)
|
||||||
|
return RedirectToFrontendError("authentication_failed");
|
||||||
|
|
||||||
|
var principal = authentication.Principal;
|
||||||
|
var subject = principal.FindFirstValue("sub") ??
|
||||||
|
principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrWhiteSpace(subject))
|
||||||
|
return RedirectToFrontendError("missing_subject");
|
||||||
|
|
||||||
|
var user = await userManager.FindByLoginAsync(
|
||||||
|
SsoAuthSchemes.LoginProvider,
|
||||||
|
subject);
|
||||||
|
if (user is null && _options.LinkExistingUsersByUserName)
|
||||||
|
{
|
||||||
|
var userName = principal.FindFirstValue(_options.UserNameClaim)?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(userName))
|
||||||
|
{
|
||||||
|
user = await userManager.FindByNameAsync(userName);
|
||||||
|
if (user is not null)
|
||||||
|
{
|
||||||
|
var linkResult = await userManager.AddLoginAsync(
|
||||||
|
user,
|
||||||
|
new UserLoginInfo(
|
||||||
|
SsoAuthSchemes.LoginProvider,
|
||||||
|
subject,
|
||||||
|
_options.DisplayName));
|
||||||
|
if (!linkResult.Succeeded)
|
||||||
|
return RedirectToFrontendError("account_link_failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
var bindingCode = WebEncoders.Base64UrlEncode(
|
||||||
|
RandomNumberGenerator.GetBytes(32));
|
||||||
|
var externalUserName =
|
||||||
|
principal.FindFirstValue(_options.UserNameClaim)?.Trim() ??
|
||||||
|
principal.FindFirstValue("name")?.Trim() ??
|
||||||
|
subject;
|
||||||
|
await cache.SetStringAsync(
|
||||||
|
BindingCacheKey(bindingCode),
|
||||||
|
JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName)),
|
||||||
|
new DistributedCacheEntryOptions
|
||||||
|
{
|
||||||
|
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
|
||||||
|
|
||||||
|
var bindingPage = BuildFrontendUrl("/sso/bind") +
|
||||||
|
$"?code={Uri.EscapeDataString(bindingCode)}" +
|
||||||
|
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
|
||||||
|
return Redirect(bindingPage);
|
||||||
|
}
|
||||||
|
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||||
|
return RedirectToFrontendError("account_disabled");
|
||||||
|
|
||||||
|
user.LastLoginAt = DateTime.UtcNow;
|
||||||
|
var updateResult = await userManager.UpdateAsync(user);
|
||||||
|
if (!updateResult.Succeeded)
|
||||||
|
return RedirectToFrontendError("account_update_failed");
|
||||||
|
|
||||||
|
var exchangeCode = WebEncoders.Base64UrlEncode(
|
||||||
|
RandomNumberGenerator.GetBytes(32));
|
||||||
|
await cache.SetStringAsync(
|
||||||
|
ExchangeCacheKey(exchangeCode),
|
||||||
|
user.Id.ToString("D"),
|
||||||
|
new DistributedCacheEntryOptions
|
||||||
|
{
|
||||||
|
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
|
||||||
|
|
||||||
|
var callback = BuildFrontendUrl("/sso/callback") +
|
||||||
|
$"?code={Uri.EscapeDataString(exchangeCode)}" +
|
||||||
|
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
|
||||||
|
return Redirect(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
[EnableRateLimiting("public-auth")]
|
||||||
|
[HttpPost("exchange")]
|
||||||
|
public async Task<ActionResult<LoginResponse>> Exchange(
|
||||||
|
SsoExchangeRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_options.Enabled)
|
||||||
|
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
var cacheKey = ExchangeCacheKey(request.Code);
|
||||||
|
var userId = await cache.GetStringAsync(cacheKey, cancellationToken);
|
||||||
|
if (userId is null)
|
||||||
|
return SsoProblem(
|
||||||
|
"统一身份认证结果已失效,请重新登录。",
|
||||||
|
StatusCodes.Status401Unauthorized);
|
||||||
|
|
||||||
|
await cache.RemoveAsync(cacheKey, cancellationToken);
|
||||||
|
var user = await userManager.FindByIdAsync(userId);
|
||||||
|
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||||
|
return SsoProblem(
|
||||||
|
"本地账号不存在、已停用或已锁定。",
|
||||||
|
StatusCodes.Status401Unauthorized);
|
||||||
|
|
||||||
|
var roles = await userManager.GetRolesAsync(user);
|
||||||
|
return new LoginResponse(
|
||||||
|
tokenService.Create(user, roles),
|
||||||
|
new CurrentUserResponse(
|
||||||
|
user.Id,
|
||||||
|
user.UserName!,
|
||||||
|
user.DisplayName,
|
||||||
|
roles,
|
||||||
|
user.CollegeId,
|
||||||
|
EffectiveDataScopeResolver.Resolve(roles).ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[EnableRateLimiting("public-auth")]
|
||||||
|
[HttpGet("binding")]
|
||||||
|
public async Task<ActionResult<SsoBindingInfoResponse>> BindingInfo(
|
||||||
|
[FromQuery, Required, MinLength(20), MaxLength(200)] string code,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_options.Enabled)
|
||||||
|
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
var ticket = await ReadBindingTicketAsync(code, cancellationToken);
|
||||||
|
if (ticket is null)
|
||||||
|
return SsoProblem(
|
||||||
|
"账户绑定请求已失效,请重新使用统一身份认证登录。",
|
||||||
|
StatusCodes.Status401Unauthorized);
|
||||||
|
|
||||||
|
return new SsoBindingInfoResponse(_options.DisplayName, ticket.ExternalUserName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[EnableRateLimiting("public-auth")]
|
||||||
|
[HttpPost("bind")]
|
||||||
|
public async Task<ActionResult<LoginResponse>> Bind(
|
||||||
|
SsoBindRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_options.Enabled)
|
||||||
|
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
var ticket = await ReadBindingTicketAsync(request.Code, cancellationToken);
|
||||||
|
if (ticket is null)
|
||||||
|
return SsoProblem(
|
||||||
|
"账户绑定请求已失效,请重新使用统一身份认证登录。",
|
||||||
|
StatusCodes.Status401Unauthorized);
|
||||||
|
|
||||||
|
var user = await userManager.FindByNameAsync(request.UserName.Trim());
|
||||||
|
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||||
|
return InvalidLocalCredentials();
|
||||||
|
|
||||||
|
if (!await userManager.CheckPasswordAsync(user, request.Password))
|
||||||
|
{
|
||||||
|
await userManager.AccessFailedAsync(user);
|
||||||
|
return InvalidLocalCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
var subjectOwner = await userManager.FindByLoginAsync(
|
||||||
|
SsoAuthSchemes.LoginProvider,
|
||||||
|
ticket.Subject);
|
||||||
|
if (subjectOwner is not null && subjectOwner.Id != user.Id)
|
||||||
|
return BindingConflict("该统一身份账号已绑定其他教务系统账号。");
|
||||||
|
|
||||||
|
var keycloakLogins = (await userManager.GetLoginsAsync(user))
|
||||||
|
.Where(x => x.LoginProvider == SsoAuthSchemes.LoginProvider)
|
||||||
|
.ToList();
|
||||||
|
if (keycloakLogins.Any(x => x.ProviderKey != ticket.Subject))
|
||||||
|
return BindingConflict("该教务系统账号已绑定其他统一身份账号。");
|
||||||
|
|
||||||
|
if (subjectOwner is null)
|
||||||
|
{
|
||||||
|
var linkResult = await userManager.AddLoginAsync(
|
||||||
|
user,
|
||||||
|
new UserLoginInfo(
|
||||||
|
SsoAuthSchemes.LoginProvider,
|
||||||
|
ticket.Subject,
|
||||||
|
_options.DisplayName));
|
||||||
|
if (!linkResult.Succeeded)
|
||||||
|
{
|
||||||
|
subjectOwner = await userManager.FindByLoginAsync(
|
||||||
|
SsoAuthSchemes.LoginProvider,
|
||||||
|
ticket.Subject);
|
||||||
|
if (subjectOwner?.Id != user.Id)
|
||||||
|
return BindingConflict("账户绑定失败,请重新发起统一身份认证。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await userManager.ResetAccessFailedCountAsync(user);
|
||||||
|
user.LastLoginAt = DateTime.UtcNow;
|
||||||
|
var updateResult = await userManager.UpdateAsync(user);
|
||||||
|
if (!updateResult.Succeeded)
|
||||||
|
return SsoProblem("本地账号状态更新失败,请稍后重试。", StatusCodes.Status500InternalServerError);
|
||||||
|
|
||||||
|
await cache.RemoveAsync(BindingCacheKey(request.Code), cancellationToken);
|
||||||
|
var roles = await userManager.GetRolesAsync(user);
|
||||||
|
return new LoginResponse(
|
||||||
|
tokenService.Create(user, roles),
|
||||||
|
new CurrentUserResponse(
|
||||||
|
user.Id,
|
||||||
|
user.UserName!,
|
||||||
|
user.DisplayName,
|
||||||
|
roles,
|
||||||
|
user.CollegeId,
|
||||||
|
EffectiveDataScopeResolver.Resolve(roles).ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string NormalizeReturnUrl(string? returnUrl) =>
|
||||||
|
!string.IsNullOrWhiteSpace(returnUrl) &&
|
||||||
|
returnUrl.StartsWith('/') &&
|
||||||
|
!returnUrl.StartsWith("//", StringComparison.Ordinal)
|
||||||
|
? returnUrl
|
||||||
|
: "/dashboard";
|
||||||
|
|
||||||
|
private string BuildFrontendUrl(string path) =>
|
||||||
|
string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)
|
||||||
|
? path
|
||||||
|
: _options.FrontendBaseUrl.TrimEnd('/') + path;
|
||||||
|
|
||||||
|
private RedirectResult RedirectToFrontendError(string error) =>
|
||||||
|
Redirect(BuildFrontendUrl("/login") +
|
||||||
|
$"?ssoError={Uri.EscapeDataString(error)}");
|
||||||
|
|
||||||
|
private static string ExchangeCacheKey(string code) => $"sso:exchange:{code}";
|
||||||
|
|
||||||
|
private static string BindingCacheKey(string code) => $"sso:binding:{code}";
|
||||||
|
|
||||||
|
private async Task<SsoBindingTicket?> ReadBindingTicketAsync(
|
||||||
|
string code,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var json = await cache.GetStringAsync(BindingCacheKey(code), cancellationToken);
|
||||||
|
if (json is null)
|
||||||
|
return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<SsoBindingTicket>(json);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private UnauthorizedObjectResult InvalidLocalCredentials() =>
|
||||||
|
Unauthorized(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "账户绑定失败",
|
||||||
|
Detail = "教务系统账号或密码不正确,或账号已停用。",
|
||||||
|
Status = StatusCodes.Status401Unauthorized
|
||||||
|
});
|
||||||
|
|
||||||
|
private ObjectResult BindingConflict(string detail) =>
|
||||||
|
SsoProblem(detail, StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
|
private ObjectResult SsoProblem(string detail, int status) =>
|
||||||
|
StatusCode(status, new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "统一身份认证失败",
|
||||||
|
Detail = detail,
|
||||||
|
Status = status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SsoSettingsResponse(bool Enabled, string DisplayName);
|
||||||
|
|
||||||
|
public sealed record SsoExchangeRequest(
|
||||||
|
[Required, MinLength(20), MaxLength(200)] string Code);
|
||||||
|
|
||||||
|
public sealed record SsoBindingInfoResponse(
|
||||||
|
string ProviderDisplayName,
|
||||||
|
string ExternalUserName);
|
||||||
|
|
||||||
|
public sealed record SsoBindRequest(
|
||||||
|
[Required, MinLength(20), MaxLength(200)] string Code,
|
||||||
|
[Required, MaxLength(100)] string UserName,
|
||||||
|
[Required, MaxLength(100)] string Password);
|
||||||
|
|
||||||
|
internal sealed record SsoBindingTicket(string Subject, string ExternalUserName);
|
||||||
Loaded 3 of 16 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user