Files
WinStudentGoalTracker/api/src/Controllers/AuthController.cs
T

456 lines
16 KiB
C#

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<LoginResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<LoginResponse>), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ResponseResult<LoginResponse>>> Login([FromBody] LoginDto login)
{
if (string.IsNullOrWhiteSpace(login.Email) || string.IsNullOrWhiteSpace(login.Password))
{
return BadRequest(new ResponseResult<LoginResponse>
{
Success = false,
Message = "Email and password are required."
});
}
var user = await _userRepo.GetByEmailAsync(login.Email);
if (user == null)
{
return Ok(new ResponseResult<LoginResponse>
{
Success = false,
Message = "Invalid email or password."
});
}
if (user.LockedUntil.HasValue && user.LockedUntil.Value > DateTime.UtcNow)
{
return Ok(new ResponseResult<LoginResponse>
{
Success = false,
Message = "Account is temporarily locked. Please try again later."
});
}
if (user.PasswordHash == null || user.PasswordSalt == null)
{
return Ok(new ResponseResult<LoginResponse>
{
Success = false,
Message = "Password not set. Please contact an administrator."
});
}
if (!PasswordHasher.VerifyPassword(login.Password, user.PasswordHash, user.PasswordSalt))
{
return Ok(new ResponseResult<LoginResponse>
{
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<LoginResponse>
{
Success = false,
Message = "No active programs found for this account."
});
}
var sessionToken = _tokenService.GenerateSessionToken(user.IdUser, user.Email!);
return Ok(new ResponseResult<LoginResponse>
{
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<SelectProgramResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<SelectProgramResponse>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ResponseResult<SelectProgramResponse>), StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<ResponseResult<SelectProgramResponse>>> SelectProgram([FromBody] SelectProgramDto dto)
{
var authStage = User.FindFirst("auth_stage")?.Value;
if (authStage != "selecting_program")
{
return Unauthorized(new ResponseResult<SelectProgramResponse>
{
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<SelectProgramResponse> { Success = false, Message = "Invalid session token." });
if (!Guid.TryParse(dto.ProgramId, out Guid programId))
{
return BadRequest(new ResponseResult<SelectProgramResponse>
{
Success = false,
Message = "Invalid program ID format."
});
}
var programUser = await _userRepo.GetByIdWithProgramAsync(userId, programId);
if (programUser == null)
{
return Unauthorized(new ResponseResult<SelectProgramResponse>
{
Success = false,
Message = "User does not have access to this program."
});
}
if (programUser.Status != "active")
{
return Unauthorized(new ResponseResult<SelectProgramResponse>
{
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<SelectProgramResponse>
{
Success = false,
Message = "Failed to create refresh token."
});
}
var fullRefreshToken = $"{refreshTokenId.Value}.{refreshToken}";
return Ok(new ResponseResult<SelectProgramResponse>
{
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<TokenRefreshResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<TokenRefreshResponse>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ResponseResult<TokenRefreshResponse>), StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<ResponseResult<TokenRefreshResponse>>> RefreshToken([FromBody] RefreshTokenDto refreshTokenDto)
{
if (string.IsNullOrWhiteSpace(refreshTokenDto.RefreshToken))
{
return BadRequest(new ResponseResult<TokenRefreshResponse>
{
Success = false,
Message = "Refresh token is required."
});
}
var dotIndex = refreshTokenDto.RefreshToken.IndexOf('.');
if (dotIndex < 1)
{
return BadRequest(new ResponseResult<TokenRefreshResponse>
{
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<TokenRefreshResponse>
{
Success = false,
Message = "Invalid refresh token ID."
});
}
var matchedToken = await _authRepo.GetRefreshTokenByIdAsync(tokenId);
if (matchedToken == null)
{
return Unauthorized(new ResponseResult<TokenRefreshResponse>
{
Success = false,
Message = "Invalid refresh token."
});
}
if (!PasswordHasher.VerifyPassword(secretToken, matchedToken.TokenHash, matchedToken.TokenSalt))
{
return Unauthorized(new ResponseResult<TokenRefreshResponse>
{
Success = false,
Message = "Invalid refresh token."
});
}
if (matchedToken.ExpiresAt < DateTime.UtcNow)
{
return Unauthorized(new ResponseResult<TokenRefreshResponse>
{
Success = false,
Message = "Refresh token has expired."
});
}
if (matchedToken.RevokedAt.HasValue)
{
return Unauthorized(new ResponseResult<TokenRefreshResponse>
{
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<TokenRefreshResponse>
{
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<TokenRefreshResponse>
{
Success = false,
Message = "Failed to create new refresh token."
});
}
var fullNewRefreshToken = $"{newRefreshTokenId.Value}.{newSecretToken}";
return Ok(new ResponseResult<TokenRefreshResponse>
{
Success = true,
Message = "Token refreshed successfully.",
Data = new TokenRefreshResponse
{
Jwt = newJwt,
NewRefreshToken = fullNewRefreshToken,
JwtExpiresIn = jwtExpiresIn
}
});
}
[HttpPost("Logout")]
[Authorize]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ResponseResult<object>>> Logout([FromBody] RefreshTokenDto logoutDto)
{
if (string.IsNullOrWhiteSpace(logoutDto.RefreshToken))
{
return BadRequest(new ResponseResult<object>
{
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<object>
{
Success = false,
Message = "Invalid refresh token format."
});
}
var tokenData = await _authRepo.GetRefreshTokenByIdAsync(tokenId);
if (tokenData == null || tokenData.IdUser != userId)
{
return Unauthorized(new ResponseResult<object>
{
Success = false,
Message = "Invalid refresh token."
});
}
if (tokenData.RevokedAt.HasValue)
{
return Ok(new ResponseResult<object>
{
Success = true,
Message = "Already logged out."
});
}
await _authRepo.RevokeRefreshTokenAsync(tokenId);
return Ok(new ResponseResult<object>
{
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<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<object>), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ResponseResult<object>>> SetPassword([FromBody] SetPasswordDto dto)
{
if (string.IsNullOrWhiteSpace(dto.UserId) || string.IsNullOrWhiteSpace(dto.Password))
{
return BadRequest(new ResponseResult<object>
{
Success = false,
Message = "User ID and password are required."
});
}
if (!Guid.TryParse(dto.UserId, out Guid userId))
{
return BadRequest(new ResponseResult<object>
{
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<object>
{
Success = false,
Message = "User not found."
});
}
return Ok(new ResponseResult<object>
{
Success = true,
Message = "Password set successfully."
});
}
}