Web:访问令牌 10 分钟;活跃时自动轮换刷新令牌;连续无操作 30 分钟后清除登录并跳转登录页。 App:会话窗口 3 天;打开 App、恢复前台或请求接口时自动刷新并重新顺延 3 天。 普通登录和 SSO 使用同一策略。 刷新令牌只以 SHA-256 摘要入库,每次刷新都会轮换,旧令牌无法再次使用;退出登录会吊销刷新令牌。 网络临时故障不会误清登录状态,多标签页同时刷新也做了竞争处理。
181 lines
6.2 KiB
C#
181 lines
6.2 KiB
C#
using System.Security.Cryptography;
|
|
using Jiaowu.Api.Domain.Identity;
|
|
using Jiaowu.Api.Infrastructure.Persistence;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace Jiaowu.Api.Infrastructure.Auth;
|
|
|
|
public interface IAuthSessionService
|
|
{
|
|
Task<AuthSessionResult> CreateAsync(
|
|
ApplicationUser user,
|
|
IEnumerable<string> roles,
|
|
AuthenticationClientType clientType,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
Task<AuthSessionResult?> RefreshAsync(
|
|
string refreshToken,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
Task RevokeAsync(
|
|
string refreshToken,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
public sealed record AuthSessionResult(
|
|
string AccessToken,
|
|
DateTime AccessTokenExpiresAt,
|
|
string RefreshToken,
|
|
DateTime SessionExpiresAt,
|
|
ApplicationUser User,
|
|
IReadOnlyList<string> Roles);
|
|
|
|
public sealed class AuthSessionService(
|
|
AppDbContext db,
|
|
UserManager<ApplicationUser> userManager,
|
|
ITokenService tokenService,
|
|
IOptions<JwtOptions> options) : IAuthSessionService
|
|
{
|
|
private readonly JwtOptions _options = options.Value;
|
|
|
|
public async Task<AuthSessionResult> CreateAsync(
|
|
ApplicationUser user,
|
|
IEnumerable<string> roles,
|
|
AuthenticationClientType clientType,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var roleList = roles.ToList();
|
|
var now = DateTime.UtcNow;
|
|
var rawRefreshToken = CreateRefreshToken();
|
|
var session = new RefreshSession
|
|
{
|
|
UserId = user.Id,
|
|
TokenHash = HashToken(rawRefreshToken),
|
|
ClientType = clientType,
|
|
SecurityStamp = user.SecurityStamp ?? string.Empty,
|
|
CreatedAt = now,
|
|
LastRefreshedAt = now,
|
|
ExpiresAt = now.Add(GetIdleTimeout(clientType))
|
|
};
|
|
|
|
await RemoveExpiredSessionsAsync(user.Id, now, cancellationToken);
|
|
db.RefreshSessions.Add(session);
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
|
|
return BuildResult(user, roleList, rawRefreshToken, session.ExpiresAt);
|
|
}
|
|
|
|
public async Task<AuthSessionResult?> RefreshAsync(
|
|
string refreshToken,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var tokenHash = HashToken(refreshToken);
|
|
var now = DateTime.UtcNow;
|
|
var current = await db.RefreshSessions
|
|
.Include(x => x.User)
|
|
.SingleOrDefaultAsync(x => x.TokenHash == tokenHash, cancellationToken);
|
|
var user = current?.User;
|
|
if (current is null || user is null || current.RevokedAt.HasValue ||
|
|
current.ExpiresAt <= now || !user.IsEnabled ||
|
|
await userManager.IsLockedOutAsync(user) ||
|
|
!string.Equals(current.SecurityStamp, user.SecurityStamp ?? string.Empty,
|
|
StringComparison.Ordinal))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var newRawToken = CreateRefreshToken();
|
|
var replacement = new RefreshSession
|
|
{
|
|
UserId = user.Id,
|
|
TokenHash = HashToken(newRawToken),
|
|
ClientType = current.ClientType,
|
|
SecurityStamp = current.SecurityStamp,
|
|
CreatedAt = now,
|
|
LastRefreshedAt = now,
|
|
ExpiresAt = now.Add(GetIdleTimeout(current.ClientType))
|
|
};
|
|
|
|
var rotated = await db.ExecuteInRetriableTransactionAsync(
|
|
async transaction =>
|
|
{
|
|
db.ChangeTracker.Clear();
|
|
var updated = await db.RefreshSessions
|
|
.Where(x => x.Id == current.Id && x.RevokedAt == null && x.ExpiresAt > now)
|
|
.ExecuteUpdateAsync(setters => setters
|
|
.SetProperty(x => x.RevokedAt, now)
|
|
.SetProperty(x => x.ReplacedBySessionId, replacement.Id),
|
|
cancellationToken);
|
|
if (updated != 1)
|
|
{
|
|
await transaction.RollbackAsync(cancellationToken);
|
|
return false;
|
|
}
|
|
|
|
db.RefreshSessions.Add(replacement);
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
return true;
|
|
},
|
|
cancellationToken);
|
|
if (!rotated) return null;
|
|
|
|
var roles = await userManager.GetRolesAsync(user);
|
|
return BuildResult(user, roles.ToList(), newRawToken, replacement.ExpiresAt);
|
|
}
|
|
|
|
public async Task RevokeAsync(
|
|
string refreshToken,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var tokenHash = HashToken(refreshToken);
|
|
var now = DateTime.UtcNow;
|
|
await db.RefreshSessions
|
|
.Where(x => x.TokenHash == tokenHash && x.RevokedAt == null)
|
|
.ExecuteUpdateAsync(
|
|
setters => setters.SetProperty(x => x.RevokedAt, now),
|
|
cancellationToken);
|
|
}
|
|
|
|
private AuthSessionResult BuildResult(
|
|
ApplicationUser user,
|
|
IReadOnlyList<string> roles,
|
|
string refreshToken,
|
|
DateTime sessionExpiresAt)
|
|
{
|
|
var accessToken = tokenService.Create(user, roles);
|
|
return new AuthSessionResult(
|
|
accessToken.Token,
|
|
accessToken.ExpiresAt,
|
|
refreshToken,
|
|
sessionExpiresAt,
|
|
user,
|
|
roles);
|
|
}
|
|
|
|
private TimeSpan GetIdleTimeout(AuthenticationClientType clientType) =>
|
|
TimeSpan.FromMinutes(clientType == AuthenticationClientType.App
|
|
? _options.AppIdleMinutes
|
|
: _options.WebIdleMinutes);
|
|
|
|
private async Task RemoveExpiredSessionsAsync(
|
|
Guid userId,
|
|
DateTime now,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var retentionCutoff = now.AddDays(-7);
|
|
await db.RefreshSessions
|
|
.Where(x => x.UserId == userId &&
|
|
(x.ExpiresAt < now || x.RevokedAt < retentionCutoff))
|
|
.ExecuteDeleteAsync(cancellationToken);
|
|
}
|
|
|
|
private static string CreateRefreshToken() =>
|
|
Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
|
|
|
|
private static string HashToken(string token) =>
|
|
Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)));
|
|
}
|