滑动续期与自动刷新:
Web:访问令牌 10 分钟;活跃时自动轮换刷新令牌;连续无操作 30 分钟后清除登录并跳转登录页。 App:会话窗口 3 天;打开 App、恢复前台或请求接口时自动刷新并重新顺延 3 天。 普通登录和 SSO 使用同一策略。 刷新令牌只以 SHA-256 摘要入库,每次刷新都会轮换,旧令牌无法再次使用;退出登录会吊销刷新令牌。 网络临时故障不会误清登录状态,多标签页同时刷新也做了竞争处理。
This commit is contained in:
@@ -107,7 +107,7 @@ public sealed class AuthControllerTests
|
||||
var controller = new AuthController(
|
||||
db,
|
||||
userManager,
|
||||
new StubTokenService(),
|
||||
new StubAuthSessionService(),
|
||||
NoOpAppCache.Instance);
|
||||
var request = new StudentActivationRequest(
|
||||
student.Name,
|
||||
@@ -143,8 +143,27 @@ public sealed class AuthControllerTests
|
||||
protected override bool ShouldRetryOn(Exception exception) => false;
|
||||
}
|
||||
|
||||
private sealed class StubTokenService : ITokenService
|
||||
private sealed class StubAuthSessionService : IAuthSessionService
|
||||
{
|
||||
public string Create(ApplicationUser user, IEnumerable<string> roles) => string.Empty;
|
||||
public Task<AuthSessionResult> CreateAsync(
|
||||
ApplicationUser user,
|
||||
IEnumerable<string> roles,
|
||||
AuthenticationClientType clientType,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new AuthSessionResult(
|
||||
string.Empty,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ public sealed class SsoControllerTests
|
||||
var cache = provider.GetRequiredService<IDistributedCache>();
|
||||
var controller = new SsoController(
|
||||
userManager,
|
||||
new StubTokenService(),
|
||||
new StubAuthSessionService(),
|
||||
cache,
|
||||
Options.Create(new SsoOptions
|
||||
{
|
||||
@@ -154,9 +154,27 @@ public sealed class SsoControllerTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubTokenService : ITokenService
|
||||
private sealed class StubAuthSessionService : IAuthSessionService
|
||||
{
|
||||
public string Create(ApplicationUser user, IEnumerable<string> roles) =>
|
||||
"test-token";
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,16 @@ public sealed class TokenServiceTests
|
||||
};
|
||||
var service = new TokenService(options);
|
||||
|
||||
var token = new JwtSecurityTokenHandler().ReadJwtToken(
|
||||
service.Create(user, [SystemRoles.Teacher]));
|
||||
var result = service.Create(user, [SystemRoles.Teacher]);
|
||||
var token = new JwtSecurityTokenHandler().ReadJwtToken(result.Token);
|
||||
|
||||
Assert.Contains(token.Claims, x =>
|
||||
x.Type == ClaimTypes.Role && x.Value == SystemRoles.Teacher);
|
||||
Assert.Contains(token.Claims, x =>
|
||||
x.Type == ClaimTypes.Name && x.Value == "陈老师");
|
||||
Assert.InRange(
|
||||
result.ExpiresAt,
|
||||
DateTime.UtcNow.AddMinutes(9),
|
||||
DateTime.UtcNow.AddMinutes(11));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user