sso优化

This commit is contained in:
biss committed 2026-08-03 20:30:32 +08:00
1 parent 5ec62a03ca
commit 6735a6d3fc
14 files changed
+540 -30

No files matched your search

+2
View File
@@ -85,6 +85,8 @@ Sso__Enabled=false
# Sso__LinkExistingUsersByUserName=true # Sso__LinkExistingUsersByUserName=true
# 前后端同域部署时留空;开发或分离部署时填写前端公开根地址。 # 前后端同域部署时留空;开发或分离部署时填写前端公开根地址。
# Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn # Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
# 必须与 Keycloak 客户端的 Valid redirect URI 完全一致。
# Sso__CallbackUrl=https://jiaowu.example.edu.cn/signin-keycloak
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
+10 -4
View File
@@ -173,14 +173,20 @@ Sso__UserNameClaim=preferred_username
Sso__RequireHttpsMetadata=true Sso__RequireHttpsMetadata=true
Sso__LinkExistingUsersByUserName=true Sso__LinkExistingUsersByUserName=true
Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn
Sso__CallbackUrl=https://jiaowu.example.edu.cn/signin-keycloak
``` ```
前后端同域时 `Sso__FrontendBaseUrl` 可以留空。本地 Vite 开发默认回到 前后端同域时 `Sso__FrontendBaseUrl` 可以留空。本地 Vite 开发默认回到
`http://localhost:5173`,Keycloak 测试客户端需同时允许 `http://localhost:5173`,Keycloak 测试客户端需同时允许
`http://localhost:5255/signin-keycloak`。生产环境位于反向代理之后时,应确保应用收到 `http://localhost:5255/signin-keycloak`。`Sso__CallbackUrl` 是应用实际发送给 Keycloak
正确的公开 HTTPS scheme(例如设置 `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true`), 的 `redirect_uri`,必须与客户端的 Valid redirect URI 完全一致;建议生产环境始终显式
否则生成的 Keycloak 回调地址可能错误。多实例部署应配置 Redis,以便任意实例都能兑换 配置它,避免反向代理导致 scheme 或 host 推导错误。个人账户页的“管理员配置参考”也会
两分钟内有效、使用后即删除的 SSO 登录码。 显示当前生效的完整回调地址。多实例部署应配置 Redis,以便任意实例都能兑换两分钟内
有效、使用后即删除的 SSO 登录码及五分钟内有效的绑定意图。
用户登录后可从页面右上角进入“个人账户”,主动绑定或解除 Keycloak 账号。主动绑定先
使用当前 JWT 创建五分钟有效的一次性绑定意图,再跳转 Keycloak;回调只能绑定到发起该
意图的本地账号。解绑需要再次验证本地密码,避免仅凭未锁屏的登录会话解除身份关联。
### Linux systemd 服务 ### Linux systemd 服务
+230 -19
View File
@@ -12,40 +12,74 @@ using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.WebUtilities; using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
namespace Jiaowu.Api.Controllers; namespace Jiaowu.Api.Controllers;
[ApiController] [ApiController]
[AllowAnonymous]
[Route("api/auth/sso")] [Route("api/auth/sso")]
public sealed class SsoController( public sealed class SsoController(
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
IAuthSessionService authSessionService, IAuthSessionService authSessionService,
IDistributedCache cache, IDistributedCache cache,
IOptions<SsoOptions> options) : ControllerBase IOptions<SsoOptions> options,
ILogger<SsoController> logger) : ControllerBase
{ {
private const string BindingIntentProperty = "sso-binding-intent";
private readonly SsoOptions _options = options.Value; private readonly SsoOptions _options = options.Value;
[AllowAnonymous]
[HttpGet("settings")] [HttpGet("settings")]
public ActionResult<SsoSettingsResponse> Settings() => public ActionResult<SsoSettingsResponse> Settings() =>
new SsoSettingsResponse(_options.Enabled, _options.DisplayName); new SsoSettingsResponse(
_options.Enabled,
_options.DisplayName,
EffectiveCallbackUrl());
[AllowAnonymous]
[EnableRateLimiting("public-auth")] [EnableRateLimiting("public-auth")]
[HttpGet("login")] [HttpGet("login")]
public ActionResult Login([FromQuery] string? returnUrl = null) public async Task<IActionResult> Login(
[FromQuery] string? returnUrl = null,
[FromQuery] string? bindingIntent = null,
CancellationToken cancellationToken = default)
{ {
if (!_options.Enabled) if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var safeReturnUrl = NormalizeReturnUrl(returnUrl); var safeReturnUrl = NormalizeReturnUrl(returnUrl);
var properties = new AuthenticationProperties();
if (!string.IsNullOrWhiteSpace(bindingIntent))
{
var targetUserId = await cache.GetStringAsync(
BindingIntentCacheKey(bindingIntent),
cancellationToken);
if (targetUserId is null)
return RedirectToFrontendError("binding_intent_expired", "/account");
properties.Items[BindingIntentProperty] = bindingIntent;
}
var completeUrl = Url.Action( var completeUrl = Url.Action(
nameof(Complete), nameof(Complete),
values: new { returnUrl = safeReturnUrl })!; values: new { returnUrl = safeReturnUrl })!;
return Challenge( properties.RedirectUri = completeUrl;
new AuthenticationProperties { RedirectUri = completeUrl }, try
SsoAuthSchemes.Keycloak); {
await HttpContext.ChallengeAsync(SsoAuthSchemes.Keycloak, properties);
return new EmptyResult();
}
catch (OpenIdConnectProtocolException exception)
{
logger.LogWarning(
exception,
"Keycloak 拒绝了 OIDC 授权请求。当前回调地址为 {CallbackUrl}",
EffectiveCallbackUrl());
return RedirectToFrontendError(
"configuration_error",
string.IsNullOrWhiteSpace(bindingIntent) ? "/login" : "/account");
}
} }
[AllowAnonymous]
[ApiExplorerSettings(IgnoreApi = true)] [ApiExplorerSettings(IgnoreApi = true)]
[HttpGet("complete")] [HttpGet("complete")]
public async Task<ActionResult> Complete( public async Task<ActionResult> Complete(
@@ -66,7 +100,36 @@ public sealed class SsoController(
if (string.IsNullOrWhiteSpace(subject)) if (string.IsNullOrWhiteSpace(subject))
return RedirectToFrontendError("missing_subject"); return RedirectToFrontendError("missing_subject");
var user = await userManager.FindByLoginAsync( ApplicationUser? user = null;
var bindingIntent =
authentication.Properties is { } authenticationProperties &&
authenticationProperties.Items.TryGetValue(
BindingIntentProperty,
out var storedBindingIntent)
? storedBindingIntent
: null;
if (!string.IsNullOrWhiteSpace(bindingIntent))
{
var targetUserId = await cache.GetStringAsync(
BindingIntentCacheKey(bindingIntent),
cancellationToken);
user = targetUserId is null
? null
: await userManager.FindByIdAsync(targetUserId);
if (user is null)
return RedirectToFrontendError("binding_intent_expired", "/account");
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
return RedirectToFrontendError("account_disabled", "/account");
var linkError = await LinkSsoIdentityAsync(user, subject);
if (linkError is not null)
return RedirectToFrontendError(linkError, "/account");
await cache.RemoveAsync(
BindingIntentCacheKey(bindingIntent),
cancellationToken);
}
user ??= await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider, SsoAuthSchemes.LoginProvider,
subject); subject);
if (user is null && _options.LinkExistingUsersByUserName) if (user is null && _options.LinkExistingUsersByUserName)
@@ -77,14 +140,9 @@ public sealed class SsoController(
user = await userManager.FindByNameAsync(userName); user = await userManager.FindByNameAsync(userName);
if (user is not null) if (user is not null)
{ {
var linkResult = await userManager.AddLoginAsync( var linkError = await LinkSsoIdentityAsync(user, subject);
user, if (linkError is not null)
new UserLoginInfo( return RedirectToFrontendError(linkError);
SsoAuthSchemes.LoginProvider,
subject,
_options.DisplayName));
if (!linkResult.Succeeded)
return RedirectToFrontendError("account_link_failed");
} }
} }
} }
@@ -138,6 +196,7 @@ public sealed class SsoController(
return Redirect(callback); return Redirect(callback);
} }
[AllowAnonymous]
[EnableRateLimiting("public-auth")] [EnableRateLimiting("public-auth")]
[HttpPost("exchange")] [HttpPost("exchange")]
public async Task<ActionResult<LoginResponse>> Exchange( public async Task<ActionResult<LoginResponse>> Exchange(
@@ -172,6 +231,7 @@ public sealed class SsoController(
return AuthController.CreateLoginResponse(session); return AuthController.CreateLoginResponse(session);
} }
[AllowAnonymous]
[EnableRateLimiting("public-auth")] [EnableRateLimiting("public-auth")]
[HttpGet("binding")] [HttpGet("binding")]
public async Task<ActionResult<SsoBindingInfoResponse>> BindingInfo( public async Task<ActionResult<SsoBindingInfoResponse>> BindingInfo(
@@ -190,6 +250,7 @@ public sealed class SsoController(
return new SsoBindingInfoResponse(_options.DisplayName, ticket.ExternalUserName); return new SsoBindingInfoResponse(_options.DisplayName, ticket.ExternalUserName);
} }
[AllowAnonymous]
[EnableRateLimiting("public-auth")] [EnableRateLimiting("public-auth")]
[HttpPost("bind")] [HttpPost("bind")]
public async Task<ActionResult<LoginResponse>> Bind( public async Task<ActionResult<LoginResponse>> Bind(
@@ -263,6 +324,92 @@ public sealed class SsoController(
return AuthController.CreateLoginResponse(session); return AuthController.CreateLoginResponse(session);
} }
[Authorize]
[HttpGet("account")]
public async Task<ActionResult<SsoAccountResponse>> Account()
{
var user = await CurrentUserAsync();
if (user is null)
return Unauthorized();
var login = (await userManager.GetLoginsAsync(user))
.SingleOrDefault(x => x.LoginProvider == SsoAuthSchemes.LoginProvider);
return new SsoAccountResponse(
_options.Enabled,
_options.DisplayName,
login is not null,
EffectiveCallbackUrl());
}
[Authorize]
[HttpPost("prepare-binding")]
public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding(
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var user = await CurrentUserAsync();
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
return Unauthorized();
if ((await userManager.GetLoginsAsync(user))
.Any(x => x.LoginProvider == SsoAuthSchemes.LoginProvider))
{
return BindingConflict("当前账号已绑定统一身份账号,请先解绑后再更换绑定。");
}
var intentCode = WebEncoders.Base64UrlEncode(
RandomNumberGenerator.GetBytes(32));
await cache.SetStringAsync(
BindingIntentCacheKey(intentCode),
user.Id.ToString("D"),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
},
cancellationToken);
var loginUrl = Url.Action(
nameof(Login),
values: new
{
returnUrl = "/account",
bindingIntent = intentCode
})!;
return new SsoBindingStartResponse(loginUrl);
}
[Authorize]
[EnableRateLimiting("public-auth")]
[HttpPost("unbind")]
public async Task<IActionResult> Unbind(SsoUnbindRequest request)
{
var user = await CurrentUserAsync();
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
return Unauthorized();
if (!await userManager.HasPasswordAsync(user))
return BindingConflict("当前账号没有本地密码,不能自行解绑,请联系管理员处理。");
if (!await userManager.CheckPasswordAsync(user, request.Password))
{
await userManager.AccessFailedAsync(user);
return InvalidLocalCredentials();
}
var login = (await userManager.GetLoginsAsync(user))
.SingleOrDefault(x => x.LoginProvider == SsoAuthSchemes.LoginProvider);
if (login is null)
return NoContent();
var result = await userManager.RemoveLoginAsync(
user,
login.LoginProvider,
login.ProviderKey);
if (!result.Succeeded)
return SsoProblem("解除统一身份绑定失败,请稍后重试。", StatusCodes.Status500InternalServerError);
await userManager.ResetAccessFailedCountAsync(user);
return NoContent();
}
internal static string NormalizeReturnUrl(string? returnUrl) => internal static string NormalizeReturnUrl(string? returnUrl) =>
!string.IsNullOrWhiteSpace(returnUrl) && !string.IsNullOrWhiteSpace(returnUrl) &&
returnUrl.StartsWith('/') && returnUrl.StartsWith('/') &&
@@ -275,14 +422,64 @@ public sealed class SsoController(
? path ? path
: _options.FrontendBaseUrl.TrimEnd('/') + path; : _options.FrontendBaseUrl.TrimEnd('/') + path;
private RedirectResult RedirectToFrontendError(string error) => private RedirectResult RedirectToFrontendError(
Redirect(BuildFrontendUrl("/login") + string error,
string path = "/login") =>
Redirect(BuildFrontendUrl(path) +
$"?ssoError={Uri.EscapeDataString(error)}"); $"?ssoError={Uri.EscapeDataString(error)}");
private static string ExchangeCacheKey(string code) => $"sso:exchange:{code}"; private static string ExchangeCacheKey(string code) => $"sso:exchange:{code}";
private static string BindingCacheKey(string code) => $"sso:binding:{code}"; private static string BindingCacheKey(string code) => $"sso:binding:{code}";
private static string BindingIntentCacheKey(string code) =>
$"sso:binding-intent:{code}";
private async Task<ApplicationUser?> CurrentUserAsync()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return userId is null ? null : await userManager.FindByIdAsync(userId);
}
private async Task<string?> LinkSsoIdentityAsync(
ApplicationUser user,
string subject)
{
var subjectOwner = await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
subject);
if (subjectOwner is not null)
return subjectOwner.Id == user.Id ? null : "identity_already_bound";
if ((await userManager.GetLoginsAsync(user)).Any(x =>
x.LoginProvider == SsoAuthSchemes.LoginProvider &&
x.ProviderKey != subject))
{
return "account_already_bound";
}
var result = await userManager.AddLoginAsync(
user,
new UserLoginInfo(
SsoAuthSchemes.LoginProvider,
subject,
_options.DisplayName));
if (result.Succeeded)
return null;
subjectOwner = await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
subject);
return subjectOwner?.Id == user.Id ? null : "account_link_failed";
}
private string EffectiveCallbackUrl()
{
if (!string.IsNullOrWhiteSpace(_options.CallbackUrl))
return _options.CallbackUrl;
return $"{Request.Scheme}://{Request.Host}{Request.PathBase}/signin-keycloak";
}
private async Task<SsoBindingTicket?> ReadBindingTicketAsync( private async Task<SsoBindingTicket?> ReadBindingTicketAsync(
string code, string code,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -320,7 +517,10 @@ public sealed class SsoController(
}); });
} }
public sealed record SsoSettingsResponse(bool Enabled, string DisplayName); public sealed record SsoSettingsResponse(
bool Enabled,
string DisplayName,
string CallbackUrl);
public sealed record SsoExchangeRequest( public sealed record SsoExchangeRequest(
[Required, MinLength(20), MaxLength(200)] string Code, [Required, MinLength(20), MaxLength(200)] string Code,
@@ -337,3 +537,14 @@ public sealed record SsoBindRequest(
bool IsNativeApp = false); bool IsNativeApp = false);
internal sealed record SsoBindingTicket(string Subject, string ExternalUserName); internal sealed record SsoBindingTicket(string Subject, string ExternalUserName);
public sealed record SsoAccountResponse(
bool Enabled,
string ProviderDisplayName,
bool IsBound,
string CallbackUrl);
public sealed record SsoBindingStartResponse(string LoginUrl);
public sealed record SsoUnbindRequest(
[Required, MaxLength(100)] string Password);
Loaded 3 of 14 files, more files were not shown because too many files have changed in this diff. Show more