diff --git a/.gitignore b/.gitignore index be47843..91cbf29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store .env +DraftAPI/ diff --git a/api/Program.cs b/api/Program.cs index c4dff81..ebd05eb 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,9 +1,40 @@ +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; using WinStudentGoalTracker.Api.Configuration; +using WinStudentGoalTracker.Services; var builder = WebApplication.CreateBuilder(args); ConfigHelper.Configuration = builder.Configuration; +var jwtKey = builder.Configuration["Jwt:Key"] ?? "super_secret_key_change_me_in_production_123!"; +var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "WinStudentGoalTrackerAPI"; + +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}) +.AddJwtBearer(options => +{ + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtIssuer, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), + ClockSkew = TimeSpan.Zero, + RoleClaimType = System.Security.Claims.ClaimTypes.Role + }; +}); + +builder.Services.AddAuthorization(); + +builder.Services.AddScoped(); + builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); @@ -25,6 +56,10 @@ if (app.Environment.IsDevelopment()) app.UseCors(); app.UseHttpsRedirection(); + +app.UseAuthentication(); +app.UseAuthorization(); + app.MapControllers(); app.Run(); diff --git a/api/api.csproj b/api/api.csproj index 4b8535c..ab39f78 100644 --- a/api/api.csproj +++ b/api/api.csproj @@ -8,8 +8,10 @@ + + diff --git a/api/appsettings.json b/api/appsettings.json index a76fc22..1e6f72a 100644 --- a/api/appsettings.json +++ b/api/appsettings.json @@ -2,6 +2,10 @@ "ConnectionStrings": { "DefaultConnection": "Server=localhost;Port=3306;Database=win_student_goal_tracker;Uid=root;Pwd=change_me;" }, + "Jwt": { + "Key": "super_secret_key_change_me_in_production_123!", + "Issuer": "WinStudentGoalTrackerAPI" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/api/src/Controllers/AuthController.cs b/api/src/Controllers/AuthController.cs new file mode 100644 index 0000000..a4e5488 --- /dev/null +++ b/api/src/Controllers/AuthController.cs @@ -0,0 +1,311 @@ +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; + + public AuthController(TokenService tokenService) + { + _tokenService = tokenService; + } + + [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 + { + 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." + }); + } + + // Generate JWT access token + var accessToken = _tokenService.GenerateToken(user.IdUser, user.Email!, user.RoleName); + + // Generate refresh token secret + var secretToken = Guid.NewGuid().ToString(); + var (refreshTokenHash, refreshTokenSalt) = PasswordHasher.HashPassword(secretToken); + + // Get device info + 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( + user.IdUser, + refreshTokenHash, + refreshTokenSalt, + expiresInSeconds: 2592000, // 30 days + deviceInfo: deviceInfo, + userAgent: userAgent + ); + + if (!refreshTokenId.HasValue) + { + return Ok(new ResponseResult + { + Success = false, + Message = "Failed to create refresh token." + }); + } + + // Build full refresh token: {id}.{secret} + var fullRefreshToken = $"{refreshTokenId.Value}.{secretToken}"; + + return Ok(new ResponseResult + { + Success = true, + Message = "Login successful.", + Data = new LoginResponse + { + UserId = user.IdUser, + Email = user.Email!, + Jwt = accessToken, + RefreshToken = fullRefreshToken, + Role = user.RoleName + } + }); + } + + [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." + }); + } + + // Split token into ID and secret: {id}.{secret} + 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 (!int.TryParse(tokenIdStr, out int 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." + }); + } + + var tokenUser = await _userRepo.GetByIdAsync(matchedToken.IdUser); + if (tokenUser == null) + { + return Unauthorized(new ResponseResult + { + Success = false, + Message = "User not found." + }); + } + + // Generate new JWT + var newJwtToken = _tokenService.GenerateToken(tokenUser.IdUser, tokenUser.Email!, tokenUser.RoleName); + var jwtExpiresIn = _tokenService.GetTokenExpiryInSeconds(newJwtToken); + + // Generate new refresh token (rotation) + 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, + tokenUser.IdUser, + 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 = newJwtToken, + 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, error) = GetUserIdFromClaims(); + if (error != null) return error; + + var dotIndex = logoutDto.RefreshToken.IndexOf('.'); + if (dotIndex < 1 || !int.TryParse(logoutDto.RefreshToken[..dotIndex], out int 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." + }); + } +} diff --git a/api/src/DataAccess/Models/DataTransferObjects/LoginDto.cs b/api/src/DataAccess/Models/DataTransferObjects/LoginDto.cs new file mode 100644 index 0000000..1cc1368 --- /dev/null +++ b/api/src/DataAccess/Models/DataTransferObjects/LoginDto.cs @@ -0,0 +1,7 @@ +namespace WinStudentGoalTracker.DataAccess; + +public class LoginDto +{ + public string? Email { get; set; } + public string? Password { get; set; } +} diff --git a/api/src/DataAccess/Models/DataTransferObjects/RefreshTokenDto.cs b/api/src/DataAccess/Models/DataTransferObjects/RefreshTokenDto.cs new file mode 100644 index 0000000..2066e67 --- /dev/null +++ b/api/src/DataAccess/Models/DataTransferObjects/RefreshTokenDto.cs @@ -0,0 +1,6 @@ +namespace WinStudentGoalTracker.DataAccess; + +public class RefreshTokenDto +{ + public string? RefreshToken { get; set; } +} diff --git a/api/src/DataAccess/Models/DatabaseObjects/dbRefreshToken.cs b/api/src/DataAccess/Models/DatabaseObjects/dbRefreshToken.cs new file mode 100644 index 0000000..ea9dd95 --- /dev/null +++ b/api/src/DataAccess/Models/DatabaseObjects/dbRefreshToken.cs @@ -0,0 +1,17 @@ +namespace WinStudentGoalTracker.DataAccess; + +public class dbRefreshToken +{ + public int IdRefreshToken { get; set; } + public int IdUser { get; set; } + public required string TokenHash { get; set; } + public required string TokenSalt { get; set; } + public DateTime ExpiresAt { get; set; } + public DateTime LastUsedAt { get; set; } + public DateTime? RevokedAt { get; set; } + public string? DeviceInfo { get; set; } + public string? UserAgent { get; set; } + public int? ReplacedByTokenId { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/api/src/DataAccess/Models/DatabaseObjects/dbUser.cs b/api/src/DataAccess/Models/DatabaseObjects/dbUser.cs new file mode 100644 index 0000000..f2a56f0 --- /dev/null +++ b/api/src/DataAccess/Models/DatabaseObjects/dbUser.cs @@ -0,0 +1,15 @@ +namespace WinStudentGoalTracker.DataAccess; + +public class dbUser +{ + public required int IdUser { get; set; } + public int? IdRole { get; set; } + public string? Email { get; set; } + public string? Name { get; set; } + public string? PasswordHash { get; set; } + public string? PasswordSalt { get; set; } + public int FailedLoginAttempts { get; set; } + public DateTime? LockedUntil { get; set; } + public DateTime? CreatedAt { get; set; } + public string? RoleName { get; set; } +} diff --git a/api/src/DataAccess/Repositories/AuthRepository.cs b/api/src/DataAccess/Repositories/AuthRepository.cs new file mode 100644 index 0000000..d4b5f85 --- /dev/null +++ b/api/src/DataAccess/Repositories/AuthRepository.cs @@ -0,0 +1,77 @@ +using System.Data; +using Dapper; +using MySql.Data.MySqlClient; + +namespace WinStudentGoalTracker.DataAccess; + +public class AuthRepository +{ + private IDbConnection Connection => new MySqlConnection(DatabaseManager.ConnectionString); + + public async Task CreateRefreshTokenAsync( + int userId, + string tokenHash, + string tokenSalt, + int expiresInSeconds, + string? deviceInfo, + string? userAgent) + { + using var db = Connection; + return await db.QuerySingleOrDefaultAsync( + "sp_RefreshToken_Create", + new + { + p_id_user = userId, + p_token_hash = tokenHash, + p_token_salt = tokenSalt, + p_expires_in_seconds = expiresInSeconds, + p_device_info = deviceInfo, + p_user_agent = userAgent + }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetRefreshTokenByIdAsync(int refreshTokenId) + { + using var db = Connection; + return await db.QuerySingleOrDefaultAsync( + "sp_RefreshToken_GetById", + new { p_id_refresh_token = refreshTokenId }, + commandType: CommandType.StoredProcedure); + } + + public async Task RevokeRefreshTokenAsync(int refreshTokenId) + { + using var db = Connection; + var rowsAffected = await db.QuerySingleOrDefaultAsync( + "sp_RefreshToken_Revoke", + new { p_id_refresh_token = refreshTokenId }, + commandType: CommandType.StoredProcedure); + return rowsAffected > 0; + } + + public async Task ReplaceRefreshTokenAsync( + int oldTokenId, + int userId, + string tokenHash, + string tokenSalt, + int expiresInSeconds, + string? deviceInfo, + string? userAgent) + { + using var db = Connection; + return await db.QuerySingleOrDefaultAsync( + "sp_RefreshToken_Replace", + new + { + p_old_token_id = oldTokenId, + p_id_user = userId, + p_token_hash = tokenHash, + p_token_salt = tokenSalt, + p_expires_in_seconds = expiresInSeconds, + p_device_info = deviceInfo, + p_user_agent = userAgent + }, + commandType: CommandType.StoredProcedure); + } +} diff --git a/api/src/DataAccess/Repositories/UserRepository.cs b/api/src/DataAccess/Repositories/UserRepository.cs new file mode 100644 index 0000000..97421ea --- /dev/null +++ b/api/src/DataAccess/Repositories/UserRepository.cs @@ -0,0 +1,28 @@ +using System.Data; +using Dapper; +using MySql.Data.MySqlClient; + +namespace WinStudentGoalTracker.DataAccess; + +public class UserRepository +{ + private IDbConnection Connection => new MySqlConnection(DatabaseManager.ConnectionString); + + public async Task GetByEmailAsync(string email) + { + using var db = Connection; + return await db.QuerySingleOrDefaultAsync( + "sp_User_GetByEmail", + new { p_email = email }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetByIdAsync(int idUser) + { + using var db = Connection; + return await db.QuerySingleOrDefaultAsync( + "sp_User_GetById", + new { p_id_user = idUser }, + commandType: CommandType.StoredProcedure); + } +} diff --git a/api/src/Models/ResponseTypes/LoginResponse.cs b/api/src/Models/ResponseTypes/LoginResponse.cs new file mode 100644 index 0000000..dfdb325 --- /dev/null +++ b/api/src/Models/ResponseTypes/LoginResponse.cs @@ -0,0 +1,10 @@ +namespace WinStudentGoalTracker.Models; + +public class LoginResponse +{ + public int 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; } +} diff --git a/api/src/Models/ResponseTypes/TokenRefreshResponse.cs b/api/src/Models/ResponseTypes/TokenRefreshResponse.cs new file mode 100644 index 0000000..024e327 --- /dev/null +++ b/api/src/Models/ResponseTypes/TokenRefreshResponse.cs @@ -0,0 +1,8 @@ +namespace WinStudentGoalTracker.Models; + +public class TokenRefreshResponse +{ + public required string Jwt { get; set; } + public required string NewRefreshToken { get; set; } + public int JwtExpiresIn { get; set; } +} diff --git a/api/src/Services/PasswordHasher.cs b/api/src/Services/PasswordHasher.cs new file mode 100644 index 0000000..70d47aa --- /dev/null +++ b/api/src/Services/PasswordHasher.cs @@ -0,0 +1,37 @@ +using System.Security.Cryptography; +using System.Text; + +namespace WinStudentGoalTracker.Services; + +public class PasswordHasher +{ + private const int SaltSize = 16; // 128 bit + private const int HashSize = 32; // 256 bit + private const int Iterations = 100_000; + + public static (string Hash, string Salt) HashPassword(string password) + { + using var rng = RandomNumberGenerator.Create(); + byte[] saltBytes = new byte[SaltSize]; + rng.GetBytes(saltBytes); + + using var pbkdf2 = new Rfc2898DeriveBytes(password, saltBytes, Iterations, HashAlgorithmName.SHA256); + byte[] hashBytes = pbkdf2.GetBytes(HashSize); + + return (Convert.ToBase64String(hashBytes), Convert.ToBase64String(saltBytes)); + } + + public static bool VerifyPassword(string password, string storedHash, string storedSalt) + { + byte[] saltBytes = Convert.FromBase64String(storedSalt); + + using var pbkdf2 = new Rfc2898DeriveBytes(password, saltBytes, Iterations, HashAlgorithmName.SHA256); + byte[] hashBytes = pbkdf2.GetBytes(HashSize); + + string incomingHash = Convert.ToBase64String(hashBytes); + + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(incomingHash), + Encoding.UTF8.GetBytes(storedHash)); + } +} diff --git a/api/src/Services/TokenService.cs b/api/src/Services/TokenService.cs new file mode 100644 index 0000000..2512ea1 --- /dev/null +++ b/api/src/Services/TokenService.cs @@ -0,0 +1,66 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace WinStudentGoalTracker.Services; + +public class TokenService +{ + private readonly IConfiguration _config; + private readonly int _tokenExpiryInSeconds = 60 * 15; // 15 minutes + + public TokenService(IConfiguration config) + { + _config = config; + } + + public string GenerateToken(int userId, string email, string? roleName) + { + 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()) + }; + + if (!string.IsNullOrWhiteSpace(roleName)) + { + claims.Add(new Claim(ClaimTypes.Role, roleName)); + } + + 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(_tokenExpiryInSeconds), + signingCredentials: creds + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public int GetTokenExpiryInSeconds(string token) + { + try + { + var tokenHandler = new JwtSecurityTokenHandler(); + var jwtToken = tokenHandler.ReadJwtToken(token); + + var expiryTime = jwtToken.ValidTo; + var currentTime = DateTime.UtcNow; + + var timeUntilExpiry = expiryTime - currentTime; + + return timeUntilExpiry.TotalSeconds > 0 ? (int)timeUntilExpiry.TotalSeconds : 0; + } + catch + { + return 0; + } + } +} diff --git a/db/Objects/procedures/sp_RefreshToken_Create.sql b/db/Objects/procedures/sp_RefreshToken_Create.sql new file mode 100644 index 0000000..8eae78b --- /dev/null +++ b/db/Objects/procedures/sp_RefreshToken_Create.sql @@ -0,0 +1,31 @@ +DELIMITER ;; +CREATE DEFINER=`root`@`%` PROCEDURE `sp_RefreshToken_Create`( + IN p_id_user INT, + IN p_token_hash VARCHAR(512), + IN p_token_salt VARCHAR(512), + IN p_expires_in_seconds INT, + IN p_device_info VARCHAR(255), + IN p_user_agent VARCHAR(512) +) +BEGIN + INSERT INTO refresh_token + ( + id_user, + token_hash, + token_salt, + expires_at, + device_info, + user_agent + ) + VALUES + ( + p_id_user, + p_token_hash, + p_token_salt, + DATE_ADD(UTC_TIMESTAMP(), INTERVAL p_expires_in_seconds SECOND), + p_device_info, + p_user_agent + ); + SELECT LAST_INSERT_ID() AS id_refresh_token; +utf8mb4_0900_ai_ci;; +DELIMITER ; diff --git a/db/Objects/procedures/sp_RefreshToken_GetById.sql b/db/Objects/procedures/sp_RefreshToken_GetById.sql new file mode 100644 index 0000000..ca8559a --- /dev/null +++ b/db/Objects/procedures/sp_RefreshToken_GetById.sql @@ -0,0 +1,21 @@ +DELIMITER ;; +CREATE DEFINER=`root`@`%` PROCEDURE `sp_RefreshToken_GetById`(IN p_id_refresh_token INT) +BEGIN + SELECT + id_refresh_token, + id_user, + token_hash, + token_salt, + expires_at, + last_used_at, + revoked_at, + device_info, + user_agent, + replaced_by_token_id, + created_at, + updated_at + FROM refresh_token + WHERE id_refresh_token = p_id_refresh_token + LIMIT 1; +utf8mb4_0900_ai_ci;; +DELIMITER ; diff --git a/db/Objects/procedures/sp_RefreshToken_Replace.sql b/db/Objects/procedures/sp_RefreshToken_Replace.sql new file mode 100644 index 0000000..131fc6c --- /dev/null +++ b/db/Objects/procedures/sp_RefreshToken_Replace.sql @@ -0,0 +1,43 @@ +DELIMITER ;; +CREATE DEFINER=`root`@`%` PROCEDURE `sp_RefreshToken_Replace`( + IN p_old_token_id INT, + IN p_id_user INT, + IN p_token_hash VARCHAR(512), + IN p_token_salt VARCHAR(512), + IN p_expires_in_seconds INT, + IN p_device_info VARCHAR(255), + IN p_user_agent VARCHAR(512) +) +BEGIN + -- Revoke the old token + UPDATE refresh_token + SET revoked_at = UTC_TIMESTAMP() + WHERE id_refresh_token = p_old_token_id + AND revoked_at IS NULL; + -- Create the new token + INSERT INTO refresh_token + ( + id_user, + token_hash, + token_salt, + expires_at, + device_info, + user_agent + ) + VALUES + ( + p_id_user, + p_token_hash, + p_token_salt, + DATE_ADD(UTC_TIMESTAMP(), INTERVAL p_expires_in_seconds SECOND), + p_device_info, + p_user_agent + ); + -- Link old token to new one + SET @new_id = LAST_INSERT_ID(); + UPDATE refresh_token + SET replaced_by_token_id = @new_id + WHERE id_refresh_token = p_old_token_id; + SELECT @new_id AS id_refresh_token; +utf8mb4_0900_ai_ci;; +DELIMITER ; diff --git a/db/Objects/procedures/sp_RefreshToken_Revoke.sql b/db/Objects/procedures/sp_RefreshToken_Revoke.sql new file mode 100644 index 0000000..d6a6889 --- /dev/null +++ b/db/Objects/procedures/sp_RefreshToken_Revoke.sql @@ -0,0 +1,10 @@ +DELIMITER ;; +CREATE DEFINER=`root`@`%` PROCEDURE `sp_RefreshToken_Revoke`(IN p_id_refresh_token INT) +BEGIN + UPDATE refresh_token + SET revoked_at = UTC_TIMESTAMP() + WHERE id_refresh_token = p_id_refresh_token + AND revoked_at IS NULL; + SELECT ROW_COUNT() AS rows_affected; +utf8mb4_0900_ai_ci;; +DELIMITER ; diff --git a/db/Objects/procedures/sp_Student_Delete.sql b/db/Objects/procedures/sp_Student_Delete.sql index 0599406..94fdd33 100644 --- a/db/Objects/procedures/sp_Student_Delete.sql +++ b/db/Objects/procedures/sp_Student_Delete.sql @@ -1,12 +1,8 @@ -DROP PROCEDURE IF EXISTS sp_Student_Delete; -DELIMITER $$ - -CREATE PROCEDURE sp_Student_Delete(IN p_id_student INT) +DELIMITER ;; +CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_Delete`(IN p_id_student INT) BEGIN DELETE FROM student WHERE id_student = p_id_student; - SELECT ROW_COUNT() AS rows_affected; -END$$ - +utf8mb4_0900_ai_ci;; DELIMITER ; diff --git a/db/Objects/procedures/sp_Student_GetAll.sql b/db/Objects/procedures/sp_Student_GetAll.sql index a554bd7..0ebd11c 100644 --- a/db/Objects/procedures/sp_Student_GetAll.sql +++ b/db/Objects/procedures/sp_Student_GetAll.sql @@ -1,7 +1,5 @@ -DROP PROCEDURE IF EXISTS sp_Student_GetAll; -DELIMITER $$ - -CREATE PROCEDURE sp_Student_GetAll() +DELIMITER ;; +CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_GetAll`() BEGIN SELECT id_student, @@ -13,6 +11,5 @@ BEGIN created_at FROM student ORDER BY id_student; -END$$ - +utf8mb4_0900_ai_ci;; DELIMITER ; diff --git a/db/Objects/procedures/sp_Student_GetById.sql b/db/Objects/procedures/sp_Student_GetById.sql index fe1abaa..6b6ca0e 100644 --- a/db/Objects/procedures/sp_Student_GetById.sql +++ b/db/Objects/procedures/sp_Student_GetById.sql @@ -1,7 +1,5 @@ -DROP PROCEDURE IF EXISTS sp_Student_GetById; -DELIMITER $$ - -CREATE PROCEDURE sp_Student_GetById(IN p_id_student INT) +DELIMITER ;; +CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_GetById`(IN p_id_student INT) BEGIN SELECT id_student, @@ -14,6 +12,5 @@ BEGIN FROM student WHERE id_student = p_id_student LIMIT 1; -END$$ - +utf8mb4_0900_ai_ci;; DELIMITER ; diff --git a/db/Objects/procedures/sp_Student_Insert.sql b/db/Objects/procedures/sp_Student_Insert.sql index 11dce58..7e2714f 100644 --- a/db/Objects/procedures/sp_Student_Insert.sql +++ b/db/Objects/procedures/sp_Student_Insert.sql @@ -1,7 +1,5 @@ -DROP PROCEDURE IF EXISTS sp_Student_Insert; -DELIMITER $$ - -CREATE PROCEDURE sp_Student_Insert( +DELIMITER ;; +CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_Insert`( IN p_id_student INT, IN p_id_program INT, IN p_identifier VARCHAR(50), @@ -30,7 +28,6 @@ BEGIN p_expected_grad, UTC_TIMESTAMP() ); - SELECT id_student, id_program, @@ -42,6 +39,5 @@ BEGIN FROM student WHERE id_student = p_id_student LIMIT 1; -END$$ - +utf8mb4_0900_ai_ci;; DELIMITER ; diff --git a/db/Objects/procedures/sp_Student_Update.sql b/db/Objects/procedures/sp_Student_Update.sql index dec1524..b3f19f1 100644 --- a/db/Objects/procedures/sp_Student_Update.sql +++ b/db/Objects/procedures/sp_Student_Update.sql @@ -1,7 +1,5 @@ -DROP PROCEDURE IF EXISTS sp_Student_Update; -DELIMITER $$ - -CREATE PROCEDURE sp_Student_Update( +DELIMITER ;; +CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_Update`( IN p_id_student INT, IN p_id_program INT, IN p_identifier VARCHAR(50), @@ -18,8 +16,6 @@ BEGIN enrollment_date = COALESCE(p_enrollment_date, enrollment_date), expected_grad = COALESCE(p_expected_grad, expected_grad) WHERE id_student = p_id_student; - SELECT ROW_COUNT() AS rows_affected; -END$$ - +utf8mb4_0900_ai_ci;; DELIMITER ; diff --git a/db/Objects/procedures/sp_User_GetByEmail.sql b/db/Objects/procedures/sp_User_GetByEmail.sql new file mode 100644 index 0000000..3a0ea84 --- /dev/null +++ b/db/Objects/procedures/sp_User_GetByEmail.sql @@ -0,0 +1,20 @@ +DELIMITER ;; +CREATE DEFINER=`root`@`%` PROCEDURE `sp_User_GetByEmail`(IN p_email VARCHAR(255)) +BEGIN + SELECT + u.id_user, + u.id_role, + u.email, + u.name, + u.password_hash, + u.password_salt, + u.failed_login_attempts, + u.locked_until, + u.created_at, + r.name AS role_name + FROM `user` u + LEFT JOIN role r ON u.id_role = r.id_role + WHERE u.email = p_email + LIMIT 1; +utf8mb4_0900_ai_ci;; +DELIMITER ; diff --git a/db/Objects/procedures/sp_User_GetById.sql b/db/Objects/procedures/sp_User_GetById.sql new file mode 100644 index 0000000..2175613 --- /dev/null +++ b/db/Objects/procedures/sp_User_GetById.sql @@ -0,0 +1,20 @@ +DELIMITER ;; +CREATE DEFINER=`root`@`%` PROCEDURE `sp_User_GetById`(IN p_id_user INT) +BEGIN + SELECT + u.id_user, + u.id_role, + u.email, + u.name, + u.password_hash, + u.password_salt, + u.failed_login_attempts, + u.locked_until, + u.created_at, + r.name AS role_name + FROM `user` u + LEFT JOIN role r ON u.id_role = r.id_role + WHERE u.id_user = p_id_user + LIMIT 1; +utf8mb4_0900_ai_ci;; +DELIMITER ; diff --git a/db/Objects/tables/refresh_token.sql b/db/Objects/tables/refresh_token.sql new file mode 100644 index 0000000..36d92d9 --- /dev/null +++ b/db/Objects/tables/refresh_token.sql @@ -0,0 +1,20 @@ +CREATE TABLE `refresh_token` ( + `id_refresh_token` int NOT NULL AUTO_INCREMENT, + `id_user` int NOT NULL, + `token_hash` varchar(512) NOT NULL, + `token_salt` varchar(512) NOT NULL, + `expires_at` timestamp NOT NULL, + `last_used_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `revoked_at` timestamp NULL DEFAULT NULL, + `device_info` varchar(255) DEFAULT NULL, + `user_agent` varchar(512) DEFAULT NULL, + `replaced_by_token_id` int DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id_refresh_token`), + KEY `idx_refresh_token_user` (`id_user`), + KEY `idx_refresh_token_expires` (`expires_at`), + KEY `refresh_token_ibfk_2` (`replaced_by_token_id`), + CONSTRAINT `refresh_token_ibfk_1` FOREIGN KEY (`id_user`) REFERENCES `user` (`id_user`), + CONSTRAINT `refresh_token_ibfk_2` FOREIGN KEY (`replaced_by_token_id`) REFERENCES `refresh_token` (`id_refresh_token`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/db/Objects/tables/user.sql b/db/Objects/tables/user.sql index 633b7b7..33b9961 100644 --- a/db/Objects/tables/user.sql +++ b/db/Objects/tables/user.sql @@ -4,6 +4,7 @@ CREATE TABLE `user` ( `email` varchar(255) DEFAULT NULL, `name` varchar(255) DEFAULT NULL, `password_hash` varchar(255) DEFAULT NULL, + `password_salt` varchar(255) DEFAULT NULL, `password_updated_at` timestamp NULL DEFAULT NULL, `failed_login_attempts` int DEFAULT '0', `locked_until` timestamp NULL DEFAULT NULL, diff --git a/db/dump-objects.sh b/db/dump-objects.sh new file mode 100755 index 0000000..458ceb9 --- /dev/null +++ b/db/dump-objects.sh @@ -0,0 +1,194 @@ +#!/bin/bash +# dump-objects.sh + +MYSQL="mysql" +BASE_OUTPUT_DIR="$(cd "$(dirname "$0")" && pwd)/Objects" +DATABASE="winstudentgoaltracker" + +# Get password once +read -s -p "Enter MySQL password: " PASS +echo + +# Connection parameters +CONN_PARAMS=(-h 10.66.66.1 -P 3309 -u root -p"$PASS") + +# ============================================================================= +# CONNECTION TEST +# ============================================================================= +echo "Testing connection to MySQL..." +if ! $MYSQL "${CONN_PARAMS[@]}" -N -B --raw -e "SELECT 1" &>/dev/null; then + echo "" + echo "ERROR: Could not connect to MySQL." + echo "Check your password, host (10.66.66.1), port (3309), and that the MySQL server is reachable." + exit 1 +fi +echo "Connection OK." + +# Helper function to initialize output directory +initialize_output_dir() { + local path="$1" + if [ -d "$path" ]; then + rm -f "$path"/*.sql 2>/dev/null + else + mkdir -p "$path" + fi + echo "$path" +} + +# Helper function to run mysql and clean output +invoke_mysql_query() { + local query="$1" + $MYSQL "${CONN_PARAMS[@]}" -N -B --raw -e "$query" 2>/dev/null | tr -d '\r' | sed '/^$/d' +} + +# ============================================================================= +# TABLES (includes indexes; triggers handled separately) +# ============================================================================= +TABLE_DIR=$(initialize_output_dir "$BASE_OUTPUT_DIR/tables") + +echo "" +echo "Fetching table list..." +TABLES=() +while IFS= read -r line; do + [ -n "$line" ] && TABLES+=("$line") +done < <(invoke_mysql_query "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = '$DATABASE' AND TABLE_TYPE = 'BASE TABLE'") +echo "Found ${#TABLES[@]} tables" + +for table in "${TABLES[@]}"; do + table=$(echo "$table" | xargs) + [ -z "$table" ] && continue + echo " Dumping: $table" + + create_stmt=$(invoke_mysql_query "SHOW CREATE TABLE \`$DATABASE\`.\`$table\`" | cut -f2-) + if [ -z "$create_stmt" ]; then + echo "WARNING: Failed to dump table: $table" >&2 + continue + fi + + # Get triggers for this table + TRIGGERS=() + while IFS= read -r line; do + [ -n "$line" ] && TRIGGERS+=("$line") + done < <(invoke_mysql_query "SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE EVENT_OBJECT_SCHEMA = '$DATABASE' AND EVENT_OBJECT_TABLE = '$table'") + + trigger_sql="" + for trigger in "${TRIGGERS[@]}"; do + trigger=$(echo "$trigger" | xargs) + [ -z "$trigger" ] && continue + + trigger_def=$(invoke_mysql_query "SHOW CREATE TRIGGER \`$DATABASE\`.\`$trigger\`") + if [ -n "$trigger_def" ]; then + # SHOW CREATE TRIGGER: TriggerNamesql_modeCreateStatement... + trigger_create=$(echo "$trigger_def" | cut -f3 | sed 's/\t.*$//') + trigger_sql+=$'\n\nDELIMITER ;;\n'"$trigger_create"$';;\nDELIMITER ;' + fi + done + + printf '%s;%s\n' "$create_stmt" "$trigger_sql" > "$TABLE_DIR/$table.sql" +done + +# ============================================================================= +# VIEWS +# ============================================================================= +VIEW_DIR=$(initialize_output_dir "$BASE_OUTPUT_DIR/views") + +echo "" +echo "Fetching view list..." +VIEWS=() +while IFS= read -r line; do + [ -n "$line" ] && VIEWS+=("$line") +done < <(invoke_mysql_query "SELECT TABLE_NAME FROM information_schema.VIEWS WHERE TABLE_SCHEMA = '$DATABASE'") +echo "Found ${#VIEWS[@]} views" + +for view in "${VIEWS[@]}"; do + view=$(echo "$view" | xargs) + [ -z "$view" ] && continue + echo " Dumping: $view" + + create_stmt=$(invoke_mysql_query "SHOW CREATE VIEW \`$DATABASE\`.\`$view\`" | cut -f2 | sed 's/\t[^\t]*\t[^\t]*$//') + if [ -z "$create_stmt" ]; then + echo "WARNING: Failed to dump view: $view" >&2 + continue + fi + + # Basic formatting to break long lines + create_stmt=$(echo "$create_stmt" | sed \ + -e 's/ select /\nselect /g' \ + -e 's/ from /\nfrom /g' \ + -e 's/ left join /\nleft join /g' \ + -e 's/ inner join /\ninner join /g' \ + -e 's/ join /\njoin /g' \ + -e 's/ where /\nwhere /g' \ + -e 's/ and /\n and /g' \ + -e 's/ or /\n or /g') + + printf '%s;\n' "$create_stmt" > "$VIEW_DIR/$view.sql" +done + +# ============================================================================= +# FUNCTIONS +# ============================================================================= +FUNCTION_DIR=$(initialize_output_dir "$BASE_OUTPUT_DIR/functions") + +echo "" +echo "Fetching function list..." +FUNCTIONS=() +while IFS= read -r line; do + [ -n "$line" ] && FUNCTIONS+=("$line") +done < <(invoke_mysql_query "SELECT ROUTINE_NAME FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = '$DATABASE' AND ROUTINE_TYPE = 'FUNCTION'") +echo "Found ${#FUNCTIONS[@]} functions" + +for func in "${FUNCTIONS[@]}"; do + func=$(echo "$func" | xargs) + [ -z "$func" ] && continue + echo " Dumping: $func" + + # SHOW CREATE FUNCTION: FuncNamesql_modeCreateStatement... + create_stmt=$(invoke_mysql_query "SHOW CREATE FUNCTION \`$DATABASE\`.\`$func\`" | cut -f3 | sed 's/END\t.*/END/') + if [ -z "$create_stmt" ]; then + echo "WARNING: Failed to dump function: $func" >&2 + continue + fi + + printf 'DELIMITER ;;\n%s;;\nDELIMITER ;\n' "$create_stmt" > "$FUNCTION_DIR/$func.sql" +done + +# ============================================================================= +# PROCEDURES +# ============================================================================= +PROCEDURE_DIR=$(initialize_output_dir "$BASE_OUTPUT_DIR/procedures") + +echo "" +echo "Fetching procedure list..." +PROCS=() +while IFS= read -r line; do + [ -n "$line" ] && PROCS+=("$line") +done < <(invoke_mysql_query "SELECT ROUTINE_NAME FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = '$DATABASE' AND ROUTINE_TYPE = 'PROCEDURE'") +echo "Found ${#PROCS[@]} procedures" + +for proc in "${PROCS[@]}"; do + proc=$(echo "$proc" | xargs) + [ -z "$proc" ] && continue + echo " Dumping: $proc" + + # SHOW CREATE PROCEDURE: ProcNamesql_modeCreateStatement... + create_stmt=$(invoke_mysql_query "SHOW CREATE PROCEDURE \`$DATABASE\`.\`$proc\`" | cut -f3 | sed 's/END\t.*/END/') + if [ -z "$create_stmt" ]; then + echo "WARNING: Failed to dump procedure: $proc" >&2 + continue + fi + + printf 'DELIMITER ;;\n%s;;\nDELIMITER ;\n' "$create_stmt" > "$PROCEDURE_DIR/$proc.sql" +done + +# ============================================================================= +# SUMMARY +# ============================================================================= +echo "" +echo "==========================================" +echo "Done! Schema exported to: $BASE_OUTPUT_DIR" +echo " Tables: ${#TABLES[@]}" +echo " Views: ${#VIEWS[@]}" +echo " Functions: ${#FUNCTIONS[@]}" +echo " Procedures: ${#PROCS[@]}" +echo "=========================================="