You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
PortfolioLink/backend/Controllers/AuthorizationController.cs

248 lines
12 KiB

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<AuthorizationController> logger, PortfolioPortalDbContext dbContext)
{
_config = config;
_logger = logger;
_dbContext = dbContext;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="requestBody">The login details submitted in the request body.</param>
/// <returns>An ActionResult indicating the success or failure of the login attempt.</returns>
[HttpPost]
public async Task<ActionResult> 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<AuthWithLevel> 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!");
}
/// <summary>
/// Generates a JSON Web Token (JWT) for the specified user and authorizations using the provided client key, fingerprint hash, and expiry date.
/// </summary>
/// <param name="userId">The ID of the user for whom the JWT is being generated.</param>
/// <param name="authorizations">The list of authorizations for the user.</param>
/// <param name="clientKey">The client key for verifying header signatures.</param>
/// <param name="fingerprintHash">The hashed fingerprint for user identification.</param>
/// <param name="expiry">The expiry date for the JWT.</param>
/// <returns>The generated JWT.</returns>
private string GenerateJwt(int userId, List<AuthWithLevel> authorizations, string clientKey, string fingerprintHash, DateTime expiry)
{
// Create a new list of claims for the JWT
List<Claim> claimsList = new List<Claim>
{
// 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;
}
/// <summary>
/// Resets the failed attempt count for the specified user to zero in the database.
/// </summary>
/// <param name="user">The user whose failed attempt count is being reset.</param>
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();
}
/// <summary>
/// Retrieves the authorization levels for the specified user from the database.
/// </summary>
/// <param name="user">The user whose authorization levels are being retrieved.</param>
/// <returns>A list of AuthWithLevel objects representing the user's authorization levels.</returns>
private List<AuthWithLevel> GetUserAuths(User user)
{
List<AuthWithLevel> 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;
}
/// <summary>
/// Records a failed login attempt for the specified user, and updates the user's failed attempt count and account lock status if necessary.
/// </summary>
/// <param name="user">The user whose login attempt failed.</param>
/// <returns>A boolean value indicating whether the user's account is currently enabled.</returns>
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;
}
/// <summary>
/// Sets a hardened cookie in the HTTP response with the given cookie name, value, and expiry time.
/// </summary>
/// <param name="httpContext">The HttpContext object for the current request.</param>
/// <param name="cookieName">The name of the cookie.</param>
/// <param name="cookieValue">The value of the cookie.</param>
/// <param name="expiry">The expiry time of the cookie.</param>
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.