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>
<Import Project="dependencies.props" />
<PropertyGroup>
<Version>2.4.1</Version>
<Version>2.4.2</Version>
<Authors>Tudor Stanciu</Authors>
<Company>STA</Company>
<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.
</Content>
</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>

View File

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

View File

@ -1,8 +1,6 @@
// Copyright (c) 2020 Tudor Stanciu
using AutoMapper;
using Tuitio.Application.Extensions;
using Tuitio.Domain.Entities;
using dto = Tuitio.PublishedLanguage.Dto;
using models = Tuitio.Domain.Models;
@ -13,9 +11,6 @@ namespace Tuitio.Application.Mappings
public MappingProfile()
{
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>();
}
}

View File

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

View File

@ -1,10 +1,7 @@
// Copyright (c) 2020 Tudor Stanciu
using AutoMapper;
using Newtonsoft.Json;
using System;
using System.Text;
using System.Text.RegularExpressions;
using System.Linq;
using Tuitio.Application.Extensions;
using Tuitio.Application.Services.Abstractions;
using Tuitio.Domain.Abstractions;
using Tuitio.Domain.Entities;
@ -14,63 +11,21 @@ namespace Tuitio.Application.Services
{
internal class TokenService : ITokenService
{
private readonly IMapper _mapper;
private readonly IConfigProvider _configProvider;
public TokenService(IMapper mapper, IConfigProvider configProvider)
public TokenService(IConfigProvider configProvider)
{
_mapper=mapper;
_configProvider=configProvider;
}
public Token GenerateToken(AppUser user)
{
var currentDate = DateTime.UtcNow;
var validUntil = currentDate.AddMinutes(_configProvider.Token.ValidityInMinutes);
var token = _mapper.Map<Token>(user);
token.TokenId = Guid.NewGuid();
token.LockStamp = Regex.Replace(Convert.ToBase64String(Guid.NewGuid().ToByteArray()), "[/+=]", "");
token.CreatedAt = currentDate;
token.ExpiresIn = (validUntil - currentDate).TotalMilliseconds;
var token = new Token(_configProvider.Token.ValidityInMinutes);
var claims = user.Claims?.ToDictionary();
var userRoles = user.GetUserRoles().Select(z => z.UserRoleCode);
var userGroups = user.UserGroups?.Select(z => z.UserGroup.UserGroupCode);
token.SetUserData(user.UserId, user.UserName, user.FirstName, user.LastName, user.Email, user.SecurityStamp, claims, userRoles, userGroups);
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;
var token = _tokenService.GenerateToken(user);
var raw = _tokenService.GenerateTokenRaw(token);
var raw = token.Export();
_securityStore.Set(raw, token);
await _userRepository.UpdateUserAfterLogin(user, token, raw);

View File

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

View File

@ -25,6 +25,8 @@ namespace Tuitio.Domain.Data.Repositories
return _dbContext.Users
.Include(z => z.Status)
.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);
}

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
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
using Tuitio.Domain.Helpers;
namespace Tuitio.Domain.Models
{
@ -16,7 +20,68 @@ namespace Tuitio.Domain.Models
public string SecurityStamp { get; set; }
public string LockStamp { get; set; }
public DateTime CreatedAt { get; set; }
public double ExpiresIn { get; set; }
public long ExpiresIn { 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>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="$(NewtonsoftJsonPackageVersion)" />
</ItemGroup>
</Project>

View File

@ -18,7 +18,9 @@ namespace Tuitio.PublishedLanguage.Dto
public string SecurityStamp { get; init; }
public string LockStamp { 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 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
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>
<RepositoryType>Git</RepositoryType>
<PackageReleaseNotes>$([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/ReleaseNotes.txt"))</PackageReleaseNotes>
<Version>2.2.0</Version>
<Version>2.2.1</Version>
<PackageIcon>logo.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<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
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>
<RepositoryType>Git</RepositoryType>
<PackageReleaseNotes>$([System.IO.File]::ReadAllText("$(MSBuildProjectDirectory)/ReleaseNotes.txt"))</PackageReleaseNotes>
<Version>2.2.0</Version>
<Version>2.2.1</Version>
<PackageIcon>logo.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Company>Toodle HomeLab</Company>

View File

@ -3,6 +3,7 @@
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Linq;
using System.Threading.Tasks;
using Tuitio.Application.CommandHandlers;
using Tuitio.Application.Tests.Fixtures;
@ -132,6 +133,7 @@ namespace Tuitio.Application.Tests
Assert.NotNull(authorizationResult.Result.LockStamp);
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.UserRoles.Any(), "User must have at least one role.");
}
[Fact]

View File

@ -14,6 +14,7 @@ using System.IO;
using System.Threading.Tasks;
using Tuitio.Domain.Data;
using Tuitio.Domain.Data.DbContexts;
using Tuitio.Domain.Entities;
using Xunit;
namespace Tuitio.Application.Tests.Fixtures
@ -136,7 +137,21 @@ namespace Tuitio.Application.Tests.Fixtures
SecurityStamp = "A93650FF-1FC4-4999-BAB6-3EEB174F6892",
StatusId = 1,
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

View File

@ -2,9 +2,11 @@
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Text;
using Tuitio.Application.Services.Abstractions;
using Tuitio.Application.Tests.Fixtures;
using Tuitio.Domain.Entities;
using Tuitio.Domain.Models;
using Xunit;
namespace Tuitio.Application.Tests
@ -24,7 +26,7 @@ namespace Tuitio.Application.Tests
{
var user = new AppUser()
{
UserId = 0,
UserId = 1,
UserName = "tuitio.test",
Password = "9B8769A4A742959A2D0298C36FB70623F2DFACDA8436237DF08D8DFD5B37374C", //pass123
Email = "tuitio.test@test.test",
@ -33,7 +35,21 @@ namespace Tuitio.Application.Tests
StatusId = 1,
FailedLoginAttempts = 0,
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;
@ -52,16 +68,22 @@ namespace Tuitio.Application.Tests
// Act
var token = _tokenService.GenerateToken(_userMock);
var raw = _tokenService.GenerateTokenRaw(token);
var extracted = _tokenService.ExtractToken(raw);
var raw = token.Export();
var extracted = Token.Import(raw);
// Assert
Assert.NotNull(token);
Assert.NotNull(raw);
Assert.NotNull(extracted);
Assert.True(_userMock.UserName == extracted.UserName);
Assert.True(_userMock.SecurityStamp == extracted.SecurityStamp);
Assert.Equal(_userMock.UserName, extracted.UserName);
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
{
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]
public void Set_ShouldSetTokenInStore()
{
// Arrange
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();
// Act
@ -30,7 +38,7 @@ namespace Tuitio.Application.Tests
{
// Arrange
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();
// Act