Web:访问令牌 10 分钟;活跃时自动轮换刷新令牌;连续无操作 30 分钟后清除登录并跳转登录页。 App:会话窗口 3 天;打开 App、恢复前台或请求接口时自动刷新并重新顺延 3 天。 普通登录和 SSO 使用同一策略。 刷新令牌只以 SHA-256 摘要入库,每次刷新都会轮换,旧令牌无法再次使用;退出登录会吊销刷新令牌。 网络临时故障不会误清登录状态,多标签页同时刷新也做了竞争处理。
181 lines
6.6 KiB
C#
181 lines
6.6 KiB
C#
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<LoginResponse>(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<ApplicationUser> userManager,
|
|
IDistributedCache cache,
|
|
SsoController controller)
|
|
{
|
|
_connection = connection;
|
|
_provider = provider;
|
|
User = user;
|
|
UserManager = userManager;
|
|
Cache = cache;
|
|
Controller = controller;
|
|
}
|
|
|
|
public ApplicationUser User { get; }
|
|
public UserManager<ApplicationUser> UserManager { get; }
|
|
public IDistributedCache Cache { get; }
|
|
public SsoController Controller { get; }
|
|
|
|
public static async Task<BindingFixture> CreateAsync()
|
|
{
|
|
var connection = new SqliteConnection("Data Source=:memory:");
|
|
await connection.OpenAsync();
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddDistributedMemoryCache();
|
|
services.AddDbContext<AppDbContext>(options => options.UseSqlite(connection));
|
|
services
|
|
.AddIdentityCore<ApplicationUser>()
|
|
.AddRoles<ApplicationRole>()
|
|
.AddEntityFrameworkStores<AppDbContext>();
|
|
|
|
var provider = services.BuildServiceProvider();
|
|
var db = provider.GetRequiredService<AppDbContext>();
|
|
await db.Database.EnsureCreatedAsync();
|
|
var userManager = provider.GetRequiredService<UserManager<ApplicationUser>>();
|
|
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<IDistributedCache>();
|
|
var controller = new SsoController(
|
|
userManager,
|
|
new StubAuthSessionService(),
|
|
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 StubAuthSessionService : IAuthSessionService
|
|
{
|
|
public Task<AuthSessionResult> CreateAsync(
|
|
ApplicationUser user,
|
|
IEnumerable<string> roles,
|
|
AuthenticationClientType clientType,
|
|
CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(new AuthSessionResult(
|
|
"test-token",
|
|
DateTime.UtcNow.AddMinutes(10),
|
|
"test-refresh-token-value-with-sufficient-length",
|
|
DateTime.UtcNow.AddMinutes(30),
|
|
user,
|
|
roles.ToList()));
|
|
|
|
public Task<AuthSessionResult?> RefreshAsync(
|
|
string refreshToken,
|
|
CancellationToken cancellationToken = default) => Task.FromResult<AuthSessionResult?>(null);
|
|
|
|
public Task RevokeAsync(
|
|
string refreshToken,
|
|
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
|
}
|
|
}
|