Resolve "API tokens" #41

Merged
leon.hoppe merged 4 commits from feature/tokens into dev 2024-12-21 17:36:13 +01:00
27 changed files with 214 additions and 72 deletions

View File

@@ -5,7 +5,7 @@
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/test/RestApiTest/bin/Debug/net8.0/test.db</jdbc-url>
<jdbc-url>jdbc:sqlite:C:\Users\leon\Documents\Projekte\HopFrame\testing\HopFrame.Testing.Api\bin\Debug\net8.0\test.db</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
</jdbc-additional-properties>

14
.idea/.idea.HopFrame/.idea/discord.xml generated Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
<option name="applicationTheme" value="default" />
<option name="iconsTheme" value="default" />
<option name="button1Title" value="" />
<option name="button1Url" value="" />
<option name="button2Title" value="" />
<option name="button2Url" value="" />
<option name="customApplicationId" value="" />
</component>
</project>

View File

@@ -1,4 +1,5 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fca451c12d69fe026a0e7e9b1a0ddbf4cf6f6b8316cb2aec7984a7241813f648_003FAuthenticationHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationSchemeOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8525b7a9e58c77f532f1a88d4f2897e3c2baf316b9eb2c391b242a3885fcce6_003FAuthenticationSchemeOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEditContextDataAnnotationsExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbc307cd57fb42fc4c7fb9795381958122734d3750f41b6c1735c7d132ecda70_003FEditContextDataAnnotationsExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2024_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@@ -67,6 +68,9 @@

View File

@@ -23,8 +23,6 @@ by configuring your configuration to load these.
> `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.
@@ -49,3 +47,28 @@ HOPFRAME__AUTHENTICATION__ACCESSTOKEN__MINUTES=30
HOPFRAME__AUTHENTICATION__REFRESHTOKEN__DAYS=10
HOPFRAME__AUTHENTICATION__REFRESHTOKEN__HOURS=5
```
## API tokens
API tokens are useful to use in automation environments that need to access an endpoint or page of your application.
The HopFrame supports this natively and no further configuration is required in order to use them.
### Create an api token
You can create an api token via the `ITokenRepository`:
```csharp
tokens.CreateApiToken(user, DateTime.MaxValue);
```
This creates a new api token that is valid until the provided DateTime has passed. Note that in the database and the token
model the `CreatedAt` property represents the expiration date on an api token. For security reasons the api token by default
has no permissions. This allows you to create tokens that are just permitted to perform a single action. Note that an api token
can **never** have more permissions than the user associated with it.
### Add permissions to an api token
You can add permissions to an api token like you would to a normal user or group:
```csharp
permissions.AddPermission(apiToken, "token.permission");
```

View File

@@ -35,16 +35,18 @@ public class Permission {
public DateTime GrantedAt { get; set; }
public virtual User User { get; set; }
public virtual PermissionGroup Group { get; set; }
public virtual Token Token { get; set; }
}
```
## Token
```csharp
public class Token {
public class Token : IPermissionOwner {
public int Type { get; set; }
public Guid Content { get; set; }
public DateTime CreatedAt { get; set; }
public virtual User Owner { get; set; }
public virtual List<Permission> Permissions { get; set; }
}
```

View File

@@ -71,5 +71,9 @@ public interface ITokenRepository {
Task<Token> CreateToken(int type, User owner);
Task DeleteUserTokens(User owner);
Task DeleteToken(Token token);
Task<Token> CreateApiToken(User owner, DateTime expirationDate);
}
```

View File

@@ -23,18 +23,18 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC
var refreshToken = await tokens.CreateToken(Token.RefreshTokenType, user);
var accessToken = await tokens.CreateToken(Token.AccessTokenType, user);
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions {
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.TokenId.ToString(), new CookieOptions {
MaxAge = options.Value.RefreshTokenTime,
HttpOnly = true,
Secure = true
});
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions {
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions {
MaxAge = options.Value.AccessTokenTime,
HttpOnly = true,
Secure = true
});
return LogicResult<SingleValueResult<string>>.Ok(accessToken.Content.ToString());
return LogicResult<SingleValueResult<string>>.Ok(accessToken.TokenId.ToString());
}
public async Task<LogicResult<SingleValueResult<string>>> Register(UserRegister register) {
@@ -54,18 +54,18 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC
var refreshToken = await tokens.CreateToken(Token.RefreshTokenType, user);
var accessToken = await tokens.CreateToken(Token.AccessTokenType, user);
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions {
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.TokenId.ToString(), new CookieOptions {
MaxAge = options.Value.RefreshTokenTime,
HttpOnly = true,
Secure = true
});
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions {
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions {
MaxAge = options.Value.AccessTokenTime,
HttpOnly = false,
Secure = true
});
return LogicResult<SingleValueResult<string>>.Ok(accessToken.Content.ToString());
return LogicResult<SingleValueResult<string>>.Ok(accessToken.TokenId.ToString());
}
public async Task<LogicResult<SingleValueResult<string>>> Authenticate() {
@@ -87,13 +87,13 @@ internal class AuthLogic(IUserRepository users, ITokenRepository tokens, ITokenC
var accessToken = await tokens.CreateToken(Token.AccessTokenType, token.Owner);
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions {
accessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions {
MaxAge = options.Value.AccessTokenTime,
HttpOnly = false,
Secure = true
});
return LogicResult<SingleValueResult<string>>.Ok(accessToken.Content.ToString());
return LogicResult<SingleValueResult<string>>.Ok(accessToken.TokenId.ToString());
}
public async Task<LogicResult> Logout() {

View File

@@ -30,5 +30,10 @@ public abstract class HopDbContextBase : DbContext {
.HasMany(g => g.Permissions)
.WithOne(p => p.Group)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Token>()
.HasMany(t => t.Permissions)
.WithOne(t => t.Token)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -21,6 +21,9 @@ public class Permission {
[ForeignKey("GroupName"), JsonIgnore]
public virtual PermissionGroup Group { get; set; }
[ForeignKey("TokenId"), JsonIgnore]
public virtual Token Token { get; set; }
}
public interface IPermissionOwner;

View File

@@ -4,24 +4,32 @@ using System.Text.Json.Serialization;
namespace HopFrame.Database.Models;
public class Token {
public class Token : IPermissionOwner {
public const int RefreshTokenType = 0;
public const int AccessTokenType = 1;
public const int ApiTokenType = 2;
/// <summary>
/// Defines the Type of the stored Token
/// 0: Refresh token
/// 1: Access token
/// 2: Api token
/// </summary>
[Required, MinLength(1), MaxLength(1)]
public int Type { get; set; }
[Key, Required, MinLength(36), MaxLength(36)]
public Guid Content { get; set; }
public Guid TokenId { get; set; }
/// <summary>
/// Defines the creation date of the token
/// In case of an api token it defines the date it becomes invalid
/// </summary>
[Required]
public DateTime CreatedAt { get; set; }
[ForeignKey("UserId"), JsonIgnore]
public virtual User Owner { get; set; }
public virtual List<Permission> Permissions { get; set; }
}

View File

@@ -5,5 +5,7 @@ namespace HopFrame.Database.Repositories;
public interface ITokenRepository {
Task<Token> GetToken(string content);
Task<Token> CreateToken(int type, User owner);
Task DeleteUserTokens(User owner);
Task DeleteUserTokens(User owner, bool includeApiTokens = false);
Task DeleteToken(Token token);
Task<Token> CreateApiToken(User owner, DateTime expirationDate);
}

View File

@@ -5,6 +5,10 @@ namespace HopFrame.Database.Repositories.Implementation;
internal sealed class PermissionRepository<TDbContext>(TDbContext context, IGroupRepository groupRepository) : IPermissionRepository where TDbContext : HopDbContextBase {
public async Task<bool> HasPermission(IPermissionOwner owner, params string[] permissions) {
if (owner is Token { Type: Token.ApiTokenType } token) {
if (!await HasPermission(token.Owner, permissions)) return false;
}
var perms = (await GetFullPermissions(owner)).ToArray();
foreach (var permission in permissions) {
@@ -24,6 +28,12 @@ internal sealed class PermissionRepository<TDbContext>(TDbContext context, IGrou
entry.User = user;
}else if (owner is PermissionGroup group) {
entry.Group = group;
}else if (owner is Token token) {
if (token.Type != Token.ApiTokenType)
throw new ArgumentException("Only API tokens can have permissions!");
if (!await HasPermission(token.Owner, permission))
throw new ArgumentException("An api token cannot have more permissions than the owner has!");
entry.Token = token;
}
await context.Permissions.AddAsync(entry);
@@ -48,6 +58,13 @@ internal sealed class PermissionRepository<TDbContext>(TDbContext context, IGrou
.Where(p =>p.Group.Name == group.Name)
.Where(p => p.PermissionName == permission)
.SingleOrDefaultAsync();
}else if (owner is Token token) {
entry = await context.Permissions
.Include(p => p.Token)
.Where(p => p.Token != null)
.Where(p => p.Token.TokenId == token.TokenId)
.Where(p => p.PermissionName == permission)
.SingleOrDefaultAsync();
}
if (entry is not null) {
@@ -58,6 +75,10 @@ internal sealed class PermissionRepository<TDbContext>(TDbContext context, IGrou
public async Task<IList<string>> GetFullPermissions(IPermissionOwner owner) {
var permissions = new List<string>();
if (owner is Token token && token.Type != Token.ApiTokenType) {
owner = token.Owner;
}
if (owner is User user) {
var perms = await context.Permissions
@@ -74,6 +95,14 @@ internal sealed class PermissionRepository<TDbContext>(TDbContext context, IGrou
.Where(p =>p.Group.Name == group.Name)
.ToListAsync();
permissions.AddRange(perms.Select(p => p.PermissionName));
}else if (owner is Token apiToken) {
var perms = await context.Permissions
.Include(p => p.Token)
.Where(p => p.Token != null)
.Where(p =>p.Token.TokenId == apiToken.TokenId)
.ToListAsync();
permissions.AddRange(perms.Select(p => p.PermissionName));
}

View File

@@ -11,14 +11,14 @@ internal sealed class TokenRepository<TDbContext>(TDbContext context) : ITokenRe
return await context.Tokens
.Include(t => t.Owner)
.Where(t => t.Content == guid)
.Where(t => t.TokenId == guid)
.SingleOrDefaultAsync();
}
public async Task<Token> CreateToken(int type, User owner) {
var token = new Token {
CreatedAt = DateTime.Now,
Content = Guid.NewGuid(),
TokenId = Guid.NewGuid(),
Type = type,
Owner = owner
};
@@ -29,13 +29,37 @@ internal sealed class TokenRepository<TDbContext>(TDbContext context) : ITokenRe
return token;
}
public async Task DeleteUserTokens(User owner) {
public async Task DeleteUserTokens(User owner, bool includeApiTokens = false) {
var tokens = await context.Tokens
.Include(t => t.Owner)
.Where(t => t.Owner.Id == owner.Id)
.ToListAsync();
if (!includeApiTokens)
tokens = tokens
.Where(t => t.Type != Token.ApiTokenType)
.ToList();
context.Tokens.RemoveRange(tokens);
await context.SaveChangesAsync();
}
public async Task DeleteToken(Token token) {
context.Tokens.Remove(token);
await context.SaveChangesAsync();
}
public async Task<Token> CreateApiToken(User owner, DateTime expirationDate) {
var token = new Token {
CreatedAt = expirationDate,
TokenId = Guid.NewGuid(),
Type = Token.ApiTokenType,
Owner = owner
};
await context.Tokens.AddAsync(token);
await context.SaveChangesAsync();
return token;
}
}

View File

@@ -1,5 +1,6 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using HopFrame.Database.Models;
using HopFrame.Database.Repositories;
using HopFrame.Security.Claims;
using Microsoft.AspNetCore.Authentication;
@@ -33,7 +34,10 @@ public class HopFrameAuthentication(
var tokenEntry = await tokens.GetToken(accessToken);
if (tokenEntry is null) return AuthenticateResult.Fail("The provided Access Token does not exist");
if (tokenEntry.CreatedAt + tokenOptions.Value.AccessTokenTime < DateTime.Now) return AuthenticateResult.Fail("The provided Access Token is expired");
if (tokenEntry.Type == Token.ApiTokenType) {
if (tokenEntry.CreatedAt < DateTime.Now) return AuthenticateResult.Fail("The provided API Token is expired");
}else 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");
@@ -43,7 +47,7 @@ public class HopFrameAuthentication(
new(HopFrameClaimTypes.UserId, tokenEntry.Owner.Id.ToString())
};
var permissions = await perms.GetFullPermissions(tokenEntry.Owner);
var permissions = await perms.GetFullPermissions(tokenEntry);
claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm)));
var principal = new ClaimsPrincipal();

View File

@@ -21,4 +21,6 @@ public interface ITokenContext {
/// The access token the user provided
/// </summary>
Token AccessToken { get; }
IList<string> ContextualPermissions { get; }
}

View File

@@ -4,10 +4,12 @@ using Microsoft.AspNetCore.Http;
namespace HopFrame.Security.Claims;
internal sealed class TokenContextImplementor(IHttpContextAccessor accessor, IUserRepository users, ITokenRepository tokens) : ITokenContext {
internal sealed class TokenContextImplementor(IHttpContextAccessor accessor, IUserRepository users, ITokenRepository tokens, IPermissionRepository permissions) : ITokenContext {
public bool IsAuthenticated => !string.IsNullOrEmpty(accessor.HttpContext?.User.GetAccessTokenId());
public User User => users.GetUser(Guid.Parse(accessor.HttpContext?.User.GetUserId() ?? Guid.Empty.ToString())).GetAwaiter().GetResult();
public Token AccessToken => tokens.GetToken(accessor.HttpContext?.User.GetAccessTokenId()).GetAwaiter().GetResult();
public IList<string> ContextualPermissions => permissions.GetFullPermissions(AccessToken).GetAwaiter().GetResult();
}

View File

@@ -22,11 +22,11 @@ public sealed class AuthMiddleware(IAuthService auth, IPermissionRepository perm
}
var claims = new List<Claim> {
new(HopFrameClaimTypes.AccessTokenId, token.Content.ToString()),
new(HopFrameClaimTypes.AccessTokenId, token.TokenId.ToString()),
new(HopFrameClaimTypes.UserId, token.Owner.Id.ToString())
};
var permissions = await perms.GetFullPermissions(token.Owner);
var permissions = await perms.GetFullPermissions(token);
claims.AddRange(permissions.Select(perm => new Claim(HopFrameClaimTypes.Permission, perm)));
context.User.AddIdentity(new ClaimsIdentity(claims, HopFrameAuthentication.SchemeName));

View File

@@ -321,7 +321,7 @@
private async void Save() {
if (_isEdit && _currentPage.Permissions.Update is not null) {
if (!await Permissions.HasPermission(Auth.User, _currentPage.Permissions.Update)) {
if (!await Permissions.HasPermission(Auth.AccessToken, _currentPage.Permissions.Update)) {
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Unauthorized!",
Text = "You don't have the required permissions to edit an entry!",
@@ -330,7 +330,7 @@
return;
}
}else if (_currentPage.Permissions.Create is not null) {
if (!await Permissions.HasPermission(Auth.User, _currentPage.Permissions.Create)) {
if (!await Permissions.HasPermission(Auth.AccessToken, _currentPage.Permissions.Create)) {
await Alerts.FireAsync(new SweetAlertOptions {
Title = "Unauthorized!",
Text = "You don't have the required permissions to add an entry!",

View File

@@ -140,8 +140,8 @@
throw new ArgumentException($"AdminPage '{_pageData.Title}' does not specify a model repository!'");
_modelProvider = _pageData.LoadModelProvider(Provider);
_hasEditPermission = _pageData.Permissions.Update is null || await Permissions.HasPermission(Auth.User, _pageData.Permissions.Update);
_hasDeletePermission = _pageData.Permissions.Delete is null || await Permissions.HasPermission(Auth.User, _pageData.Permissions.Delete);
_hasEditPermission = _pageData.Permissions.Update is null || await Permissions.HasPermission(Auth.AccessToken, _pageData.Permissions.Update);
_hasDeletePermission = _pageData.Permissions.Delete is null || await Permissions.HasPermission(Auth.AccessToken, _pageData.Permissions.Delete);
await Reload();
}

View File

@@ -28,12 +28,12 @@ internal class AuthService(
var refreshToken = await tokens.CreateToken(Token.RefreshTokenType, user);
var accessToken = await tokens.CreateToken(Token.AccessTokenType, user);
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions {
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.TokenId.ToString(), new CookieOptions {
MaxAge = options.Value.RefreshTokenTime,
HttpOnly = true,
Secure = true
});
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions {
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions {
MaxAge = options.Value.AccessTokenTime,
HttpOnly = false,
Secure = true
@@ -49,12 +49,12 @@ internal class AuthService(
var refreshToken = await tokens.CreateToken(Token.RefreshTokenType, user);
var accessToken = await tokens.CreateToken(Token.AccessTokenType, user);
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.Content.ToString(), new CookieOptions {
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.RefreshTokenType, refreshToken.TokenId.ToString(), new CookieOptions {
MaxAge = options.Value.RefreshTokenTime,
HttpOnly = true,
Secure = true
});
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions {
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions {
MaxAge = options.Value.AccessTokenTime,
HttpOnly = false,
Secure = true
@@ -83,7 +83,7 @@ internal class AuthService(
var accessToken = await tokens.CreateToken(Token.AccessTokenType, token.Owner);
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.Content.ToString(), new CookieOptions {
httpAccessor.HttpContext?.Response.Cookies.Append(ITokenContext.AccessTokenType, accessToken.TokenId.ToString(), new CookieOptions {
MaxAge = options.Value.AccessTokenTime,
HttpOnly = false,
Secure = true

View File

@@ -1,5 +1,7 @@
using HopFrame.Api.Logic;
using HopFrame.Api.Models;
using HopFrame.Database.Models;
using HopFrame.Database.Repositories;
using HopFrame.Security.Authorization;
using HopFrame.Security.Claims;
using HopFrame.Testing.Api.Models;
@@ -10,11 +12,11 @@ namespace HopFrame.Testing.Api.Controllers;
[ApiController]
[Route("test")]
public class TestController(ITokenContext userContext, DatabaseContext context) : ControllerBase {
public class TestController(ITokenContext userContext, DatabaseContext context, ITokenRepository tokens, IPermissionRepository permissions) : ControllerBase {
[HttpGet("permissions"), Authorized]
public ActionResult<IList<Permission>> Permissions() {
return new ActionResult<IList<Permission>>(userContext.User.Permissions);
public ActionResult<IList<string>> Permissions() {
return new ActionResult<IList<string>>(userContext.ContextualPermissions);
}
[HttpGet("generate")]
@@ -50,5 +52,19 @@ public class TestController(ITokenContext userContext, DatabaseContext context)
public async Task<ActionResult<IList<Address>>> GetAddresses() {
return LogicResult<IList<Address>>.Ok(await context.Addresses.Include(e => e.Employee).ToListAsync());
}
[HttpGet("token"), Authorized]
public async Task<ActionResult<SingleValueResult<string>>> GetApiToken() {
var token = await tokens.CreateApiToken(userContext.User, DateTime.MaxValue);
await permissions.AddPermission(token, "hopframe.admin");
await permissions.AddPermission(token, "hopframe.admin.users.read");
return LogicResult<SingleValueResult<string>>.Ok(token.TokenId.ToString());
}
[HttpDelete("token/{tokenId}")]
public async Task DeleteToken(string tokenId) {
var token = await tokens.GetToken(tokenId);
await tokens.DeleteToken(token);
}
}

View File

@@ -18,7 +18,7 @@ builder.Services.AddSwaggerGen(c => {
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme {
Description = @"JWT Authorization header using the Bearer scheme. \r\n\r\n
Enter 'Bearer' [space] and then your token in the text input below.",
Name = "Authorization",
Name = "Token",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
Scheme = "Bearer"

View File

@@ -58,13 +58,13 @@ public class AuthLogicTests {
tokens
.Setup(t => t.CreateToken(It.Is<int>(t => t == Token.RefreshTokenType), It.IsAny<User>()))
.ReturnsAsync(new Token {
Content = _refreshToken,
TokenId = _refreshToken,
Type = Token.RefreshTokenType
});
tokens
.Setup(t => t.CreateToken(It.Is<int>(t => t == Token.AccessTokenType), It.IsAny<User>()))
.ReturnsAsync(new Token {
Content = _accessToken,
TokenId = _accessToken,
Type = Token.AccessTokenType
});
tokens
@@ -229,11 +229,11 @@ public class AuthLogicTests {
// Arrange
var token = new Token {
Type = Token.RefreshTokenType,
Content = _refreshToken,
TokenId = _refreshToken,
CreatedAt = DateTime.Now,
Owner = CreateDummyUser()
};
var (auth, context) = SetupEnvironment(true, token, token.Content.ToString());
var (auth, context) = SetupEnvironment(true, token, token.TokenId.ToString());
// Act
var result = await auth.Authenticate();
@@ -277,11 +277,11 @@ public class AuthLogicTests {
// Arrange
var token = new Token {
Type = Token.AccessTokenType,
Content = _refreshToken,
TokenId = _refreshToken,
CreatedAt = DateTime.Now,
Owner = CreateDummyUser()
};
var (auth, context) = SetupEnvironment(true, token, token.Content.ToString());
var (auth, context) = SetupEnvironment(true, token, token.TokenId.ToString());
// Act
var result = await auth.Authenticate();
@@ -297,11 +297,11 @@ public class AuthLogicTests {
// Arrange
var token = new Token {
Type = Token.RefreshTokenType,
Content = _refreshToken,
TokenId = _refreshToken,
CreatedAt = DateTime.MinValue,
Owner = CreateDummyUser()
};
var (auth, context) = SetupEnvironment(true, token, token.Content.ToString());
var (auth, context) = SetupEnvironment(true, token, token.TokenId.ToString());
// Act
var result = await auth.Authenticate();

View File

@@ -14,7 +14,7 @@ public class TokenRepositoryTests {
for (int i = 0; i < count; i++) {
await context.Tokens.AddAsync(new() {
Content = Guid.NewGuid(),
TokenId = Guid.NewGuid(),
Owner = CreateTestUser(),
Type = Token.AccessTokenType
});
@@ -37,7 +37,7 @@ public class TokenRepositoryTests {
var token = context.Tokens.First();
// Act
var result = await repo.GetToken(token.Content.ToString());
var result = await repo.GetToken(token.TokenId.ToString());
// Assert
Assert.Equal(token, result);
@@ -64,12 +64,12 @@ public class TokenRepositoryTests {
var user = CreateTestUser();
await context.Tokens.AddRangeAsync(new List<Token> {
new() {
Content = Guid.NewGuid(),
TokenId = Guid.NewGuid(),
Owner = user,
Type = Token.AccessTokenType
},
new() {
Content = Guid.NewGuid(),
TokenId = Guid.NewGuid(),
Owner = user,
Type = Token.RefreshTokenType
}

View File

@@ -30,7 +30,7 @@ public class AuthenticationTests {
var provideCorrectToken = correctToken is null;
correctToken ??= new Token {
Content = Guid.NewGuid(),
TokenId = Guid.NewGuid(),
CreatedAt = DateTime.Now,
Type = Token.AccessTokenType,
Owner = new User {
@@ -39,17 +39,17 @@ public class AuthenticationTests {
};
tokens
.Setup(x => x.GetToken(It.Is<string>(t => t == correctToken.Content.ToString())))
.Setup(x => x.GetToken(It.Is<string>(t => t == correctToken.TokenId.ToString())))
.ReturnsAsync(correctToken);
perms
.Setup(x => x.GetFullPermissions(It.IsAny<User>()))
.Setup(x => x.GetFullPermissions(It.IsAny<Token>()))
.ReturnsAsync(new List<string>());
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());
context.HttpContext.Request.Headers.Append(HopFrameAuthentication.SchemeName, correctToken.TokenId.ToString());
if (providedToken is not null)
context.HttpContext.Request.Headers.Append(HopFrameAuthentication.SchemeName, providedToken);
@@ -101,12 +101,12 @@ public class AuthenticationTests {
public async Task Authentication_With_ExpiredToken_Should_Fail() {
// Arrange
var token = new Token {
Content = Guid.NewGuid(),
TokenId = Guid.NewGuid(),
CreatedAt = DateTime.MinValue,
Type = Token.AccessTokenType,
Owner = new User()
};
var auth = await SetupEnvironment(token, token.Content.ToString());
var auth = await SetupEnvironment(token, token.TokenId.ToString());
// Act
var result = await auth.AuthenticateAsync();
@@ -121,12 +121,12 @@ public class AuthenticationTests {
public async Task Authentication_With_UnownedToken_Should_Fail() {
// Arrange
var token = new Token {
Content = Guid.NewGuid(),
TokenId = Guid.NewGuid(),
CreatedAt = DateTime.Now,
Type = Token.AccessTokenType,
Owner = null
};
var auth = await SetupEnvironment(token, token.Content.ToString());
var auth = await SetupEnvironment(token, token.TokenId.ToString());
// Act
var result = await auth.AuthenticateAsync();

View File

@@ -23,7 +23,7 @@ public class AuthMiddlewareTests {
var perms = new Mock<IPermissionRepository>();
perms
.Setup(p => p.GetFullPermissions(It.Is<User>(u => newToken.Owner.Id == u.Id)))
.Setup(p => p.GetFullPermissions(It.Is<Token>(u => newToken.Owner.Id == u.Owner.Id)))
.ReturnsAsync(CreateDummyUser().Permissions.Select(p => p.PermissionName).ToList);
return new AuthMiddleware(auth.Object, perms.Object);
@@ -61,7 +61,7 @@ public class AuthMiddlewareTests {
public async Task InvokeAsync_With_InvalidLoginValidToken_Should_Succeed() {
// Arrange
var token = new Token {
Content = Guid.NewGuid(),
TokenId = Guid.NewGuid(),
CreatedAt = DateTime.Now,
Type = Token.AccessTokenType,
Owner = CreateDummyUser()
@@ -74,7 +74,7 @@ public class AuthMiddlewareTests {
// Assert
Assert.Equal(token.Owner.Id.ToString(), context.User.FindFirstValue(HopFrameClaimTypes.UserId));
Assert.Equal(token.Content.ToString(), context.User.FindFirstValue(HopFrameClaimTypes.AccessTokenId));
Assert.Equal(token.TokenId.ToString(), context.User.FindFirstValue(HopFrameClaimTypes.AccessTokenId));
Assert.Equal(token.Owner.Permissions.First().PermissionName, context.User.FindFirstValue(HopFrameClaimTypes.Permission));
}

View File

@@ -47,13 +47,13 @@ public class AuthServiceTests {
tokens
.Setup(t => t.CreateToken(It.Is<int>(t => t == Token.RefreshTokenType), It.IsAny<User>()))
.ReturnsAsync(new Token {
Content = _refreshToken,
TokenId = _refreshToken,
Type = Token.RefreshTokenType
});
tokens
.Setup(t => t.CreateToken(It.Is<int>(t => t == Token.AccessTokenType), It.IsAny<User>()))
.ReturnsAsync(new Token {
Content = _accessToken,
TokenId = _accessToken,
Type = Token.AccessTokenType
});
tokens
@@ -171,18 +171,18 @@ public class AuthServiceTests {
// Arrange
var token = new Token {
Type = Token.RefreshTokenType,
Content = _refreshToken,
TokenId = _refreshToken,
CreatedAt = DateTime.Now,
Owner = CreateDummyUser()
};
var (service, context) = SetupEnvironment(true, token, token.Content.ToString());
var (service, context) = SetupEnvironment(true, token, token.TokenId.ToString());
// Act
var result = await service.RefreshLogin();
// Assert
Assert.NotNull(result);
Assert.Equal(_accessToken, result.Content);
Assert.Equal(_accessToken, result.TokenId);
Assert.Equal(_accessToken.ToString(), context.Response.Headers.FindCookie(ITokenContext.AccessTokenType));
}
@@ -217,11 +217,11 @@ public class AuthServiceTests {
// Arrange
var token = new Token {
Type = Token.AccessTokenType,
Content = _refreshToken,
TokenId = _refreshToken,
CreatedAt = DateTime.Now,
Owner = CreateDummyUser()
};
var (service, context) = SetupEnvironment(true, token, token.Content.ToString());
var (service, context) = SetupEnvironment(true, token, token.TokenId.ToString());
// Act
var result = await service.RefreshLogin();
@@ -236,11 +236,11 @@ public class AuthServiceTests {
// Arrange
var token = new Token {
Type = Token.RefreshTokenType,
Content = _refreshToken,
TokenId = _refreshToken,
CreatedAt = DateTime.MinValue,
Owner = CreateDummyUser()
};
var (service, context) = SetupEnvironment(true, token, token.Content.ToString());
var (service, context) = SetupEnvironment(true, token, token.TokenId.ToString());
// Act
var result = await service.RefreshLogin();
@@ -255,7 +255,7 @@ public class AuthServiceTests {
// Arrange
var token = new Token {
Type = Token.AccessTokenType,
Content = _accessToken,
TokenId = _accessToken,
CreatedAt = DateTime.Now,
Owner = CreateDummyUser()
};
@@ -285,7 +285,7 @@ public class AuthServiceTests {
// Arrange
var token = new Token {
Type = Token.RefreshTokenType,
Content = _accessToken,
TokenId = _accessToken,
CreatedAt = DateTime.Now,
Owner = CreateDummyUser()
};
@@ -303,7 +303,7 @@ public class AuthServiceTests {
// Arrange
var token = new Token {
Type = Token.AccessTokenType,
Content = _accessToken,
TokenId = _accessToken,
CreatedAt = DateTime.MinValue,
Owner = CreateDummyUser()
};
@@ -321,7 +321,7 @@ public class AuthServiceTests {
// Arrange
var token = new Token {
Type = Token.AccessTokenType,
Content = _accessToken,
TokenId = _accessToken,
CreatedAt = DateTime.Now,
Owner = null
};