Resolve "Configuratable token times" #40

Merged
leon.hoppe merged 4 commits from feature/config into dev 2024-12-21 15:03:48 +01:00
15 changed files with 134 additions and 34 deletions
Showing only changes of commit 88c8fe612d - Show all commits

43
docs/authentication.md Normal file
View File

@@ -0,0 +1,43 @@
# HopFrame Authentication
HopFrame uses a token system with a short term access token and a long term refresh token for authenticating users.
These tokens are usually provided to the endpoints of the API / Blazor Pages through Cookies:
| Cookie key | Cookie value sample | Description |
|--------------------------------|----------------------------------------|-----------------------------|
| HopFrame.Security.RefreshToken | `42047983-914d-418b-841a-4382614231be` | The long term refresh token |
| HopFrame.Security.AccessToken | `d39c9432-0831-42df-8844-5e2b70f03eda` | The short term access token |
The advantage of these cookies is that they are automatically set by the backend and delete themselves, when they are
no longer valid.
The access token can also be delivered through a header called `HopFrame.Authentication` or `Token`.
It can also be delivered through a query parameter called `token`. This simplifies requests for images for example
because you can directly specify the url in the img tag in html.
## Authentication configuration
You can also configure the time span that the tokens are valid using the `appsettings.json` or environment variables
by configuring your configuration to load these.
>**Hint**: Configuring your application to use environment variables works by simply adding
> `builder.Configuration.AddEnvironmentVariables();` to your startup configuration before you add the
> custom configurations / HopFrame services.
### Example
You can specify `Seconds`, `Minutes`, `Hours` and `Days` for either of the two token types.
These get combined to a single time span.
```json
"HopFrame": {
"Authentication": {
"AccessToken": {
"Minutes": 30
},
"RefreshToken": {
"Days": 10,
"Hours": 5
}
}
}
```

View File

@@ -4,6 +4,7 @@ using HopFrame.Api.Logic.Implementation;
using HopFrame.Database;
using HopFrame.Security.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -15,23 +16,25 @@ public static class ServiceCollectionExtensions {
/// Adds all HopFrame endpoints and services to the application
/// </summary>
/// <param name="services">The service provider to add the services to</param>
/// <param name="configuration">The configuration used to configure HopFrame authentication</param>
/// <typeparam name="TDbContext">The data source for all HopFrame entities</typeparam>
public static void AddHopFrame<TDbContext>(this IServiceCollection services) where TDbContext : HopDbContextBase {
public static void AddHopFrame<TDbContext>(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase {
services.AddMvcCore().UseSpecificControllers(typeof(SecurityController));
AddHopFrameNoEndpoints<TDbContext>(services);
AddHopFrameNoEndpoints<TDbContext>(services, configuration);
}
/// <summary>
/// Adds all HopFrame services to the application
/// </summary>
/// <param name="services">The service provider to add the services to</param>
/// <param name="configuration">The configuration used to configure HopFrame authentication</param>
/// <typeparam name="TDbContext">The data source for all HopFrame entities</typeparam>
public static void AddHopFrameNoEndpoints<TDbContext>(this IServiceCollection services) where TDbContext : HopDbContextBase {
public static void AddHopFrameNoEndpoints<TDbContext>(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase {
services.AddHopFrameRepositories<TDbContext>();
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddScoped<IAuthLogic, AuthLogic>();
services.AddHopFrameAuthentication();
services.AddHopFrameAuthentication(configuration);
}
}

View File

@@ -5,10 +5,11 @@ using HopFrame.Security.Authentication;
using HopFrame.Security.Claims;
using HopFrame.Security.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace HopFrame.Api.Logic.Implementation;
internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor) : IAuthLogic {
internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenContext tokenContext, IHttpContextAccessor accessor, IOptions<HopFrameAuthenticationOptions> options) : IAuthLogic {
public async Task<LogicResult<SingleValueResult<string>>> Login(UserLogin login) {
var user = await users.GetUserByEmail(login.Email);
@@ -23,12 +24,12 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC
var accessToken = await tokens.CreateToken(Token.AccessTokenType, user);
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions {
MaxAge = HopFrameAuthentication.RefreshTokenTime,
MaxAge = options.Value.RefreshTokenTime,
HttpOnly = true,
Secure = true
});
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions {
MaxAge = HopFrameAuthentication.AccessTokenTime,
MaxAge = options.Value.AccessTokenTime,
HttpOnly = true,
Secure = true
});
@@ -54,12 +55,12 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC
var accessToken = await tokens.CreateToken(Token.AccessTokenType, user);
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions {
MaxAge = HopFrameAuthentication.RefreshTokenTime,
MaxAge = options.Value.RefreshTokenTime,
HttpOnly = true,
Secure = true
});
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions {
MaxAge = HopFrameAuthentication.AccessTokenTime,
MaxAge = options.Value.AccessTokenTime,
HttpOnly = false,
Secure = true
});
@@ -81,13 +82,13 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC
if (token.Type != Token.RefreshTokenType)
return LogicResult<SingleValueResult<string>>.Conflict("The provided token is not a refresh token");
if (token.CreatedAt + HopFrameAuthentication.RefreshTokenTime < DateTime.Now)
if (token.CreatedAt + options.Value.RefreshTokenTime < DateTime.Now)
return LogicResult<SingleValueResult<string>>.Forbidden("Refresh token is expired");
var accessToken = await tokens.CreateToken(Token.AccessTokenType, token.Owner);
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions {
MaxAge = HopFrameAuthentication.AccessTokenTime,
MaxAge = options.Value.AccessTokenTime,
HttpOnly = false,
Secure = true
});

View File

@@ -17,23 +17,23 @@ public class HopFrameAuthentication(
UrlEncoder encoder,
ISystemClock clock,
ITokenRepository tokens,
IPermissionRepository perms)
IPermissionRepository perms,
IOptions<HopFrameAuthenticationOptions> tokenOptions)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder, clock) {
public const string SchemeName = "HopCore.Authentication";
public static readonly TimeSpan AccessTokenTime = new(0, 0, 5, 0);
public static readonly TimeSpan RefreshTokenTime = new(30, 0, 0, 0);
public const string SchemeName = "HopFrame.Authentication";
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() {
var accessToken = Request.Cookies[ITokenContext.AccessTokenType];
if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Headers[SchemeName];
if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Headers["Token"];
if (string.IsNullOrEmpty(accessToken)) accessToken = Request.Query["token"];
if (string.IsNullOrEmpty(accessToken)) return AuthenticateResult.Fail("No Access Token provided");
var tokenEntry = await tokens.GetToken(accessToken);
if (tokenEntry is null) return AuthenticateResult.Fail("The provided Access Token does not exist");
if (tokenEntry.CreatedAt + AccessTokenTime < DateTime.Now) return AuthenticateResult.Fail("The provided Access Token is expired");
if (tokenEntry.CreatedAt + tokenOptions.Value.AccessTokenTime < DateTime.Now) return AuthenticateResult.Fail("The provided Access Token is expired");
if (tokenEntry.Owner is null)
return AuthenticateResult.Fail("The provided Access Token does not match any user");

View File

@@ -1,23 +1,26 @@
using HopFrame.Security.Claims;
using HopFrame.Security.Options;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace HopFrame.Security.Authentication;
public static class HopFrameAuthenticationExtensions {
/// <summary>
/// Configures the WebApplication to use the authentication and authorization of the HopFrame API
/// </summary>
/// <param name="service">The service provider to add the services to</param>
/// <typeparam name="TDbContext">The database object that saves all entities that are important for the security api</typeparam>
/// <param name="configuration">The configuration used to configure HopFrame authentication</param>
/// <returns></returns>
public static IServiceCollection AddHopFrameAuthentication(this IServiceCollection service) {
public static IServiceCollection AddHopFrameAuthentication(this IServiceCollection service, ConfigurationManager configuration) {
service.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
service.AddScoped<ITokenContext, TokenContextImplementor>();
service.AddOptionsFromConfiguration<HopFrameAuthenticationOptions>(configuration);
service.AddAuthentication(HopFrameAuthentication.SchemeName).AddScheme<AuthenticationSchemeOptions, HopFrameAuthentication>(HopFrameAuthentication.SchemeName, _ => {});
service.AddAuthorization();

View File

@@ -0,0 +1,20 @@
using HopFrame.Security.Options;
namespace HopFrame.Security.Authentication;
public class HopFrameAuthenticationOptions : OptionsFromConfiguration {
public override string Position { get; } = "HopFrame:Authentication";
public TimeSpan AccessTokenTime => AccessToken is null ? new(0, 0, 5, 0) : new(AccessToken.Days, AccessToken.Hours, AccessToken.Minutes, AccessToken.Seconds);
public TimeSpan RefreshTokenTime => RefreshToken is null ? new(30, 0, 0, 0) : new(RefreshToken.Days, RefreshToken.Hours, RefreshToken.Minutes, RefreshToken.Seconds);
public TokenTime AccessToken { get; set; }
public TokenTime RefreshToken { get; set; }
public class TokenTime {
public int Days { get; set; }
public int Hours { get; set; }
public int Minutes { get; set; }
public int Seconds { get; set; }
}
}

View File

@@ -0,0 +1,5 @@
namespace HopFrame.Security.Options;
public abstract class OptionsFromConfiguration {
public abstract string Position { get; }
}

View File

@@ -0,0 +1,19 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Security.Options;
public static class OptionsFromConfigurationExtensions {
public static void AddOptionsFromConfiguration<T>(this IServiceCollection services, IConfiguration configuration) where T : OptionsFromConfiguration {
T optionsInstance = (T)Activator.CreateInstance(typeof(T));
string position = optionsInstance?.Position;
if (position is null) {
throw new ArgumentException($"""Configuration "{typeof(T).Name}" has no position configured!""");
}
services.Configure((Action<T>)(options => {
IConfigurationSection section = configuration.GetSection(position);
section.Bind(options);
}));
}
}

View File

@@ -6,12 +6,13 @@ using HopFrame.Web.Admin;
using HopFrame.Web.Services;
using HopFrame.Web.Services.Implementation;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Web;
public static class ServiceCollectionExtensions {
public static IServiceCollection AddHopFrame<TDbContext>(this IServiceCollection services) where TDbContext : HopDbContextBase {
public static IServiceCollection AddHopFrame<TDbContext>(this IServiceCollection services, ConfigurationManager configuration) where TDbContext : HopDbContextBase {
services.AddHttpClient();
services.AddHopFrameRepositories<TDbContext>();
services.AddScoped<IAuthService, AuthService>();
@@ -22,7 +23,7 @@ public static class ServiceCollectionExtensions {
services.AddSweetAlert2();
services.AddBlazorStrap();
services.AddHopFrameAuthentication();
services.AddHopFrameAuthentication(configuration);
return services;
}

View File

@@ -4,6 +4,7 @@ using HopFrame.Security.Authentication;
using HopFrame.Security.Claims;
using HopFrame.Security.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace HopFrame.Web.Services.Implementation;
@@ -11,7 +12,8 @@ internal class AuthService(
IUserRepository userService,
IHttpContextAccessor httpAccessor,
ITokenRepository tokens,
ITokenContext context)
ITokenContext context,
IOptions<HopFrameAuthenticationOptions> options)
: IAuthService {
public async Task Register(UserRegister register) {
@@ -27,12 +29,12 @@ internal class AuthService(
var accessToken = await tokens.CreateToken(Token.AccessTokenType, user);
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions {
MaxAge = HopFrameAuthentication.RefreshTokenTime,
MaxAge = options.Value.RefreshTokenTime,
HttpOnly = true,
Secure = true
});
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions {
MaxAge = HopFrameAuthentication.AccessTokenTime,
MaxAge = options.Value.AccessTokenTime,
HttpOnly = false,
Secure = true
});
@@ -48,12 +50,12 @@ internal class AuthService(
var accessToken = await tokens.CreateToken(Token.AccessTokenType, user);
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions {
MaxAge = HopFrameAuthentication.RefreshTokenTime,
MaxAge = options.Value.RefreshTokenTime,
HttpOnly = true,
Secure = true
});
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions {
MaxAge = HopFrameAuthentication.AccessTokenTime,
MaxAge = options.Value.AccessTokenTime,
HttpOnly = false,
Secure = true
});
@@ -77,12 +79,12 @@ internal class AuthService(
if (token is null || token.Type != Token.RefreshTokenType) return null;
if (token.CreatedAt + HopFrameAuthentication.RefreshTokenTime < DateTime.Now) return null;
if (token.CreatedAt + options.Value.RefreshTokenTime < DateTime.Now) return null;
var accessToken = await tokens.CreateToken(Token.AccessTokenType, token.Owner);
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions {
MaxAge = HopFrameAuthentication.AccessTokenTime,
MaxAge = options.Value.AccessTokenTime,
HttpOnly = false,
Secure = true
});
@@ -95,7 +97,7 @@ internal class AuthService(
if (accessToken is null) return false;
if (accessToken.Type != Token.AccessTokenType) return false;
if (accessToken.CreatedAt + HopFrameAuthentication.AccessTokenTime < DateTime.Now) return false;
if (accessToken.CreatedAt + options.Value.AccessTokenTime < DateTime.Now) return false;
if (accessToken.Owner is null) return false;
return true;

View File

@@ -7,7 +7,7 @@ var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddHopFrame<DatabaseContext>();
builder.Services.AddHopFrame<DatabaseContext>(builder.Configuration);
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

View File

@@ -6,7 +6,7 @@ using HopFrame.Web.Admin;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<DatabaseContext>();
builder.Services.AddHopFrame<DatabaseContext>();
builder.Services.AddHopFrame<DatabaseContext>(builder.Configuration);
builder.Services.AddAdminContext<AdminContext>();
// Add services to the container.

View File

@@ -10,6 +10,7 @@ using HopFrame.Security.Authentication;
using HopFrame.Security.Claims;
using HopFrame.Security.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Moq;
namespace HopFrame.Tests.Api;
@@ -75,7 +76,7 @@ public class AuthLogicTests {
.Setup(c => c.User)
.Returns(CreateDummyUser());
return (new AuthLogic(users.Object, tokens.Object, context.Object, accessor), accessor.HttpContext);
return (new AuthLogic(users.Object, tokens.Object, context.Object, accessor, new OptionsWrapper<HopFrameAuthenticationOptions>(new HopFrameAuthenticationOptions())), accessor.HttpContext);
}
private User CreateDummyUser() => new() {

View File

@@ -46,7 +46,7 @@ public class AuthenticationTests {
.Setup(x => x.GetFullPermissions(It.IsAny<User>()))
.ReturnsAsync(new List<string>());
var auth = new HopFrameAuthentication(options.Object, logger.Object, encoder.Object, clock.Object, tokens.Object, perms.Object);
var auth = new HopFrameAuthentication(options.Object, logger.Object, encoder.Object, clock.Object, tokens.Object, perms.Object, new OptionsWrapper<HopFrameAuthenticationOptions>(new HopFrameAuthenticationOptions()));
var context = new DefaultHttpContext();
if (provideCorrectToken)
context.HttpContext.Request.Headers.Append(HopFrameAuthentication.SchemeName, correctToken.Content.ToString());

View File

@@ -1,11 +1,13 @@
using HopFrame.Database.Models;
using HopFrame.Database.Repositories;
using HopFrame.Security.Authentication;
using HopFrame.Security.Claims;
using HopFrame.Security.Models;
using HopFrame.Tests.Web.Extensions;
using HopFrame.Web.Services;
using HopFrame.Web.Services.Implementation;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Moq;
namespace HopFrame.Tests.Web;
@@ -66,7 +68,7 @@ public class AuthServiceTests {
.Setup(c => c.AccessToken)
.Returns(providedAccessToken);
return (new AuthService(users.Object, accessor, tokens.Object, context.Object), accessor.HttpContext);
return (new AuthService(users.Object, accessor, tokens.Object, context.Object, new OptionsWrapper<HopFrameAuthenticationOptions>(new HopFrameAuthenticationOptions())), accessor.HttpContext);
}
private User CreateDummyUser() => new() {