添加sso

This commit is contained in:
2026-08-03 15:57:40 +08:00 Unverified
parent fb812268d7
commit 6bee29a351
16 changed files with 917 additions and 5 deletions
+339
View File
@@ -0,0 +1,339 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
namespace Jiaowu.Api.Controllers;
[ApiController]
[AllowAnonymous]
[Route("api/auth/sso")]
public sealed class SsoController(
UserManager<ApplicationUser> userManager,
ITokenService tokenService,
IDistributedCache cache,
IOptions<SsoOptions> options) : ControllerBase
{
private readonly SsoOptions _options = options.Value;
[HttpGet("settings")]
public ActionResult<SsoSettingsResponse> Settings() =>
new SsoSettingsResponse(_options.Enabled, _options.DisplayName);
[EnableRateLimiting("public-auth")]
[HttpGet("login")]
public ActionResult Login([FromQuery] string? returnUrl = null)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
var completeUrl = Url.Action(
nameof(Complete),
values: new { returnUrl = safeReturnUrl })!;
return Challenge(
new AuthenticationProperties { RedirectUri = completeUrl },
SsoAuthSchemes.Keycloak);
}
[ApiExplorerSettings(IgnoreApi = true)]
[HttpGet("complete")]
public async Task<ActionResult> Complete(
[FromQuery] string? returnUrl,
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var authentication = await HttpContext.AuthenticateAsync(
SsoAuthSchemes.ExternalCookie);
if (!authentication.Succeeded || authentication.Principal is null)
return RedirectToFrontendError("authentication_failed");
var principal = authentication.Principal;
var subject = principal.FindFirstValue("sub") ??
principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(subject))
return RedirectToFrontendError("missing_subject");
var user = await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
subject);
if (user is null && _options.LinkExistingUsersByUserName)
{
var userName = principal.FindFirstValue(_options.UserNameClaim)?.Trim();
if (!string.IsNullOrWhiteSpace(userName))
{
user = await userManager.FindByNameAsync(userName);
if (user is not null)
{
var linkResult = await userManager.AddLoginAsync(
user,
new UserLoginInfo(
SsoAuthSchemes.LoginProvider,
subject,
_options.DisplayName));
if (!linkResult.Succeeded)
return RedirectToFrontendError("account_link_failed");
}
}
}
if (user is null)
{
var bindingCode = WebEncoders.Base64UrlEncode(
RandomNumberGenerator.GetBytes(32));
var externalUserName =
principal.FindFirstValue(_options.UserNameClaim)?.Trim() ??
principal.FindFirstValue("name")?.Trim() ??
subject;
await cache.SetStringAsync(
BindingCacheKey(bindingCode),
JsonSerializer.Serialize(new SsoBindingTicket(subject, externalUserName)),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
},
cancellationToken);
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
var bindingPage = BuildFrontendUrl("/sso/bind") +
$"?code={Uri.EscapeDataString(bindingCode)}" +
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
return Redirect(bindingPage);
}
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
return RedirectToFrontendError("account_disabled");
user.LastLoginAt = DateTime.UtcNow;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
return RedirectToFrontendError("account_update_failed");
var exchangeCode = WebEncoders.Base64UrlEncode(
RandomNumberGenerator.GetBytes(32));
await cache.SetStringAsync(
ExchangeCacheKey(exchangeCode),
user.Id.ToString("D"),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
},
cancellationToken);
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
var callback = BuildFrontendUrl("/sso/callback") +
$"?code={Uri.EscapeDataString(exchangeCode)}" +
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
return Redirect(callback);
}
[EnableRateLimiting("public-auth")]
[HttpPost("exchange")]
public async Task<ActionResult<LoginResponse>> Exchange(
SsoExchangeRequest request,
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var cacheKey = ExchangeCacheKey(request.Code);
var userId = await cache.GetStringAsync(cacheKey, cancellationToken);
if (userId is null)
return SsoProblem(
"统一身份认证结果已失效,请重新登录。",
StatusCodes.Status401Unauthorized);
await cache.RemoveAsync(cacheKey, cancellationToken);
var user = await userManager.FindByIdAsync(userId);
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
return SsoProblem(
"本地账号不存在、已停用或已锁定。",
StatusCodes.Status401Unauthorized);
var roles = await userManager.GetRolesAsync(user);
return new LoginResponse(
tokenService.Create(user, roles),
new CurrentUserResponse(
user.Id,
user.UserName!,
user.DisplayName,
roles,
user.CollegeId,
EffectiveDataScopeResolver.Resolve(roles).ToString()));
}
[EnableRateLimiting("public-auth")]
[HttpGet("binding")]
public async Task<ActionResult<SsoBindingInfoResponse>> BindingInfo(
[FromQuery, Required, MinLength(20), MaxLength(200)] string code,
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var ticket = await ReadBindingTicketAsync(code, cancellationToken);
if (ticket is null)
return SsoProblem(
"账户绑定请求已失效,请重新使用统一身份认证登录。",
StatusCodes.Status401Unauthorized);
return new SsoBindingInfoResponse(_options.DisplayName, ticket.ExternalUserName);
}
[EnableRateLimiting("public-auth")]
[HttpPost("bind")]
public async Task<ActionResult<LoginResponse>> Bind(
SsoBindRequest request,
CancellationToken cancellationToken)
{
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var ticket = await ReadBindingTicketAsync(request.Code, cancellationToken);
if (ticket is null)
return SsoProblem(
"账户绑定请求已失效,请重新使用统一身份认证登录。",
StatusCodes.Status401Unauthorized);
var user = await userManager.FindByNameAsync(request.UserName.Trim());
if (user is null || !user.IsEnabled || await userManager.IsLockedOutAsync(user))
return InvalidLocalCredentials();
if (!await userManager.CheckPasswordAsync(user, request.Password))
{
await userManager.AccessFailedAsync(user);
return InvalidLocalCredentials();
}
var subjectOwner = await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
ticket.Subject);
if (subjectOwner is not null && subjectOwner.Id != user.Id)
return BindingConflict("该统一身份账号已绑定其他教务系统账号。");
var keycloakLogins = (await userManager.GetLoginsAsync(user))
.Where(x => x.LoginProvider == SsoAuthSchemes.LoginProvider)
.ToList();
if (keycloakLogins.Any(x => x.ProviderKey != ticket.Subject))
return BindingConflict("该教务系统账号已绑定其他统一身份账号。");
if (subjectOwner is null)
{
var linkResult = await userManager.AddLoginAsync(
user,
new UserLoginInfo(
SsoAuthSchemes.LoginProvider,
ticket.Subject,
_options.DisplayName));
if (!linkResult.Succeeded)
{
subjectOwner = await userManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
ticket.Subject);
if (subjectOwner?.Id != user.Id)
return BindingConflict("账户绑定失败,请重新发起统一身份认证。");
}
}
await userManager.ResetAccessFailedCountAsync(user);
user.LastLoginAt = DateTime.UtcNow;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
return SsoProblem("本地账号状态更新失败,请稍后重试。", StatusCodes.Status500InternalServerError);
await cache.RemoveAsync(BindingCacheKey(request.Code), cancellationToken);
var roles = await userManager.GetRolesAsync(user);
return new LoginResponse(
tokenService.Create(user, roles),
new CurrentUserResponse(
user.Id,
user.UserName!,
user.DisplayName,
roles,
user.CollegeId,
EffectiveDataScopeResolver.Resolve(roles).ToString()));
}
internal static string NormalizeReturnUrl(string? returnUrl) =>
!string.IsNullOrWhiteSpace(returnUrl) &&
returnUrl.StartsWith('/') &&
!returnUrl.StartsWith("//", StringComparison.Ordinal)
? returnUrl
: "/dashboard";
private string BuildFrontendUrl(string path) =>
string.IsNullOrWhiteSpace(_options.FrontendBaseUrl)
? path
: _options.FrontendBaseUrl.TrimEnd('/') + path;
private RedirectResult RedirectToFrontendError(string error) =>
Redirect(BuildFrontendUrl("/login") +
$"?ssoError={Uri.EscapeDataString(error)}");
private static string ExchangeCacheKey(string code) => $"sso:exchange:{code}";
private static string BindingCacheKey(string code) => $"sso:binding:{code}";
private async Task<SsoBindingTicket?> ReadBindingTicketAsync(
string code,
CancellationToken cancellationToken)
{
var json = await cache.GetStringAsync(BindingCacheKey(code), cancellationToken);
if (json is null)
return null;
try
{
return JsonSerializer.Deserialize<SsoBindingTicket>(json);
}
catch (JsonException)
{
return null;
}
}
private UnauthorizedObjectResult InvalidLocalCredentials() =>
Unauthorized(new ProblemDetails
{
Title = "账户绑定失败",
Detail = "教务系统账号或密码不正确,或账号已停用。",
Status = StatusCodes.Status401Unauthorized
});
private ObjectResult BindingConflict(string detail) =>
SsoProblem(detail, StatusCodes.Status409Conflict);
private ObjectResult SsoProblem(string detail, int status) =>
StatusCode(status, new ProblemDetails
{
Title = "统一身份认证失败",
Detail = detail,
Status = status
});
}
public sealed record SsoSettingsResponse(bool Enabled, string DisplayName);
public sealed record SsoExchangeRequest(
[Required, MinLength(20), MaxLength(200)] string Code);
public sealed record SsoBindingInfoResponse(
string ProviderDisplayName,
string ExternalUserName);
public sealed record SsoBindRequest(
[Required, MinLength(20), MaxLength(200)] string Code,
[Required, MaxLength(100)] string UserName,
[Required, MaxLength(100)] string Password);
internal sealed record SsoBindingTicket(string Subject, string ExternalUserName);
@@ -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";
}
+1
View File
@@ -23,6 +23,7 @@
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.1.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.10" />
+70 -2
View File
@@ -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<RabbitMqOptions>() ?? new RabbitMqOptions();
var ssoOptions = builder.Configuration
.GetSection(SsoOptions.SectionName)
.Get<SsoOptions>() ?? 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<JwtOptions>(
builder.Configuration.GetSection(JwtOptions.SectionName));
builder.Services.Configure<SsoOptions>(
builder.Configuration.GetSection(SsoOptions.SectionName));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
@@ -407,8 +433,12 @@ builder.Services.AddHostedService<BackgroundJobOutboxPublisher>();
builder.Services.AddSingleton<IOfficialDocumentPdfGenerator, OfficialDocumentPdfGenerator>();
builder.Services.AddScoped<OfficialDocumentService>();
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 =>
{
@@ -12,6 +12,9 @@
"Key": "jiaowu-development-secret-key-change-before-production",
"ExpireMinutes": 480
},
"Sso": {
"FrontendBaseUrl": "http://localhost:5173"
},
"Cors": {
"Origins": [
"http://localhost:5173"
+11
View File
@@ -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",