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. +