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

170 lines
6.4 KiB
C#

using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.DependencyInjection;
namespace Jiaowu.Api.Tests;
public sealed class AuthControllerTests
{
[Fact]
public async Task RetriableTransaction_WorksWithRetryingExecutionStrategy()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.ReplaceService<IExecutionStrategyFactory, RetryingExecutionStrategyFactory>()
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
Assert.True(db.Database.CreateExecutionStrategy().RetriesOnFailure);
await db.ExecuteInRetriableTransactionAsync(
async transaction =>
{
db.Colleges.Add(new College { Code = "TX", Name = "事务测试学院" });
await db.SaveChangesAsync();
await transaction.CommitAsync();
},
CancellationToken.None);
Assert.Equal(1, await db.Colleges.CountAsync());
}
[Fact]
public async Task ActivateStudent_WorksWithRetryingExecutionStrategy()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var services = new ServiceCollection();
services.AddLogging();
services.AddDbContext<AppDbContext>(options => options
.UseSqlite(connection)
.ReplaceService<IExecutionStrategyFactory, RetryingExecutionStrategyFactory>());
services
.AddIdentityCore<ApplicationUser>(options =>
{
options.Password.RequiredLength = 8;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = true;
})
.AddRoles<ApplicationRole>()
.AddEntityFrameworkStores<AppDbContext>();
await using var provider = services.BuildServiceProvider();
await using var scope = provider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
var roleManager = scope.ServiceProvider
.GetRequiredService<RoleManager<ApplicationRole>>();
Assert.True((await roleManager.CreateAsync(new ApplicationRole
{
Name = SystemRoles.Student,
Description = "学生"
})).Succeeded);
var college = new College { Code = "CS", Name = "计算机学院" };
var major = new Major
{
Code = "SE",
Name = "软件工程",
CollegeId = college.Id,
DegreeType = "工学"
};
var administrativeClass = new AdministrativeClass
{
Code = "SE202601",
Name = "软件工程 2026 级 1 班",
MajorId = major.Id,
Grade = 2026
};
var student = new Student
{
StudentNumber = "202601999",
Name = "测试学生",
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1),
Status = StudentStatus.Active
};
db.AddRange(college, major, administrativeClass, student);
await db.SaveChangesAsync();
var userManager = scope.ServiceProvider
.GetRequiredService<UserManager<ApplicationUser>>();
Assert.True(db.Database.CreateExecutionStrategy().RetriesOnFailure);
var controller = new AuthController(
db,
userManager,
new StubAuthSessionService(),
NoOpAppCache.Instance);
var request = new StudentActivationRequest(
student.Name,
student.StudentNumber,
college.Id,
major.Id,
administrativeClass.Grade,
administrativeClass.Id,
"Student@123");
var result = await controller.ActivateStudent(request, CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
var user = await userManager.FindByNameAsync(student.StudentNumber);
Assert.NotNull(user);
Assert.Equal(student.Name, user.DisplayName);
Assert.Equal(college.Id, user.CollegeId);
Assert.True(await userManager.IsInRoleAsync(user, SystemRoles.Student));
await db.Entry(student).ReloadAsync();
Assert.Equal(user.Id, student.UserId);
}
private sealed class RetryingExecutionStrategyFactory(
ExecutionStrategyDependencies dependencies) : IExecutionStrategyFactory
{
public IExecutionStrategy Create() => new TestRetryingExecutionStrategy(dependencies);
}
private sealed class TestRetryingExecutionStrategy(
ExecutionStrategyDependencies dependencies)
: ExecutionStrategy(dependencies, 1, TimeSpan.Zero)
{
protected override bool ShouldRetryOn(Exception exception) => false;
}
private sealed class StubAuthSessionService : IAuthSessionService
{
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;
}
}