sso优化

This commit is contained in:
2026-08-03 20:30:32 +08:00 Unverified
parent 5ec62a03ca
commit 6735a6d3fc
14 changed files with 540 additions and 30 deletions
+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);
@@ -13,6 +13,7 @@ public sealed class SsoOptions
public bool RequireHttpsMetadata { get; set; } = true; public bool RequireHttpsMetadata { get; set; } = true;
public bool LinkExistingUsersByUserName { get; set; } = true; public bool LinkExistingUsersByUserName { get; set; } = true;
public string FrontendBaseUrl { get; set; } = string.Empty; public string FrontendBaseUrl { get; set; } = string.Empty;
public string CallbackUrl { get; set; } = string.Empty;
} }
public static class SsoAuthSchemes public static class SsoAuthSchemes
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Version>2.2.0</Version> <Version>2.3.0</Version>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<SpaRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web'))</SpaRoot> <SpaRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web'))</SpaRoot>
+13 -1
View File
@@ -104,12 +104,18 @@ if (ssoOptions.Enabled &&
ssoAuthority.Scheme is not ("http" or "https") || ssoAuthority.Scheme is not ("http" or "https") ||
(ssoOptions.RequireHttpsMetadata && ssoAuthority.Scheme != "https") || (ssoOptions.RequireHttpsMetadata && ssoAuthority.Scheme != "https") ||
string.IsNullOrWhiteSpace(ssoOptions.UserNameClaim) || string.IsNullOrWhiteSpace(ssoOptions.UserNameClaim) ||
(!string.IsNullOrWhiteSpace(ssoOptions.CallbackUrl) &&
(!Uri.TryCreate(ssoOptions.CallbackUrl, UriKind.Absolute, out var callbackUrl) ||
callbackUrl.Scheme is not ("http" or "https") ||
!callbackUrl.AbsolutePath.EndsWith(
"/signin-keycloak",
StringComparison.OrdinalIgnoreCase))) ||
(!string.IsNullOrWhiteSpace(ssoOptions.FrontendBaseUrl) && (!string.IsNullOrWhiteSpace(ssoOptions.FrontendBaseUrl) &&
(!Uri.TryCreate(ssoOptions.FrontendBaseUrl, UriKind.Absolute, out var frontendBaseUrl) || (!Uri.TryCreate(ssoOptions.FrontendBaseUrl, UriKind.Absolute, out var frontendBaseUrl) ||
frontendBaseUrl.Scheme is not ("http" or "https"))))) frontendBaseUrl.Scheme is not ("http" or "https")))))
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
"启用 Sso 时必须配置有效的 Authority、ClientId、UserNameClaim 和 FrontendBaseUrl;生产元数据地址必须使用 HTTPS。"); "启用 Sso 时必须配置有效的 Authority、ClientId、UserNameClaim、CallbackUrl 和 FrontendBaseUrlCallbackUrl 必须以 /signin-keycloak 结尾,生产元数据地址必须使用 HTTPS。");
} }
if (string.IsNullOrWhiteSpace(officialDocumentOptions.InstitutionName) || if (string.IsNullOrWhiteSpace(officialDocumentOptions.InstitutionName) ||
@@ -490,6 +496,12 @@ if (ssoOptions.Enabled)
ssoOptions.UserNameClaim, ssoOptions.UserNameClaim,
ssoOptions.UserNameClaim); ssoOptions.UserNameClaim);
options.TokenValidationParameters.NameClaimType = ssoOptions.UserNameClaim; options.TokenValidationParameters.NameClaimType = ssoOptions.UserNameClaim;
options.Events.OnRedirectToIdentityProvider = context =>
{
if (!string.IsNullOrWhiteSpace(ssoOptions.CallbackUrl))
context.ProtocolMessage.RedirectUri = ssoOptions.CallbackUrl;
return Task.CompletedTask;
};
options.Events.OnRemoteFailure = context => options.Events.OnRemoteFailure = context =>
{ {
context.HandleResponse(); context.HandleResponse();
+2 -1
View File
@@ -15,7 +15,8 @@
"AppIdleMinutes": 4320 "AppIdleMinutes": 4320
}, },
"Sso": { "Sso": {
"FrontendBaseUrl": "http://localhost:5173" "FrontendBaseUrl": "http://localhost:5173",
"CallbackUrl": "http://localhost:5255/signin-keycloak"
}, },
"Cors": { "Cors": {
"Origins": [ "Origins": [
+2 -1
View File
@@ -90,7 +90,8 @@
"UserNameClaim": "preferred_username", "UserNameClaim": "preferred_username",
"RequireHttpsMetadata": true, "RequireHttpsMetadata": true,
"LinkExistingUsersByUserName": true, "LinkExistingUsersByUserName": true,
"FrontendBaseUrl": "" "FrontendBaseUrl": "",
"CallbackUrl": ""
}, },
"Cors": { "Cors": {
"Origins": [ "Origins": [
+67 -2
View File
@@ -1,13 +1,17 @@
using System.Text.Json; using System.Text.Json;
using System.Security.Claims;
using Jiaowu.Api.Controllers; using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Jiaowu.Api.Tests; namespace Jiaowu.Api.Tests;
@@ -54,6 +58,52 @@ public sealed class SsoControllerTests
Assert.Equal(1, user?.AccessFailedCount); Assert.Equal(1, user?.AccessFailedCount);
} }
[Fact]
public async Task Account_ReportsCurrentKeycloakBindingAndConfiguredCallback()
{
await using var fixture = await BindingFixture.CreateAsync();
Assert.True((await fixture.UserManager.AddLoginAsync(
fixture.User,
new UserLoginInfo(
SsoAuthSchemes.LoginProvider,
"keycloak-subject",
"学校统一身份认证"))).Succeeded);
fixture.SignInController();
var result = await fixture.Controller.Account();
var response = Assert.IsType<SsoAccountResponse>(result.Value);
Assert.True(response.Enabled);
Assert.True(response.IsBound);
Assert.Equal(
"https://jiaowu.example.edu.cn/signin-keycloak",
response.CallbackUrl);
}
[Fact]
public async Task Unbind_WithCurrentPassword_RemovesOnlyKeycloakLogin()
{
await using var fixture = await BindingFixture.CreateAsync();
Assert.True((await fixture.UserManager.AddLoginAsync(
fixture.User,
new UserLoginInfo(
SsoAuthSchemes.LoginProvider,
"keycloak-subject",
"学校统一身份认证"))).Succeeded);
fixture.SignInController();
var result = await fixture.Controller.Unbind(
new SsoUnbindRequest("LocalUser@123"));
Assert.IsType<NoContentResult>(result);
Assert.Null(await fixture.UserManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
"keycloak-subject"));
Assert.True(await fixture.UserManager.CheckPasswordAsync(
fixture.User,
"LocalUser@123"));
}
[Theory] [Theory]
[InlineData(null, "/dashboard")] [InlineData(null, "/dashboard")]
[InlineData("", "/dashboard")] [InlineData("", "/dashboard")]
@@ -93,6 +143,19 @@ public sealed class SsoControllerTests
public IDistributedCache Cache { get; } public IDistributedCache Cache { get; }
public SsoController Controller { get; } public SsoController Controller { get; }
public void SignInController()
{
Controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.NameIdentifier, User.Id.ToString())],
"test"))
}
};
}
public static async Task<BindingFixture> CreateAsync() public static async Task<BindingFixture> CreateAsync()
{ {
var connection = new SqliteConnection("Data Source=:memory:"); var connection = new SqliteConnection("Data Source=:memory:");
@@ -127,8 +190,10 @@ public sealed class SsoControllerTests
Options.Create(new SsoOptions Options.Create(new SsoOptions
{ {
Enabled = true, Enabled = true,
DisplayName = "学校统一身份认证" DisplayName = "学校统一身份认证",
})); CallbackUrl = "https://jiaowu.example.edu.cn/signin-keycloak"
}),
NullLogger<SsoController>.Instance);
return new BindingFixture( return new BindingFixture(
connection, connection,
provider, provider,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "web", "name": "web",
"private": true, "private": true,
"version": "2.2.0-rc1", "version": "2.3.0-rc1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2
View File
@@ -263,6 +263,7 @@ const workspaceLabel = computed(() => {
}) })
const pageTitle = computed(() => { const pageTitle = computed(() => {
if (route.path === '/account') return '个人账户'
const matchedItem = navigationGroups.value const matchedItem = navigationGroups.value
.flatMap((group) => group.items) .flatMap((group) => group.items)
.find((item) => item.path === route.path) .find((item) => item.path === route.path)
@@ -318,6 +319,7 @@ onMounted(() => {
<b>{{ auth.user?.displayName ?? '系统管理员' }}</b> <b>{{ auth.user?.displayName ?? '系统管理员' }}</b>
<span>{{ auth.user?.roles?.[0] ?? '教务人员' }}</span> <span>{{ auth.user?.roles?.[0] ?? '教务人员' }}</span>
</div> </div>
<el-button text @click="router.push('/account')">个人账户</el-button>
<el-button text @click="signOut">退出</el-button> <el-button text @click="signOut">退出</el-button>
</div> </div>
</div> </div>
+5
View File
@@ -53,6 +53,11 @@ const router = createRouter({
name: 'dashboard', name: 'dashboard',
component: () => import('../views/DashboardView.vue'), component: () => import('../views/DashboardView.vue'),
}, },
{
path: 'account',
name: 'account',
component: () => import('../views/AccountView.vue'),
},
{ {
path: 'base-data', path: 'base-data',
redirect: '/base-data/organization', redirect: '/base-data/organization',
+203
View File
@@ -0,0 +1,203 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const loading = ref(true)
const actionLoading = ref(false)
const sso = reactive({
enabled: false,
providerDisplayName: '学校统一身份认证',
isBound: false,
callbackUrl: '',
})
const ssoErrors: Record<string, string> = {
binding_intent_expired: '绑定请求已失效,请重新发起绑定。',
identity_already_bound: '该统一身份账号已经绑定其他教务系统账号。',
account_already_bound: '当前教务系统账号已经绑定其他统一身份账号。',
account_disabled: '当前教务系统账号已停用或锁定。',
account_link_failed: '统一身份账户绑定失败,请重新尝试。',
configuration_error: '统一身份认证回调地址配置不正确,请联系管理员。',
}
async function loadAccount() {
loading.value = true
try {
const { data } = await http.get('/auth/sso/account')
sso.enabled = Boolean(data.enabled)
sso.providerDisplayName = String(data.providerDisplayName || sso.providerDisplayName)
sso.isBound = Boolean(data.isBound)
sso.callbackUrl = String(data.callbackUrl || '')
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function startBinding() {
actionLoading.value = true
try {
const { data } = await http.post('/auth/sso/prepare-binding')
window.location.assign(String(data.loginUrl))
} catch (error) {
ElMessage.error(apiErrorMessage(error))
actionLoading.value = false
}
}
async function unbind() {
try {
const result = await ElMessageBox.prompt(
'解绑后将不能再使用当前统一身份账号登录。请输入教务系统密码确认。',
'解除统一身份绑定',
{
inputType: 'password',
inputPlaceholder: '请输入教务系统密码',
inputValidator: (value) => Boolean(value) || '密码不能为空',
confirmButtonText: '确认解绑',
cancelButtonText: '取消',
type: 'warning',
},
)
actionLoading.value = true
await http.post('/auth/sso/unbind', { password: result.value })
ElMessage.success('统一身份账户已解绑。')
await loadAccount()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
} finally {
actionLoading.value = false
}
}
onMounted(async () => {
const ssoError = String(route.query.ssoError ?? '')
if (ssoError) {
ElMessage.error(ssoErrors[ssoError] ?? '统一身份账户绑定失败,请重新尝试。')
await router.replace('/account')
}
await loadAccount()
})
</script>
<template>
<section class="account-page">
<header class="account-heading">
<div>
<span class="eyebrow">ACCOUNT &amp; SECURITY</span>
<h1>个人账户</h1>
<p>管理您的登录身份与单点登录绑定</p>
</div>
<div class="account-avatar">{{ auth.user?.displayName?.slice(0, 1) ?? '用' }}</div>
</header>
<div class="account-grid">
<article class="account-card identity-card">
<div class="card-title">
<div>
<span>基本信息</span>
<h2>{{ auth.user?.displayName }}</h2>
</div>
<el-tag type="success" effect="light">账号已启用</el-tag>
</div>
<dl>
<div>
<dt>登录账号</dt>
<dd>{{ auth.user?.userName }}</dd>
</div>
<div>
<dt>系统角色</dt>
<dd class="role-list">
<el-tag v-for="role in auth.user?.roles" :key="role" effect="plain">{{ role }}</el-tag>
</dd>
</div>
<div>
<dt>数据范围</dt>
<dd>{{ auth.user?.effectiveDataScope }}</dd>
</div>
</dl>
</article>
<article v-loading="loading" class="account-card sso-card">
<div class="card-title">
<div>
<span>单点登录</span>
<h2>{{ sso.providerDisplayName }}</h2>
</div>
<el-tag v-if="sso.isBound" type="success">已绑定</el-tag>
<el-tag v-else-if="sso.enabled" type="info">未绑定</el-tag>
<el-tag v-else type="warning">未启用</el-tag>
</div>
<p v-if="sso.isBound" class="sso-description">
当前教务系统账号已经关联统一身份认证您可以直接通过 Keycloak 登录
</p>
<p v-else-if="sso.enabled" class="sso-description">
绑定后即使统一身份用户名与教务系统账号不同也可以直接登录当前账号
</p>
<p v-else class="sso-description">管理员尚未启用统一身份认证</p>
<el-button
v-if="sso.enabled && !sso.isBound"
type="primary"
:loading="actionLoading"
@click="startBinding"
>
绑定统一身份账户
</el-button>
<el-button
v-else-if="sso.isBound"
type="danger"
plain
:loading="actionLoading"
@click="unbind"
>
解除绑定
</el-button>
<details v-if="sso.enabled && sso.callbackUrl" class="callback-details">
<summary>管理员配置参考</summary>
<p>Keycloak Valid redirect URI 必须与下列地址完全一致</p>
<code>{{ sso.callbackUrl }}</code>
</details>
</article>
</div>
</section>
</template>
<style scoped>
.account-page { display: grid; gap: 22px; }
.account-heading { display: flex; align-items: center; justify-content: space-between; padding: 26px 30px; border-radius: 18px; background: linear-gradient(125deg, #173a50, #176b87 65%, #2e9b91); color: white; box-shadow: 0 18px 45px rgba(23, 58, 80, .18); }
.account-heading h1 { margin: 7px 0 5px; font-family: "STZhongsong", "Songti SC", serif; font-size: 30px; }
.account-heading p { margin: 0; color: rgba(255,255,255,.72); }
.account-heading .eyebrow { color: #8ee4d8; }
.account-avatar { display: grid; place-items: center; width: 68px; height: 68px; border: 1px solid rgba(255,255,255,.34); border-radius: 22px; background: rgba(255,255,255,.12); font-size: 28px; font-weight: 700; }
.account-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
.account-card { min-width: 0; min-height: 310px; padding: 28px; border: 1px solid #e2e8ee; border-radius: 16px; background: white; box-shadow: 0 12px 34px rgba(35, 57, 78, .07); }
.card-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 20px; border-bottom: 1px solid #edf0f3; }
.card-title span { color: #73808d; font-size: 12px; }
.card-title h2 { margin: 6px 0 0; color: #263445; font-size: 21px; }
dl { margin: 6px 0 0; }
dl > div { display: grid; grid-template-columns: 100px 1fr; gap: 18px; padding: 16px 0; border-bottom: 1px solid #f0f2f4; }
dt { color: #7b8794; font-size: 13px; }
dd { margin: 0; color: #263445; font-weight: 650; }
.role-list { display: flex; flex-wrap: wrap; gap: 7px; }
.sso-description { min-height: 54px; margin: 22px 0; color: #65717e; line-height: 1.7; }
.callback-details { margin-top: 24px; color: #65717e; font-size: 12px; }
.callback-details summary { cursor: pointer; color: #315b73; font-weight: 650; }
.callback-details p { margin: 10px 0 7px; }
.callback-details code { display: block; overflow-wrap: anywhere; padding: 10px 12px; border-radius: 8px; background: #f4f7f9; color: #176b87; }
@media (max-width: 1100px) {
.account-grid { grid-template-columns: 1fr; }
.account-heading { padding: 22px; }
.account-avatar { width: 56px; height: 56px; border-radius: 18px; }
.account-card { min-height: 0; padding: 22px; }
}
</style>
+1
View File
@@ -44,6 +44,7 @@ const ssoErrors: Record<string, string> = {
account_link_failed: '统一身份账号绑定失败,请联系管理员。', account_link_failed: '统一身份账号绑定失败,请联系管理员。',
account_update_failed: '登录状态更新失败,请稍后重试。', account_update_failed: '登录状态更新失败,请稍后重试。',
binding_expired: '账户绑定请求已失效,请重新使用统一身份认证登录。', binding_expired: '账户绑定请求已失效,请重新使用统一身份认证登录。',
configuration_error: '统一身份认证回调地址配置不正确,请联系管理员。',
} }
onMounted(async () => { onMounted(async () => {