diff --git a/.env.example b/.env.example index 6916de5..26209b5 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,21 @@ Jwt__Audience=Jiaowu.Web Jwt__Key=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES 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 Cors__Origins__0=https://jiaowu.example.edu.cn Cors__Origins__1=capacitor://localhost diff --git a/README.md b/README.md index dfbd917..3e432e6 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,44 @@ Kubernetes 或密钥管理系统仍可覆盖文件中的值。 `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 服务 仓库提供 [`deploy/systemd/jiaowu.service`](deploy/systemd/jiaowu.service),适用于 diff --git a/src/Jiaowu.Api/Controllers/SsoController.cs b/src/Jiaowu.Api/Controllers/SsoController.cs new file mode 100644 index 0000000..27a4782 --- /dev/null +++ b/src/Jiaowu.Api/Controllers/SsoController.cs @@ -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 userManager, + ITokenService tokenService, + IDistributedCache cache, + IOptions options) : ControllerBase +{ + private readonly SsoOptions _options = options.Value; + + [HttpGet("settings")] + public ActionResult 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 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> 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> 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> 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 ReadBindingTicketAsync( + string code, + CancellationToken cancellationToken) + { + var json = await cache.GetStringAsync(BindingCacheKey(code), cancellationToken); + if (json is null) + return null; + try + { + return JsonSerializer.Deserialize(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); diff --git a/src/Jiaowu.Api/Infrastructure/Auth/SsoOptions.cs b/src/Jiaowu.Api/Infrastructure/Auth/SsoOptions.cs new file mode 100644 index 0000000..7d779a7 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Auth/SsoOptions.cs @@ -0,0 +1,23 @@ +namespace Jiaowu.Api.Infrastructure.Auth; + +public sealed class SsoOptions +{ + public const string SectionName = "Sso"; + + public bool Enabled { get; set; } + public string DisplayName { get; set; } = "学校统一身份认证"; + public string Authority { get; set; } = string.Empty; + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + public string UserNameClaim { get; set; } = "preferred_username"; + public bool RequireHttpsMetadata { get; set; } = true; + public bool LinkExistingUsersByUserName { get; set; } = true; + public string FrontendBaseUrl { get; set; } = string.Empty; +} + +public static class SsoAuthSchemes +{ + public const string Keycloak = "Keycloak"; + public const string ExternalCookie = "KeycloakExternal"; + public const string LoginProvider = "Keycloak"; +} diff --git a/src/Jiaowu.Api/Jiaowu.Api.csproj b/src/Jiaowu.Api/Jiaowu.Api.csproj index 65c6016..ebddbff 100644 --- a/src/Jiaowu.Api/Jiaowu.Api.csproj +++ b/src/Jiaowu.Api/Jiaowu.Api.csproj @@ -23,6 +23,7 @@ + diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index 5973004..fd5c6a4 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -13,7 +13,10 @@ using Jiaowu.Api.Infrastructure.Operations; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Scheduling; using Jiaowu.Api.Infrastructure.Timetables; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -91,6 +94,23 @@ var performanceReportingOptions = builder.Configuration var rabbitMqOptions = builder.Configuration .GetSection(RabbitMqOptions.SectionName) .Get() ?? new RabbitMqOptions(); +var ssoOptions = builder.Configuration + .GetSection(SsoOptions.SectionName) + .Get() ?? new SsoOptions(); + +if (ssoOptions.Enabled && + (string.IsNullOrWhiteSpace(ssoOptions.ClientId) || + !Uri.TryCreate(ssoOptions.Authority, UriKind.Absolute, out var ssoAuthority) || + ssoAuthority.Scheme is not ("http" or "https") || + (ssoOptions.RequireHttpsMetadata && ssoAuthority.Scheme != "https") || + string.IsNullOrWhiteSpace(ssoOptions.UserNameClaim) || + (!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。"); +} if (string.IsNullOrWhiteSpace(officialDocumentOptions.InstitutionName) || string.IsNullOrWhiteSpace(officialDocumentOptions.IssuingOffice) || @@ -330,6 +350,10 @@ if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString)) builder.Services.AddStackExchangeRedisCache(options => options.Configuration = redisConnectionString); } +else +{ + builder.Services.AddDistributedMemoryCache(); +} builder.Services.AddHybridCache(options => { options.MaximumKeyLength = 512; @@ -363,6 +387,8 @@ if (Encoding.UTF8.GetByteCount(jwtOptions.Key) < 32 || builder.Services.Configure( builder.Configuration.GetSection(JwtOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(SsoOptions.SectionName)); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -407,8 +433,12 @@ builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddScoped(); -builder.Services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) +var authentication = builder.Services + .AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters @@ -423,7 +453,45 @@ builder.Services Encoding.UTF8.GetBytes(jwtOptions.Key)), ClockSkew = TimeSpan.FromMinutes(1) }; + }) + .AddCookie(SsoAuthSchemes.ExternalCookie, options => + { + options.Cookie.Name = "__Host-jiaowu-sso"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.ExpireTimeSpan = TimeSpan.FromMinutes(5); }); +if (ssoOptions.Enabled) +{ + authentication.AddOpenIdConnect(SsoAuthSchemes.Keycloak, options => + { + options.Authority = ssoOptions.Authority.TrimEnd('/'); + options.ClientId = ssoOptions.ClientId; + options.ClientSecret = ssoOptions.ClientSecret; + options.SignInScheme = SsoAuthSchemes.ExternalCookie; + options.ResponseType = "code"; + options.UsePkce = true; + options.SaveTokens = false; + options.RequireHttpsMetadata = ssoOptions.RequireHttpsMetadata; + options.CallbackPath = "/signin-keycloak"; + options.GetClaimsFromUserInfoEndpoint = true; + options.MapInboundClaims = false; + options.ClaimActions.MapUniqueJsonKey( + ssoOptions.UserNameClaim, + ssoOptions.UserNameClaim); + options.TokenValidationParameters.NameClaimType = ssoOptions.UserNameClaim; + options.Events.OnRemoteFailure = context => + { + context.HandleResponse(); + var loginUrl = string.IsNullOrWhiteSpace(ssoOptions.FrontendBaseUrl) + ? "/login" + : ssoOptions.FrontendBaseUrl.TrimEnd('/') + "/login"; + context.Response.Redirect(loginUrl + "?ssoError=authentication_failed"); + return Task.CompletedTask; + }; + }); +} builder.Services.AddAuthorization(); builder.Services.AddRateLimiter(options => { diff --git a/src/Jiaowu.Api/appsettings.Development.json b/src/Jiaowu.Api/appsettings.Development.json index 66f41f6..a80f1e4 100644 --- a/src/Jiaowu.Api/appsettings.Development.json +++ b/src/Jiaowu.Api/appsettings.Development.json @@ -12,6 +12,9 @@ "Key": "jiaowu-development-secret-key-change-before-production", "ExpireMinutes": 480 }, + "Sso": { + "FrontendBaseUrl": "http://localhost:5173" + }, "Cors": { "Origins": [ "http://localhost:5173" diff --git a/src/Jiaowu.Api/appsettings.json b/src/Jiaowu.Api/appsettings.json index 46e1124..6b06405 100644 --- a/src/Jiaowu.Api/appsettings.json +++ b/src/Jiaowu.Api/appsettings.json @@ -79,6 +79,17 @@ "Key": "", "ExpireMinutes": 60 }, + "Sso": { + "Enabled": false, + "DisplayName": "学校统一身份认证", + "Authority": "", + "ClientId": "", + "ClientSecret": "", + "UserNameClaim": "preferred_username", + "RequireHttpsMetadata": true, + "LinkExistingUsersByUserName": true, + "FrontendBaseUrl": "" + }, "Cors": { "Origins": [ "capacitor://localhost", diff --git a/tests/Jiaowu.Api.Tests/SsoControllerTests.cs b/tests/Jiaowu.Api.Tests/SsoControllerTests.cs new file mode 100644 index 0000000..e64f310 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/SsoControllerTests.cs @@ -0,0 +1,162 @@ +using System.Text.Json; +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.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Jiaowu.Api.Tests; + +public sealed class SsoControllerTests +{ + [Fact] + public async Task Bind_WithValidLocalCredentials_LinksKeycloakIdentityAndLogsIn() + { + await using var fixture = await BindingFixture.CreateAsync(); + var code = new string('a', 32); + await fixture.StoreTicketAsync(code, "keycloak-subject", "external.name"); + + var result = await fixture.Controller.Bind( + new SsoBindRequest(code, fixture.User.UserName!, "LocalUser@123"), + CancellationToken.None); + + var response = Assert.IsType(result.Value); + Assert.Equal("test-token", response.Token); + Assert.Equal(fixture.User.Id, response.User.Id); + var linkedUser = await fixture.UserManager.FindByLoginAsync( + SsoAuthSchemes.LoginProvider, + "keycloak-subject"); + Assert.Equal(fixture.User.Id, linkedUser?.Id); + Assert.Null(await fixture.Cache.GetStringAsync($"sso:binding:{code}")); + } + + [Fact] + public async Task Bind_WithWrongPassword_DoesNotLinkKeycloakIdentity() + { + await using var fixture = await BindingFixture.CreateAsync(); + var code = new string('b', 32); + await fixture.StoreTicketAsync(code, "keycloak-subject", "external.name"); + + var result = await fixture.Controller.Bind( + new SsoBindRequest(code, fixture.User.UserName!, "WrongPassword@123"), + CancellationToken.None); + + Assert.NotNull(result.Result); + Assert.Null(await fixture.UserManager.FindByLoginAsync( + SsoAuthSchemes.LoginProvider, + "keycloak-subject")); + var user = await fixture.UserManager.FindByIdAsync(fixture.User.Id.ToString()); + Assert.Equal(1, user?.AccessFailedCount); + } + + [Theory] + [InlineData(null, "/dashboard")] + [InlineData("", "/dashboard")] + [InlineData("https://evil.example/path", "/dashboard")] + [InlineData("//evil.example/path", "/dashboard")] + [InlineData("/grades?term=2026-1", "/grades?term=2026-1")] + public void NormalizeReturnUrl_AllowsOnlyLocalApplicationPaths( + string? value, + string expected) + { + Assert.Equal(expected, SsoController.NormalizeReturnUrl(value)); + } + + private sealed class BindingFixture : IAsyncDisposable + { + private readonly SqliteConnection _connection; + private readonly ServiceProvider _provider; + + private BindingFixture( + SqliteConnection connection, + ServiceProvider provider, + ApplicationUser user, + UserManager userManager, + IDistributedCache cache, + SsoController controller) + { + _connection = connection; + _provider = provider; + User = user; + UserManager = userManager; + Cache = cache; + Controller = controller; + } + + public ApplicationUser User { get; } + public UserManager UserManager { get; } + public IDistributedCache Cache { get; } + public SsoController Controller { get; } + + public static async Task CreateAsync() + { + var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDistributedMemoryCache(); + services.AddDbContext(options => options.UseSqlite(connection)); + services + .AddIdentityCore() + .AddRoles() + .AddEntityFrameworkStores(); + + var provider = services.BuildServiceProvider(); + var db = provider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + var userManager = provider.GetRequiredService>(); + var user = new ApplicationUser + { + UserName = "local.user", + DisplayName = "本地用户", + IsEnabled = true, + LockoutEnabled = true + }; + Assert.True((await userManager.CreateAsync(user, "LocalUser@123")).Succeeded); + + var cache = provider.GetRequiredService(); + var controller = new SsoController( + userManager, + new StubTokenService(), + cache, + Options.Create(new SsoOptions + { + Enabled = true, + DisplayName = "学校统一身份认证" + })); + return new BindingFixture( + connection, + provider, + user, + userManager, + cache, + controller); + } + + public Task StoreTicketAsync( + string code, + string subject, + string externalUserName) => + Cache.SetStringAsync( + $"sso:binding:{code}", + JsonSerializer.Serialize( + new SsoBindingTicket(subject, externalUserName))); + + public async ValueTask DisposeAsync() + { + await _provider.DisposeAsync(); + await _connection.DisposeAsync(); + } + } + + private sealed class StubTokenService : ITokenService + { + public string Create(ApplicationUser user, IEnumerable roles) => + "test-token"; + } +} diff --git a/web/src/api/http.ts b/web/src/api/http.ts index 61af430..b3a7f71 100644 --- a/web/src/api/http.ts +++ b/web/src/api/http.ts @@ -15,7 +15,11 @@ http.interceptors.request.use((config) => { http.interceptors.response.use( (response) => response, (error) => { - if (error.response?.status === 401 && !error.config?.url?.endsWith('/auth/login')) { + const isAuthenticationRequest = + error.config?.url?.endsWith('/auth/login') || + error.config?.url?.endsWith('/auth/sso/exchange') || + error.config?.url?.endsWith('/auth/sso/bind') + if (error.response?.status === 401 && !isAuthenticationRequest) { localStorage.removeItem('jiaowu_token') localStorage.removeItem('jiaowu_user') goLogin(location.pathname + location.search + location.hash) diff --git a/web/src/router/index.ts b/web/src/router/index.ts index c428e1e..3f04c4d 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -13,6 +13,18 @@ const router = createRouter({ component: () => import('../views/LoginView.vue'), meta: { public: true }, }, + { + path: '/sso/callback', + name: 'sso-callback', + component: () => import('../views/SsoCallbackView.vue'), + meta: { public: true }, + }, + { + path: '/sso/bind', + name: 'sso-bind', + component: () => import('../views/SsoBindView.vue'), + meta: { public: true }, + }, { path: '/timetable', name: 'public-timetable', diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 4e8a904..802b132 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -27,6 +27,24 @@ export const useAuthStore = defineStore('auth', () => { window.dispatchEvent(new Event('mingxu-auth-changed')) } + async function exchangeSso(code: string) { + const { data } = await http.post('/auth/sso/exchange', { code }) + token.value = data.token + user.value = data.user + localStorage.setItem('jiaowu_token', data.token) + localStorage.setItem('jiaowu_user', JSON.stringify(data.user)) + window.dispatchEvent(new Event('mingxu-auth-changed')) + } + + async function bindSso(code: string, userName: string, password: string) { + const { data } = await http.post('/auth/sso/bind', { code, userName, password }) + token.value = data.token + user.value = data.user + localStorage.setItem('jiaowu_token', data.token) + localStorage.setItem('jiaowu_user', JSON.stringify(data.user)) + window.dispatchEvent(new Event('mingxu-auth-changed')) + } + async function refresh() { if (!token.value) return const { data } = await http.get('/auth/me') @@ -42,5 +60,15 @@ export const useAuthStore = defineStore('auth', () => { window.dispatchEvent(new Event('mingxu-auth-changed')) } - return { token, user, isLoggedIn, isSuperAdmin, login, refresh, logout } + return { + token, + user, + isLoggedIn, + isSuperAdmin, + login, + exchangeSso, + bindSso, + refresh, + logout, + } }) diff --git a/web/src/style.css b/web/src/style.css index b313b9d..0d7a9cf 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1208,6 +1208,23 @@ button { cursor: pointer; } .public-timetable-link { display: block; margin: 14px 0 18px; color: #176b87; font-size: 13px; font-weight: 650; text-align: center; text-decoration: none; } .account-activation-link { display: block; margin: -8px 0 18px; color: #315b73; font-size: 13px; font-weight: 650; text-align: center; text-decoration: none; } .login-submit { width: 100%; margin-top: 6px; height: 46px; } +.sso-login-submit { width: 100%; height: 46px; border-color: #176b87; color: #176b87; font-weight: 650; } +.login-divider { display: flex; align-items: center; gap: 12px; margin: 16px 0; color: #9aa4b2; font-size: 12px; } +.login-divider::before, .login-divider::after { content: ""; flex: 1; height: 1px; background: #e5e9ef; } +.sso-callback-page { min-height: 100vh; display: grid; place-content: center; justify-items: center; padding: 24px; background: #f4f7fa; color: #263445; text-align: center; } +.sso-callback-page h1 { margin: 18px 0 8px; font-size: 22px; } +.sso-callback-page p { margin: 0; color: var(--muted); } +.sso-binding-page { min-height: 100vh; display: grid; place-items: center; padding: 28px; background: radial-gradient(circle at top left, #e3f5f2, transparent 42%), #f4f7fa; } +.sso-binding-card { width: min(470px, 100%); padding: 38px; border: 1px solid #e3e8ee; border-radius: 18px; background: #fff; box-shadow: 0 20px 55px rgba(35, 57, 78, .12); } +.sso-binding-card .brand-mark { margin-bottom: 24px; } +.sso-binding-card h1 { margin: 8px 0 12px; font-family: "STZhongsong", "Songti SC", serif; font-size: 28px; } +.binding-description { margin: 0 0 26px; color: var(--muted); line-height: 1.75; } +.binding-description strong { color: #176b87; } +.sso-binding-card label { display: block; margin-bottom: 18px; } +.sso-binding-card label > span { display: block; margin-bottom: 8px; color: #525b6d; font-size: 12px; font-weight: 650; } +.binding-submit { width: 100%; height: 46px; } +.binding-security-note { margin: 18px 0 10px; color: #7b8794; font-size: 12px; line-height: 1.65; } +.sso-binding-card > a { color: #315b73; font-size: 13px; font-weight: 650; text-decoration: none; } @media (max-width: 1100px) { .metric-grid { grid-template-columns: repeat(2, 1fr); } diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index 8d70cf9..9402c8f 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -1,13 +1,16 @@