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 signingsecurity
parent
a3b1ff40d8
commit
09d868fc10
@ -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 |
||||
@ -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<AuthorizationController> logger) |
||||
{ |
||||
_config = config; |
||||
_logger = logger; |
||||
} |
||||
|
||||
/// <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; |
||||
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<AuthWithLevel> 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."); |
||||
} |
||||
|
||||
/// <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) |
||||
{ |
||||
// 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(); |
||||
} |
||||
} |
||||
|
||||
|
||||
/// <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; |
||||
|
||||
// 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; |
||||
} |
||||
|
||||
|
||||
/// <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) |
||||
{ |
||||
// 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; |
||||
} |
||||
|
||||
/// <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. |
||||
@ -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<Auth> 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); |
||||
} |
||||
|
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -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<JwtAuthenticationMiddleware> 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); |
||||
} |
||||
} |
||||
@ -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; } |
||||
} |
||||
} |
||||
|
||||
@ -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; |
||||
|
||||
/// <summary> |
||||
/// Returns a string representation of the object but replaces all of the characters in password with *. |
||||
/// </summary> |
||||
/// <returns>A string representation of the object with password replaced by *</returns> |
||||
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); |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -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 |
||||
Loading…
Reference in new issue