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 userManager, IAuthSessionService authSessionService, IAppCache cache) : ControllerBase { [AllowAnonymous] [HttpGet("activation-options")] public async Task 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 ActivateStudent( StudentActivationRequest request, CancellationToken cancellationToken) { var name = request.Name.Trim(); var studentNumber = request.StudentNumber.Trim(); var executionStrategy = db.Database.CreateExecutionStrategy(); return await executionStrategy.ExecuteAsync(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> 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> 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 Logout( RefreshTokenRequest request, CancellationToken cancellationToken) { await authSessionService.RevokeAsync(request.RefreshToken, cancellationToken); return NoContent(); } [Authorize] [HttpGet("me")] public async Task> 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 Roles, Guid? CollegeId, string EffectiveDataScope); public sealed record ActivationOptionsResponse( IReadOnlyList Colleges, IReadOnlyList Majors, IReadOnlyList Classes, IReadOnlyList 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);