Merged PR 79: Added better token persistence in the database

- Store the full token in database
- [2.4.4] release notes update
- Better field names
master
Tudor Stanciu 2023-05-03 16:17:45 +00:00
parent 4e15b40bee
commit ba50e1b186
22 changed files with 156 additions and 90 deletions

View File

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

View File

@ -123,4 +123,13 @@
◾ New versions of nuget packages have been released.
</Content>
</Note>
<Note>
<Version>2.4.4</Version>
<Date>2023-05-03 19:02</Date>
<Content>
Added better token persistence in the database
◾ All tokens are persisted in the database after a login operation to recover them in case of a system crash or restart.
◾ Expired tokens are still deleted periodically.
</Content>
</Note>
</ReleaseNotes>

View File

@ -36,7 +36,7 @@ namespace Tuitio.Application.CommandHandlers
_logger.LogDebug($"Login succeeded for user '{command.UserName}'.");
var result = new AccountLoginResult(loginResult.Raw, loginResult.Token.ExpiresIn);
var result = new AccountLoginResult(loginResult.Raw, loginResult.TokenExtension.Token.ExpiresIn);
return Envelope<AccountLoginResult>.Success(result);
}
}

View File

@ -11,7 +11,21 @@ namespace Tuitio.Application.Mappings
public MappingProfile()
{
CreateMap<models.RecordIdentifier, dto.RecordIdentifier>();
CreateMap<models.Token, dto.AuthorizationResult>();
CreateMap<models.TokenExtension, dto.AuthorizationResult>()
.ForMember(z => z.TokenId, src => src.MapFrom(z => z.Token.TokenId))
.ForMember(z => z.UserId, src => src.MapFrom(z => z.Token.UserId))
.ForMember(z => z.UserName, src => src.MapFrom(z => z.Token.UserName))
.ForMember(z => z.FirstName, src => src.MapFrom(z => z.Token.FirstName))
.ForMember(z => z.LastName, src => src.MapFrom(z => z.Token.LastName))
.ForMember(z => z.Email, src => src.MapFrom(z => z.Token.Email))
.ForMember(z => z.SecurityStamp, src => src.MapFrom(z => z.Token.SecurityStamp))
.ForMember(z => z.LockStamp, src => src.MapFrom(z => z.Token.LockStamp))
.ForMember(z => z.CreatedAt, src => src.MapFrom(z => z.Token.CreatedAt))
.ForMember(z => z.ExpiresIn, src => src.MapFrom(z => z.Token.ExpiresIn))
.ForMember(z => z.Claims, src => src.MapFrom(z => z.Token.Claims))
;
CreateMap<models.Account.LogoutResult, dto.AccountLogoutResult>();
}
}

View File

@ -7,6 +7,6 @@ namespace Tuitio.Application.Services.Abstractions
{
internal interface ITokenService
{
Token GenerateToken(AppUser user);
TokenExtension GenerateToken(AppUser user);
}
}

View File

@ -10,6 +10,6 @@ namespace Tuitio.Application.Services.Abstractions
{
Task<LoginResult> Login(string userName, string password);
Task<LogoutResult> Logout(string tokenRaw);
Token Authorize(string tokenRaw);
TokenExtension Authorize(string tokenRaw);
}
}

View File

@ -43,8 +43,8 @@ namespace Tuitio.Application.Services
_logger.LogInformation($"BehaviorService: {activeTokens.Length} active tokens were found in database.");
foreach (var token in activeTokens)
{
var storeToken = Token.Import(token.Token);
_securityStore.Set(token.Token, storeToken);
var storeToken = TokenExtension.Deserialize(token.TokenExtension);
_securityStore.Set(token.TokenExtension, storeToken);
}
}
}

View File

@ -18,15 +18,14 @@ namespace Tuitio.Application.Services
_configProvider=configProvider;
}
public Token GenerateToken(AppUser user)
public TokenExtension GenerateToken(AppUser user)
{
var token = new Token(_configProvider.Token.ValidityInMinutes);
var claims = user.Claims?.ToDictionary();
var userRoles = user.GetUserRoles().AsRecordIdentifiers();
var userGroups = user.UserGroups?.Select(z => z.UserGroup).AsRecordIdentifiers();
token.SetUserData(user.UserId, user.UserName, user.FirstName, user.LastName, user.Email, user.SecurityStamp, claims, userRoles, userGroups);
return token;
var token = Token.Initialize(_configProvider.Token.ValidityInMinutes, user.UserId, user.UserName, user.FirstName, user.LastName, user.Email, user.SecurityStamp, claims);
var extension = token.Extend(userRoles, userGroups);
return extension;
}
}
}

View File

@ -42,30 +42,30 @@ namespace Tuitio.Application.Services
if (!valid)
return null;
var token = _tokenService.GenerateToken(user);
var raw = token.Export();
_securityStore.Set(raw, token);
var tokenExtension = _tokenService.GenerateToken(user);
var raw = tokenExtension.Token.Export();
_securityStore.Set(raw, tokenExtension);
await _userRepository.UpdateUserAfterLogin(user, token, raw);
await _userRepository.UpdateUserAfterLogin(user, tokenExtension, raw);
var result = new LoginResult(token, raw);
var result = new LoginResult(tokenExtension, raw);
return result;
}
public async Task<LogoutResult> Logout(string tokenRaw)
{
var token = _securityStore.Get(tokenRaw);
if (token == null)
var tokenExtension = _securityStore.Get(tokenRaw);
if (tokenExtension == null)
return null;
await _userRepository.RemoveToken(token.TokenId);
await _userRepository.RemoveToken(tokenExtension.Token.TokenId);
_securityStore.Remove(tokenRaw);
var result = new LogoutResult(token.UserId, token.UserName, DateTime.UtcNow);
var result = new LogoutResult(tokenExtension.Token.UserId, tokenExtension.Token.UserName, DateTime.UtcNow);
return result;
}
public Token Authorize(string tokenRaw)
public TokenExtension Authorize(string tokenRaw)
{
var token = _securityStore.Get(tokenRaw);
return token;

View File

@ -1,14 +1,13 @@
// Copyright (c) 2020 Tudor Stanciu
using System;
using Tuitio.Domain.Models;
namespace Tuitio.Application.Stores
{
internal interface ITokenStore
{
Token Get(string key);
bool Set(string key, Token token);
TokenExtension Get(string key);
bool Set(string key, TokenExtension token);
void Remove(string key);
}
}

View File

@ -8,14 +8,14 @@ namespace Tuitio.Application.Stores
{
internal class TokenStore : ITokenStore
{
private ConcurrentDictionary<string, Token> Tokens { get; }
private ConcurrentDictionary<string, TokenExtension> Tokens { get; }
public TokenStore()
{
Tokens = new ConcurrentDictionary<string, Token>();
Tokens = new ConcurrentDictionary<string, TokenExtension>();
}
public Token Get(string key)
public TokenExtension Get(string key)
{
var registered = Tokens.ContainsKey(key);
if (!registered)
@ -24,7 +24,7 @@ namespace Tuitio.Application.Stores
return Tokens[key];
}
public bool Set(string key, Token token)
public bool Set(string key, TokenExtension token)
{
var registered = Tokens.ContainsKey(key);
if (registered)

View File

@ -41,13 +41,16 @@ namespace Tuitio.Domain.Data.Repositories
.FirstOrDefaultAsync(z => z.UserId == userId);
}
public async Task UpdateUserAfterLogin(AppUser user, Token token, string tokenRaw)
public async Task UpdateUserAfterLogin(AppUser user, TokenExtension tokenExtension, string tokenRaw)
{
var token = tokenExtension.Token;
var extension = tokenExtension.Serialize();
var userToken = new UserToken()
{
TokenId = token.TokenId,
UserId = token.UserId,
Token = tokenRaw,
TokenExtension = extension,
ValidFrom = token.CreatedAt,
ValidUntil = token.CreatedAt.AddSeconds(token.ExpiresIn)
};

View File

@ -5,6 +5,7 @@ begin
TokenId uniqueidentifier constraint PK_Token primary key,
UserId int not null constraint FK_Token_AppUser foreign key references AppUser(UserId),
Token varchar(1000) not null,
TokenExtension varchar(2000) not null,
ValidFrom datetime not null,
ValidUntil datetime not null
)

View File

@ -9,6 +9,7 @@ namespace Tuitio.Domain.Entities
public Guid TokenId { get; set; }
public int UserId { get; set; }
public string Token { get; set; }
public string TokenExtension { get; set; }
public DateTime ValidFrom { get; set; }
public DateTime ValidUntil { get; set; }
}

View File

@ -4,6 +4,6 @@ using System;
namespace Tuitio.Domain.Models.Account
{
public record LoginResult(Token Token, string Raw);
public record LoginResult(TokenExtension TokenExtension, string Raw);
public record LogoutResult(int UserId, string UserName, DateTime LogoutDate);
}

View File

@ -23,16 +23,10 @@ namespace Tuitio.Domain.Models
public long ExpiresIn { get; set; }
public Dictionary<string, string> Claims { get; set; }
[JsonIgnore]
public IEnumerable<RecordIdentifier> UserRoles { get; set; }
[JsonIgnore]
public IEnumerable<RecordIdentifier> 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)
private Token(int validityInMinutes)
{
TokenId = Guid.NewGuid();
CreatedAt = DateTime.UtcNow;
@ -40,17 +34,20 @@ namespace Tuitio.Domain.Models
ExpiresIn = validityInMinutes * 60; // seconds
}
public void SetUserData(int userId, string userName, string firstName, string lastName, string email, string securityStamp, Dictionary<string, string> claims, IEnumerable<RecordIdentifier> userRoles, IEnumerable<RecordIdentifier> userGroups)
public static Token Initialize(int validityInMinutes, int userId, string userName, string firstName, string lastName, string email, string securityStamp, Dictionary<string, string> claims)
{
UserId = userId;
UserName = userName;
FirstName = firstName;
LastName = lastName;
Email = email;
SecurityStamp = securityStamp;
Claims = claims;
UserRoles = userRoles;
UserGroups = userGroups;
var token = new Token(validityInMinutes)
{
UserId = userId,
UserName = userName,
FirstName = firstName,
LastName = lastName,
Email = email,
SecurityStamp = securityStamp,
Claims = claims
};
return token;
}
public string Export()
@ -73,6 +70,9 @@ namespace Tuitio.Domain.Models
return token;
}
public TokenExtension Extend(IEnumerable<RecordIdentifier> userRoles, IEnumerable<RecordIdentifier> userGroups)
=> TokenExtension.Create(this, userRoles, userGroups);
private static bool ValidateTokenRaw(string tokenRaw)
{
if (string.IsNullOrWhiteSpace(tokenRaw))

View File

@ -0,0 +1,40 @@
// Copyright (c) 2020 Tudor Stanciu
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
namespace Tuitio.Domain.Models
{
public class TokenExtension
{
public Token Token { get; set; }
public IEnumerable<RecordIdentifier> UserRoles { get; set; }
public IEnumerable<RecordIdentifier> UserGroups { get; set; }
[Obsolete("This constructor is only used for deserialization and should not be used for any other purpose.")]
public TokenExtension() { }
private TokenExtension(Token token, IEnumerable<RecordIdentifier> userRoles, IEnumerable<RecordIdentifier> userGroups)
{
Token=token;
UserRoles=userRoles;
UserGroups=userGroups;
}
public static TokenExtension Create(Token token, IEnumerable<RecordIdentifier> userRoles, IEnumerable<RecordIdentifier> userGroups)
=> new(token, userRoles, userGroups);
public string Serialize()
{
var text = JsonConvert.SerializeObject(this);
return text;
}
public static TokenExtension Deserialize(string text)
{
var token = JsonConvert.DeserializeObject<TokenExtension>(text);
return token;
}
}
}

View File

@ -11,7 +11,7 @@ namespace Tuitio.Domain.Repositories
{
Task<AppUser> GetUser(string userName, string password);
Task<AppUser> GetFullUser(int userId);
Task UpdateUserAfterLogin(AppUser user, Token token, string tokenRaw);
Task UpdateUserAfterLogin(AppUser user, TokenExtension tokenExtension, string tokenRaw);
Task<UserToken[]> GetActiveTokens();
Task RemoveToken(Guid tokenId);
}

View File

@ -59,26 +59,26 @@ namespace Tuitio.Authentication
return null;
}
private AuthenticationTicket GetAuthenticationTicket(Token result)
private AuthenticationTicket GetAuthenticationTicket(TokenExtension result)
{
var claimCollection = new Dictionary<string, string>()
{
{ ClaimTypes.NameIdentifier, result.UserId.ToString() },
{ ClaimTypes.Name, result.UserName },
{ ClaimTypes.NameIdentifier, result.Token.UserId.ToString() },
{ ClaimTypes.Name, result.Token.UserName },
};
if (result.FirstName != null)
claimCollection.Add(ClaimTypes.GivenName, result.FirstName);
if (result.Token.FirstName != null)
claimCollection.Add(ClaimTypes.GivenName, result.Token.FirstName);
if (result.LastName != null)
claimCollection.Add(ClaimTypes.Surname, result.FirstName);
if (result.Token.LastName != null)
claimCollection.Add(ClaimTypes.Surname, result.Token.FirstName);
if (result.Email != null)
claimCollection.Add(ClaimTypes.Email, result.Email);
if (result.Token.Email != null)
claimCollection.Add(ClaimTypes.Email, result.Token.Email);
if (result.Claims != null && result.Claims.Any())
if (result.Token.Claims != null && result.Token.Claims.Any())
{
foreach (var claim in result.Claims)
foreach (var claim in result.Token.Claims)
{
if (claimCollection.ContainsKey(claim.Key))
{

View File

@ -68,7 +68,7 @@ namespace Tuitio.Application.Tests
// Act
var token = _tokenService.GenerateToken(_userMock);
var raw = token.Export();
var raw = token.Token.Export();
var extracted = Token.Import(raw);
// Assert

View File

@ -9,11 +9,11 @@ namespace Tuitio.Application.Tests
{
public class TokenStoreTests
{
private Token GetMockedToken()
private TokenExtension 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;
var token = Token.Initialize(1, 0, "test.tuitio", "tuitio", "user", "user.tuitio@lab.com", Guid.NewGuid().ToString(), null);
var extension = token.Extend(null, null);
return extension;
}
[Fact]

View File

@ -68,13 +68,13 @@ namespace Tuitio.Application.Tests
// Assert
Assert.NotNull(result);
Assert.NotNull(result.Token);
Assert.NotNull(result.TokenExtension.Token);
Assert.NotEmpty(result.Raw);
Assert.Equal(userName, result.Token.UserName);
Assert.True(result.Token.TokenId != Guid.Empty, "Token id cannot be an empty guid.");
Assert.NotEmpty(result.Token.LockStamp);
Assert.True(result.Token.ExpiresIn > 0, "Token expiration must be a positive number.");
Assert.True((DateTime.UtcNow - result.Token.CreatedAt).TotalMinutes <= 1, "Token creation date must be within the last minute.");
Assert.Equal(userName, result.TokenExtension.Token.UserName);
Assert.True(result.TokenExtension.Token.TokenId != Guid.Empty, "Token id cannot be an empty guid.");
Assert.NotEmpty(result.TokenExtension.Token.LockStamp);
Assert.True(result.TokenExtension.Token.ExpiresIn > 0, "Token expiration must be a positive number.");
Assert.True((DateTime.UtcNow - result.TokenExtension.Token.CreatedAt).TotalMinutes <= 1, "Token creation date must be within the last minute.");
}
[Fact]
@ -105,11 +105,11 @@ namespace Tuitio.Application.Tests
var loginResult = await _userService.Login(userName, password);
// Act
var userTokenFromDb = await dbContext.UserTokens.FirstOrDefaultAsync(z => z.TokenId == loginResult.Token.TokenId);
var userTokenFromDb = await dbContext.UserTokens.FirstOrDefaultAsync(z => z.TokenId == loginResult.TokenExtension.Token.TokenId);
// Assert
Assert.NotNull(userTokenFromDb);
Assert.True(loginResult.Token.TokenId != Guid.Empty, "Token id cannot be an empty guid.");
Assert.True(loginResult.TokenExtension.Token.TokenId != Guid.Empty, "Token id cannot be an empty guid.");
Assert.True((DateTime.UtcNow - userTokenFromDb.ValidFrom).TotalMinutes <= 1, "Token valid from date must be within the last minute.");
Assert.True(userTokenFromDb.ValidUntil > DateTime.UtcNow, "Token valid until date must be greater than the current date.");
}
@ -141,11 +141,11 @@ namespace Tuitio.Application.Tests
// Assert
Assert.NotNull(result);
Assert.Equal(userName, result.UserName);
Assert.NotNull(result.SecurityStamp);
Assert.NotNull(result.LockStamp);
Assert.True(result.TokenId != Guid.Empty, "Token id cannot be an empty guid.");
Assert.True(result.ExpiresIn > 0, "Token expiration must be a positive number.");
Assert.Equal(userName, result.Token.UserName);
Assert.NotNull(result.Token.SecurityStamp);
Assert.NotNull(result.Token.LockStamp);
Assert.True(result.Token.TokenId != Guid.Empty, "Token id cannot be an empty guid.");
Assert.True(result.Token.ExpiresIn > 0, "Token expiration must be a positive number.");
}
[Fact]