From 09d868fc10a9d5d28ec0ea0b557af249a669d960 Mon Sep 17 00:00:00 2001 From: Griffiths Lott Date: Sun, 19 Mar 2023 02:16:02 -0400 Subject: [PATCH] Authorization and authentication in place (not tested) Authorization issues JWT and fingerprint Created authentication middleware -> validate JWT, fingerprint and check signed headers Basically rewrote login (now AuthorizationController) Name numnerous model changes (I think all reflected in DB create script) Added to appsettings (hide none dev cause secret keys) Some changes to JS ecc class for signing --- .gitignore | 3 +- TODO.txt | 5 + .../Controllers/AuthorizationController.cs | 259 ++++++++++++++++++ backend/Controllers/Login.cs | 97 ------- backend/ExcelSave.cs | 24 ++ backend/Hasher.cs | 71 +++++ backend/JwtAuthenticationMiddelware.cs | 105 +++++++ .../20230314000540_InitialCreate.Designer.cs | 2 +- .../Migrations/MyDbContextModelSnapshot.cs | 2 +- backend/Models/AuthLevels.cs | 4 +- backend/Models/Auths.cs | 7 + backend/Models/LoginDetails.cs | 34 +++ backend/Models/User.cs | 2 + backend/PPTLDbContext.cs | 5 +- backend/Program.cs | 32 ++- backend/appsettings.Development.json | 8 +- backend/backend.csproj | 6 + backend/logs/portfolioportal20230319.txt | 12 + database/CREATE_TABLES.sql | 4 + src/components/FileUpload.vue | 3 + src/ecc.js | 36 +-- 21 files changed, 583 insertions(+), 138 deletions(-) create mode 100644 TODO.txt create mode 100644 backend/Controllers/AuthorizationController.cs delete mode 100644 backend/Controllers/Login.cs create mode 100644 backend/ExcelSave.cs create mode 100644 backend/JwtAuthenticationMiddelware.cs create mode 100644 backend/Models/LoginDetails.cs create mode 100644 backend/logs/portfolioportal20230319.txt diff --git a/.gitignore b/.gitignore index eafc283..3d93090 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,5 @@ pnpm-debug.log* .leaf-ecc.txt # DO INCULDE -!portfolio-portal-pub.key \ No newline at end of file +!portfolio-portal-pub.key +!backend/appsettings.json \ No newline at end of file diff --git a/TODO.txt b/TODO.txt new file mode 100644 index 0000000..d9d875d --- /dev/null +++ b/TODO.txt @@ -0,0 +1,5 @@ +###BACKEND +[ ] Set up production settings (JWT secrets, logging, db connection) +[X] Confirm working logging +[ ] Normalize appsettings case convention +[ ] TemplateHeaders controler use database, not config \ No newline at end of file diff --git a/backend/Controllers/AuthorizationController.cs b/backend/Controllers/AuthorizationController.cs new file mode 100644 index 0000000..2237029 --- /dev/null +++ b/backend/Controllers/AuthorizationController.cs @@ -0,0 +1,259 @@ +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/login")] + [ApiController] + public class AuthorizationController : ControllerBase + { + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public AuthorizationController(IConfiguration config, ILogger logger) + { + _config = config; + _logger = logger; + } + + /// + /// 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; + using (var db = new PortfolioPortalDbContext()) + { + // Check for a user with that email in the database + user = db.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 not 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("Invalid 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) + { + // Create a new database context using the PortfolioPortalDbContext class + await using (var db = new PortfolioPortalDbContext()) + { + // Set the user's failed attempt count to zero + user.FailedAttempts = 0; + + // Save changes to the database asynchronously + await db.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; + + // Create a new database context using the PortfolioPortalDbContext class + using (var db = new PortfolioPortalDbContext()) + { + // Reset the user's failed attempt count to zero + user.FailedAttempts = 0; + + // Save changes to the database + db.SaveChanges(); + + // Retrieve the authorization levels for the specified user from the database + authsWithLevels = (from auth in db.Auths + join level in db.AuthLevels on auth.AuthLevelId equals level.AuthLevelId into authLevelGroup + from authLevel in authLevelGroup.DefaultIfEmpty() + where auth.UserId == user.UserId + 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) + { + // Create a new database context using the PortfolioPortalDbContext class + using (var db = new PortfolioPortalDbContext()) + { + // 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 + db.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. \ No newline at end of file diff --git a/backend/Controllers/Login.cs b/backend/Controllers/Login.cs deleted file mode 100644 index 12782ff..0000000 --- a/backend/Controllers/Login.cs +++ /dev/null @@ -1,97 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.Data.SqlClient; -using Hasher; -using System.Linq; -using Models; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; -using Microsoft.IdentityModel.Tokens; -using System.Security.Cryptography; - -namespace backend.Controllers -{ - [Route("api/login")] - [ApiController] - public class Login : ControllerBase - { - private readonly IConfiguration _config; - - public Login(IConfiguration config) - { - _config = config; - } - - [HttpPost] - public void ProcessLogin([FromBody] dynamic requestBody) - { - string email = requestBody.username; - string password = requestBody.password; - string clientPublicKey = requestBody.client_verification_key; - - // Try to get a name from the database - using (var db = new MyDbContext()) - { - User user = db.Users.FirstOrDefault(u => u.Email.ToLower() == email.ToLower()); - - if (user != null) - { - int userId = user.UserId; - string passwordHash = user.PasswordHash; - - // Check the hash - if (Argon2dHasher.VerifyArgon2Hash(password, passwordHash)) - { - // Get the user authorizations - List auths = db.Auths.Where((auth) => auth.UserId == userId).ToList(); - - // Generate a random string for fingerprinting - var fingerprint = GenerateRandomString(32); - - //Create a JWT - // Claims: [userId, clientPublicKey, auths] - var claims = new[] - { - // Add the user ID as a subject claim - new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), - - // Add the client public key as a custom claim - new Claim("ClientPublicKey", clientPublicKey) - - // Add the fingerprint as a hashed custom claim - new Claim("Fingerprint", HashFingerprint(fingerprint)) - }; - - // Add the user authorizations as custom claims - foreach (var auth in auths) - { - claims.Append(new Claim("Auth", auth.AuthId.ToString())); - } - } - - } - else - { - //TODO Handle case where no user was found with the given email - } - } - - } - - // Helper function to generate a random string - public static string GetRandomString(int length) - { - var r = new Random(); - return new String(Enumerable.Range(0, length).Select(n => (Char)(r.Next(32, 127))).ToArray()); - } - - // Helper function to hash the fingerprint using SHA-256 - private string HashFingerprint(string fingerprint) - { - using var sha256 = SHA256.Create(); - var hashedFingerprint = sha256.ComputeHash(Encoding.UTF8.GetBytes(fingerprint)); - return Convert.ToBase64String(hashedFingerprint); - } - - } -} \ No newline at end of file diff --git a/backend/ExcelSave.cs b/backend/ExcelSave.cs new file mode 100644 index 0000000..fddc7f0 --- /dev/null +++ b/backend/ExcelSave.cs @@ -0,0 +1,24 @@ +using System; +using System.IO; +namespace customExcel +{ + public class ExcelSaver + { + public static void SaveBase64ExcelFile(string base64ExcelData, string outputPath) + { + try + { + // Decode the Base64 string to a byte array + byte[] excelBytes = Convert.FromBase64String(base64ExcelData); + + // Write the byte array to a file with the appropriate Excel file extension + File.WriteAllBytes(outputPath, excelBytes); + } + catch (Exception ex) + { + // Handle exceptions as needed + Console.WriteLine("Error saving Excel file: " + ex.Message); + } + } + } +} diff --git a/backend/Hasher.cs b/backend/Hasher.cs index 57cfc45..e6dd6c9 100644 --- a/backend/Hasher.cs +++ b/backend/Hasher.cs @@ -2,6 +2,8 @@ using Isopoh.Cryptography.Argon2; using Isopoh.Cryptography.SecureArray; using System.Security.Cryptography; using System.Text; +using Chaos.NaCl; + namespace Hasher { @@ -63,5 +65,74 @@ namespace Hasher return valid; } } + + /// + /// The FingerPrinter class provides methods to generate and hash fingerprints. + /// + public class FingerPrinter + { + /// + /// Helper function to generate a random string of a given length. + /// + /// The length of the string to generate. + /// A random string of the specified length. + private static string GetRandomString(int length) + { + var r = new Random(); + return new String(Enumerable.Range(0, length).Select(n => (Char)(r.Next(32, 127))).ToArray()); + } + + /// + /// Helper function to hash a fingerprint using SHA-256. + /// + /// The fingerprint to hash. + /// The SHA-256 hash of the input fingerprint as a base64-encoded string. + private static string HashFingerprint(string fingerprint) + { + using var sha256 = SHA256.Create(); + var hashedFingerprint = sha256.ComputeHash(Encoding.UTF8.GetBytes(fingerprint)); + return Convert.ToBase64String(hashedFingerprint); + } + + /// + /// Returns an array containing a randomly generated plain text fingerprint and its hashed value. + /// + /// The length of the plain text fingerprint to generate (default is 32). + /// An array of two strings: the plain text fingerprint and its hashed value. + public static string[] GetFingerprintSet(int length = 32) + { + string plainText = GetRandomString(length); + string hashed = HashFingerprint(plainText); + + return new string[] { plainText, hashed }; + } + + /// + /// Validates whether the specified fingerprint matches the provided hashed fingerprint using SHA256 encryption. + /// + /// The fingerprint string to be validated. + /// The hashed fingerprint to be compared against the fingerprint. + /// Returns a boolean value indicating whether the fingerprint and its hash match or not. + public static bool ValidateFingerpint(string fingerprint, string fpHash) + { + using var sha256 = SHA256.Create(); + var newFpHash = sha256.ComputeHash(Encoding.UTF8.GetBytes(fingerprint)); + return Convert.ToBase64String(newFpHash) == fpHash ; + } + } + + public class KeyHelper + { + private static byte[] FromBase64String(string input) + { + return Convert.FromBase64String(input); + } + + public static bool validateSignature(string signature, string message, string publicKey) + { + return Ed25519.Verify(FromBase64String(signature), Encoding.UTF8.GetBytes(message), FromBase64String(publicKey)); + } + } + } diff --git a/backend/JwtAuthenticationMiddelware.cs b/backend/JwtAuthenticationMiddelware.cs new file mode 100644 index 0000000..b5513eb --- /dev/null +++ b/backend/JwtAuthenticationMiddelware.cs @@ -0,0 +1,105 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; +using Hasher; + +public class JwtAuthenticationMiddleware +{ + private readonly RequestDelegate _next; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public JwtAuthenticationMiddleware(RequestDelegate next, IConfiguration configuration, ILogger logger) + { + _next = next; + _configuration = configuration; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + _logger.LogDebug($"Request cookies: {context.Request.Cookies}"); + // Read the Authorization and UserFingerprint cookies + context.Request.Cookies.TryGetValue("__Secure-Authorization", out string token); + context.Request.Cookies.TryGetValue("__Secure-UserFingerprint", out string fingerprint); + + if (!string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(fingerprint)) + { + // Validate the JWT and fingerprint + var validationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + + ValidIssuer = _configuration["JwtSettings:issuer"], + ValidAudience = _configuration["JwtSettings:audience"], + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JwtSettings:SecretKey"])), + ClockSkew = TimeSpan.Zero + }; + + try + { + var tokenHandler = new JwtSecurityTokenHandler(); + tokenHandler.ValidateToken(token, validationParameters, out SecurityToken validatedToken); + + // Extract claims from the validated token + var jwtToken = (JwtSecurityToken)validatedToken; + var claims = jwtToken.Claims; + + // Validate the fingerprint + var fingerprintClaim = claims.FirstOrDefault(c => c.Type == "Fingerprint"); + if (fingerprintClaim != null) + { + var hashedFingerprint = fingerprintClaim.Value; + _logger.LogDebug($"HashedFp: {hashedFingerprint} | Cookie Fp: {fingerprintClaim}"); + // Verify the fingerprint here, based on how you generate the fingerprint hash + // If the fingerprint is valid, set the User property with the claims + if (FingerPrinter.ValidateFingerpint(fingerprint,hashedFingerprint)) + { + // Record the claims so that they can be accessed by controllers + context.User = new ClaimsPrincipal(new ClaimsIdentity(claims.Select(c => c.Type == "Auth" ? new Claim(ClaimTypes.Role, c.Value) : c), "Jwt")); + } + } + + // Validate signed headers + string payloadSig = context.Request.Headers["X-Payload-Signature"]; + // Read the request body content + string payload; + using (var streamReader = new StreamReader(context.Request.Body)) + { + payload = await streamReader.ReadToEndAsync(); + } + string clientPublicKey = claims.FirstOrDefault(c => c.Type == "ClientPublicKey").Value; + _logger.LogDebug($"PayloadSig: {payloadSig} | payload: {payload} | Key: {clientPublicKey}"); + //FIXME This could use some testing + try + { + bool validSig = KeyHelper.validateSignature(payloadSig, payload, clientPublicKey); + _logger.LogDebug($"Valid sig!"); + } + catch + { + _logger.LogDebug($"INVALID sig!"); + } + + + } + catch (SecurityTokenValidationException) + { + //FIXME Log the validation error or perform necessary actions + _logger.LogWarning("Invlaid JWT Exception!"); + } + } + + // Call the next middleware in the pipeline + await _next(context); + } +} diff --git a/backend/Migrations/20230314000540_InitialCreate.Designer.cs b/backend/Migrations/20230314000540_InitialCreate.Designer.cs index 0088f0f..da3fa9d 100644 --- a/backend/Migrations/20230314000540_InitialCreate.Designer.cs +++ b/backend/Migrations/20230314000540_InitialCreate.Designer.cs @@ -11,7 +11,7 @@ using Models; namespace backend.Migrations { - [DbContext(typeof(MyDbContext))] + [DbContext(typeof(PortfolioPortalDbContext))] [Migration("20230314000540_InitialCreate")] partial class InitialCreate { diff --git a/backend/Migrations/MyDbContextModelSnapshot.cs b/backend/Migrations/MyDbContextModelSnapshot.cs index d997ff7..cdc62dd 100644 --- a/backend/Migrations/MyDbContextModelSnapshot.cs +++ b/backend/Migrations/MyDbContextModelSnapshot.cs @@ -10,7 +10,7 @@ using Models; namespace backend.Migrations { - [DbContext(typeof(MyDbContext))] + [DbContext(typeof(PortfolioPortalDbContext))] partial class MyDbContextModelSnapshot : ModelSnapshot { protected override void BuildModel(ModelBuilder modelBuilder) diff --git a/backend/Models/AuthLevels.cs b/backend/Models/AuthLevels.cs index 6a802ac..41baace 100644 --- a/backend/Models/AuthLevels.cs +++ b/backend/Models/AuthLevels.cs @@ -1,10 +1,10 @@ namespace Models { - public class AuthLevel + public class AuthLevels { public int AuthLevelId { get; set; } - public string AuthLevelAuthLevel { get; set; } + public string AuthLevel { get; set; } public string Description { get; set; } } } diff --git a/backend/Models/Auths.cs b/backend/Models/Auths.cs index 8887812..662d84e 100644 --- a/backend/Models/Auths.cs +++ b/backend/Models/Auths.cs @@ -7,4 +7,11 @@ namespace Models public int UserId { get; set; } public int AuthLevelId { get; set; } } + + public class AuthWithLevel + { + public Auth Auth { get; set; } + public AuthLevels AuthLevel { get; set; } + } + } diff --git a/backend/Models/LoginDetails.cs b/backend/Models/LoginDetails.cs new file mode 100644 index 0000000..afd3606 --- /dev/null +++ b/backend/Models/LoginDetails.cs @@ -0,0 +1,34 @@ +using Newtonsoft.Json; + +namespace Models +{ + // TODO: Do we want them to be able to log in with username? + // Could be nice, but adds complexity not worth at the moment + public class LoginDetails + { + // Email associated with an account + public string email; + // Plain text password, must be hashed (Argon2d) and compared against database + public string password; + // This is a Ed25519 public key which can be used to verify that + // a header was signed by a client. It will be included in the JWT + public string clientVerificationKey; + + /// + /// Returns a string representation of the object but replaces all of the characters in password with *. + /// + /// A string representation of the object with password replaced by * + public override string ToString() + { + // Create a copy of the object so that we can modify its password field + var copy = (LoginDetails)this.MemberwiseClone(); + + // Replace the characters in the password field with asterisks + copy.password = new string('*', copy.password.Length); + + // Return the string representation of the modified copy + return JsonConvert.SerializeObject(copy, Formatting.Indented); + } + } + +} \ No newline at end of file diff --git a/backend/Models/User.cs b/backend/Models/User.cs index ff0eb21..1157f19 100644 --- a/backend/Models/User.cs +++ b/backend/Models/User.cs @@ -13,5 +13,7 @@ namespace Models public DateTimeOffset PasswordUpdated { get; set; } public int CompanyId { get; set; } public DateTimeOffset LastLogin { get; set; } + public bool IsEnabled { get; set; } + public int FailedAttempts { get; set; } } } diff --git a/backend/PPTLDbContext.cs b/backend/PPTLDbContext.cs index 4be0c77..42ea5b2 100644 --- a/backend/PPTLDbContext.cs +++ b/backend/PPTLDbContext.cs @@ -3,11 +3,11 @@ using Models; namespace Models { - public class MyDbContext : DbContext + public class PortfolioPortalDbContext : DbContext { public DbSet Portfolios { get; set; } public DbSet PortfolioData { get; set; } - public DbSet AuthLevels { get; set; } + public DbSet AuthLevels { get; set; } public DbSet Auths { get; set; } public DbSet Companies { get; set; } public DbSet Users { get; set; } @@ -16,6 +16,7 @@ namespace Models protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { + //FIXME: This couldn optionsBuilder.UseSqlServer("Server=localhost,3341;Database=PortfolioPortalTest;User Id=SA;Password=LEAF-portal-test-enviroment1;"); } } diff --git a/backend/Program.cs b/backend/Program.cs index 82bfcf8..1453dae 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,12 +1,23 @@ -using Microsoft.EntityFrameworkCore; using Models; +using Serilog; + +//TODO Make this configurable from appsettings +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.Console() + .WriteTo.File("logs/portfolioportal.txt", rollingInterval: RollingInterval.Day) + .CreateLogger(); +Log.Information("Starting Portfolio Portal ASP.NET Core Backend"); var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllers() .AddNewtonsoftJson(); -builder.Services.AddDbContext(); +builder.Services.AddDbContext(); +builder.Logging.AddSerilog(); + + var app = builder.Build(); @@ -16,4 +27,21 @@ app.UseAuthorization(); app.MapControllers(); +// Custom JWT handling with Ed25519 validation +app.UseMiddleware(); + +// If this is the production build make sure we're always using HTTPS +if (!app.Environment.IsDevelopment()) +{ + // If an unhandled exception is encountered redirect to Error endpoint + //TODO Create an error endpoint + app.UseExceptionHandler("/Error"); + app.UseHsts(); +} +else +{ + // Generates a rich error page with detailed information such as the exception type, message, stack trace, request details, and more + app.UseDeveloperExceptionPage(); +} + app.Run(); diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json index d9cc75c..f7a93a4 100644 --- a/backend/appsettings.Development.json +++ b/backend/appsettings.Development.json @@ -1,13 +1,19 @@ { "Logging": { "LogLevel": { - "Default": "Information", + "Default": "Debug", "Microsoft.AspNetCore": "Warning" } }, "ConnectionStrings": { "TestConnectionString" : "Server=localhost,3341;Database=PortfolioPortalTest;User Id=SA;Password=LEAF-portal-test-enviroment1;" }, + "JwtSettings": { + "issuer": "LEAFPPTL-Test", + "audience": "PortfolioPartner-Test", + "SecretKey": "DEV-KEY-PortPortal", + "expirtyTime": 60 + }, "AllowedHosts": "*", "UploadSavePath": "../Uploaded/", "validateData": false, diff --git a/backend/backend.csproj b/backend/backend.csproj index 8027670..da62f7b 100644 --- a/backend/backend.csproj +++ b/backend/backend.csproj @@ -7,6 +7,7 @@ + @@ -20,6 +21,11 @@ + + + + + diff --git a/backend/logs/portfolioportal20230319.txt b/backend/logs/portfolioportal20230319.txt new file mode 100644 index 0000000..e2e888b --- /dev/null +++ b/backend/logs/portfolioportal20230319.txt @@ -0,0 +1,12 @@ +2023-03-19 00:31:01.401 -04:00 [INF] Starting Portfolio Portal ASP.NET Core Backend +2023-03-19 00:31:01.529 -04:00 [DBG] Registered model binder providers, in the following order: ["Microsoft.AspNetCore.Mvc.ModelBinding.Binders.BinderTypeModelBinderProvider","Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ServicesModelBinderProvider","Microsoft.AspNetCore.Mvc.ModelBinding.Binders.BodyModelBinderProvider","Microsoft.AspNetCore.Mvc.ModelBinding.Binders.HeaderModelBinderProvider","Microsoft.AspNetCore.Mvc.ModelBinding.Binders.FloatingPointTypeModelBinderProvider","Microsoft.AspNetCore.Mvc.ModelBinding.Binders.EnumTypeModelBinderProvider","Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DateTimeModelBinderProvider","Microsoft.AspNetCore.Mvc.ModelBinding.Binders.TryParseModelBinderProvider","Microsoft.AspNetCore.Mvc.ModelBinding.Binders.SimpleTypeModelBinderProvider","Microsoft.AspNetCore.Mvc.ModelBinding.Binders.CancellationTokenModelBinderProvider","Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ByteArrayModelBinderProvider","Microsoft.AspNetCore.Mvc.ModelBinding.Binders.FormFileModelBinderProvider","Microsoft.AspNetCore.Mvc.ModelBinding.Binders.FormCollectionModelBinderProvider","Microsoft.AspNetCore.Mvc.ModelBinding.Binders.KeyValuePairModelBinderProvider","Microsoft.AspNetCore.Mvc.ModelBinding.Binders.DictionaryModelBinderProvider","Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ArrayModelBinderProvider","Microsoft.AspNetCore.Mvc.ModelBinding.Binders.CollectionModelBinderProvider","Microsoft.AspNetCore.Mvc.ModelBinding.Binders.ComplexObjectModelBinderProvider"] +2023-03-19 00:31:01.571 -04:00 [DBG] Hosting starting +2023-03-19 00:31:01.586 -04:00 [INF] Now listening on: http://localhost:5252 +2023-03-19 00:31:01.586 -04:00 [DBG] Loaded hosting startup assembly backend +2023-03-19 00:31:01.586 -04:00 [INF] Application started. Press Ctrl+C to shut down. +2023-03-19 00:31:01.586 -04:00 [INF] Hosting environment: Development +2023-03-19 00:31:01.586 -04:00 [INF] Content root path: /home/g/Code/JavaScript/portfolio_vue/backend +2023-03-19 00:31:01.587 -04:00 [DBG] Hosting started +2023-03-19 00:31:41.888 -04:00 [INF] Application is shutting down... +2023-03-19 00:31:41.889 -04:00 [DBG] Hosting stopping +2023-03-19 00:31:41.895 -04:00 [DBG] Hosting stopped diff --git a/database/CREATE_TABLES.sql b/database/CREATE_TABLES.sql index 37f36e7..f61a64b 100644 --- a/database/CREATE_TABLES.sql +++ b/database/CREATE_TABLES.sql @@ -1,3 +1,5 @@ +--//TODO: Review reservered words + -- Auth table CREATE TABLE AuthLevels ( AuthLevelID TINYINT IDENTITY(1,1) PRIMARY KEY, @@ -41,6 +43,8 @@ CREATE TABLE Users ( PasswordUpdated DATETIMEOFFSET, CompanyID SMALLINT FOREIGN KEY REFERENCES Company(CompanyID) NOT NULL, LastLogin DATETIMEOFFSET, + FailedAttempts INT NOT NULL DEFAULT 0, + IsEnabled BIT NOT NULL DEFAULT 0 ); -- Link the company and users tables ALTER TABLE Company diff --git a/src/components/FileUpload.vue b/src/components/FileUpload.vue index ea98b56..fcc64d1 100644 --- a/src/components/FileUpload.vue +++ b/src/components/FileUpload.vue @@ -12,6 +12,9 @@ +TODO: When sending binary data like an Excel file, you should consider using a different encoding, such as Base64, to ensure that the binary data is transmitted correctly. +Base64 encoding converts binary data into a string representation that can be safely transmitted over text-based protocols like HTTP. +