Major refactor of AdminController and related services to support full CRUD for candidate resume, projects, hobbies, skills, academics, experiences, certifications, and contact info, all using access token claims for candidate identity. Introduced AdminService and expanded IResumeRepository for granular entity management. Updated DTOs for upsert operations and date support. Improved API versioning (Asp.Versioning.Mvc), Swagger integration, and middleware setup. Added unit test project. Enhanced error handling, documentation, and mapping.
193 lines
8.5 KiB
C#
193 lines
8.5 KiB
C#
using KBR.Cache;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using OtpNet;
|
|
using PortBlog.API.DbContexts;
|
|
using PortBlog.API.Entities;
|
|
using PortBlog.API.Services.Contracts;
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
namespace PortBlog.API.Services
|
|
{
|
|
/// <summary>
|
|
/// Provides authentication services such as OTP generation, validation, and JWT token management.
|
|
/// </summary>
|
|
public class AuthService(IConfiguration configuration, IAppDistributedCache cache, CvBlogContext context) : IAuthService
|
|
{
|
|
/// <summary>
|
|
/// Generates a one-time password (OTP) for the specified email address and stores the secret key in the cache.
|
|
/// </summary>
|
|
/// <param name="email">The email address for which to generate the OTP.</param>
|
|
/// <param name="ct">A cancellation token that can be used to cancel the operation.</param>
|
|
/// <returns>A task that represents the asynchronous operation. The task result contains the generated OTP as a string.</returns>
|
|
public async Task<string> GenerateOtp(string email, CancellationToken ct = default)
|
|
{
|
|
var secretKey = KeyGeneration.GenerateRandomKey(20);
|
|
var secretKeyBase32 = Base32Encoding.ToString(secretKey);
|
|
var otp = this.GenerateOtp(secretKeyBase32);
|
|
|
|
await CacheHelpers.SetUserSecretsCacheAsync(cache, email, secretKeyBase32, ct);
|
|
|
|
return otp;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates the provided OTP against the secret key.
|
|
/// </summary>
|
|
/// <param name="secretKey">The Base32 encoded secret key.</param>
|
|
/// <param name="otp">The OTP to validate.</param>
|
|
/// <returns>True if the OTP is valid; otherwise, false.</returns>
|
|
public bool ValidateOtp(string secretKey, string otp)
|
|
{
|
|
var key = Base32Encoding.ToBytes(secretKey);
|
|
var otpExpiryInMinutes = configuration.GetValue<int>("OtpExpiryInMinutes", 3);
|
|
var totp = new Totp(key, step: otpExpiryInMinutes * 60);
|
|
|
|
return totp.VerifyTotp(DateTime.UtcNow, otp, out long _, VerificationWindow.RfcSpecifiedNetworkDelay);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a JWT access token for the specified username.
|
|
/// </summary>
|
|
/// <param name="username">The username for which to generate the access token.</param>
|
|
/// <returns>The generated JWT access token as a string.</returns>
|
|
public string GenerateAccessToken(string username)
|
|
{
|
|
var candidate = context.Candidates.FirstOrDefault(c => c.Email == username);
|
|
|
|
var claims = new List<Claim>
|
|
{
|
|
new Claim(ClaimTypes.Name, username),
|
|
new Claim(ClaimTypes.Role, "Admin")
|
|
};
|
|
|
|
if (candidate != null)
|
|
{
|
|
claims.Add(new Claim("CandidateId", candidate.CandidateId.ToString()));
|
|
}
|
|
|
|
var jwtKey = configuration["Jwt:Key"];
|
|
if (string.IsNullOrEmpty(jwtKey))
|
|
throw new InvalidOperationException("JWT key is not configured.");
|
|
|
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
|
|
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
|
|
|
// Safely read expiry minutes from configuration with a fallback default
|
|
var mins = configuration.GetValue<int?>("Jwt:AccessTokenExpiryInMinutes") ?? 5;
|
|
|
|
var token = new JwtSecurityToken(
|
|
issuer: configuration["Jwt:Issuer"],
|
|
audience: configuration["Jwt:Audience"],
|
|
claims: claims,
|
|
expires: DateTime.UtcNow.AddMinutes(mins),
|
|
signingCredentials: creds
|
|
);
|
|
|
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a secure random refresh token as a Base64-encoded string.
|
|
/// </summary>
|
|
/// <returns>The generated refresh token.</returns>
|
|
public string GenerateRefreshToken()
|
|
{
|
|
return Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves a refresh token for the specified user email and associates it with the current HTTP context.
|
|
/// </summary>
|
|
/// <param name="userEmail">The email address of the user.</param>
|
|
/// <param name="refreshToken">The refresh token to be saved.</param>
|
|
/// <param name="httpContext">The current HTTP context containing request information.</param>
|
|
/// <returns>A task that represents the asynchronous save operation.</returns>
|
|
public async Task SaveRefreshToken(string userEmail, string refreshToken, HttpContext httpContext)
|
|
{
|
|
var user = await GetUser(userEmail) ?? throw new InvalidOperationException("User not found.");
|
|
|
|
// Store hashed refresh token in DB
|
|
var hashedToken = BCrypt.Net.BCrypt.HashPassword(refreshToken);
|
|
context.RefreshTokens.Add(new RefreshToken
|
|
{
|
|
UserId = user.UserId,
|
|
Token = hashedToken,
|
|
ExpiryDate = DateTime.UtcNow.AddHours(configuration.GetValue<int>("Jwt:RefreshTokenExpiryInHours", 24)),
|
|
JwtId = Guid.NewGuid().ToString(),
|
|
DeviceInfo = httpContext.Request.Headers.UserAgent.ToString()
|
|
});
|
|
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves a refresh token entity to the database.
|
|
/// </summary>
|
|
/// <param name="refreshToken">The refresh token entity to save.</param>
|
|
/// <returns>A task that represents the asynchronous save operation.</returns>
|
|
public async Task SaveRefreshToken(RefreshToken refreshToken)
|
|
{
|
|
context.RefreshTokens.Add(refreshToken);
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves a valid, non-revoked, and non-expired refresh token entity matching the provided refresh token string.
|
|
/// </summary>
|
|
/// <param name="refreshToken">The refresh token string to validate and retrieve.</param>
|
|
/// <returns>
|
|
/// A task that represents the asynchronous operation. The task result contains the matching <see cref="RefreshToken"/> entity if found and valid; otherwise, throws <see cref="InvalidOperationException"/>.
|
|
/// </returns>
|
|
/// <exception cref="InvalidOperationException">Thrown if no valid refresh token is found.</exception>
|
|
public async Task<RefreshToken> GetRefreshToken(string refreshToken)
|
|
{
|
|
// Find user and validate refresh token
|
|
var refreshEntity = await context.RefreshTokens
|
|
.Include(rt => rt.User)
|
|
.Where(rt => !rt.Revoked && rt.ExpiryDate > DateTime.UtcNow)
|
|
.ToListAsync();
|
|
|
|
var token = refreshEntity.FirstOrDefault(rt => BCrypt.Net.BCrypt.Verify(refreshToken, rt.Token)) ?? throw new InvalidOperationException("Refresh token not found or invalid.");
|
|
return token;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Revokes (removes) a refresh token by marking it as revoked in the database if it is valid and not expired.
|
|
/// </summary>
|
|
/// <param name="refreshToken">The refresh token to revoke.</param>
|
|
/// <returns>A task that represents the asynchronous revoke operation.</returns>
|
|
public async Task RemoveRefreshToken(string refreshToken)
|
|
{
|
|
var tokens = await context.RefreshTokens
|
|
.Where(rt => rt.Revoked == false && rt.ExpiryDate > DateTime.UtcNow)
|
|
.ToListAsync();
|
|
|
|
var refreshTokenEntity = tokens
|
|
.FirstOrDefault(rt => BCrypt.Net.BCrypt.Verify(refreshToken, rt.Token));
|
|
|
|
if (refreshTokenEntity != null)
|
|
{
|
|
refreshTokenEntity.Revoked = true;
|
|
await context.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
private async Task<User?> GetUser(string userEmail)
|
|
{
|
|
return await context.Users.FirstOrDefaultAsync(u => u.Email == userEmail);
|
|
}
|
|
|
|
private string GenerateOtp(string secretKey)
|
|
{
|
|
var key = Base32Encoding.ToBytes(secretKey);
|
|
var otpExpiryInMinutes = configuration.GetValue<int>("OtpExpiryInMinutes", 3);
|
|
var totp = new Totp(key, otpExpiryInMinutes * 60);
|
|
return totp.ComputeTotp(DateTime.UtcNow);
|
|
}
|
|
}
|
|
}
|