Files
biss 5ec62a03ca 滑动续期与自动刷新:
Web:访问令牌 10 分钟;活跃时自动轮换刷新令牌;连续无操作 30 分钟后清除登录并跳转登录页。
App:会话窗口 3 天;打开 App、恢复前台或请求接口时自动刷新并重新顺延 3 天。
普通登录和 SSO 使用同一策略。
刷新令牌只以 SHA-256 摘要入库,每次刷新都会轮换,旧令牌无法再次使用;退出登录会吊销刷新令牌。
网络临时故障不会误清登录状态,多标签页同时刷新也做了竞争处理。
2026-08-03 19:20:39 +08:00

134 lines
4.7 KiB
C#

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.DependencyInjection;
namespace Jiaowu.Api.Tests;
public sealed class AuthSessionServiceTests
{
[Fact]
public async Task Web_session_rotates_refresh_token_and_rejects_reuse()
{
await using var fixture = await SessionFixture.CreateAsync();
var issued = await fixture.Service.CreateAsync(
fixture.User,
[SystemRoles.Student],
AuthenticationClientType.Web);
Assert.InRange(
issued.SessionExpiresAt,
DateTime.UtcNow.AddMinutes(29),
DateTime.UtcNow.AddMinutes(31));
var stored = await fixture.Db.RefreshSessions.SingleAsync();
Assert.NotEqual(issued.RefreshToken, stored.TokenHash);
var refreshed = await fixture.Service.RefreshAsync(issued.RefreshToken);
Assert.NotNull(refreshed);
Assert.NotEqual(issued.RefreshToken, refreshed.RefreshToken);
Assert.Null(await fixture.Service.RefreshAsync(issued.RefreshToken));
Assert.Equal(1, await fixture.Db.RefreshSessions.CountAsync(x => x.RevokedAt == null));
}
[Fact]
public async Task App_session_uses_three_day_sliding_window()
{
await using var fixture = await SessionFixture.CreateAsync();
var issued = await fixture.Service.CreateAsync(
fixture.User,
[SystemRoles.Student],
AuthenticationClientType.App);
Assert.InRange(
issued.SessionExpiresAt,
DateTime.UtcNow.AddDays(3).AddMinutes(-1),
DateTime.UtcNow.AddDays(3).AddMinutes(1));
}
private sealed class SessionFixture : IAsyncDisposable
{
private readonly SqliteConnection _connection;
private readonly ServiceProvider _provider;
private readonly AsyncServiceScope _scope;
private SessionFixture(
SqliteConnection connection,
ServiceProvider provider,
AsyncServiceScope scope,
AppDbContext db,
ApplicationUser user,
IAuthSessionService service)
{
_connection = connection;
_provider = provider;
_scope = scope;
Db = db;
User = user;
Service = service;
}
public AppDbContext Db { get; }
public ApplicationUser User { get; }
public IAuthSessionService Service { get; }
public static async Task<SessionFixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var services = new ServiceCollection();
services.AddLogging();
services.AddDbContext<AppDbContext>(options => options.UseSqlite(connection));
services.Configure<JwtOptions>(options =>
{
options.Issuer = "tests";
options.Audience = "tests-web";
options.Key = "a-test-signing-key-that-is-at-least-32-bytes-long";
options.AccessTokenMinutes = 10;
options.WebIdleMinutes = 30;
options.AppIdleMinutes = 4320;
});
services
.AddIdentityCore<ApplicationUser>()
.AddRoles<ApplicationRole>()
.AddEntityFrameworkStores<AppDbContext>();
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<IAuthSessionService, AuthSessionService>();
var provider = services.BuildServiceProvider();
var scope = provider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var user = new ApplicationUser
{
UserName = "session-user",
DisplayName = "会话测试用户",
IsEnabled = true,
LockoutEnabled = true
};
Assert.True((await userManager.CreateAsync(user, "SessionUser@123")).Succeeded);
return new SessionFixture(
connection,
provider,
scope,
db,
user,
scope.ServiceProvider.GetRequiredService<IAuthSessionService>());
}
public async ValueTask DisposeAsync()
{
await _scope.DisposeAsync();
await _provider.DisposeAsync();
await _connection.DisposeAsync();
}
}
}