This commit is contained in:
2026-02-20 20:10:46 -08:00
parent 27fb986f58
commit b7a78b7bb9
29 changed files with 999 additions and 33 deletions
+35
View File
@@ -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<TokenService>();
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();
+2
View File
@@ -8,8 +8,10 @@
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
<PackageReference Include="MySql.Data" Version="8.4.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.0" />
</ItemGroup>
</Project>
+4
View File
@@ -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",
+311
View File
@@ -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<LoginResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ResponseResult<LoginResponse>), StatusCodes.Status400BadRequest)]
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))
{
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."
});
}
// 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<LoginResponse>
{
Success = false,
Message = "Failed to create refresh token."
});
}
// Build full refresh token: {id}.{secret}
var fullRefreshToken = $"{refreshTokenId.Value}.{secretToken}";
return Ok(new ResponseResult<LoginResponse>
{
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<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."
});
}
// Split token into ID and secret: {id}.{secret}
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 (!int.TryParse(tokenIdStr, out int 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."
});
}
var tokenUser = await _userRepo.GetByIdAsync(matchedToken.IdUser);
if (tokenUser == null)
{
return Unauthorized(new ResponseResult<TokenRefreshResponse>
{
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<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 = newJwtToken,
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, 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<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."
});
}
}
@@ -0,0 +1,7 @@
namespace WinStudentGoalTracker.DataAccess;
public class LoginDto
{
public string? Email { get; set; }
public string? Password { get; set; }
}
@@ -0,0 +1,6 @@
namespace WinStudentGoalTracker.DataAccess;
public class RefreshTokenDto
{
public string? RefreshToken { get; set; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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<int?> CreateRefreshTokenAsync(
int userId,
string tokenHash,
string tokenSalt,
int expiresInSeconds,
string? deviceInfo,
string? userAgent)
{
using var db = Connection;
return await db.QuerySingleOrDefaultAsync<int?>(
"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<dbRefreshToken?> GetRefreshTokenByIdAsync(int refreshTokenId)
{
using var db = Connection;
return await db.QuerySingleOrDefaultAsync<dbRefreshToken>(
"sp_RefreshToken_GetById",
new { p_id_refresh_token = refreshTokenId },
commandType: CommandType.StoredProcedure);
}
public async Task<bool> RevokeRefreshTokenAsync(int refreshTokenId)
{
using var db = Connection;
var rowsAffected = await db.QuerySingleOrDefaultAsync<int>(
"sp_RefreshToken_Revoke",
new { p_id_refresh_token = refreshTokenId },
commandType: CommandType.StoredProcedure);
return rowsAffected > 0;
}
public async Task<int?> ReplaceRefreshTokenAsync(
int oldTokenId,
int userId,
string tokenHash,
string tokenSalt,
int expiresInSeconds,
string? deviceInfo,
string? userAgent)
{
using var db = Connection;
return await db.QuerySingleOrDefaultAsync<int?>(
"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);
}
}
@@ -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<dbUser?> GetByEmailAsync(string email)
{
using var db = Connection;
return await db.QuerySingleOrDefaultAsync<dbUser>(
"sp_User_GetByEmail",
new { p_email = email },
commandType: CommandType.StoredProcedure);
}
public async Task<dbUser?> GetByIdAsync(int idUser)
{
using var db = Connection;
return await db.QuerySingleOrDefaultAsync<dbUser>(
"sp_User_GetById",
new { p_id_user = idUser },
commandType: CommandType.StoredProcedure);
}
}
@@ -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; }
}
@@ -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; }
}
+37
View File
@@ -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));
}
}
+66
View File
@@ -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<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())
};
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;
}
}
}