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.
248 lines
12 KiB
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. |