Settings for the server are now combined with the settings from the SDK. This introduces a large breaking change and a migration should be created. This refactor utilizes .NET Configuration and the Options pattern. This will allow for the overriding of any setting using envionment variables. It also means that Settings.yml can be used to override any .NET configuration. A SettingsProvider implementation was created, and any updating of settings was changed from SettingsService.SaveSettings() to SettingsProvider.Update(s => { ... })
287 lines
11 KiB
C#
287 lines
11 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using AutoMapper;
|
|
using LANCommander.SDK.Abstractions;
|
|
using LANCommander.SDK.Enums;
|
|
using LANCommander.SDK.Models;
|
|
using LANCommander.Server.Services.Exceptions;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using YamlDotNet.Serialization;
|
|
using PascalCaseNamingConvention = YamlDotNet.Serialization.NamingConventions.PascalCaseNamingConvention;
|
|
|
|
namespace LANCommander.Server.Services
|
|
{
|
|
public class AuthenticationService(
|
|
ILogger<AuthenticationService> logger,
|
|
IMapper mapper,
|
|
SettingsProvider<Settings.Settings> settingsProvider,
|
|
UserService userService,
|
|
RoleService roleService,
|
|
ScriptService scriptService,
|
|
ITokenProvider tokenProvider,
|
|
SDK.Services.ScriptClient scriptClient) : BaseService(logger, settingsProvider)
|
|
{
|
|
public async Task<AuthToken> LoginAsync(string userName, string password)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(userName) || await userService.CheckPassword(userName, password) == false)
|
|
throw new UserAuthenticationException("Invalid username or password");
|
|
|
|
var token = await LoginAsync(userName);
|
|
|
|
try
|
|
{
|
|
var user = await userService.GetAsync<User>(userName);
|
|
var scripts = await scriptService.GetAsync<SDK.Models.Script>(s => s.Type == ScriptType.UserLogin);
|
|
|
|
if (scripts.Any())
|
|
{
|
|
tokenProvider.SetToken(token.AccessToken);
|
|
|
|
foreach (var script in scripts)
|
|
{
|
|
await scriptClient.RunUserLoginScript(script, user);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger?.LogError(ex, "Could not execute user login script");
|
|
}
|
|
|
|
return token;
|
|
}
|
|
|
|
public async Task<AuthToken> LoginAsync(string userName)
|
|
{
|
|
var user = await userService.GetAsync(userName);
|
|
|
|
if (user == null)
|
|
throw new UserAuthenticationException("Invalid username or password");
|
|
|
|
_logger?.LogDebug("Password check for user {UserName} was successful", user.UserName);
|
|
|
|
if (_settingsProvider.CurrentValue.Server.Authentication.RequireApproval && !user.Approved && !await userService.IsInRoleAsync(user, RoleService.AdministratorRoleName))
|
|
throw new UserAuthenticationException("Account must be approved by an administrator");
|
|
|
|
var userRoles = await userService.GetRolesAsync(user);
|
|
|
|
var authClaims = new List<Claim>
|
|
{
|
|
new Claim(ClaimTypes.Name, user.UserName),
|
|
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
|
};
|
|
|
|
foreach (var userRole in userRoles)
|
|
{
|
|
authClaims.Add(new Claim(ClaimTypes.Role, userRole.Name));
|
|
}
|
|
|
|
_logger?.LogDebug("Generating authentication token for user {UserName}", user.UserName);
|
|
|
|
var token = GetToken(authClaims);
|
|
var refreshToken = GenerateRefreshToken();
|
|
|
|
user.RefreshToken = refreshToken;
|
|
user.RefreshTokenExpiration = DateTime.UtcNow.AddDays(_settingsProvider.CurrentValue.Server.Authentication.TokenLifetime);
|
|
|
|
await userService.UpdateAsync(user);
|
|
|
|
return new AuthToken
|
|
{
|
|
AccessToken = new JwtSecurityTokenHandler().WriteToken(token),
|
|
RefreshToken = refreshToken,
|
|
Expiration = token.ValidTo
|
|
};
|
|
}
|
|
|
|
public async Task<AuthToken> RefreshTokenAsync(AuthToken token)
|
|
{
|
|
if (token == null)
|
|
{
|
|
_logger?.LogDebug("Refresh token is null");
|
|
|
|
throw new Exception("Invalid refresh token");
|
|
}
|
|
|
|
var principal = GetPrincipalFromExpiredToken(token.AccessToken);
|
|
|
|
if (principal == null)
|
|
{
|
|
_logger?.LogDebug("Invalid access token or refresh token");
|
|
throw new Exception("Invalid access token or refresh token");
|
|
}
|
|
|
|
var user = await userService.GetAsync(principal.Identity.Name);
|
|
|
|
if (user == null || user.RefreshToken != token.RefreshToken ||
|
|
user.RefreshTokenExpiration <= DateTime.UtcNow)
|
|
{
|
|
_logger?.LogDebug("Refresh token expired");
|
|
throw new Exception("Invalid refresh token");
|
|
}
|
|
|
|
var newAccessToken = GetToken(principal.Claims.ToList());
|
|
var newRefreshToken = GenerateRefreshToken();
|
|
|
|
user.RefreshToken = newRefreshToken;
|
|
|
|
await userService.UpdateAsync(user);
|
|
|
|
_logger?.LogDebug("Refresh token updated for user {UserName}", user.UserName);
|
|
|
|
return new AuthToken
|
|
{
|
|
AccessToken = new JwtSecurityTokenHandler().WriteToken(newAccessToken),
|
|
RefreshToken = newRefreshToken,
|
|
Expiration = newAccessToken.ValidTo
|
|
};
|
|
}
|
|
|
|
public async Task<AuthToken> RegisterAsync(string userName, string password)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(password))
|
|
throw new UserRegistrationException("Password is empty");
|
|
|
|
var user = await userService.GetAsync(userName);
|
|
|
|
if (user != null)
|
|
{
|
|
_logger?.LogDebug("Cannot register user with username {UserName}, already exists", userName);
|
|
|
|
throw new UserRegistrationException("Username is unavailable");
|
|
}
|
|
|
|
user = new Data.Models.User();
|
|
user.UserName = userName;
|
|
|
|
var registerResult = await userService.CheckRegister(user, password);
|
|
if (!registerResult.Succeeded)
|
|
{
|
|
throw new UserRegistrationException(registerResult, "Could not register user");
|
|
}
|
|
|
|
user = await userService.AddAsync(user);
|
|
|
|
if (user != null)
|
|
{
|
|
await userService.ChangePassword(user.UserName, password);
|
|
|
|
try
|
|
{
|
|
if (_settingsProvider.CurrentValue.Server.Roles.DefaultRoleId == Guid.Empty)
|
|
{
|
|
var defaultRole = await roleService.GetAsync(_settingsProvider.CurrentValue.Server.Roles.DefaultRoleId);
|
|
|
|
if (defaultRole != null)
|
|
await userService.AddToRoleAsync(user.UserName, defaultRole.Name);
|
|
}
|
|
|
|
var token = await LoginAsync(user.UserName, password);
|
|
|
|
logger?.LogDebug("Successfully registered user {UserName}", user.UserName);
|
|
|
|
try
|
|
{
|
|
var scripts = await scriptService.GetAsync<SDK.Models.Script>(s => s.Type == ScriptType.UserLogin);
|
|
|
|
if (scripts.Any())
|
|
{
|
|
tokenProvider.SetToken(token.AccessToken);
|
|
|
|
foreach (var script in scripts)
|
|
{
|
|
await scriptClient.RunUserRegistrationScript(script, mapper.Map<SDK.Models.User>(user));
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger?.LogError(ex, "Could not execute user login script");
|
|
}
|
|
|
|
return token;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger?.LogError(ex, "Could not register user {UserName}", user.UserName);
|
|
throw new UserRegistrationException("An unknown error occurred while registering");
|
|
}
|
|
}
|
|
else
|
|
throw new UserRegistrationException("Unknown Error");
|
|
}
|
|
|
|
private ClaimsPrincipal? GetPrincipalFromExpiredToken(string? token)
|
|
{
|
|
var tokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateAudience = false,
|
|
ValidateIssuer = false,
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settingsProvider.CurrentValue.Server.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;
|
|
}
|
|
|
|
private static string GenerateRefreshToken()
|
|
{
|
|
var randomNumber = new byte[64];
|
|
|
|
using (var rng = RandomNumberGenerator.Create())
|
|
{
|
|
rng.GetBytes(randomNumber);
|
|
|
|
return Convert.ToBase64String(randomNumber);
|
|
}
|
|
}
|
|
|
|
private JwtSecurityToken GetToken(List<Claim> authClaims)
|
|
{
|
|
var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settingsProvider.CurrentValue.Server.Authentication.TokenSecret));
|
|
|
|
var token = new JwtSecurityToken(
|
|
expires: DateTime.UtcNow.AddDays(_settingsProvider.CurrentValue.Server.Authentication.TokenLifetime),
|
|
claims: authClaims,
|
|
signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256)
|
|
);
|
|
|
|
return token;
|
|
}
|
|
|
|
public static async Task<List<Settings.Models.AuthenticationProvider>> GetAuthenticationProviderTemplatesAsync()
|
|
{
|
|
var files = Directory.GetFiles(@"Templates/AuthenticationProviders", "*.yml", SearchOption.AllDirectories);
|
|
|
|
var externalProviders = new List<Settings.Models.AuthenticationProvider>();
|
|
|
|
|
|
var deserializer = new DeserializerBuilder()
|
|
.WithNamingConvention(PascalCaseNamingConvention.Instance)
|
|
.Build();
|
|
|
|
foreach (var file in files)
|
|
{
|
|
try
|
|
{
|
|
var contents = await File.ReadAllTextAsync(file);
|
|
|
|
externalProviders.Add(deserializer.Deserialize<Settings.Models.AuthenticationProvider>(contents));
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
return externalProviders;
|
|
}
|
|
}
|
|
}
|