using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using WinStudentGoalTracker.BaseClasses; using WinStudentGoalTracker.DataAccess; using WinStudentGoalTracker.Models; using WinStudentGoalTracker.Services; namespace WinStudentGoalTracker.Controllers; [ApiController] [Route("api/[controller]")] public class AuthController : BaseController { private readonly UserRepository _userRepo = new(); private readonly AuthRepository _authRepo = new(); private readonly TokenService _tokenService; private static readonly int _loginExpiration = 60 * 60 * 24 * 31; // Refresh token expires after 1 month. public AuthController(TokenService tokenService) { _tokenService = tokenService; } // Phase 1: verify credentials, return session token + list of programs [HttpPost("Login")] [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status400BadRequest)] public async Task>> Login([FromBody] LoginDto login) { if (string.IsNullOrWhiteSpace(login.Email) || string.IsNullOrWhiteSpace(login.Password)) { return BadRequest(new ResponseResult { Success = false, Message = "Email and password are required." }); } var user = await _userRepo.GetByEmailAsync(login.Email); if (user == null) { return Ok(new ResponseResult { Success = false, Message = "Invalid email or password." }); } if (user.LockedUntil.HasValue && user.LockedUntil.Value > DateTime.UtcNow) { return Ok(new ResponseResult { Success = false, Message = "Account is temporarily locked. Please try again later." }); } if (user.PasswordHash == null || user.PasswordSalt == null) { return Ok(new ResponseResult { Success = false, Message = "Password not set. Please contact an administrator." }); } if (!PasswordHasher.VerifyPassword(login.Password, user.PasswordHash, user.PasswordSalt)) { return Ok(new ResponseResult { Success = false, Message = "Invalid email or password." }); } var programs = await _userRepo.GetProgramsForUserIdAsync(user.IdUser); var programList = programs.ToList(); if (programList.Count == 0) { return Ok(new ResponseResult { Success = false, Message = "No active programs found for this account." }); } var sessionToken = _tokenService.GenerateSessionToken(user.IdUser, user.Email!); return Ok(new ResponseResult { Success = true, Message = "Login successful.", Data = new LoginResponse { SessionToken = sessionToken, Programs = programList.Select(p => new UserProgramSummary { ProgramId = p.IdProgram, ProgramName = p.ProgramName!, Role = p.RoleInternalName, RoleDisplayName = p.RoleDisplayName, IsPrimary = p.IsPrimary }).ToList() } }); } // Phase 2: user selects a program, receive program-scoped JWT + refresh token // Requires the phase 1 session token in the Authorization: Bearer header [HttpPost("SelectProgram")] [Authorize] [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status401Unauthorized)] public async Task>> SelectProgram([FromBody] SelectProgramDto dto) { var authStage = User.FindFirst("auth_stage")?.Value; if (authStage != "selecting_program") { return Unauthorized(new ResponseResult { Success = false, Message = "A session token is required to select a program." }); } var userIdClaim = User.FindFirst("user_id")?.Value; if (!Guid.TryParse(userIdClaim, out Guid userId)) return Unauthorized(new ResponseResult { Success = false, Message = "Invalid session token." }); if (!Guid.TryParse(dto.ProgramId, out Guid programId)) { return BadRequest(new ResponseResult { Success = false, Message = "Invalid program ID format." }); } var programUser = await _userRepo.GetByIdWithProgramAsync(userId, programId); if (programUser == null) { return Unauthorized(new ResponseResult { Success = false, Message = "User does not have access to this program." }); } if (programUser.Status != "active") { return Unauthorized(new ResponseResult { Success = false, Message = "Access to this program is inactive." }); } var accessToken = _tokenService.GenerateToken( programUser.IdUser, programUser.Email!, programUser.RoleInternalName, programUser.IdProgram); var jwtExpiresIn = _tokenService.GetTokenExpiryInSeconds(accessToken); var refreshToken = Guid.NewGuid().ToString(); var (refreshTokenHash, refreshTokenSalt) = PasswordHasher.HashPassword(refreshToken); var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); var userAgent = HttpContext.Request.Headers.UserAgent.ToString(); var deviceInfo = JsonSerializer.Serialize(new { ip_address = ipAddress }); var refreshTokenId = await _authRepo.CreateRefreshTokenAsync( Guid.NewGuid(), programUser.IdUser, programUser.IdProgram, refreshTokenHash, refreshTokenSalt, expiresInSeconds: _loginExpiration, deviceInfo: deviceInfo, userAgent: userAgent ); if (!refreshTokenId.HasValue) { return Ok(new ResponseResult { Success = false, Message = "Failed to create refresh token." }); } var fullRefreshToken = $"{refreshTokenId.Value}.{refreshToken}"; return Ok(new ResponseResult { Success = true, Message = "Program selected.", Data = new SelectProgramResponse { UserId = programUser.IdUser, Email = programUser.Email!, ProgramName = programUser.ProgramName!, SchoolDistrictName = programUser.SchoolDistrictName ?? "", Jwt = accessToken, RefreshToken = fullRefreshToken, Role = programUser.RoleInternalName, RoleDisplayName = programUser.RoleDisplayName, JwtExpiresIn = jwtExpiresIn } }); } [HttpPost("RefreshToken")] [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status401Unauthorized)] public async Task>> RefreshToken([FromBody] RefreshTokenDto refreshTokenDto) { if (string.IsNullOrWhiteSpace(refreshTokenDto.RefreshToken)) { return BadRequest(new ResponseResult { Success = false, Message = "Refresh token is required." }); } var dotIndex = refreshTokenDto.RefreshToken.IndexOf('.'); if (dotIndex < 1) { return BadRequest(new ResponseResult { Success = false, Message = "Invalid refresh token format." }); } var tokenIdStr = refreshTokenDto.RefreshToken[..dotIndex]; var secretToken = refreshTokenDto.RefreshToken[(dotIndex + 1)..]; if (!Guid.TryParse(tokenIdStr, out Guid tokenId)) { return BadRequest(new ResponseResult { Success = false, Message = "Invalid refresh token ID." }); } var matchedToken = await _authRepo.GetRefreshTokenByIdAsync(tokenId); if (matchedToken == null) { return Unauthorized(new ResponseResult { Success = false, Message = "Invalid refresh token." }); } if (!PasswordHasher.VerifyPassword(secretToken, matchedToken.TokenHash, matchedToken.TokenSalt)) { return Unauthorized(new ResponseResult { Success = false, Message = "Invalid refresh token." }); } if (matchedToken.ExpiresAt < DateTime.UtcNow) { return Unauthorized(new ResponseResult { Success = false, Message = "Refresh token has expired." }); } if (matchedToken.RevokedAt.HasValue) { return Unauthorized(new ResponseResult { Success = false, Message = "Refresh token has been revoked." }); } // Use program-scoped lookup so the new JWT carries current role + program var programUser = await _userRepo.GetByIdWithProgramAsync(matchedToken.IdUser, matchedToken.IdProgram); if (programUser == null) { return Unauthorized(new ResponseResult { Success = false, Message = "User or program not found." }); } var newJwt = _tokenService.GenerateToken( programUser.IdUser, programUser.Email!, programUser.RoleInternalName, programUser.IdProgram); var jwtExpiresIn = _tokenService.GetTokenExpiryInSeconds(newJwt); var newSecretToken = Guid.NewGuid().ToString(); var (newRefreshTokenHash, newRefreshTokenSalt) = PasswordHasher.HashPassword(newSecretToken); var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); var userAgent = HttpContext.Request.Headers.UserAgent.ToString(); var deviceInfo = JsonSerializer.Serialize(new { ip_address = ipAddress }); var newRefreshTokenId = await _authRepo.ReplaceRefreshTokenAsync( matchedToken.IdRefreshToken, Guid.NewGuid(), programUser.IdUser, programUser.IdProgram, newRefreshTokenHash, newRefreshTokenSalt, expiresInSeconds: 2592000, deviceInfo: deviceInfo, userAgent: userAgent ); if (!newRefreshTokenId.HasValue) { return Ok(new ResponseResult { Success = false, Message = "Failed to create new refresh token." }); } var fullNewRefreshToken = $"{newRefreshTokenId.Value}.{newSecretToken}"; return Ok(new ResponseResult { Success = true, Message = "Token refreshed successfully.", Data = new TokenRefreshResponse { Jwt = newJwt, NewRefreshToken = fullNewRefreshToken, JwtExpiresIn = jwtExpiresIn } }); } [HttpPost("Logout")] [Authorize] [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status400BadRequest)] public async Task>> Logout([FromBody] RefreshTokenDto logoutDto) { if (string.IsNullOrWhiteSpace(logoutDto.RefreshToken)) { return BadRequest(new ResponseResult { Success = false, Message = "Refresh token is required." }); } var (userId, _, _, _, claimsError) = GetProgramUserFromClaims(); if (claimsError != null) return claimsError; var dotIndex = logoutDto.RefreshToken.IndexOf('.'); if (dotIndex < 1 || !Guid.TryParse(logoutDto.RefreshToken[..dotIndex], out Guid tokenId)) { return BadRequest(new ResponseResult { Success = false, Message = "Invalid refresh token format." }); } var tokenData = await _authRepo.GetRefreshTokenByIdAsync(tokenId); if (tokenData == null || tokenData.IdUser != userId) { return Unauthorized(new ResponseResult { Success = false, Message = "Invalid refresh token." }); } if (tokenData.RevokedAt.HasValue) { return Ok(new ResponseResult { Success = true, Message = "Already logged out." }); } await _authRepo.RevokeRefreshTokenAsync(tokenId); return Ok(new ResponseResult { Success = true, Message = "Logged out successfully." }); } // ***************************************************************** // Sets the password hash and salt for an existing user. // Accepts a user ID and plaintext password, hashes it, and stores // the result in the user table. // ***************************************************************** [HttpPost("SetPassword")] [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ResponseResult), StatusCodes.Status400BadRequest)] public async Task>> SetPassword([FromBody] SetPasswordDto dto) { if (string.IsNullOrWhiteSpace(dto.UserId) || string.IsNullOrWhiteSpace(dto.Password)) { return BadRequest(new ResponseResult { Success = false, Message = "User ID and password are required." }); } if (!Guid.TryParse(dto.UserId, out Guid userId)) { return BadRequest(new ResponseResult { Success = false, Message = "Invalid user ID format." }); } var (hash, salt) = PasswordHasher.HashPassword(dto.Password); var updated = await _userRepo.SetPasswordAsync(userId, hash, salt); if (!updated) { return Ok(new ResponseResult { Success = false, Message = "User not found." }); } return Ok(new ResponseResult { Success = true, Message = "Password set successfully." }); } }