mirror of
https://github.com/opelly27/WinStudentGoalTracker.git
synced 2026-05-20 01:47:41 +00:00
456 lines
16 KiB
C#
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."
|
|
});
|
|
}
|
|
}
|