changed login flow to support 2 phase program selection login.

This commit is contained in:
2026-02-21 15:40:04 -08:00
parent d90eefacdd
commit 043ff337c1
14 changed files with 280 additions and 44 deletions
+12
View File
@@ -39,4 +39,16 @@ public class BaseController : ControllerBase
{ {
return roles.Any(User.IsInRole); return roles.Any(User.IsInRole);
} }
protected (Guid programId, ActionResult? error) GetProgramIdFromClaims()
{
var programIdClaim = User.FindFirst("program_id")?.Value;
if (string.IsNullOrWhiteSpace(programIdClaim) || !Guid.TryParse(programIdClaim, out var programId))
{
return (Guid.Empty, Unauthorized("Missing or invalid program_id claim."));
}
return (programId, null);
}
} }
+119 -28
View File
@@ -21,13 +21,12 @@ public class AuthController : BaseController
_tokenService = tokenService; _tokenService = tokenService;
} }
// Phase 1: verify credentials, return session token + list of programs
[HttpPost("Login")] [HttpPost("Login")]
[ProducesResponseType(typeof(ResponseResult<LoginResponse>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ResponseResult<LoginResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<LoginResponse>), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ResponseResult<LoginResponse>), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ResponseResult<LoginResponse>>> Login([FromBody] LoginDto login) public async Task<ActionResult<ResponseResult<LoginResponse>>> Login([FromBody] LoginDto login)
{ {
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
if (string.IsNullOrWhiteSpace(login.Email) || string.IsNullOrWhiteSpace(login.Password)) if (string.IsNullOrWhiteSpace(login.Email) || string.IsNullOrWhiteSpace(login.Password))
{ {
return BadRequest(new ResponseResult<LoginResponse> return BadRequest(new ResponseResult<LoginResponse>
@@ -74,21 +73,108 @@ public class AuthController : BaseController
}); });
} }
// Generate JWT access token var programs = await _userRepo.GetProgramsForUserIdAsync(user.IdUser);
var accessToken = _tokenService.GenerateToken(user.IdUser, user.Email!, user.RoleInternalName); var programList = programs.ToList();
// Generate refresh token secret if (programList.Count == 0)
var secretToken = Guid.NewGuid().ToString(); {
var (refreshTokenHash, refreshTokenSalt) = PasswordHasher.HashPassword(secretToken); return Ok(new ResponseResult<LoginResponse>
{
Success = false,
Message = "No active programs found for this account."
});
}
// Get device info 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 (userId, userIdError) = GetUserIdFromClaims();
if (userIdError != null) return userIdError;
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 userAgent = HttpContext.Request.Headers.UserAgent.ToString();
var deviceInfo = JsonSerializer.Serialize(new { ip_address = ipAddress }); var deviceInfo = JsonSerializer.Serialize(new { ip_address = ipAddress });
// Store refresh token in database (30 days expiration)
var refreshTokenId = await _authRepo.CreateRefreshTokenAsync( var refreshTokenId = await _authRepo.CreateRefreshTokenAsync(
Guid.NewGuid(), Guid.NewGuid(),
user.IdUser, programUser.IdUser,
programUser.IdProgram,
refreshTokenHash, refreshTokenHash,
refreshTokenSalt, refreshTokenSalt,
expiresInSeconds: 2592000, // 30 days expiresInSeconds: 2592000, // 30 days
@@ -98,28 +184,29 @@ public class AuthController : BaseController
if (!refreshTokenId.HasValue) if (!refreshTokenId.HasValue)
{ {
return Ok(new ResponseResult<LoginResponse> return Ok(new ResponseResult<SelectProgramResponse>
{ {
Success = false, Success = false,
Message = "Failed to create refresh token." Message = "Failed to create refresh token."
}); });
} }
// Build full refresh token: {id}.{secret} var fullRefreshToken = $"{refreshTokenId.Value}.{refreshToken}";
var fullRefreshToken = $"{refreshTokenId.Value}.{secretToken}";
return Ok(new ResponseResult<LoginResponse> return Ok(new ResponseResult<SelectProgramResponse>
{ {
Success = true, Success = true,
Message = "Login successful.", Message = "Program selected.",
Data = new LoginResponse Data = new SelectProgramResponse
{ {
UserId = user.IdUser, UserId = programUser.IdUser,
Email = user.Email!, Email = programUser.Email!,
ProgramName = programUser.ProgramName!,
Jwt = accessToken, Jwt = accessToken,
RefreshToken = fullRefreshToken, RefreshToken = fullRefreshToken,
Role = user.RoleInternalName, Role = programUser.RoleInternalName,
RoleDisplayName = user.RoleDisplayName RoleDisplayName = programUser.RoleDisplayName,
JwtExpiresIn = jwtExpiresIn
} }
}); });
} }
@@ -139,7 +226,6 @@ public class AuthController : BaseController
}); });
} }
// Split token into ID and secret: {id}.{secret}
var dotIndex = refreshTokenDto.RefreshToken.IndexOf('.'); var dotIndex = refreshTokenDto.RefreshToken.IndexOf('.');
if (dotIndex < 1) if (dotIndex < 1)
{ {
@@ -199,21 +285,25 @@ public class AuthController : BaseController
}); });
} }
var tokenUser = await _userRepo.GetByIdAsync(matchedToken.IdUser); // Use program-scoped lookup so the new JWT carries current role + program
if (tokenUser == null) var programUser = await _userRepo.GetByIdWithProgramAsync(matchedToken.IdUser, matchedToken.IdProgram);
if (programUser == null)
{ {
return Unauthorized(new ResponseResult<TokenRefreshResponse> return Unauthorized(new ResponseResult<TokenRefreshResponse>
{ {
Success = false, Success = false,
Message = "User not found." Message = "User or program not found."
}); });
} }
// Generate new JWT var newJwtToken = _tokenService.GenerateToken(
var newJwtToken = _tokenService.GenerateToken(tokenUser.IdUser, tokenUser.Email!, tokenUser.RoleInternalName); programUser.IdUser,
programUser.Email!,
programUser.RoleInternalName,
programUser.IdProgram);
var jwtExpiresIn = _tokenService.GetTokenExpiryInSeconds(newJwtToken); var jwtExpiresIn = _tokenService.GetTokenExpiryInSeconds(newJwtToken);
// Generate new refresh token (rotation)
var newSecretToken = Guid.NewGuid().ToString(); var newSecretToken = Guid.NewGuid().ToString();
var (newRefreshTokenHash, newRefreshTokenSalt) = PasswordHasher.HashPassword(newSecretToken); var (newRefreshTokenHash, newRefreshTokenSalt) = PasswordHasher.HashPassword(newSecretToken);
@@ -224,7 +314,8 @@ public class AuthController : BaseController
var newRefreshTokenId = await _authRepo.ReplaceRefreshTokenAsync( var newRefreshTokenId = await _authRepo.ReplaceRefreshTokenAsync(
matchedToken.IdRefreshToken, matchedToken.IdRefreshToken,
Guid.NewGuid(), Guid.NewGuid(),
tokenUser.IdUser, programUser.IdUser,
programUser.IdProgram,
newRefreshTokenHash, newRefreshTokenHash,
newRefreshTokenSalt, newRefreshTokenSalt,
expiresInSeconds: 2592000, expiresInSeconds: 2592000,
@@ -0,0 +1,6 @@
namespace WinStudentGoalTracker.DataAccess;
public class SelectProgramDto
{
public required string ProgramId { get; set; }
}
@@ -0,0 +1,13 @@
namespace WinStudentGoalTracker.DataAccess;
public class dbProgramUser
{
public required Guid IdUser { get; set; }
public string? Email { get; set; }
public string? Name { get; set; }
public required Guid IdProgram { get; set; }
public string? ProgramName { get; set; }
public required string RoleInternalName { get; set; }
public required string RoleDisplayName { get; set; }
public string? Status { get; set; }
}
@@ -4,6 +4,7 @@ public class dbRefreshToken
{ {
public Guid IdRefreshToken { get; set; } public Guid IdRefreshToken { get; set; }
public Guid IdUser { get; set; } public Guid IdUser { get; set; }
public Guid IdProgram { get; set; }
public required string TokenHash { get; set; } public required string TokenHash { get; set; }
public required string TokenSalt { get; set; } public required string TokenSalt { get; set; }
public DateTime ExpiresAt { get; set; } public DateTime ExpiresAt { get; set; }
@@ -3,7 +3,6 @@ namespace WinStudentGoalTracker.DataAccess;
public class dbUser public class dbUser
{ {
public required Guid IdUser { get; set; } public required Guid IdUser { get; set; }
public Guid? IdRole { get; set; }
public string? Email { get; set; } public string? Email { get; set; }
public string? Name { get; set; } public string? Name { get; set; }
public string? PasswordHash { get; set; } public string? PasswordHash { get; set; }
@@ -11,6 +10,4 @@ public class dbUser
public int FailedLoginAttempts { get; set; } public int FailedLoginAttempts { get; set; }
public DateTime? LockedUntil { get; set; } public DateTime? LockedUntil { get; set; }
public DateTime? CreatedAt { get; set; } public DateTime? CreatedAt { get; set; }
public required string RoleInternalName { get; set; }
public required string RoleDisplayName { get; set; }
} }
@@ -0,0 +1,11 @@
namespace WinStudentGoalTracker.DataAccess;
public class dbUserProgram
{
public required Guid IdProgram { get; set; }
public string? ProgramName { get; set; }
public required string RoleInternalName { get; set; }
public required string RoleDisplayName { get; set; }
public bool IsPrimary { get; set; }
public string? Status { get; set; }
}
@@ -11,6 +11,7 @@ public class AuthRepository
public async Task<Guid?> CreateRefreshTokenAsync( public async Task<Guid?> CreateRefreshTokenAsync(
Guid refreshTokenId, Guid refreshTokenId,
Guid userId, Guid userId,
Guid programId,
string tokenHash, string tokenHash,
string tokenSalt, string tokenSalt,
int expiresInSeconds, int expiresInSeconds,
@@ -24,6 +25,7 @@ public class AuthRepository
{ {
p_id_refresh_token = refreshTokenId.ToString(), p_id_refresh_token = refreshTokenId.ToString(),
p_id_user = userId.ToString(), p_id_user = userId.ToString(),
p_id_program = programId.ToString(),
p_token_hash = tokenHash, p_token_hash = tokenHash,
p_token_salt = tokenSalt, p_token_salt = tokenSalt,
p_expires_in_seconds = expiresInSeconds, p_expires_in_seconds = expiresInSeconds,
@@ -57,6 +59,7 @@ public class AuthRepository
Guid oldTokenId, Guid oldTokenId,
Guid newTokenId, Guid newTokenId,
Guid userId, Guid userId,
Guid programId,
string tokenHash, string tokenHash,
string tokenSalt, string tokenSalt,
int expiresInSeconds, int expiresInSeconds,
@@ -71,6 +74,7 @@ public class AuthRepository
p_old_token_id = oldTokenId.ToString(), p_old_token_id = oldTokenId.ToString(),
p_id_refresh_token = newTokenId.ToString(), p_id_refresh_token = newTokenId.ToString(),
p_id_user = userId.ToString(), p_id_user = userId.ToString(),
p_id_program = programId.ToString(),
p_token_hash = tokenHash, p_token_hash = tokenHash,
p_token_salt = tokenSalt, p_token_salt = tokenSalt,
p_expires_in_seconds = expiresInSeconds, p_expires_in_seconds = expiresInSeconds,
@@ -25,4 +25,22 @@ public class UserRepository
new { p_id_user = idUser.ToString() }, new { p_id_user = idUser.ToString() },
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
public async Task<dbProgramUser?> GetByIdWithProgramAsync(Guid idUser, Guid idProgram)
{
using var db = Connection;
return await db.QuerySingleOrDefaultAsync<dbProgramUser>(
"sp_User_GetById_WithProgram",
new { p_id_user = idUser.ToString(), p_id_program = idProgram.ToString() },
commandType: CommandType.StoredProcedure);
}
public async Task<IEnumerable<dbUserProgram>> GetProgramsForUserIdAsync(Guid idUser)
{
using var db = Connection;
return await db.QueryAsync<dbUserProgram>(
"sp_UserPrograms_GetByUserId",
new { p_id_user = idUser.ToString() },
commandType: CommandType.StoredProcedure);
}
} }
+11 -6
View File
@@ -2,10 +2,15 @@ namespace WinStudentGoalTracker.Models;
public class LoginResponse public class LoginResponse
{ {
public Guid UserId { get; set; } public required string SessionToken { get; set; }
public required string Email { get; set; } public required List<UserProgramSummary> Programs { get; set; }
public required string Jwt { get; set; } }
public required string RefreshToken { get; set; }
public string? Role { get; set; } public class UserProgramSummary
public string? RoleDisplayName { get; set; } {
public Guid ProgramId { get; set; }
public required string ProgramName { get; set; }
public required string Role { get; set; }
public required string RoleDisplayName { get; set; }
public bool IsPrimary { get; set; }
} }
@@ -0,0 +1,13 @@
namespace WinStudentGoalTracker.Models;
public class SelectProgramResponse
{
public Guid UserId { get; set; }
public required string Email { get; set; }
public required string ProgramName { get; set; }
public required string Jwt { get; set; }
public required string RefreshToken { get; set; }
public required string Role { get; set; }
public required string RoleDisplayName { get; set; }
public int JwtExpiresIn { get; set; }
}
+31 -7
View File
@@ -10,15 +10,42 @@ public class TokenService
{ {
private readonly IConfiguration _config; private readonly IConfiguration _config;
private readonly int _tokenExpiryInSeconds = 60 * 15; // 15 minutes private readonly int _tokenExpiryInSeconds = 60 * 15; // 15 minutes
private readonly int _sessionTokenExpiryInSeconds = 60 * 5; // 5 minutes
public TokenService(IConfiguration config) public TokenService(IConfiguration config)
{ {
_config = config; _config = config;
} }
public string GenerateToken(Guid userId, string email, string role) // Phase 1: short-lived token with no program/role scope, only valid for SelectProgram
public string GenerateSessionToken(Guid userId, string email)
{ {
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.Email, email),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim("user_id", userId.ToString()),
new Claim("auth_stage", "selecting_program")
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
audience: null,
claims: claims,
expires: DateTime.UtcNow.AddSeconds(_sessionTokenExpiryInSeconds),
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
// Phase 2: full program-scoped token
public string GenerateToken(Guid userId, string email, string role, Guid programId)
{
if (UserRoles.TryParse(role) is null) if (UserRoles.TryParse(role) is null)
{ {
throw new ArgumentException("Invalid role name"); throw new ArgumentException("Invalid role name");
@@ -29,14 +56,11 @@ public class TokenService
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.Email, email), new Claim(JwtRegisteredClaimNames.Email, email),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim("user_id", userId.ToString()) new Claim("user_id", userId.ToString()),
new Claim("program_id", programId.ToString()),
new Claim(ClaimTypes.Role, role)
}; };
if (role is not null)
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]!)); var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
@@ -0,0 +1,18 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_UserPrograms_GetByUserId`(IN p_id_user CHAR(36))
BEGIN
SELECT
up.id_program,
p.name AS program_name,
r.internal_name AS role_internal_name,
r.name AS role_display_name,
up.is_primary,
up.status
FROM user_program up
JOIN program p ON up.id_program = p.id_program
JOIN role r ON up.id_role = r.id_role
WHERE up.id_user = p_id_user
AND up.status = 'active'
ORDER BY up.is_primary DESC, p.name ASC;
END;;
DELIMITER ;
@@ -0,0 +1,23 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_User_GetById_WithProgram`(
IN p_id_user CHAR(36),
IN p_id_program CHAR(36)
)
BEGIN
SELECT
u.id_user,
u.email,
u.name,
up.id_program,
p.name AS program_name,
r.internal_name AS role_internal_name,
r.name AS role_display_name,
up.status
FROM `user` u
JOIN user_program up ON u.id_user = up.id_user AND up.id_program = p_id_program
JOIN role r ON up.id_role = r.id_role
JOIN program p ON up.id_program = p.id_program
WHERE u.id_user = p_id_user
LIMIT 1;
END;;
DELIMITER ;