Merged PR 79: Added better token persistence in the database
- Store the full token in database - [2.4.4] release notes update - Better field namesmaster
parent
4e15b40bee
commit
ba50e1b186
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,6 @@ namespace Tuitio.Application.Services.Abstractions
|
|||
{
|
||||
internal interface ITokenService
|
||||
{
|
||||
Token GenerateToken(AppUser user);
|
||||
TokenExtension GenerateToken(AppUser user);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
};
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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]
|
||||
|
|
Loading…
Reference in New Issue