using Microsoft.AspNetCore.Mvc; using Hasher; using Models; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.IdentityModel.Tokens; namespace backend.Controllers { [Route("api/auth")] [ApiController] public class AuthorizationController : ControllerBase { private readonly IConfiguration _config; private readonly ILogger _logger; private readonly PortfolioPortalDbContext _dbContext; public AuthorizationController(IConfiguration config, ILogger logger, PortfolioPortalDbContext dbContext) { _config = config; _logger = logger; _dbContext = dbContext; } /// /// Processes a user login request by checking the provided email and password against the database, /// generating a JWT if successful, and setting the appropriate cookies in the response. /// /// The login details submitted in the request body. /// An ActionResult indicating the success or failure of the login attempt. [HttpPost] public async Task ProcessLogin(LoginDetails requestBody) { // Log the request (password is censored) _logger.LogDebug($"Login request with body: {requestBody.ToString()}"); User? user; // Check for a user with that email in the database user = _dbContext.Users.FirstOrDefault(u => u.Email.ToLower() == requestBody.email.ToLower()); if (user != null) { _logger.LogDebug($"Found user: {user}"); // Make sure the user is enabled if (!user.IsEnabled) { _logger.LogWarning($"Disabled user {user.Username} attempted to log in."); return Unauthorized("Account disabled."); } // Check the password against the hash from the database if (Argon2dHasher.VerifyArgon2Hash(requestBody.password, user.PasswordHash)) { _logger.LogDebug($"{user.Username} successfully authenticated!"); // Reset the users failed attempts to 0 Task attemptResetTask = ResetFailedAttempts(user); // Get the user authorizations List auths = GetUserAuths(user); if (auths.Count == 0) { _logger.LogWarning($"{user.Username} has no authorization levels!"); return Unauthorized("No authorizations."); } // Generate a random string for fingerprinting, hashed used in JWT string[] fingerprintSet = FingerPrinter.GetFingerprintSet(); string fingerprint = fingerprintSet[0]; string hashedFp = fingerprintSet[1]; int authExpiryMinutes = Convert.ToInt32(_config["JwtSettings:expiryTime"]); DateTime authExpiration = DateTime.Now.AddMinutes(authExpiryMinutes); string jwToken = GenerateJwt(user.UserId, auths,requestBody.clientVerificationKey,hashedFp, authExpiration); // Add the JWT to the reponse cookies SetHardenedCookie(HttpContext, "Authorization", jwToken, authExpiration); SetHardenedCookie(HttpContext, "UserFingerprint", fingerprint, authExpiration); _logger.LogDebug($"{user.Username} authorized with {Response.Cookies}"); _logger.LogInformation($"Authorized {user} with token {jwToken}"); await attemptResetTask; return Ok($"Successfuly authorized! Session is valid for {authExpiryMinutes} minutes."); } // Password did not match records // Record the failed attempt bool accountLocked = RecordFailedAttempt(user); if (accountLocked) { return Unauthorized("Account disabled."); } return Unauthorized("Incorrect username or password!"); } // No User found | It's best not to distinguish between invalid pw and unknown un for security reasons return Unauthorized("Incorrect username or password!"); } /// /// Generates a JSON Web Token (JWT) for the specified user and authorizations using the provided client key, fingerprint hash, and expiry date. /// /// The ID of the user for whom the JWT is being generated. /// The list of authorizations for the user. /// The client key for verifying header signatures. /// The hashed fingerprint for user identification. /// The expiry date for the JWT. /// The generated JWT. private string GenerateJwt(int userId, List authorizations, string clientKey, string fingerprintHash, DateTime expiry) { // Create a new list of claims for the JWT List claimsList = new List { // Add the user ID, client key, and fingerprint hash as claims new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim("ClientPublicKey", clientKey), new Claim("Fingerprint", fingerprintHash) }; // Add authorization claims for each authorization in the list foreach (var auth in authorizations) { claimsList.Add(new Claim("Auth", auth.AuthLevel.AuthLevel)); } // Convert the claims list to an array var claims = claimsList.ToArray(); // Get the secret key from the app configuration and create signing credentials var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["JwtSettings:SecretKey"])); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); // Create a new JWT with the specified issuer, audience, claims, expiry, and signing credentials var token = new JwtSecurityToken( issuer: _config["JwtSettings:issuer"], audience: _config["JwtSettings:audience"], claims: claims, expires: expiry, signingCredentials: creds); // Write the JWT to a string and log it for debugging purposes string jwToken = new JwtSecurityTokenHandler().WriteToken(token); _logger.LogDebug($"New JWT created for {userId}\n{jwToken}"); return jwToken; } /// /// Resets the failed attempt count for the specified user to zero in the database. /// /// The user whose failed attempt count is being reset. private async Task ResetFailedAttempts(User user) { // Set the user's failed attempt count to zero user.FailedAttempts = 0; // Save changes to the database asynchronously await _dbContext.SaveChangesAsync(); } /// /// Retrieves the authorization levels for the specified user from the database. /// /// The user whose authorization levels are being retrieved. /// A list of AuthWithLevel objects representing the user's authorization levels. private List GetUserAuths(User user) { List authsWithLevels; // Reset the user's failed attempt count to zero user.FailedAttempts = 0; // Save changes to the database _dbContext.SaveChanges(); // Retrieve the authorization levels for the specified user from the database authsWithLevels = (from auth in _dbContext.Auths join level in _dbContext.AuthLevels on auth.AuthLevelId equals level.AuthLevelsId into authLevelGroup from authLevel in authLevelGroup.DefaultIfEmpty() where auth.UserId == user.UserId //FIXME Just take the AuthLevel select new AuthWithLevel { Auth = auth, AuthLevel = authLevel }).ToList(); // Log a message indicating the user's authorization levels _logger.LogDebug($"{user.Username} auths: {authsWithLevels}"); // Return a list of Auth objects representing the user's authorization levels return authsWithLevels; } /// /// Records a failed login attempt for the specified user, and updates the user's failed attempt count and account lock status if necessary. /// /// The user whose login attempt failed. /// A boolean value indicating whether the user's account is currently enabled. private bool RecordFailedAttempt(User user) { // Increment the user's failed attempt count user.FailedAttempts += 1; // Log a message indicating that the user's login attempt failed, and how many failed attempts they have made so far _logger.LogInformation($"{user.Username} failed authentication! ({user.FailedAttempts}/5)"); // If the user has made too many failed attempts, lock their account if (user.FailedAttempts > 4) { _logger.LogWarning($"{user.Username} has had their account locked for too many failed attempts!"); user.IsEnabled = false; } // Save changes to the database _dbContext.SaveChanges(); // Return a boolean value indicating whether the user's account is currently enabled return user.IsEnabled; } /// /// Sets a hardened cookie in the HTTP response with the given cookie name, value, and expiry time. /// /// The HttpContext object for the current request. /// The name of the cookie. /// The value of the cookie. /// The expiry time of the cookie. private void SetHardenedCookie(HttpContext httpContext, string cookieName, string cookieValue, DateTime expiry) { // Set the cookie options var cookieOptions = new CookieOptions { HttpOnly = true, // The cookie cannot be accessed by JavaScript Secure = true, // The cookie can only be sent over HTTPS SameSite = SameSiteMode.Strict, // The cookie can only be sent to the same domain that sent it Expires = expiry, // The cookie expires at the given time (see appsettings.json) IsEssential = true // The cookie is essential for the operation of the application (prevents browesers from blocking) }; // Add the "__Secure-" prefix to the cookie name to indicate that it is a hardened cookie cookieName = "__Secure-" + cookieName; // Append the cookie to the HTTP response httpContext.Response.Cookies.Append(cookieName, cookieValue, cookieOptions); } } } //TODO: Consider separating the database access code into a separate repository class to follow the Repository pattern. //This would help you decouple the controller from the data access layer, making the code more maintainable and testable.