Reorganized config. Re implemented auth controller to use token-pair authentication and oauth2

This commit is contained in:
Masu Baumgartner
2024-10-19 19:27:22 +02:00
parent 8883b521e9
commit 71dc81c4dc
16 changed files with 238 additions and 142 deletions

View File

@@ -23,25 +23,32 @@ public class AppConfiguration
public class AuthenticationConfig public class AuthenticationConfig
{ {
public string MlAccessSecret { get; set; } = Formatter.GenerateString(32);
public string MlRefreshSecret { get; set; } = Formatter.GenerateString(32);
public string Secret { get; set; } = Formatter.GenerateString(32);
public int TokenDuration { get; set; } = 10;
public bool UseLocalOAuth2Service { get; set; } = true;
public string AccessSecret { get; set; } = Formatter.GenerateString(32); public string AccessSecret { get; set; } = Formatter.GenerateString(32);
public string RefreshSecret { get; set; } = Formatter.GenerateString(32); public string RefreshSecret { get; set; } = Formatter.GenerateString(32);
public string ClientId { get; set; } = Formatter.GenerateString(8); public int AccessDuration { get; set; } = 60;
public string ClientSecret { get; set; } = Formatter.GenerateString(32); public int RefreshDuration { get; set; } = 3600;
public string? AuthorizationUri { get; set; }
public string? AuthorizationRedirect { get; set; }
public string? AccessEndpoint { get; set; }
public string? RefreshEndpoint { get; set; }
// Local OAuth2 Service public OAuth2Data OAuth2 { get; set; } = new();
public string CodeSecret { get; set; } = Formatter.GenerateString(32); public bool UseLocalOAuth2 { get; set; } = true;
public LocalOAuth2Data LocalOAuth2 { get; set; } = new();
public class LocalOAuth2Data
{
public string AccessSecret { get; set; } = Formatter.GenerateString(32);
public string RefreshSecret { get; set; } = Formatter.GenerateString(32);
public string CodeSecret { get; set; } = Formatter.GenerateString(32);
}
public class OAuth2Data
{
public string ClientId { get; set; } = Formatter.GenerateString(8);
public string ClientSecret { get; set; } = Formatter.GenerateString(32);
public string? AuthorizationUri { get; set; }
public string? AuthorizationRedirect { get; set; }
public string? AccessEndpoint { get; set; }
public string? RefreshEndpoint { get; set; }
}
} }
public class DevelopmentConfig public class DevelopmentConfig

View File

@@ -4,11 +4,14 @@ using MoonCore.Extended.Abstractions;
using MoonCore.Extended.Helpers; using MoonCore.Extended.Helpers;
using MoonCore.Extended.OAuth2.ApiServer; using MoonCore.Extended.OAuth2.ApiServer;
using MoonCore.Helpers; using MoonCore.Helpers;
using MoonCore.PluginFramework.Services;
using MoonCore.Services; using MoonCore.Services;
using Moonlight.ApiServer.Attributes; using Moonlight.ApiServer.Attributes;
using Moonlight.ApiServer.Configuration; using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database.Entities; using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Helpers.Authentication; using Moonlight.ApiServer.Helpers.Authentication;
using Moonlight.ApiServer.Interfaces.Auth;
using Moonlight.ApiServer.Interfaces.OAuth2;
using Moonlight.ApiServer.Services; using Moonlight.ApiServer.Services;
using Moonlight.Shared.Http.Requests.Auth; using Moonlight.Shared.Http.Requests.Auth;
using Moonlight.Shared.Http.Responses.Auth; using Moonlight.Shared.Http.Responses.Auth;
@@ -23,100 +26,191 @@ public class AuthController : Controller
private readonly TokenHelper TokenHelper; private readonly TokenHelper TokenHelper;
private readonly ConfigService<AppConfiguration> ConfigService; private readonly ConfigService<AppConfiguration> ConfigService;
private readonly DatabaseRepository<User> UserRepository; private readonly DatabaseRepository<User> UserRepository;
private readonly ImplementationService ImplementationService;
public AuthController(OAuth2Service oAuth2Service, TokenHelper tokenHelper, DatabaseRepository<User> userRepository, ConfigService<AppConfiguration> configService) public AuthController(
OAuth2Service oAuth2Service,
TokenHelper tokenHelper,
DatabaseRepository<User> userRepository,
ConfigService<AppConfiguration> configService,
ImplementationService implementationService
)
{ {
OAuth2Service = oAuth2Service; OAuth2Service = oAuth2Service;
TokenHelper = tokenHelper; TokenHelper = tokenHelper;
UserRepository = userRepository; UserRepository = userRepository;
ConfigService = configService; ConfigService = configService;
ImplementationService = implementationService;
} }
[HttpGet("start")] [HttpGet]
public async Task<AuthStartResponse> Start() public async Task<OAuth2StartResponse> Start()
{ {
var data = await OAuth2Service.StartAuthorizing(); var data = await OAuth2Service.StartAuthorizing();
return Mapper.Map<AuthStartResponse>(data); return Mapper.Map<OAuth2StartResponse>(data);
}
[HttpPost]
public async Task<OAuth2HandleResponse> Handle([FromBody] OAuth2HandleRequest request)
{
var accessData = await OAuth2Service.RequestAccess(request.Code);
// Find oauth2 provider
var provider = ImplementationService.Get<IOAuth2Provider>().FirstOrDefault();
if (provider == null)
throw new HttpApiException("No oauth2 provider has been registered", 500);
// Sync user from oauth2 provider
var user = await provider.Sync(HttpContext.RequestServices, accessData.AccessToken, accessData.RefreshToken);
if (user == null)
throw new HttpApiException("The oauth2 provider was unable to authenticate you", 401);
// Allow plugins to intercept access calls
var interceptors = ImplementationService.Get<IAuthInterceptor>();
if (interceptors.Any(interceptor => !interceptor.AllowAccess(user, HttpContext.RequestServices)))
throw new HttpApiException("Unable to get access token", 401);
// Save oauth2 refresh and access tokens for later use (re-authentication etc.).
// Fetch user model in current db context, just in case the oauth2 provider
// uses a different db context or smth
var userModel = UserRepository
.Get()
.First(x => x.Id == user.Id);
userModel.AccessToken = accessData.AccessToken;
userModel.RefreshToken = accessData.RefreshToken;
userModel.RefreshTimestamp = DateTime.UtcNow.AddSeconds(accessData.ExpiresIn);
UserRepository.Update(userModel);
// Generate local token-pair for the authentication
// between client and the api server
var authConfig = ConfigService.Get().Authentication;
var tokenPair = await TokenHelper.GeneratePair(
authConfig.AccessSecret,
authConfig.AccessSecret,
data => { data.Add("userId", user.Id.ToString()); },
authConfig.AccessDuration,
authConfig.RefreshDuration
);
// Authentication finished. Return data to client
return new OAuth2HandleResponse()
{
AccessToken = tokenPair.AccessToken,
RefreshToken = tokenPair.RefreshToken,
ExpiresAt = DateTime.UtcNow.AddSeconds(authConfig.AccessDuration)
};
} }
[HttpPost("refresh")] [HttpPost("refresh")]
public async Task Refresh([FromBody] RefreshRequest request) public async Task<RefreshResponse> Refresh([FromBody] RefreshRequest request)
{ {
var authConfig = ConfigService.Get().Authentication; var authConfig = ConfigService.Get().Authentication;
var tokenPair = await TokenHelper.RefreshPair( var tokenPair = await TokenHelper.RefreshPair(
request.RefreshToken, request.RefreshToken,
authConfig.MlAccessSecret, authConfig.AccessSecret,
authConfig.MlRefreshSecret, authConfig.RefreshSecret,
(refreshTokenData, newTokenData) => (refreshData, newData)
{ => ProcessRefreshData(refreshData, newData, HttpContext.RequestServices),
if (!refreshTokenData.TryGetValue("userId", out var userIdStr) || !int.TryParse(userIdStr, out var userId)) authConfig.AccessDuration,
return false; authConfig.RefreshDuration
var user = UserRepository.Get().FirstOrDefault(x => x.Id == userId);
if (user == null)
return false;
//TODO: External check
newTokenData.Add("userId", user.Id.ToString());
return true;
}
); );
// Handle refresh error
if (!tokenPair.HasValue) if (!tokenPair.HasValue)
throw new HttpApiException("Unable to refresh token", 401); throw new HttpApiException("Unable to refresh token", 401);
Response.Cookies.Append("ml-access", tokenPair.Value.AccessToken); // Return data
Response.Cookies.Append("ml-refresh", tokenPair.Value.RefreshToken); return new RefreshResponse()
Response.Cookies.Append("ml-timestamp", DateTimeOffset.UtcNow.AddSeconds(3600).ToUnixTimeSeconds().ToString()); {
AccessToken = tokenPair.Value.AccessToken,
RefreshToken = tokenPair.Value.RefreshToken,
ExpiresAt = DateTime.UtcNow.AddSeconds(authConfig.AccessDuration)
};
} }
[HttpGet("handle")] private bool ProcessRefreshData(Dictionary<string, string> refreshTokenData, Dictionary<string, string> newData, IServiceProvider serviceProvider)
public async Task Handle([FromQuery(Name = "code")] string code)
{ {
//TODO: Validate jwt syntax // Find oauth2 provider
var provider = ImplementationService.Get<IOAuth2Provider>().FirstOrDefault();
var accessData = await OAuth2Service.RequestAccess(code);
//TODO: Add modular oauth2 consumer system
var userId = 1;
var user = UserRepository.Get().First(x => x.Id == userId); if (provider == null)
throw new HttpApiException("No oauth2 provider has been registered", 500);
user.AccessToken = accessData.AccessToken;
user.RefreshToken = accessData.RefreshToken;
user.RefreshTimestamp = DateTime.UtcNow.AddSeconds(accessData.ExpiresIn);
UserRepository.Update(user); // Check if the userId is present in the refresh token
if (!refreshTokenData.TryGetValue("userId", out var userIdStr) || !int.TryParse(userIdStr, out var userId))
return false;
var authConfig = ConfigService.Get().Authentication; // Load user from database if existent
var tokenPair = await TokenHelper.GeneratePair(authConfig.MlAccessSecret, authConfig.MlAccessSecret, data => var user = UserRepository
.Get()
.FirstOrDefault(x => x.Id == userId);
if (user == null)
return false;
// Allow plugins to intercept the refresh call
var interceptors = ImplementationService.Get<IAuthInterceptor>();
if (interceptors.Any(interceptor => !interceptor.AllowRefresh(user, serviceProvider)))
return false;
// Check if it's time to resync with the oauth2 provider
if (DateTime.UtcNow >= user.RefreshTimestamp)
{ {
data.Add("userId", user.Id.ToString()); // It's time to refresh the access to the external oauth2 provider
}); var refreshData = OAuth2Service.RefreshAccess(user.RefreshToken).Result;
// Sync user with oauth2 provider
var syncedUser = provider.Sync(serviceProvider, refreshData.AccessToken, refreshData.RefreshToken).Result;
if (syncedUser == null) // User sync has failed. No refresh allowed
return false;
// Save oauth2 refresh and access tokens for later use (re-authentication etc.).
// Fetch user model in current db context, just in case the oauth2 provider
// uses a different db context or smth
var userModel = UserRepository
.Get()
.First(x => x.Id == syncedUser.Id);
userModel.AccessToken = refreshData.AccessToken;
userModel.RefreshToken = refreshData.RefreshToken;
userModel.RefreshTimestamp = DateTime.UtcNow.AddSeconds(refreshData.ExpiresIn);
UserRepository.Update(userModel);
}
Response.Cookies.Append("ml-access", tokenPair.AccessToken); // All checks have passed, allow refresh
Response.Cookies.Append("ml-refresh", tokenPair.RefreshToken); newData.Add("userId", user.Id.ToString());
Response.Cookies.Append("ml-timestamp", DateTimeOffset.UtcNow.AddSeconds(3600).ToUnixTimeSeconds().ToString()); return true;
Response.Redirect("/");
} }
[HttpGet("check")] [HttpGet("check")]
[RequirePermission("meta.authenticated")] [RequirePermission("meta.authenticated")]
public async Task<CheckResponse> Check() public Task<CheckResponse> Check()
{ {
var perm = HttpContext.User as PermClaimsPrinciple; var perm = HttpContext.User as PermClaimsPrinciple;
var user = perm!.CurrentModel; var user = perm!.CurrentModel;
return new CheckResponse() var response = new CheckResponse()
{ {
Email = user.Email, Email = user.Email,
Username = user.Username, Username = user.Username,
Permissions = perm.Permissions Permissions = perm.Permissions
}; };
return Task.FromResult(response);
} }
} }

View File

@@ -0,0 +1,9 @@
using Moonlight.ApiServer.Database.Entities;
namespace Moonlight.ApiServer.Interfaces.Auth;
public interface IAuthInterceptor
{
public bool AllowAccess(User user, IServiceProvider serviceProvider);
public bool AllowRefresh(User user, IServiceProvider serviceProvider);
}

View File

@@ -0,0 +1,8 @@
using Moonlight.ApiServer.Database.Entities;
namespace Moonlight.ApiServer.Interfaces.OAuth2;
public interface IOAuth2Provider
{
public Task<User?> Sync(IServiceProvider provider, string accessToken, string refreshToken);
}

View File

@@ -29,7 +29,6 @@
<ItemGroup> <ItemGroup>
<Folder Include="Database\Migrations\" /> <Folder Include="Database\Migrations\" />
<Folder Include="Implementations\" /> <Folder Include="Implementations\" />
<Folder Include="Interfaces\" />
<Folder Include="storage\" /> <Folder Include="storage\" />
</ItemGroup> </ItemGroup>

View File

@@ -7,6 +7,7 @@ using MoonCore.Extended.OAuth2.ApiServer;
using MoonCore.Extensions; using MoonCore.Extensions;
using MoonCore.Helpers; using MoonCore.Helpers;
using MoonCore.Models; using MoonCore.Models;
using MoonCore.PluginFramework.Services;
using MoonCore.Services; using MoonCore.Services;
using Moonlight.ApiServer.Configuration; using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database; using Moonlight.ApiServer.Database;
@@ -93,41 +94,41 @@ builder.Services.AddSingleton<TokenHelper>();
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddOAuth2Consumer(configuration => builder.Services.AddOAuth2Consumer(configuration =>
{ {
configuration.ClientId = config.Authentication.ClientId; configuration.ClientId = config.Authentication.OAuth2.ClientId;
configuration.ClientSecret = config.Authentication.ClientSecret; configuration.ClientSecret = config.Authentication.OAuth2.ClientSecret;
configuration.AuthorizationRedirect = configuration.AuthorizationRedirect =
config.Authentication.AuthorizationRedirect ?? $"{config.PublicUrl}/api/auth/handle"; config.Authentication.OAuth2.AuthorizationRedirect ?? $"{config.PublicUrl}/api/auth/handle";
configuration.AccessEndpoint = config.Authentication.AccessEndpoint ?? $"{config.PublicUrl}/oauth2/access"; configuration.AccessEndpoint = config.Authentication.OAuth2.AccessEndpoint ?? $"{config.PublicUrl}/oauth2/access";
configuration.RefreshEndpoint = config.Authentication.RefreshEndpoint ?? $"{config.PublicUrl}/oauth2/refresh"; configuration.RefreshEndpoint = config.Authentication.OAuth2.RefreshEndpoint ?? $"{config.PublicUrl}/oauth2/refresh";
if (config.Authentication.UseLocalOAuth2Service) if (config.Authentication.UseLocalOAuth2)
{ {
configuration.AuthorizationEndpoint = config.Authentication.AuthorizationRedirect ?? $"{config.PublicUrl}/oauth2/authorize"; configuration.AuthorizationEndpoint = config.Authentication.OAuth2.AuthorizationRedirect ?? $"{config.PublicUrl}/oauth2/authorize";
} }
else else
{ {
if(config.Authentication.AuthorizationUri == null) if(config.Authentication.OAuth2.AuthorizationUri == null)
logger.LogWarning("The 'AuthorizationUri' for the oauth2 client is not set. If you want to use an external oauth2 provider, you need to specify this url. If you want to use the local oauth2 service, set 'UseLocalOAuth2Service' to true"); logger.LogWarning("The 'AuthorizationUri' for the oauth2 client is not set. If you want to use an external oauth2 provider, you need to specify this url. If you want to use the local oauth2 service, set 'UseLocalOAuth2Service' to true");
configuration.AuthorizationEndpoint = config.Authentication.AuthorizationUri!; configuration.AuthorizationEndpoint = config.Authentication.OAuth2.AuthorizationUri!;
} }
}); });
if (config.Authentication.UseLocalOAuth2Service) if (config.Authentication.UseLocalOAuth2)
{ {
logger.LogInformation("Using local oauth2 provider"); logger.LogInformation("Using local oauth2 provider");
builder.Services.AddOAuth2Provider(configuration => builder.Services.AddOAuth2Provider(configuration =>
{ {
configuration.AccessSecret = config.Authentication.AccessSecret; configuration.AccessSecret = config.Authentication.LocalOAuth2.AccessSecret;
configuration.RefreshSecret = config.Authentication.RefreshSecret; configuration.RefreshSecret = config.Authentication.LocalOAuth2.RefreshSecret;
configuration.ClientId = config.Authentication.ClientId; configuration.ClientId = config.Authentication.OAuth2.ClientId;
configuration.ClientSecret = config.Authentication.ClientSecret; configuration.ClientSecret = config.Authentication.OAuth2.ClientSecret;
configuration.CodeSecret = config.Authentication.CodeSecret; configuration.CodeSecret = config.Authentication.LocalOAuth2.CodeSecret;
configuration.AuthorizationRedirect = configuration.AuthorizationRedirect =
config.Authentication.AuthorizationRedirect ?? $"{config.PublicUrl}/api/auth/handle"; config.Authentication.OAuth2.AuthorizationRedirect ?? $"{config.PublicUrl}/api/auth/handle";
configuration.AccessTokenDuration = 60; configuration.AccessTokenDuration = 60;
configuration.RefreshTokenDuration = 3600; configuration.RefreshTokenDuration = 3600;
}); });
@@ -135,7 +136,7 @@ if (config.Authentication.UseLocalOAuth2Service)
builder.Services.AddTokenAuthentication(configuration => builder.Services.AddTokenAuthentication(configuration =>
{ {
configuration.AccessSecret = config.Authentication.MlAccessSecret; configuration.AccessSecret = config.Authentication.AccessSecret;
configuration.DataLoader = async (data, provider, context) => configuration.DataLoader = async (data, provider, context) =>
{ {
if (!data.TryGetValue("userId", out var userIdStr) || !int.TryParse(userIdStr, out var userId)) if (!data.TryGetValue("userId", out var userIdStr) || !int.TryParse(userIdStr, out var userId))
@@ -210,6 +211,13 @@ if (configService.Get().Development.EnableApiDocs)
})); }));
} }
// Implementation service
var implementationService = new ImplementationService();
builder.Services.AddSingleton(implementationService);
var app = builder.Build(); var app = builder.Build();
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())

View File

@@ -70,14 +70,4 @@ public class AuthService
return Task.FromResult(user); return Task.FromResult(user);
} }
public async Task<string> GenerateToken(User user)
{
var authConfig = ConfigService.Get().Authentication;
return await JwtHelper.Create(authConfig.Secret, data =>
{
data.Add("userId", user.Id.ToString());
}, "login", TimeSpan.FromDays(authConfig.TokenDuration));
}
} }

View File

@@ -12,7 +12,7 @@
{ {
private async Task StartAuth(WButton _) private async Task StartAuth(WButton _)
{ {
var authStartData = await HttpApiClient.GetJson<AuthStartResponse>("api/auth/start"); var authStartData = await HttpApiClient.GetJson<OAuth2StartResponse>("api/auth/start");
var uri = authStartData.Endpoint var uri = authStartData.Endpoint
+ $"?client_id={authStartData.ClientId}" + + $"?client_id={authStartData.ClientId}" +

View File

@@ -1,13 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.Auth;
public class LoginRequest
{
[Required(ErrorMessage = "You need to provide an email address")]
[EmailAddress(ErrorMessage = "You need to provide a valid email address")]
public string Email { get; set; }
[Required(ErrorMessage = "You need to provide a password")]
public string Password { get; set; }
}

View File

@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.Auth;
public class OAuth2HandleRequest
{
[Required(ErrorMessage = "You need to provide the oauth2 code")]
public string Code { get; set; }
}

View File

@@ -1,19 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Moonlight.Shared.Http.Requests.Auth;
public class RegisterRequest
{
[Required(ErrorMessage = "You need to provide an email address")]
[EmailAddress(ErrorMessage = "You need to provide a valid email address")]
public string Email { get; set; }
[Required(ErrorMessage = "You need to provide a username")]
[RegularExpression("^[a-z][a-z0-9]*$", ErrorMessage = "Usernames can only contain lowercase characters and numbers and should not start with a number")]
public string Username { get; set; }
[Required(ErrorMessage = "You need to provide a password")]
[MinLength(8, ErrorMessage = "Your password needs to be at least 8 characters long")]
[MaxLength(256, ErrorMessage = "Your password should not exceed the length of 256 characters")]
public string Password { get; set; }
}

View File

@@ -1,6 +0,0 @@
namespace Moonlight.Shared.Http.Responses.Auth;
public class LoginResponse
{
public string Token { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace Moonlight.Shared.Http.Responses.Auth;
public class OAuth2HandleResponse
{
public string AccessToken { get; set; }
public string RefreshToken { get; set; }
public DateTime ExpiresAt { get; set; }
}

View File

@@ -1,6 +1,6 @@
namespace Moonlight.Shared.Http.Responses.Auth; namespace Moonlight.Shared.Http.Responses.Auth;
public class AuthStartResponse public class OAuth2StartResponse
{ {
public string Endpoint { get; set; } public string Endpoint { get; set; }
public string ClientId { get; set; } public string ClientId { get; set; }

View File

@@ -0,0 +1,8 @@
namespace Moonlight.Shared.Http.Responses.Auth;
public class RefreshResponse
{
public string AccessToken { get; set; }
public string RefreshToken { get; set; }
public DateTime ExpiresAt { get; set; }
}

View File

@@ -1,6 +0,0 @@
namespace Moonlight.Shared.Http.Responses.Auth;
public class RegisterResponse
{
public string Token { get; set; }
}