Merged PR 78: Added roles and groups in authorization result

Added roles and groups in authorization result
master
Tudor Stanciu 2023-04-08 14:55:36 +00:00
parent ebb0f4de62
commit fdb08acd21
22 changed files with 182 additions and 85 deletions

View File

@ -1,7 +1,7 @@
<Project> <Project>
<Import Project="dependencies.props" /> <Import Project="dependencies.props" />
<PropertyGroup> <PropertyGroup>
<Version>2.4.1</Version> <Version>2.4.2</Version>
<Authors>Tudor Stanciu</Authors> <Authors>Tudor Stanciu</Authors>
<Company>STA</Company> <Company>STA</Company>
<PackageTags>Tuitio</PackageTags> <PackageTags>Tuitio</PackageTags>

View File

@ -103,4 +103,15 @@
◾ The authentication handler has been updated to skip the token validation if the method from controller is marked with [AllowAnonymous] attribute. ◾ The authentication handler has been updated to skip the token validation if the method from controller is marked with [AllowAnonymous] attribute.
</Content> </Content>
</Note> </Note>
<Note>
<Version>2.4.2</Version>
<Date>2023-04-08 01:48</Date>
<Content>
Added user roles and groups in authorization result
◾ The authorization result will contain the user role and group codes. They are very useful for an application because after the token is authorized, the application can directly validate its internal permissions based on roles or groups, without calling another method to obtain this information.
◾ In addition to these changes, some refactorings were also made.
◾ The token "expires in" information measuring unit was changed from milliseconds to seconds.
◾ New versions of nuget packages have been released.
</Content>
</Note>
</ReleaseNotes> </ReleaseNotes>

View File

@ -8,7 +8,7 @@ namespace Tuitio.Application.Extensions
{ {
internal static class EntityExtensions internal static class EntityExtensions
{ {
public static UserRole[] GetUserRoles(this AppUser user) public static IEnumerable<UserRole> GetUserRoles(this AppUser user)
{ {
var roles = new List<UserRole>(); var roles = new List<UserRole>();

View File

@ -1,8 +1,6 @@
// Copyright (c) 2020 Tudor Stanciu // Copyright (c) 2020 Tudor Stanciu
using AutoMapper; using AutoMapper;
using Tuitio.Application.Extensions;
using Tuitio.Domain.Entities;
using dto = Tuitio.PublishedLanguage.Dto; using dto = Tuitio.PublishedLanguage.Dto;
using models = Tuitio.Domain.Models; using models = Tuitio.Domain.Models;
@ -13,9 +11,6 @@ namespace Tuitio.Application.Mappings
public MappingProfile() public MappingProfile()
{ {
CreateMap<models.Token, dto.AuthorizationResult>(); CreateMap<models.Token, dto.AuthorizationResult>();
CreateMap<AppUser, models.Token>()
.ForMember(z => z.Claims, src => src.MapFrom(z => z.Claims.ToDictionary()));
CreateMap<models.Account.LogoutResult, dto.AccountLogoutResult>(); CreateMap<models.Account.LogoutResult, dto.AccountLogoutResult>();
} }
} }

View File

@ -8,7 +8,5 @@ namespace Tuitio.Application.Services.Abstractions
internal interface ITokenService internal interface ITokenService
{ {
Token GenerateToken(AppUser user); Token GenerateToken(AppUser user);
string GenerateTokenRaw(Token token);
Token ExtractToken(string tokenRaw);
} }
} }

View File

@ -7,6 +7,7 @@ using System.Threading.Tasks;
using Tuitio.Application.Services.Abstractions; using Tuitio.Application.Services.Abstractions;
using Tuitio.Application.Stores; using Tuitio.Application.Stores;
using Tuitio.Domain.Entities; using Tuitio.Domain.Entities;
using Tuitio.Domain.Models;
using Tuitio.Domain.Repositories; using Tuitio.Domain.Repositories;
namespace Tuitio.Application.Services namespace Tuitio.Application.Services
@ -15,14 +16,12 @@ namespace Tuitio.Application.Services
{ {
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly ILogger<BehaviorService> _logger; private readonly ILogger<BehaviorService> _logger;
private readonly ITokenService _tokenService;
private readonly ITokenStore _securityStore; private readonly ITokenStore _securityStore;
public BehaviorService(IServiceProvider serviceProvider, ILogger<BehaviorService> logger, ITokenService tokenService, ITokenStore securityStore) public BehaviorService(IServiceProvider serviceProvider, ILogger<BehaviorService> logger, ITokenStore securityStore)
{ {
_serviceProvider=serviceProvider; _serviceProvider=serviceProvider;
_logger=logger; _logger=logger;
_tokenService=tokenService;
_securityStore=securityStore; _securityStore=securityStore;
} }
@ -44,7 +43,7 @@ namespace Tuitio.Application.Services
_logger.LogInformation($"BehaviorService: {activeTokens.Length} active tokens were found in database."); _logger.LogInformation($"BehaviorService: {activeTokens.Length} active tokens were found in database.");
foreach (var token in activeTokens) foreach (var token in activeTokens)
{ {
var storeToken = _tokenService.ExtractToken(token.Token); var storeToken = Token.Import(token.Token);
_securityStore.Set(token.Token, storeToken); _securityStore.Set(token.Token, storeToken);
} }
} }

View File

@ -1,10 +1,7 @@
// Copyright (c) 2020 Tudor Stanciu // Copyright (c) 2020 Tudor Stanciu
using AutoMapper; using System.Linq;
using Newtonsoft.Json; using Tuitio.Application.Extensions;
using System;
using System.Text;
using System.Text.RegularExpressions;
using Tuitio.Application.Services.Abstractions; using Tuitio.Application.Services.Abstractions;
using Tuitio.Domain.Abstractions; using Tuitio.Domain.Abstractions;
using Tuitio.Domain.Entities; using Tuitio.Domain.Entities;
@ -14,63 +11,21 @@ namespace Tuitio.Application.Services
{ {
internal class TokenService : ITokenService internal class TokenService : ITokenService
{ {
private readonly IMapper _mapper;
private readonly IConfigProvider _configProvider; private readonly IConfigProvider _configProvider;
public TokenService(IMapper mapper, IConfigProvider configProvider) public TokenService(IConfigProvider configProvider)
{ {
_mapper=mapper;
_configProvider=configProvider; _configProvider=configProvider;
} }
public Token GenerateToken(AppUser user) public Token GenerateToken(AppUser user)
{ {
var currentDate = DateTime.UtcNow; var token = new Token(_configProvider.Token.ValidityInMinutes);
var validUntil = currentDate.AddMinutes(_configProvider.Token.ValidityInMinutes); var claims = user.Claims?.ToDictionary();
var userRoles = user.GetUserRoles().Select(z => z.UserRoleCode);
var token = _mapper.Map<Token>(user); var userGroups = user.UserGroups?.Select(z => z.UserGroup.UserGroupCode);
token.TokenId = Guid.NewGuid(); token.SetUserData(user.UserId, user.UserName, user.FirstName, user.LastName, user.Email, user.SecurityStamp, claims, userRoles, userGroups);
token.LockStamp = Regex.Replace(Convert.ToBase64String(Guid.NewGuid().ToByteArray()), "[/+=]", "");
token.CreatedAt = currentDate;
token.ExpiresIn = (validUntil - currentDate).TotalMilliseconds;
return token; return token;
} }
public string GenerateTokenRaw(Token token)
{
var tokenString = JsonConvert.SerializeObject(token);
var tokenBytes = Encoding.UTF8.GetBytes(tokenString);
var tokenRaw = Convert.ToBase64String(tokenBytes);
return tokenRaw;
}
public Token ExtractToken(string tokenRaw)
{
var valid = ValidateTokenRaw(tokenRaw);
if (!valid)
return null;
var tokenBytes = Convert.FromBase64String(tokenRaw);
var tokenString = Encoding.UTF8.GetString(tokenBytes);
var token = JsonConvert.DeserializeObject<Token>(tokenString);
return token;
}
private bool ValidateTokenRaw(string tokenRaw)
{
if (string.IsNullOrWhiteSpace(tokenRaw))
return false;
if (!StringIsBase64(tokenRaw))
return false;
return true;
}
private bool StringIsBase64(string str)
{
str = str.Trim();
return (str.Length % 4 == 0) && Regex.IsMatch(str, @"^[a-zA-Z0-9+/]*={0,3}$", RegexOptions.None);
}
} }
} }

View File

@ -43,7 +43,7 @@ namespace Tuitio.Application.Services
return null; return null;
var token = _tokenService.GenerateToken(user); var token = _tokenService.GenerateToken(user);
var raw = _tokenService.GenerateTokenRaw(token); var raw = token.Export();
_securityStore.Set(raw, token); _securityStore.Set(raw, token);
await _userRepository.UpdateUserAfterLogin(user, token, raw); await _userRepository.UpdateUserAfterLogin(user, token, raw);

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
@ -11,7 +11,6 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="$(MicrosoftExtensionsPackageVersion)" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="$(MicrosoftExtensionsPackageVersion)" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="$(MicrosoftExtensionsPackageVersion)" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="$(MicrosoftExtensionsPackageVersion)" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="$(MicrosoftExtensionsPackageVersion)" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="$(MicrosoftExtensionsPackageVersion)" />
<PackageReference Include="Newtonsoft.Json" Version="$(NewtonsoftJsonPackageVersion)" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -25,6 +25,8 @@ namespace Tuitio.Domain.Data.Repositories
return _dbContext.Users return _dbContext.Users
.Include(z => z.Status) .Include(z => z.Status)
.Include(z => z.Claims) .Include(z => z.Claims)
.Include(z => z.UserRoles).ThenInclude(z => z.UserRole)
.Include(z => z.UserGroups).ThenInclude(z => z.UserGroup).ThenInclude(z => z.GroupRoles).ThenInclude(z => z.UserRole)
.FirstOrDefaultAsync(z => z.UserName == userName && z.Password == password); .FirstOrDefaultAsync(z => z.UserName == userName && z.Password == password);
} }

View File

@ -0,0 +1,15 @@
// Copyright (c) 2020 Tudor Stanciu
using System.Text.RegularExpressions;
namespace Tuitio.Domain.Helpers
{
internal static class DataValidationHelper
{
public static bool StringIsBase64(string str)
{
str = str.Trim();
return (str.Length % 4 == 0) && Regex.IsMatch(str, @"^[a-zA-Z0-9+/]*={0,3}$", RegexOptions.None);
}
}
}

View File

@ -1,7 +1,11 @@
// Copyright (c) 2020 Tudor Stanciu // Copyright (c) 2020 Tudor Stanciu
using Newtonsoft.Json;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
using Tuitio.Domain.Helpers;
namespace Tuitio.Domain.Models namespace Tuitio.Domain.Models
{ {
@ -16,7 +20,68 @@ namespace Tuitio.Domain.Models
public string SecurityStamp { get; set; } public string SecurityStamp { get; set; }
public string LockStamp { get; set; } public string LockStamp { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public double ExpiresIn { get; set; } public long ExpiresIn { get; set; }
public Dictionary<string, string> Claims { get; set; } public Dictionary<string, string> Claims { get; set; }
[JsonIgnore]
public IEnumerable<string> UserRoles { get; set; }
[JsonIgnore]
public IEnumerable<string> UserGroups { get; set; }
[Obsolete("This constructor is only used for deserialization and should not be used for any other purpose.")]
public Token() { }
public Token(int validityInMinutes)
{
TokenId = Guid.NewGuid();
CreatedAt = DateTime.UtcNow;
LockStamp = Regex.Replace(Convert.ToBase64String(Guid.NewGuid().ToByteArray()), "[/+=]", "");
ExpiresIn = validityInMinutes * 60; // seconds
}
public void SetUserData(int userId, string userName, string firstName, string lastName, string email, string securityStamp, Dictionary<string, string> claims, IEnumerable<string> userRoles, IEnumerable<string> userGroups)
{
UserId = userId;
UserName = userName;
FirstName = firstName;
LastName = lastName;
Email = email;
SecurityStamp = securityStamp;
Claims = claims;
UserRoles = userRoles;
UserGroups = userGroups;
}
public string Export()
{
var tokenString = JsonConvert.SerializeObject(this);
var tokenBytes = Encoding.UTF8.GetBytes(tokenString);
var tokenRaw = Convert.ToBase64String(tokenBytes);
return tokenRaw;
}
public static Token Import(string tokenRaw)
{
var valid = ValidateTokenRaw(tokenRaw);
if (!valid)
return null;
var tokenBytes = Convert.FromBase64String(tokenRaw);
var tokenString = Encoding.UTF8.GetString(tokenBytes);
var token = JsonConvert.DeserializeObject<Token>(tokenString);
return token;
}
private static bool ValidateTokenRaw(string tokenRaw)
{
if (string.IsNullOrWhiteSpace(tokenRaw))
return false;
if (!DataValidationHelper.StringIsBase64(tokenRaw))
return false;
return true;
}
} }
} }

View File

@ -1,7 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="$(NewtonsoftJsonPackageVersion)" />
</ItemGroup>
</Project> </Project>

View File

@ -18,7 +18,9 @@ namespace Tuitio.PublishedLanguage.Dto
public string SecurityStamp { get; init; } public string SecurityStamp { get; init; }
public string LockStamp { get; init; } public string LockStamp { get; init; }
public DateTime CreatedAt { get; init; } public DateTime CreatedAt { get; init; }
public double ExpiresIn { get; init; } public long ExpiresIn { get; init; }
public Dictionary<string, string> Claims { get; init; } public Dictionary<string, string> Claims { get; init; }
public string[] UserRoles { get; init; }
public string[] UserGroups { get; init; }
} }
} }

View File

@ -1,4 +1,7 @@
2.2.0 release [2023-03-27 19:20] 2.2.1 release [2023-04-08 01:48]
◾ Added user roles and groups in authorization result
2.2.0 release [2023-03-27 19:20]
◾ Added "user-info" method in API ◾ Added "user-info" method in API
2.1.0 release [2023-03-07 22:17] 2.1.0 release [2023-03-07 22:17]

View File

@ -7,7 +7,7 @@
<RepositoryUrl>https://lab.code-rove.com/gitea/tudor.stanciu/tuitio</RepositoryUrl> <RepositoryUrl>https://lab.code-rove.com/gitea/tudor.stanciu/tuitio</RepositoryUrl>
<RepositoryType>Git</RepositoryType> <RepositoryType>Git</RepositoryType>
<PackageReleaseNotes>$([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/ReleaseNotes.txt"))</PackageReleaseNotes> <PackageReleaseNotes>$([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/ReleaseNotes.txt"))</PackageReleaseNotes>
<Version>2.2.0</Version> <Version>2.2.1</Version>
<PackageIcon>logo.png</PackageIcon> <PackageIcon>logo.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
<Company>Toodle HomeLab</Company> <Company>Toodle HomeLab</Company>

View File

@ -1,4 +1,7 @@
2.2.0 release [2023-03-27 19:20] 2.2.1 release [2023-04-08 01:48]
◾ Added user roles and groups in authorization result
2.2.0 release [2023-03-27 19:20]
◾ Added "user-info" method in API ◾ Added "user-info" method in API
2.1.0 release [2023-03-07 22:17] 2.1.0 release [2023-03-07 22:17]

View File

@ -7,7 +7,7 @@
<RepositoryUrl>https://lab.code-rove.com/gitea/tudor.stanciu/tuitio</RepositoryUrl> <RepositoryUrl>https://lab.code-rove.com/gitea/tudor.stanciu/tuitio</RepositoryUrl>
<RepositoryType>Git</RepositoryType> <RepositoryType>Git</RepositoryType>
<PackageReleaseNotes>$([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/ReleaseNotes.txt"))</PackageReleaseNotes> <PackageReleaseNotes>$([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/ReleaseNotes.txt"))</PackageReleaseNotes>
<Version>2.2.0</Version> <Version>2.2.1</Version>
<PackageIcon>logo.png</PackageIcon> <PackageIcon>logo.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
<Company>Toodle HomeLab</Company> <Company>Toodle HomeLab</Company>

View File

@ -3,6 +3,7 @@
using MediatR; using MediatR;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Tuitio.Application.CommandHandlers; using Tuitio.Application.CommandHandlers;
using Tuitio.Application.Tests.Fixtures; using Tuitio.Application.Tests.Fixtures;
@ -132,6 +133,7 @@ namespace Tuitio.Application.Tests
Assert.NotNull(authorizationResult.Result.LockStamp); Assert.NotNull(authorizationResult.Result.LockStamp);
Assert.True(authorizationResult.Result.TokenId != Guid.Empty, "Token id cannot be an empty guid."); Assert.True(authorizationResult.Result.TokenId != Guid.Empty, "Token id cannot be an empty guid.");
Assert.True(authorizationResult.Result.ExpiresIn > 0, "Token expiration must be a positive number."); Assert.True(authorizationResult.Result.ExpiresIn > 0, "Token expiration must be a positive number.");
Assert.True(authorizationResult.Result.UserRoles.Any(), "User must have at least one role.");
} }
[Fact] [Fact]

View File

@ -14,6 +14,7 @@ using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Tuitio.Domain.Data; using Tuitio.Domain.Data;
using Tuitio.Domain.Data.DbContexts; using Tuitio.Domain.Data.DbContexts;
using Tuitio.Domain.Entities;
using Xunit; using Xunit;
namespace Tuitio.Application.Tests.Fixtures namespace Tuitio.Application.Tests.Fixtures
@ -136,7 +137,21 @@ namespace Tuitio.Application.Tests.Fixtures
SecurityStamp = "A93650FF-1FC4-4999-BAB6-3EEB174F6892", SecurityStamp = "A93650FF-1FC4-4999-BAB6-3EEB174F6892",
StatusId = 1, StatusId = 1,
CreationDate = DateTime.Now, CreationDate = DateTime.Now,
FailedLoginAttempts = 0 FailedLoginAttempts = 0,
UserRoles = new UserXUserRole[]
{
new UserXUserRole()
{
UserId = 1,
UserRoleId = 1,
UserRole = new UserRole()
{
UserRoleId = 1,
UserRoleCode = "MOCK_ROLE",
UserRoleName = "Mock role"
}
}
}
}); });
#endregion #endregion

View File

@ -2,9 +2,11 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System; using System;
using System.Text;
using Tuitio.Application.Services.Abstractions; using Tuitio.Application.Services.Abstractions;
using Tuitio.Application.Tests.Fixtures; using Tuitio.Application.Tests.Fixtures;
using Tuitio.Domain.Entities; using Tuitio.Domain.Entities;
using Tuitio.Domain.Models;
using Xunit; using Xunit;
namespace Tuitio.Application.Tests namespace Tuitio.Application.Tests
@ -24,7 +26,7 @@ namespace Tuitio.Application.Tests
{ {
var user = new AppUser() var user = new AppUser()
{ {
UserId = 0, UserId = 1,
UserName = "tuitio.test", UserName = "tuitio.test",
Password = "9B8769A4A742959A2D0298C36FB70623F2DFACDA8436237DF08D8DFD5B37374C", //pass123 Password = "9B8769A4A742959A2D0298C36FB70623F2DFACDA8436237DF08D8DFD5B37374C", //pass123
Email = "tuitio.test@test.test", Email = "tuitio.test@test.test",
@ -33,7 +35,21 @@ namespace Tuitio.Application.Tests
StatusId = 1, StatusId = 1,
FailedLoginAttempts = 0, FailedLoginAttempts = 0,
SecurityStamp = "A93650FF-1FC4-4999-BAB6-3EEB174F6892", SecurityStamp = "A93650FF-1FC4-4999-BAB6-3EEB174F6892",
CreationDate = DateTime.Now CreationDate = DateTime.Now,
UserRoles = new UserXUserRole[]
{
new UserXUserRole()
{
UserId = 1,
UserRoleId = 1,
UserRole = new UserRole()
{
UserRoleId = 1,
UserRoleCode = "MOCK_ROLE",
UserRoleName = "Mock role"
}
}
}
}; };
return user; return user;
@ -52,16 +68,22 @@ namespace Tuitio.Application.Tests
// Act // Act
var token = _tokenService.GenerateToken(_userMock); var token = _tokenService.GenerateToken(_userMock);
var raw = _tokenService.GenerateTokenRaw(token); var raw = token.Export();
var extracted = _tokenService.ExtractToken(raw); var extracted = Token.Import(raw);
// Assert // Assert
Assert.NotNull(token); Assert.NotNull(token);
Assert.NotNull(raw); Assert.NotNull(raw);
Assert.NotNull(extracted); Assert.NotNull(extracted);
Assert.True(_userMock.UserName == extracted.UserName); Assert.Equal(_userMock.UserName, extracted.UserName);
Assert.True(_userMock.SecurityStamp == extracted.SecurityStamp); Assert.Equal(_userMock.FirstName, extracted.FirstName);
Assert.Equal(_userMock.LastName, extracted.LastName);
Assert.Equal(_userMock.Email, extracted.Email);
Assert.Equal(_userMock.SecurityStamp, extracted.SecurityStamp);
var decodedTokenString = Encoding.UTF8.GetString(Convert.FromBase64String(raw));
Assert.DoesNotContain("UserRoles", decodedTokenString);
} }
} }
} }

View File

@ -9,12 +9,20 @@ namespace Tuitio.Application.Tests
{ {
public class TokenStoreTests public class TokenStoreTests
{ {
private Token GetMockedToken()
{
var token = new Token(1);
token.SetUserData(0, "test.tuitio", "tuitio", "user", "user.tuitio@lab.com", Guid.NewGuid().ToString(), null, null, null);
return token;
}
[Fact] [Fact]
public void Set_ShouldSetTokenInStore() public void Set_ShouldSetTokenInStore()
{ {
// Arrange // Arrange
var key = "user001"; var key = "user001";
var expected = new Token() { TokenId = Guid.NewGuid(), UserId = 0, UserName = "test.tuitio", CreatedAt = DateTime.Now }; var expected = GetMockedToken();
var store = new TokenStore(); var store = new TokenStore();
// Act // Act
@ -30,7 +38,7 @@ namespace Tuitio.Application.Tests
{ {
// Arrange // Arrange
var key = "user001"; var key = "user001";
var mock = new Token() { TokenId = Guid.NewGuid(), UserId = 0, UserName = "test.tuitio", CreatedAt = DateTime.Now }; var mock = GetMockedToken();
var store = new TokenStore(); var store = new TokenStore();
// Act // Act