From 6735a6d3fc0ee9bf78ce626882f7a0dd47621f08 Mon Sep 17 00:00:00 2001 From: biss Date: Mon, 3 Aug 2026 20:30:32 +0800 Subject: [PATCH] =?UTF-8?q?sso=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + README.md | 14 +- src/Jiaowu.Api/Controllers/SsoController.cs | 249 ++++++++++++++++-- .../Infrastructure/Auth/SsoOptions.cs | 1 + src/Jiaowu.Api/Jiaowu.Api.csproj | 2 +- src/Jiaowu.Api/Program.cs | 14 +- src/Jiaowu.Api/appsettings.Development.json | 3 +- src/Jiaowu.Api/appsettings.json | 3 +- tests/Jiaowu.Api.Tests/SsoControllerTests.cs | 69 ++++- web/package.json | 2 +- web/src/layouts/AdminLayout.vue | 2 + web/src/router/index.ts | 5 + web/src/views/AccountView.vue | 203 ++++++++++++++ web/src/views/LoginView.vue | 1 + 14 files changed, 540 insertions(+), 30 deletions(-) create mode 100644 web/src/views/AccountView.vue diff --git a/.env.example b/.env.example index 9e803d5..e4bec1f 100644 --- a/.env.example +++ b/.env.example @@ -85,6 +85,8 @@ Sso__Enabled=false # Sso__LinkExistingUsersByUserName=true # 前后端同域部署时留空;开发或分离部署时填写前端公开根地址。 # 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 Cors__Origins__0=https://jiaowu.example.edu.cn diff --git a/README.md b/README.md index 3e432e6..11b46a6 100644 --- a/README.md +++ b/README.md @@ -173,14 +173,20 @@ Sso__UserNameClaim=preferred_username Sso__RequireHttpsMetadata=true Sso__LinkExistingUsersByUserName=true Sso__FrontendBaseUrl=https://jiaowu.example.edu.cn +Sso__CallbackUrl=https://jiaowu.example.edu.cn/signin-keycloak ``` 前后端同域时 `Sso__FrontendBaseUrl` 可以留空。本地 Vite 开发默认回到 `http://localhost:5173`,Keycloak 测试客户端需同时允许 -`http://localhost:5255/signin-keycloak`。生产环境位于反向代理之后时,应确保应用收到 -正确的公开 HTTPS scheme(例如设置 `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true`), -否则生成的 Keycloak 回调地址可能错误。多实例部署应配置 Redis,以便任意实例都能兑换 -两分钟内有效、使用后即删除的 SSO 登录码。 +`http://localhost:5255/signin-keycloak`。`Sso__CallbackUrl` 是应用实际发送给 Keycloak +的 `redirect_uri`,必须与客户端的 Valid redirect URI 完全一致;建议生产环境始终显式 +配置它,避免反向代理导致 scheme 或 host 推导错误。个人账户页的“管理员配置参考”也会 +显示当前生效的完整回调地址。多实例部署应配置 Redis,以便任意实例都能兑换两分钟内 +有效、使用后即删除的 SSO 登录码及五分钟内有效的绑定意图。 + +用户登录后可从页面右上角进入“个人账户”,主动绑定或解除 Keycloak 账号。主动绑定先 +使用当前 JWT 创建五分钟有效的一次性绑定意图,再跳转 Keycloak;回调只能绑定到发起该 +意图的本地账号。解绑需要再次验证本地密码,避免仅凭未锁屏的登录会话解除身份关联。 ### Linux systemd 服务 diff --git a/src/Jiaowu.Api/Controllers/SsoController.cs b/src/Jiaowu.Api/Controllers/SsoController.cs index d04b334..c4a258f 100644 --- a/src/Jiaowu.Api/Controllers/SsoController.cs +++ b/src/Jiaowu.Api/Controllers/SsoController.cs @@ -12,40 +12,74 @@ using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; namespace Jiaowu.Api.Controllers; [ApiController] -[AllowAnonymous] [Route("api/auth/sso")] public sealed class SsoController( UserManager userManager, IAuthSessionService authSessionService, IDistributedCache cache, - IOptions options) : ControllerBase + IOptions options, + ILogger logger) : ControllerBase { + private const string BindingIntentProperty = "sso-binding-intent"; private readonly SsoOptions _options = options.Value; + [AllowAnonymous] [HttpGet("settings")] public ActionResult Settings() => - new SsoSettingsResponse(_options.Enabled, _options.DisplayName); + new SsoSettingsResponse( + _options.Enabled, + _options.DisplayName, + EffectiveCallbackUrl()); + [AllowAnonymous] [EnableRateLimiting("public-auth")] [HttpGet("login")] - public ActionResult Login([FromQuery] string? returnUrl = null) + public async Task Login( + [FromQuery] string? returnUrl = null, + [FromQuery] string? bindingIntent = null, + CancellationToken cancellationToken = default) { if (!_options.Enabled) return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); 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( nameof(Complete), values: new { returnUrl = safeReturnUrl })!; - return Challenge( - new AuthenticationProperties { RedirectUri = completeUrl }, - SsoAuthSchemes.Keycloak); + properties.RedirectUri = completeUrl; + try + { + 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)] [HttpGet("complete")] public async Task Complete( @@ -66,7 +100,36 @@ public sealed class SsoController( if (string.IsNullOrWhiteSpace(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, subject); if (user is null && _options.LinkExistingUsersByUserName) @@ -77,14 +140,9 @@ public sealed class SsoController( 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"); + var linkError = await LinkSsoIdentityAsync(user, subject); + if (linkError is not null) + return RedirectToFrontendError(linkError); } } } @@ -138,6 +196,7 @@ public sealed class SsoController( return Redirect(callback); } + [AllowAnonymous] [EnableRateLimiting("public-auth")] [HttpPost("exchange")] public async Task> Exchange( @@ -172,6 +231,7 @@ public sealed class SsoController( return AuthController.CreateLoginResponse(session); } + [AllowAnonymous] [EnableRateLimiting("public-auth")] [HttpGet("binding")] public async Task> BindingInfo( @@ -190,6 +250,7 @@ public sealed class SsoController( return new SsoBindingInfoResponse(_options.DisplayName, ticket.ExternalUserName); } + [AllowAnonymous] [EnableRateLimiting("public-auth")] [HttpPost("bind")] public async Task> Bind( @@ -263,6 +324,92 @@ public sealed class SsoController( return AuthController.CreateLoginResponse(session); } + [Authorize] + [HttpGet("account")] + public async Task> 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> 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 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) => !string.IsNullOrWhiteSpace(returnUrl) && returnUrl.StartsWith('/') && @@ -275,14 +422,64 @@ public sealed class SsoController( ? path : _options.FrontendBaseUrl.TrimEnd('/') + path; - private RedirectResult RedirectToFrontendError(string error) => - Redirect(BuildFrontendUrl("/login") + + private RedirectResult RedirectToFrontendError( + string error, + string path = "/login") => + Redirect(BuildFrontendUrl(path) + $"?ssoError={Uri.EscapeDataString(error)}"); private static string ExchangeCacheKey(string code) => $"sso:exchange:{code}"; private static string BindingCacheKey(string code) => $"sso:binding:{code}"; + private static string BindingIntentCacheKey(string code) => + $"sso:binding-intent:{code}"; + + private async Task CurrentUserAsync() + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + return userId is null ? null : await userManager.FindByIdAsync(userId); + } + + private async Task 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 ReadBindingTicketAsync( string code, 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( [Required, MinLength(20), MaxLength(200)] string Code, @@ -337,3 +537,14 @@ public sealed record SsoBindRequest( bool IsNativeApp = false); 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); diff --git a/src/Jiaowu.Api/Infrastructure/Auth/SsoOptions.cs b/src/Jiaowu.Api/Infrastructure/Auth/SsoOptions.cs index 7d779a7..a2c2697 100644 --- a/src/Jiaowu.Api/Infrastructure/Auth/SsoOptions.cs +++ b/src/Jiaowu.Api/Infrastructure/Auth/SsoOptions.cs @@ -13,6 +13,7 @@ public sealed class SsoOptions public bool RequireHttpsMetadata { get; set; } = true; public bool LinkExistingUsersByUserName { get; set; } = true; public string FrontendBaseUrl { get; set; } = string.Empty; + public string CallbackUrl { get; set; } = string.Empty; } public static class SsoAuthSchemes diff --git a/src/Jiaowu.Api/Jiaowu.Api.csproj b/src/Jiaowu.Api/Jiaowu.Api.csproj index ebddbff..a7ed85b 100644 --- a/src/Jiaowu.Api/Jiaowu.Api.csproj +++ b/src/Jiaowu.Api/Jiaowu.Api.csproj @@ -2,7 +2,7 @@ net10.0 - 2.2.0 + 2.3.0 enable enable $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web')) diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index 32591c0..caa742e 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -104,12 +104,18 @@ if (ssoOptions.Enabled && ssoAuthority.Scheme is not ("http" or "https") || (ssoOptions.RequireHttpsMetadata && ssoAuthority.Scheme != "https") || 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) && (!Uri.TryCreate(ssoOptions.FrontendBaseUrl, UriKind.Absolute, out var frontendBaseUrl) || frontendBaseUrl.Scheme is not ("http" or "https"))))) { throw new InvalidOperationException( - "启用 Sso 时必须配置有效的 Authority、ClientId、UserNameClaim 和 FrontendBaseUrl;生产元数据地址必须使用 HTTPS。"); + "启用 Sso 时必须配置有效的 Authority、ClientId、UserNameClaim、CallbackUrl 和 FrontendBaseUrl;CallbackUrl 必须以 /signin-keycloak 结尾,生产元数据地址必须使用 HTTPS。"); } if (string.IsNullOrWhiteSpace(officialDocumentOptions.InstitutionName) || @@ -490,6 +496,12 @@ if (ssoOptions.Enabled) ssoOptions.UserNameClaim, 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 => { context.HandleResponse(); diff --git a/src/Jiaowu.Api/appsettings.Development.json b/src/Jiaowu.Api/appsettings.Development.json index d7f5cd4..803387a 100644 --- a/src/Jiaowu.Api/appsettings.Development.json +++ b/src/Jiaowu.Api/appsettings.Development.json @@ -15,7 +15,8 @@ "AppIdleMinutes": 4320 }, "Sso": { - "FrontendBaseUrl": "http://localhost:5173" + "FrontendBaseUrl": "http://localhost:5173", + "CallbackUrl": "http://localhost:5255/signin-keycloak" }, "Cors": { "Origins": [ diff --git a/src/Jiaowu.Api/appsettings.json b/src/Jiaowu.Api/appsettings.json index 8d083c4..9c0225d 100644 --- a/src/Jiaowu.Api/appsettings.json +++ b/src/Jiaowu.Api/appsettings.json @@ -90,7 +90,8 @@ "UserNameClaim": "preferred_username", "RequireHttpsMetadata": true, "LinkExistingUsersByUserName": true, - "FrontendBaseUrl": "" + "FrontendBaseUrl": "", + "CallbackUrl": "" }, "Cors": { "Origins": [ diff --git a/tests/Jiaowu.Api.Tests/SsoControllerTests.cs b/tests/Jiaowu.Api.Tests/SsoControllerTests.cs index 47abeb0..6a1658a 100644 --- a/tests/Jiaowu.Api.Tests/SsoControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/SsoControllerTests.cs @@ -1,13 +1,17 @@ using System.Text.Json; +using System.Security.Claims; using Jiaowu.Api.Controllers; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace Jiaowu.Api.Tests; @@ -54,6 +58,52 @@ public sealed class SsoControllerTests 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(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(result); + Assert.Null(await fixture.UserManager.FindByLoginAsync( + SsoAuthSchemes.LoginProvider, + "keycloak-subject")); + Assert.True(await fixture.UserManager.CheckPasswordAsync( + fixture.User, + "LocalUser@123")); + } + [Theory] [InlineData(null, "/dashboard")] [InlineData("", "/dashboard")] @@ -93,6 +143,19 @@ public sealed class SsoControllerTests public IDistributedCache Cache { 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 CreateAsync() { var connection = new SqliteConnection("Data Source=:memory:"); @@ -127,8 +190,10 @@ public sealed class SsoControllerTests Options.Create(new SsoOptions { Enabled = true, - DisplayName = "学校统一身份认证" - })); + DisplayName = "学校统一身份认证", + CallbackUrl = "https://jiaowu.example.edu.cn/signin-keycloak" + }), + NullLogger.Instance); return new BindingFixture( connection, provider, diff --git a/web/package.json b/web/package.json index c9e3cfc..6f504a1 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "2.2.0-rc1", + "version": "2.3.0-rc1", "type": "module", "scripts": { "dev": "vite", diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index cc0cd0c..bbe6f01 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -263,6 +263,7 @@ const workspaceLabel = computed(() => { }) const pageTitle = computed(() => { + if (route.path === '/account') return '个人账户' const matchedItem = navigationGroups.value .flatMap((group) => group.items) .find((item) => item.path === route.path) @@ -318,6 +319,7 @@ onMounted(() => { {{ auth.user?.displayName ?? '系统管理员' }} {{ auth.user?.roles?.[0] ?? '教务人员' }} + 个人账户 退出 diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 3f04c4d..3cb8ea3 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -53,6 +53,11 @@ const router = createRouter({ name: 'dashboard', component: () => import('../views/DashboardView.vue'), }, + { + path: 'account', + name: 'account', + component: () => import('../views/AccountView.vue'), + }, { path: 'base-data', redirect: '/base-data/organization', diff --git a/web/src/views/AccountView.vue b/web/src/views/AccountView.vue new file mode 100644 index 0000000..944cd99 --- /dev/null +++ b/web/src/views/AccountView.vue @@ -0,0 +1,203 @@ + + + + + diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index 9402c8f..dbb6a37 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -44,6 +44,7 @@ const ssoErrors: Record = { account_link_failed: '统一身份账号绑定失败,请联系管理员。', account_update_failed: '登录状态更新失败,请稍后重试。', binding_expired: '账户绑定请求已失效,请重新使用统一身份认证登录。', + configuration_error: '统一身份认证回调地址配置不正确,请联系管理员。', } onMounted(async () => {