diff --git a/api/src/BaseClasses/BaseController.cs b/api/src/BaseClasses/BaseController.cs index 83cb0c7..d17fa40 100644 --- a/api/src/BaseClasses/BaseController.cs +++ b/api/src/BaseClasses/BaseController.cs @@ -39,4 +39,16 @@ public class BaseController : ControllerBase { 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); + } } diff --git a/api/src/Controllers/AuthController.cs b/api/src/Controllers/AuthController.cs index 66b2a08..895204c 100644 --- a/api/src/Controllers/AuthController.cs +++ b/api/src/Controllers/AuthController.cs @@ -21,13 +21,12 @@ public class AuthController : BaseController _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) { - var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); - if (string.IsNullOrWhiteSpace(login.Email) || string.IsNullOrWhiteSpace(login.Password)) { return BadRequest(new ResponseResult @@ -74,21 +73,108 @@ public class AuthController : BaseController }); } - // Generate JWT access token - var accessToken = _tokenService.GenerateToken(user.IdUser, user.Email!, user.RoleInternalName); + var programs = await _userRepo.GetProgramsForUserIdAsync(user.IdUser); + var programList = programs.ToList(); - // Generate refresh token secret - var secretToken = Guid.NewGuid().ToString(); - var (refreshTokenHash, refreshTokenSalt) = PasswordHasher.HashPassword(secretToken); + if (programList.Count == 0) + { + return Ok(new ResponseResult + { + 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 + { + 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 (userId, userIdError) = GetUserIdFromClaims(); + if (userIdError != null) return userIdError; + + 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 }); - // Store refresh token in database (30 days expiration) var refreshTokenId = await _authRepo.CreateRefreshTokenAsync( Guid.NewGuid(), - user.IdUser, + programUser.IdUser, + programUser.IdProgram, refreshTokenHash, refreshTokenSalt, expiresInSeconds: 2592000, // 30 days @@ -98,28 +184,29 @@ public class AuthController : BaseController if (!refreshTokenId.HasValue) { - return Ok(new ResponseResult + return Ok(new ResponseResult { Success = false, Message = "Failed to create refresh token." }); } - // Build full refresh token: {id}.{secret} - var fullRefreshToken = $"{refreshTokenId.Value}.{secretToken}"; + var fullRefreshToken = $"{refreshTokenId.Value}.{refreshToken}"; - return Ok(new ResponseResult + return Ok(new ResponseResult { Success = true, - Message = "Login successful.", - Data = new LoginResponse + Message = "Program selected.", + Data = new SelectProgramResponse { - UserId = user.IdUser, - Email = user.Email!, + UserId = programUser.IdUser, + Email = programUser.Email!, + ProgramName = programUser.ProgramName!, Jwt = accessToken, RefreshToken = fullRefreshToken, - Role = user.RoleInternalName, - RoleDisplayName = user.RoleDisplayName + Role = programUser.RoleInternalName, + 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('.'); if (dotIndex < 1) { @@ -199,21 +285,25 @@ public class AuthController : BaseController }); } - var tokenUser = await _userRepo.GetByIdAsync(matchedToken.IdUser); - if (tokenUser == null) + // 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 not found." + Message = "User or program not found." }); } - // Generate new JWT - var newJwtToken = _tokenService.GenerateToken(tokenUser.IdUser, tokenUser.Email!, tokenUser.RoleInternalName); + var newJwtToken = _tokenService.GenerateToken( + programUser.IdUser, + programUser.Email!, + programUser.RoleInternalName, + programUser.IdProgram); + var jwtExpiresIn = _tokenService.GetTokenExpiryInSeconds(newJwtToken); - // Generate new refresh token (rotation) var newSecretToken = Guid.NewGuid().ToString(); var (newRefreshTokenHash, newRefreshTokenSalt) = PasswordHasher.HashPassword(newSecretToken); @@ -224,7 +314,8 @@ public class AuthController : BaseController var newRefreshTokenId = await _authRepo.ReplaceRefreshTokenAsync( matchedToken.IdRefreshToken, Guid.NewGuid(), - tokenUser.IdUser, + programUser.IdUser, + programUser.IdProgram, newRefreshTokenHash, newRefreshTokenSalt, expiresInSeconds: 2592000, diff --git a/api/src/DataAccess/Models/DataTransferObjects/SelectProgramDto.cs b/api/src/DataAccess/Models/DataTransferObjects/SelectProgramDto.cs new file mode 100644 index 0000000..a047f03 --- /dev/null +++ b/api/src/DataAccess/Models/DataTransferObjects/SelectProgramDto.cs @@ -0,0 +1,6 @@ +namespace WinStudentGoalTracker.DataAccess; + +public class SelectProgramDto +{ + public required string ProgramId { get; set; } +} diff --git a/api/src/DataAccess/Models/DatabaseObjects/dbProgramUser.cs b/api/src/DataAccess/Models/DatabaseObjects/dbProgramUser.cs new file mode 100644 index 0000000..7ed8f72 --- /dev/null +++ b/api/src/DataAccess/Models/DatabaseObjects/dbProgramUser.cs @@ -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; } +} diff --git a/api/src/DataAccess/Models/DatabaseObjects/dbRefreshToken.cs b/api/src/DataAccess/Models/DatabaseObjects/dbRefreshToken.cs index c54c65e..121771b 100644 --- a/api/src/DataAccess/Models/DatabaseObjects/dbRefreshToken.cs +++ b/api/src/DataAccess/Models/DatabaseObjects/dbRefreshToken.cs @@ -4,6 +4,7 @@ public class dbRefreshToken { public Guid IdRefreshToken { get; set; } public Guid IdUser { get; set; } + public Guid IdProgram { get; set; } public required string TokenHash { get; set; } public required string TokenSalt { get; set; } public DateTime ExpiresAt { get; set; } diff --git a/api/src/DataAccess/Models/DatabaseObjects/dbUser.cs b/api/src/DataAccess/Models/DatabaseObjects/dbUser.cs index cdf2af2..6196e1f 100644 --- a/api/src/DataAccess/Models/DatabaseObjects/dbUser.cs +++ b/api/src/DataAccess/Models/DatabaseObjects/dbUser.cs @@ -3,7 +3,6 @@ namespace WinStudentGoalTracker.DataAccess; public class dbUser { public required Guid IdUser { get; set; } - public Guid? IdRole { get; set; } public string? Email { get; set; } public string? Name { get; set; } public string? PasswordHash { get; set; } @@ -11,6 +10,4 @@ public class dbUser public int FailedLoginAttempts { get; set; } public DateTime? LockedUntil { get; set; } public DateTime? CreatedAt { get; set; } - public required string RoleInternalName { get; set; } - public required string RoleDisplayName { get; set; } } diff --git a/api/src/DataAccess/Models/DatabaseObjects/dbUserProgram.cs b/api/src/DataAccess/Models/DatabaseObjects/dbUserProgram.cs new file mode 100644 index 0000000..34a61d9 --- /dev/null +++ b/api/src/DataAccess/Models/DatabaseObjects/dbUserProgram.cs @@ -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; } +} diff --git a/api/src/DataAccess/Repositories/AuthRepository.cs b/api/src/DataAccess/Repositories/AuthRepository.cs index cad3d48..85ad5f9 100644 --- a/api/src/DataAccess/Repositories/AuthRepository.cs +++ b/api/src/DataAccess/Repositories/AuthRepository.cs @@ -11,6 +11,7 @@ public class AuthRepository public async Task CreateRefreshTokenAsync( Guid refreshTokenId, Guid userId, + Guid programId, string tokenHash, string tokenSalt, int expiresInSeconds, @@ -24,6 +25,7 @@ public class AuthRepository { p_id_refresh_token = refreshTokenId.ToString(), p_id_user = userId.ToString(), + p_id_program = programId.ToString(), p_token_hash = tokenHash, p_token_salt = tokenSalt, p_expires_in_seconds = expiresInSeconds, @@ -57,6 +59,7 @@ public class AuthRepository Guid oldTokenId, Guid newTokenId, Guid userId, + Guid programId, string tokenHash, string tokenSalt, int expiresInSeconds, @@ -71,6 +74,7 @@ public class AuthRepository p_old_token_id = oldTokenId.ToString(), p_id_refresh_token = newTokenId.ToString(), p_id_user = userId.ToString(), + p_id_program = programId.ToString(), p_token_hash = tokenHash, p_token_salt = tokenSalt, p_expires_in_seconds = expiresInSeconds, diff --git a/api/src/DataAccess/Repositories/UserRepository.cs b/api/src/DataAccess/Repositories/UserRepository.cs index ff679a7..0ae96f2 100644 --- a/api/src/DataAccess/Repositories/UserRepository.cs +++ b/api/src/DataAccess/Repositories/UserRepository.cs @@ -25,4 +25,22 @@ public class UserRepository new { p_id_user = idUser.ToString() }, commandType: CommandType.StoredProcedure); } + + public async Task GetByIdWithProgramAsync(Guid idUser, Guid idProgram) + { + using var db = Connection; + return await db.QuerySingleOrDefaultAsync( + "sp_User_GetById_WithProgram", + new { p_id_user = idUser.ToString(), p_id_program = idProgram.ToString() }, + commandType: CommandType.StoredProcedure); + } + + public async Task> GetProgramsForUserIdAsync(Guid idUser) + { + using var db = Connection; + return await db.QueryAsync( + "sp_UserPrograms_GetByUserId", + new { p_id_user = idUser.ToString() }, + commandType: CommandType.StoredProcedure); + } } diff --git a/api/src/Models/ResponseTypes/LoginResponse.cs b/api/src/Models/ResponseTypes/LoginResponse.cs index 7480e25..1222311 100644 --- a/api/src/Models/ResponseTypes/LoginResponse.cs +++ b/api/src/Models/ResponseTypes/LoginResponse.cs @@ -2,10 +2,15 @@ namespace WinStudentGoalTracker.Models; public class LoginResponse { - public Guid UserId { get; set; } - public required string Email { get; set; } - public required string Jwt { get; set; } - public required string RefreshToken { get; set; } - public string? Role { get; set; } - public string? RoleDisplayName { get; set; } + public required string SessionToken { get; set; } + public required List Programs { get; set; } +} + +public class UserProgramSummary +{ + 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; } } diff --git a/api/src/Models/ResponseTypes/SelectProgramResponse.cs b/api/src/Models/ResponseTypes/SelectProgramResponse.cs new file mode 100644 index 0000000..5104b7c --- /dev/null +++ b/api/src/Models/ResponseTypes/SelectProgramResponse.cs @@ -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; } +} diff --git a/api/src/Services/TokenService.cs b/api/src/Services/TokenService.cs index 3150bb6..9ebf761 100644 --- a/api/src/Services/TokenService.cs +++ b/api/src/Services/TokenService.cs @@ -10,15 +10,42 @@ public class TokenService { private readonly IConfiguration _config; private readonly int _tokenExpiryInSeconds = 60 * 15; // 15 minutes + private readonly int _sessionTokenExpiryInSeconds = 60 * 5; // 5 minutes public TokenService(IConfiguration 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 + { + 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) { throw new ArgumentException("Invalid role name"); @@ -29,14 +56,11 @@ public class TokenService 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("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 creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); diff --git a/db/Objects/procedures/sp_UserPrograms_GetByUserId.sql b/db/Objects/procedures/sp_UserPrograms_GetByUserId.sql new file mode 100644 index 0000000..4d9caa5 --- /dev/null +++ b/db/Objects/procedures/sp_UserPrograms_GetByUserId.sql @@ -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 ; diff --git a/db/Objects/procedures/sp_User_GetById_WithProgram.sql b/db/Objects/procedures/sp_User_GetById_WithProgram.sql new file mode 100644 index 0000000..cbe8926 --- /dev/null +++ b/db/Objects/procedures/sp_User_GetById_WithProgram.sql @@ -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 ;