Compare commits

..

No commits in common. "cd7fe934fd72702e59e1f875559d5cab79b7b1be" and "8b7e4b1e6489cd44728602abcde442d07dc505f3" have entirely different histories.

24 changed files with 93 additions and 158 deletions

View File

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

View File

@ -123,13 +123,4 @@
◾ New versions of nuget packages have been released. ◾ New versions of nuget packages have been released.
</Content> </Content>
</Note> </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> </ReleaseNotes>

View File

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

View File

@ -11,21 +11,7 @@ namespace Tuitio.Application.Mappings
public MappingProfile() public MappingProfile()
{ {
CreateMap<models.RecordIdentifier, dto.RecordIdentifier>(); 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>(); CreateMap<models.Account.LogoutResult, dto.AccountLogoutResult>();
} }
} }

View File

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

View File

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

View File

@ -43,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 = TokenExtension.Deserialize(token.TokenExtension); var storeToken = Token.Import(token.Token);
_securityStore.Set(token.Token, storeToken); _securityStore.Set(token.Token, storeToken);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,40 +0,0 @@
// 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> GetUser(string userName, string password);
Task<AppUser> GetFullUser(int userId); Task<AppUser> GetFullUser(int userId);
Task UpdateUserAfterLogin(AppUser user, TokenExtension tokenExtension, string tokenRaw); Task UpdateUserAfterLogin(AppUser user, Token token, string tokenRaw);
Task<UserToken[]> GetActiveTokens(); Task<UserToken[]> GetActiveTokens();
Task RemoveToken(Guid tokenId); Task RemoveToken(Guid tokenId);
} }

View File

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

View File

@ -34,7 +34,8 @@ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
ARG APP_VERSION=0.0.0.0 ARG APP_VERSION=0.0.0.0
ENV APP_VERSION=${APP_VERSION} ENV APP_VERSION=${APP_VERSION}
ARG APP_DATE="-"
ENV APP_DATE=${APP_DATE} #Workaround to lower the TLS level in container for old sql server version
RUN sed -i 's/TLSv1.2/TLSv1.0/g' /etc/ssl/openssl.cnf
ENTRYPOINT ["dotnet", "Tuitio.dll"] ENTRYPOINT ["dotnet", "Tuitio.dll"]

View File

@ -39,7 +39,7 @@ namespace Tuitio.Api
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Fatal(ex, "Tuitio API host terminated unexpectedly."); Log.Fatal(ex, "Tuitio API host terminated unexpectedly");
} }
finally finally
{ {

View File

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

View File

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

View File

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