LANCommander/LANCommander.Server/Controllers/Api/AuthController.cs
Pat Hartl 2805f34449 WIP fix for MySQL connection concurrency issues
This is a large commit. There are a number of things that this commit does to try to fix various issues that were occurring when the database provider was set to MySQL:
- The DAL `Repository` has been completely refactored to follow best practices. The repository is now being injected into services instead of the database context itself. This allows the DI to handle the repository's lifetime instead of creating a new repository for every transaction and sharing the context across repositories. As part of these changes, there is no more allowed usage of `IQueryable` and all service/repository methods must actually execute database queries before their return. This is to ensure that the context does not stay open longer than it needs to. Abusing `IQueryable`s by tossing them into Blazor components seems to be a big no-no.
- Some deletion behaviors on relationships have been tweaked as MySQL wasn't able to apply migrations with behaviors that were contradictory.
- A `ConnectionInterceptor` was added to try to keep track of `DatabaseContext` lifetimes. This is really only for debugging and should be put into `#if DEBUG` regions. This helped identify some potential issues where some contexts were basically never closing, causing the MySQL connector to not function.
- Docs for generating migrations have been updated to reflect the addition of being able to specify the database provider and connection string when adding a migration, avoiding the need to edit `Settings.yml`
- The application can now be put into a pause state on startup by adding the `--debugger` argument when used from the command line. When a debugger is attached, it resumes execution.
- The application can now log to Seq when using debug build
- Service lifetime on `DatabaseContext` has switched to transient. This may be reverted in the future.
- Lazy loading has been disabled for debugging purposes. It didn't directly help the concurrency issues, but it needs to be tested individually to be re-enabled.
- All usage of `UserManager`, `RoleManager`, and `SignInManager` have been removed from all controllers, pages, and Blazor components. Functionality has been moved to `UserService` and `RoleService`. This might have done the most amount of help, but could probably be improved upon in the future by not relying on them and instead having our own implementation.
- Application startup migrations and server autostarts have been disabled temporarily. There might be an issue of `DatabaseContext` lifetimes that spawn from this.
2024-10-13 20:42:45 -05:00

278 lines
9.4 KiB
C#

using LANCommander.Server.Data.Models;
using LANCommander.Server.Models;
using LANCommander.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
namespace LANCommander.Server.Controllers.Api
{
public class TokenModel
{
public string AccessToken { get; set; }
public string RefreshToken { get; set; }
public DateTime Expiration { get; set; }
}
public class LoginModel
{
public string UserName { get; set; }
public string Password { get; set; }
}
public class RegisterModel
{
public string UserName { get; set; }
public string Password { get; set; }
}
[Route("api/[controller]")]
[ApiController]
public class AuthController : BaseApiController
{
private readonly UserService UserService;
private readonly RoleService RoleService;
public AuthController(
ILogger<AuthController> logger,
UserService userService,
RoleService roleService) : base(logger)
{
UserService = userService;
RoleService = roleService;
}
[HttpPost]
public async Task<IActionResult> Login([FromBody] LoginModel model)
{
var user = await UserService.Get(model.UserName);
try
{
var token = await Login(user, model.Password);
Logger?.LogDebug("Successfully logged in user {UserName}", user.UserName);
return Ok(token);
}
catch (Exception ex)
{
Logger?.LogError(ex, "An error occurred while trying to log in {UserName}", model.UserName);
return Unauthorized();
}
}
[HttpPost("Logout")]
public async Task<IActionResult> Logout()
{
if (User != null && User.Identity != null && User.Identity.IsAuthenticated)
await UserService.SignOut();
Logger?.LogInformation("Logged out user {UserName}", User.Identity.Name);
return Ok();
}
[HttpPost("Validate")]
[Authorize(AuthenticationSchemes = "Bearer")]
public IActionResult Validate()
{
if (User != null && User.Identity != null && User.Identity.IsAuthenticated)
return Ok();
else
return Unauthorized();
}
[HttpPost("Refresh")]
public async Task<IActionResult> Refresh(TokenModel token)
{
if (token == null)
{
Logger?.LogDebug("Null token passed when trying to refresh");
return BadRequest("Invalid client request");
}
var principal = GetPrincipalFromExpiredToken(token.AccessToken);
if (principal == null)
{
Logger?.LogDebug("Invalid access token or refresh token");
return BadRequest("Invalid access token or refresh token");
}
var user = await UserService.Get(principal.Identity.Name);
if (user == null || user.RefreshToken != token.RefreshToken || user.RefreshTokenExpiration <= DateTime.Now)
{
Logger?.LogDebug("Invalid access token or refresh token for user {UserName}", principal.Identity.Name);
return BadRequest("Invalid access token or refresh token");
}
var newAccessToken = GetToken(principal.Claims.ToList());
var newRefreshToken = GenerateRefreshToken();
user.RefreshToken = newRefreshToken;
await UserService.Update(user);
Logger?.LogDebug("Successfully refreshed token for user {UserName}", user.UserName);
return Ok(new
{
AccessToken = new JwtSecurityTokenHandler().WriteToken(newAccessToken),
RefreshToken = newRefreshToken,
Expiration = newAccessToken.ValidTo
});
}
[HttpPost("Register")]
public async Task<IActionResult> Register([FromBody] RegisterModel model)
{
var user = await UserService.Get(model.UserName);
if (user != null)
{
Logger?.LogDebug("Cannot register user with username {UserName}, already exists", model.UserName);
return Unauthorized(new
{
Message = "Username is unavailable"
});
}
user = new User();
user.UserName = model.UserName;
user = await UserService.Add(user);
if (user != null)
{
await UserService.ChangePassword(user, model.Password);
try
{
if (Settings.Roles.DefaultRoleId != Guid.Empty)
{
var defaultRole = await RoleService.Get(Settings.Roles.DefaultRoleId);
if (defaultRole != null)
await UserService.AddToRole(user, defaultRole.Name);
}
var token = await Login(user, model.Password);
Logger?.LogDebug("Successfully registered user {UserName}", user.UserName);
return Ok(token);
}
catch (Exception ex)
{
Logger?.LogError(ex, "Could not register user {UserName}", user.UserName);
return BadRequest(new
{
Message = "An unknown error occurred"
});
}
}
return Unauthorized(new
{
//Message = "Error:\n" + String.Join('\n', result.Errors.Select(e => e.Description))
});
}
private async Task<TokenModel> Login(User user, string password)
{
if (user != null && await UserService.CheckPassword(user, password))
{
Logger?.LogDebug("Password check for user {UserName} was successful", user.UserName);
if (Settings.Authentication.RequireApproval && !user.Approved && (!await UserService.IsInRole(user, "Administrator")))
throw new Exception("Account must be approved by an administrator");
var userRoles = await UserService.GetRoles(user);
var authClaims = new List<Claim>
{
new Claim(ClaimTypes.Name, user.UserName),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
foreach (var userRole in userRoles)
{
// authClaims.Add(new Claim(ClaimTypes.Role, userRole));
}
Logger?.LogDebug("Generating authentication token for user {UserName}", user.UserName);
var token = GetToken(authClaims);
var refreshToken = GenerateRefreshToken();
user.RefreshToken = refreshToken;
user.RefreshTokenExpiration = DateTime.Now.AddDays(Settings.Authentication.TokenLifetime);
await UserService.Update(user);
return new TokenModel()
{
AccessToken = new JwtSecurityTokenHandler().WriteToken(token),
RefreshToken = refreshToken,
Expiration = token.ValidTo
};
}
throw new Exception("Invalid username or password");
}
private JwtSecurityToken GetToken(List<Claim> authClaims)
{
var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Settings.Authentication.TokenSecret));
var token = new JwtSecurityToken(
expires: DateTime.Now.AddDays(Settings.Authentication.TokenLifetime),
claims: authClaims,
signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256)
);
return token;
}
private static string GenerateRefreshToken()
{
var randomNumber = new byte[64];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(randomNumber);
return Convert.ToBase64String(randomNumber);
}
}
private ClaimsPrincipal? GetPrincipalFromExpiredToken(string? token)
{
var tokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false,
ValidateIssuer = false,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Settings.Authentication.TokenSecret)),
ValidateLifetime = false
};
var tokenHandler = new JwtSecurityTokenHandler();
var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out SecurityToken securityToken);
if (securityToken is not JwtSecurityToken jwtSecurityToken || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
throw new SecurityTokenException("Invalid token");
return principal;
}
}
}