Web:访问令牌 10 分钟;活跃时自动轮换刷新令牌;连续无操作 30 分钟后清除登录并跳转登录页。 App:会话窗口 3 天;打开 App、恢复前台或请求接口时自动刷新并重新顺延 3 天。 普通登录和 SSO 使用同一策略。 刷新令牌只以 SHA-256 摘要入库,每次刷新都会轮换,旧令牌无法再次使用;退出登录会吊销刷新令牌。 网络临时故障不会误清登录状态,多标签页同时刷新也做了竞争处理。
318 lines
11 KiB
C#
318 lines
11 KiB
C#
using System.ComponentModel.DataAnnotations;
|
|
using System.Security.Claims;
|
|
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.Authorization;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Jiaowu.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/auth")]
|
|
public sealed class AuthController(
|
|
AppDbContext db,
|
|
UserManager<ApplicationUser> userManager,
|
|
IAuthSessionService authSessionService,
|
|
IAppCache cache) : ControllerBase
|
|
{
|
|
[AllowAnonymous]
|
|
[HttpGet("activation-options")]
|
|
public async Task<ActionResult> GetActivationOptions(CancellationToken cancellationToken)
|
|
{
|
|
var result = await cache.GetOrCreateAsync(
|
|
AppCacheKeys.ActivationOptions,
|
|
async token =>
|
|
{
|
|
var colleges = await db.Colleges.AsNoTracking()
|
|
.Where(x => x.IsEnabled)
|
|
.OrderBy(x => x.Code)
|
|
.Select(x => new ActivationCollegeOption(x.Id, x.Code, x.Name))
|
|
.ToListAsync(token);
|
|
var majors = await db.Majors.AsNoTracking()
|
|
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
|
.OrderBy(x => x.Code)
|
|
.Select(x => new ActivationMajorOption(
|
|
x.Id, x.Code, x.Name, x.CollegeId))
|
|
.ToListAsync(token);
|
|
var classes = await db.AdministrativeClasses.AsNoTracking()
|
|
.Where(x =>
|
|
x.IsEnabled &&
|
|
x.Major!.IsEnabled &&
|
|
x.Major.College!.IsEnabled)
|
|
.OrderByDescending(x => x.Grade)
|
|
.ThenBy(x => x.Code)
|
|
.Select(x => new ActivationClassOption(
|
|
x.Id, x.Code, x.Name, x.Grade, x.MajorId))
|
|
.ToListAsync(token);
|
|
return new ActivationOptionsResponse(
|
|
colleges,
|
|
majors,
|
|
classes,
|
|
classes.Select(x => x.Grade)
|
|
.Distinct()
|
|
.OrderByDescending(x => x)
|
|
.ToList());
|
|
},
|
|
AppCacheProfile.ReferenceData,
|
|
[AppCacheTags.BaseData],
|
|
cancellationToken);
|
|
return Ok(result);
|
|
}
|
|
|
|
[AllowAnonymous]
|
|
[EnableRateLimiting("public-auth")]
|
|
[HttpPost("activate-student")]
|
|
public async Task<ActionResult> ActivateStudent(
|
|
StudentActivationRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var name = request.Name.Trim();
|
|
var studentNumber = request.StudentNumber.Trim();
|
|
var executionStrategy = db.Database.CreateExecutionStrategy();
|
|
return await executionStrategy.ExecuteAsync<ActionResult>(async () =>
|
|
{
|
|
var student = await db.Students
|
|
.Include(x => x.AdministrativeClass)
|
|
.ThenInclude(x => x!.Major)
|
|
.FirstOrDefaultAsync(x =>
|
|
x.Status == StudentStatus.Active &&
|
|
x.Name == name &&
|
|
x.StudentNumber == studentNumber &&
|
|
x.EnrollmentYear == request.Grade &&
|
|
x.AdministrativeClassId == request.AdministrativeClassId &&
|
|
x.AdministrativeClass!.Grade == request.Grade &&
|
|
x.AdministrativeClass.MajorId == request.MajorId &&
|
|
x.AdministrativeClass.Major!.CollegeId == request.CollegeId,
|
|
cancellationToken);
|
|
if (student is null)
|
|
return ActivationProblem(
|
|
"填写的信息与在籍学生档案不完全一致,请核对后重试。",
|
|
StatusCodes.Status400BadRequest);
|
|
if (student.UserId.HasValue)
|
|
return ActivationProblem(
|
|
"该学号已经激活,请直接登录;如忘记密码请联系管理员重置。",
|
|
StatusCodes.Status409Conflict);
|
|
if (await userManager.FindByNameAsync(studentNumber) is not null)
|
|
return ActivationProblem(
|
|
"该学号已有登录账号但未正确关联,请联系管理员处理。",
|
|
StatusCodes.Status409Conflict);
|
|
|
|
await using var transaction =
|
|
await db.Database.BeginTransactionAsync(cancellationToken);
|
|
var user = new ApplicationUser
|
|
{
|
|
UserName = studentNumber,
|
|
DisplayName = student.Name,
|
|
StaffNumber = studentNumber,
|
|
CollegeId = request.CollegeId,
|
|
IsEnabled = true,
|
|
LockoutEnabled = true
|
|
};
|
|
var result = await userManager.CreateAsync(user, request.Password);
|
|
if (!result.Succeeded)
|
|
{
|
|
await transaction.RollbackAsync(cancellationToken);
|
|
return IdentityValidationProblem(result);
|
|
}
|
|
result = await userManager.AddToRoleAsync(user, SystemRoles.Student);
|
|
if (!result.Succeeded)
|
|
{
|
|
await transaction.RollbackAsync(cancellationToken);
|
|
return IdentityValidationProblem(result);
|
|
}
|
|
|
|
student.UserId = user.Id;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
return Ok(new { UserName = studentNumber });
|
|
});
|
|
}
|
|
|
|
[AllowAnonymous]
|
|
[EnableRateLimiting("public-auth")]
|
|
[HttpPost("login")]
|
|
public async Task<ActionResult<LoginResponse>> Login(
|
|
LoginRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var user = await userManager.FindByNameAsync(request.UserName);
|
|
if (user is null || !user.IsEnabled)
|
|
{
|
|
return Unauthorized(new ProblemDetails
|
|
{
|
|
Title = "登录失败",
|
|
Detail = "账号或密码不正确,或账号已停用。",
|
|
Status = StatusCodes.Status401Unauthorized
|
|
});
|
|
}
|
|
|
|
if (await userManager.IsLockedOutAsync(user) ||
|
|
!await userManager.CheckPasswordAsync(user, request.Password))
|
|
{
|
|
await userManager.AccessFailedAsync(user);
|
|
return Unauthorized(new ProblemDetails
|
|
{
|
|
Title = "登录失败",
|
|
Detail = "账号或密码不正确,或账号已停用。",
|
|
Status = StatusCodes.Status401Unauthorized
|
|
});
|
|
}
|
|
|
|
await userManager.ResetAccessFailedCountAsync(user);
|
|
user.LastLoginAt = DateTime.UtcNow;
|
|
await userManager.UpdateAsync(user);
|
|
var roles = await userManager.GetRolesAsync(user);
|
|
|
|
var session = await authSessionService.CreateAsync(
|
|
user,
|
|
roles,
|
|
request.IsNativeApp
|
|
? AuthenticationClientType.App
|
|
: AuthenticationClientType.Web,
|
|
cancellationToken);
|
|
return CreateLoginResponse(session);
|
|
}
|
|
|
|
[AllowAnonymous]
|
|
[EnableRateLimiting("token-refresh")]
|
|
[HttpPost("refresh")]
|
|
public async Task<ActionResult<LoginResponse>> Refresh(
|
|
RefreshTokenRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var session = await authSessionService.RefreshAsync(
|
|
request.RefreshToken,
|
|
cancellationToken);
|
|
if (session is null)
|
|
{
|
|
return Unauthorized(new ProblemDetails
|
|
{
|
|
Title = "登录已过期",
|
|
Detail = "登录已过期或刷新令牌已失效,请重新登录。",
|
|
Status = StatusCodes.Status401Unauthorized
|
|
});
|
|
}
|
|
|
|
return CreateLoginResponse(session);
|
|
}
|
|
|
|
[AllowAnonymous]
|
|
[HttpPost("logout")]
|
|
public async Task<IActionResult> Logout(
|
|
RefreshTokenRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await authSessionService.RevokeAsync(request.RefreshToken, cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
[Authorize]
|
|
[HttpGet("me")]
|
|
public async Task<ActionResult<CurrentUserResponse>> Me()
|
|
{
|
|
var id = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
var user = id is null ? null : await userManager.FindByIdAsync(id);
|
|
if (user is null || !user.IsEnabled)
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
var roles = await userManager.GetRolesAsync(user);
|
|
return new CurrentUserResponse(
|
|
user.Id,
|
|
user.UserName!,
|
|
user.DisplayName,
|
|
roles,
|
|
user.CollegeId,
|
|
EffectiveDataScopeResolver.Resolve(roles).ToString());
|
|
}
|
|
|
|
private ActionResult IdentityValidationProblem(IdentityResult result)
|
|
{
|
|
foreach (var error in result.Errors)
|
|
ModelState.AddModelError(error.Code, error.Description);
|
|
return ValidationProblem(ModelState);
|
|
}
|
|
|
|
private ActionResult ActivationProblem(string detail, int status) =>
|
|
StatusCode(status, new ProblemDetails
|
|
{
|
|
Title = "账号激活失败",
|
|
Detail = detail,
|
|
Status = status
|
|
});
|
|
|
|
internal static LoginResponse CreateLoginResponse(AuthSessionResult session) =>
|
|
new(
|
|
session.AccessToken,
|
|
session.AccessTokenExpiresAt,
|
|
session.RefreshToken,
|
|
session.SessionExpiresAt,
|
|
new CurrentUserResponse(
|
|
session.User.Id,
|
|
session.User.UserName!,
|
|
session.User.DisplayName,
|
|
session.Roles,
|
|
session.User.CollegeId,
|
|
EffectiveDataScopeResolver.Resolve(session.Roles).ToString()));
|
|
}
|
|
|
|
public sealed record LoginRequest(
|
|
[Required, MaxLength(100)] string UserName,
|
|
[Required, MaxLength(100)] string Password,
|
|
bool IsNativeApp = false);
|
|
|
|
public sealed record RefreshTokenRequest(
|
|
[Required, MinLength(40), MaxLength(200)] string RefreshToken);
|
|
|
|
public sealed record StudentActivationRequest(
|
|
[Required, MaxLength(50)] string Name,
|
|
[Required, MaxLength(30)] string StudentNumber,
|
|
Guid CollegeId,
|
|
Guid MajorId,
|
|
[Range(2000, 2200)] int Grade,
|
|
Guid AdministrativeClassId,
|
|
[Required, MinLength(8), MaxLength(100)] string Password);
|
|
|
|
public sealed record LoginResponse(
|
|
string Token,
|
|
DateTime AccessTokenExpiresAt,
|
|
string RefreshToken,
|
|
DateTime SessionExpiresAt,
|
|
CurrentUserResponse User);
|
|
|
|
public sealed record CurrentUserResponse(
|
|
Guid Id,
|
|
string UserName,
|
|
string DisplayName,
|
|
IEnumerable<string> Roles,
|
|
Guid? CollegeId,
|
|
string EffectiveDataScope);
|
|
|
|
public sealed record ActivationOptionsResponse(
|
|
IReadOnlyList<ActivationCollegeOption> Colleges,
|
|
IReadOnlyList<ActivationMajorOption> Majors,
|
|
IReadOnlyList<ActivationClassOption> Classes,
|
|
IReadOnlyList<int> Grades);
|
|
|
|
public sealed record ActivationCollegeOption(Guid Id, string Code, string Name);
|
|
|
|
public sealed record ActivationMajorOption(
|
|
Guid Id,
|
|
string Code,
|
|
string Name,
|
|
Guid CollegeId);
|
|
|
|
public sealed record ActivationClassOption(
|
|
Guid Id,
|
|
string Code,
|
|
string Name,
|
|
int Grade,
|
|
Guid MajorId);
|