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 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 RefreshSecret { get; set; } = Formatter.GenerateString(32);
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 int AccessDuration { get; set; } = 60;
public int RefreshDuration { get; set; } = 3600;
// Local OAuth2 Service
public string CodeSecret { get; set; } = Formatter.GenerateString(32);
public OAuth2Data OAuth2 { get; set; } = new();
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

View File

@@ -4,11 +4,14 @@ using MoonCore.Extended.Abstractions;
using MoonCore.Extended.Helpers;
using MoonCore.Extended.OAuth2.ApiServer;
using MoonCore.Helpers;
using MoonCore.PluginFramework.Services;
using MoonCore.Services;
using Moonlight.ApiServer.Attributes;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database.Entities;
using Moonlight.ApiServer.Helpers.Authentication;
using Moonlight.ApiServer.Interfaces.Auth;
using Moonlight.ApiServer.Interfaces.OAuth2;
using Moonlight.ApiServer.Services;
using Moonlight.Shared.Http.Requests.Auth;
using Moonlight.Shared.Http.Responses.Auth;
@@ -23,100 +26,191 @@ public class AuthController : Controller
private readonly TokenHelper TokenHelper;
private readonly ConfigService<AppConfiguration> ConfigService;
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;
TokenHelper = tokenHelper;
UserRepository = userRepository;
ConfigService = configService;
ImplementationService = implementationService;
}
[HttpGet("start")]
public async Task<AuthStartResponse> Start()
[HttpGet]
public async Task<OAuth2StartResponse> Start()
{
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")]
public async Task Refresh([FromBody] RefreshRequest request)
public async Task<RefreshResponse> Refresh([FromBody] RefreshRequest request)
{
var authConfig = ConfigService.Get().Authentication;
var tokenPair = await TokenHelper.RefreshPair(
request.RefreshToken,
authConfig.MlAccessSecret,
authConfig.MlRefreshSecret,
(refreshTokenData, newTokenData) =>
{
if (!refreshTokenData.TryGetValue("userId", out var userIdStr) || !int.TryParse(userIdStr, out var userId))
return false;
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;
}
authConfig.AccessSecret,
authConfig.RefreshSecret,
(refreshData, newData)
=> ProcessRefreshData(refreshData, newData, HttpContext.RequestServices),
authConfig.AccessDuration,
authConfig.RefreshDuration
);
// Handle refresh error
if (!tokenPair.HasValue)
throw new HttpApiException("Unable to refresh token", 401);
Response.Cookies.Append("ml-access", tokenPair.Value.AccessToken);
Response.Cookies.Append("ml-refresh", tokenPair.Value.RefreshToken);
Response.Cookies.Append("ml-timestamp", DateTimeOffset.UtcNow.AddSeconds(3600).ToUnixTimeSeconds().ToString());
// Return data
return new RefreshResponse()
{
AccessToken = tokenPair.Value.AccessToken,
RefreshToken = tokenPair.Value.RefreshToken,
ExpiresAt = DateTime.UtcNow.AddSeconds(authConfig.AccessDuration)
};
}
[HttpGet("handle")]
public async Task Handle([FromQuery(Name = "code")] string code)
private bool ProcessRefreshData(Dictionary<string, string> refreshTokenData, Dictionary<string, string> newData, IServiceProvider serviceProvider)
{
//TODO: Validate jwt syntax
var accessData = await OAuth2Service.RequestAccess(code);
//TODO: Add modular oauth2 consumer system
var userId = 1;
// Find oauth2 provider
var provider = ImplementationService.Get<IOAuth2Provider>().FirstOrDefault();
var user = UserRepository.Get().First(x => x.Id == userId);
user.AccessToken = accessData.AccessToken;
user.RefreshToken = accessData.RefreshToken;
user.RefreshTimestamp = DateTime.UtcNow.AddSeconds(accessData.ExpiresIn);
if (provider == null)
throw new HttpApiException("No oauth2 provider has been registered", 500);
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;
var tokenPair = await TokenHelper.GeneratePair(authConfig.MlAccessSecret, authConfig.MlAccessSecret, data =>
// Load user from database if existent
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);
Response.Cookies.Append("ml-refresh", tokenPair.RefreshToken);
Response.Cookies.Append("ml-timestamp", DateTimeOffset.UtcNow.AddSeconds(3600).ToUnixTimeSeconds().ToString());
Response.Redirect("/");
// All checks have passed, allow refresh
newData.Add("userId", user.Id.ToString());
return true;
}
[HttpGet("check")]
[RequirePermission("meta.authenticated")]
public async Task<CheckResponse> Check()
public Task<CheckResponse> Check()
{
var perm = HttpContext.User as PermClaimsPrinciple;
var user = perm!.CurrentModel;
return new CheckResponse()
var response = new CheckResponse()
{
Email = user.Email,
Username = user.Username,
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>
<Folder Include="Database\Migrations\" />
<Folder Include="Implementations\" />
<Folder Include="Interfaces\" />
<Folder Include="storage\" />
</ItemGroup>

View File

@@ -7,6 +7,7 @@ using MoonCore.Extended.OAuth2.ApiServer;
using MoonCore.Extensions;
using MoonCore.Helpers;
using MoonCore.Models;
using MoonCore.PluginFramework.Services;
using MoonCore.Services;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database;
@@ -93,41 +94,41 @@ builder.Services.AddSingleton<TokenHelper>();
builder.Services.AddHttpClient();
builder.Services.AddOAuth2Consumer(configuration =>
{
configuration.ClientId = config.Authentication.ClientId;
configuration.ClientSecret = config.Authentication.ClientSecret;
configuration.ClientId = config.Authentication.OAuth2.ClientId;
configuration.ClientSecret = config.Authentication.OAuth2.ClientSecret;
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.RefreshEndpoint = config.Authentication.RefreshEndpoint ?? $"{config.PublicUrl}/oauth2/refresh";
configuration.AccessEndpoint = config.Authentication.OAuth2.AccessEndpoint ?? $"{config.PublicUrl}/oauth2/access";
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
{
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");
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");
builder.Services.AddOAuth2Provider(configuration =>
{
configuration.AccessSecret = config.Authentication.AccessSecret;
configuration.RefreshSecret = config.Authentication.RefreshSecret;
configuration.AccessSecret = config.Authentication.LocalOAuth2.AccessSecret;
configuration.RefreshSecret = config.Authentication.LocalOAuth2.RefreshSecret;
configuration.ClientId = config.Authentication.ClientId;
configuration.ClientSecret = config.Authentication.ClientSecret;
configuration.CodeSecret = config.Authentication.CodeSecret;
configuration.ClientId = config.Authentication.OAuth2.ClientId;
configuration.ClientSecret = config.Authentication.OAuth2.ClientSecret;
configuration.CodeSecret = config.Authentication.LocalOAuth2.CodeSecret;
configuration.AuthorizationRedirect =
config.Authentication.AuthorizationRedirect ?? $"{config.PublicUrl}/api/auth/handle";
config.Authentication.OAuth2.AuthorizationRedirect ?? $"{config.PublicUrl}/api/auth/handle";
configuration.AccessTokenDuration = 60;
configuration.RefreshTokenDuration = 3600;
});
@@ -135,7 +136,7 @@ if (config.Authentication.UseLocalOAuth2Service)
builder.Services.AddTokenAuthentication(configuration =>
{
configuration.AccessSecret = config.Authentication.MlAccessSecret;
configuration.AccessSecret = config.Authentication.AccessSecret;
configuration.DataLoader = async (data, provider, context) =>
{
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();
using (var scope = app.Services.CreateScope())

View File

@@ -70,14 +70,4 @@ public class AuthService
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 _)
{
var authStartData = await HttpApiClient.GetJson<AuthStartResponse>("api/auth/start");
var authStartData = await HttpApiClient.GetJson<OAuth2StartResponse>("api/auth/start");
var uri = authStartData.Endpoint
+ $"?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;
public class AuthStartResponse
public class OAuth2StartResponse
{
public string Endpoint { 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; }
}