Compare commits

...

31 Commits

Author SHA1 Message Date
Tudor Stanciu 8b7e4b1e64 UpdateUserAfterLogin fix 2023-04-16 03:19:59 +03:00
Tudor Stanciu eefa23b3b8 [2.4.3] - Added IDs for user roles and groups in authorization result 2023-04-12 18:13:37 +03:00
Tudor Stanciu 74d68f2329 Removed specific user group and roles. The records must be dynamic. 2023-04-11 19:02:40 +03:00
Tudor Stanciu fdb08acd21 Merged PR 78: Added roles and groups in authorization result
Added roles and groups in authorization result
2023-04-08 14:55:36 +00:00
Tudor Stanciu ebb0f4de62 The authentication handler has been updated to skip the token validation if the method from controller is marked with [AllowAnonymous] attribute. 2023-04-07 13:06:26 +03:00
Tudor Stanciu d85ed53cdf release notes 2023-04-03 02:08:28 +03:00
Tudor Stanciu 273f356e5f Added copyright header 2023-04-03 02:05:48 +03:00
Tudor Stanciu 657b9b5204 add user groups and roles to user-info result 2023-04-03 02:00:16 +03:00
Tudor Stanciu 286bebdf36 2.4.0 - Added user groups and roles 2023-04-03 01:19:56 +03:00
Tudor Stanciu 40539b3a69 Published new versions of Tuitio's nuget packages 2023-03-29 08:10:32 +03:00
Tudor Stanciu 7d7bc9e82f Merged PR 77: Added "user-info" method in API
- Added "user-info" method in API
- removed ProfilePictureUrl property from token
- contact options
- Added user contact options
- mapping fix
2023-03-28 17:01:50 +00:00
Tudor Stanciu 55a9cc002d Merge branch 'master' of https://dev.azure.com/tstanciu94/Tuitio/_git/Tuitio 2023-03-18 21:05:59 +02:00
Tudor Stanciu 54679b8442 dockerfile update 2023-03-18 21:05:49 +02:00
Tudor Stanciu 8e74ec7dc7 Merged PR 75: Revert "Tuitio.PublishedLanguage copy ReleaseNotes.txt at build"
Revert "Tuitio.PublishedLanguage copy ReleaseNotes.txt at build"

Reverted commit `0873529b`.
2023-03-18 19:04:29 +00:00
Tudor Stanciu 535621e172 Revert "Tuitio.PublishedLanguage copy ReleaseNotes.txt at build" 2023-03-18 19:04:14 +00:00
Tudor Stanciu 0873529b98 Tuitio.PublishedLanguage copy ReleaseNotes.txt at build 2023-03-18 20:56:52 +02:00
Tudor Stanciu 60ee63d0f5 typo fix 2023-03-18 19:10:36 +02:00
Tudor Stanciu 64593dbee3 Serilog config update 2023-03-18 02:54:37 +02:00
Tudor Stanciu 7cca9d9ddf Added Tuitio.Wrapper.Tests 2023-03-14 01:06:09 +02:00
Tudor Stanciu 9df140e7c0 remove ImplicitUsings from tests project 2023-03-13 23:27:13 +02:00
Tudor Stanciu 652e6bc142 Added unit tests 2023-03-13 23:24:32 +02:00
Tudor Stanciu 25baaa0a67 Added unit tests 2023-03-13 18:36:59 +02:00
Tudor Stanciu 3f0412e7c5 Added unit testing with xunit 2023-03-10 15:04:39 +02:00
Tudor Stanciu 919ab053bc Added unit testing witn xunit 2023-03-09 12:00:30 +02:00
Tudor Stanciu 8a1ae180ad [2.1.0] 2023-03-09 10:53:16 +02:00
Tudor Stanciu 7a88b3c43a TokenService: removed unused property 2023-03-08 19:45:44 +02:00
Tudor Stanciu 13ca541478 Merged PR 74: Tuitio refactoring and account logout implementation
Tuitio refactoring and account logout implementation
2023-03-08 16:42:14 +00:00
Tudor Stanciu 8b5d693201 Tuitio refactoring 2023-03-08 16:38:52 +02:00
Tudor Stanciu 476481f808 Tuitio refactoring 2023-03-08 15:15:28 +02:00
Tudor Stanciu 747b91898d Tuitio refactoring 2023-03-08 15:08:12 +02:00
Tudor Stanciu e5854ef76b Tuitio refactoring and account logout implementation 2023-03-07 19:44:55 +02:00
107 changed files with 2420 additions and 549 deletions

View File

@ -1,7 +1,7 @@
<Project> <Project>
<Import Project="dependencies.props" /> <Import Project="dependencies.props" />
<PropertyGroup> <PropertyGroup>
<Version>2.0.0</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

@ -1,9 +1,10 @@
# Tuitio # Tuitio
Tuitio is a simple identity server implementation focused strictly on the needs of my home lab. Tuitio is a simple identity server implementation focused strictly on the needs of my home lab.
At the moment it has a simple API consisting of only two methods: At the moment it has a simple API consisting of only three methods:
* ```/identity/authenticate``` - handles user authentication using credentials and generates an access token. * ```/account/login``` - handles user authentication using credentials and generates an access token.
* ```/identity/authorize``` - manages the authorization process for a token, including verification of its existence, validity, and authenticity. * ```/account/logout``` - handles user logout.
* ```/connect/authorize``` - manages the authorization process for a token, including verification of its existence, validity, and authenticity.
***Tuitio*** is a latin word that encompasses meanings such as supervision, safeguarding, defense, guard duty, and protection. ***Tuitio*** is a latin word that encompasses meanings such as supervision, safeguarding, defense, guard duty, and protection.

View File

@ -8,16 +8,16 @@
A client/consumer can do only two things: A client/consumer can do only two things:
- Authentication: An user name and a password are required in the request body. The request type is POST. The output is an object with the following structure: { token: { raw: "***", validFrom: "", validUntil: "" }, status: "SUCCESS" } - Authentication: An user name and a password are required in the request body. The request type is POST. The output is an object with the following structure: { token: { raw: "***", validFrom: "", validUntil: "" }, status: "SUCCESS" }
- Authorization: The request type is also POST and and its scope is to authorize a token. The input is just the token in string format: { token: "***" } - Authorization: The request type is also POST and and its scope is to authorize a token. The input is just the token in string format: { token: "***" }
For .NET consumers there are two nuget packages developed to facilitate the integration with this identity server: For .NET consumers there are two nuget packages developed to facilitate the integration with this Tuitio server:
- Tuitio.PublishedLanguage: It contains constants and classes for data transfer objects. - Tuitio.PublishedLanguage: It contains constants and classes for data transfer objects.
- Tuitio.Wrapper: It compose and executes all the REST requests to the identity server and offers to a consumer a simple interface with all methods. This interface can be injected with dependency injection at consumer startup with UseIdentityServices method. The only input is the server base address. - Tuitio.Wrapper: It compose and executes all the REST requests to the Tuitio server and offers to a consumer a simple interface with all methods. This interface can be injected with dependency injection at consumer startup with UseTuitioServices method. The only input is the server base address.
- The source of this nugets is public, but on my personal server: https://lab.code-rove.com/public-nuget-server/nuget - The source of this nugets is public, but on my personal server: https://lab.code-rove.com/public-nuget-server/nuget
</Content> </Content>
</Note> </Note>
<Note> <Note>
<Version>1.0.1</Version> <Version>1.0.1</Version>
<Content> <Content>
Hard changes in token structure. Now the token format is base64 and contains a json with all user data like username, first name, last name, profile picture url, email address and a list of claims that can be configured from the database for each user independently. Big changes in token structure. Now the token format is base64 and contains a json with all user data like username, first name, last name, profile picture url, email address and a list of claims that can be configured from the database for each user independently.
◾ The generation and validation mechanism for the token has been rewritten to meet the new token structure. ◾ The generation and validation mechanism for the token has been rewritten to meet the new token structure.
◾ The complexity of user information has grown a lot. All users have now besides the data from token other information such as statuses, failed login attempts, last login date, password change date and security stamp. ◾ The complexity of user information has grown a lot. All users have now besides the data from token other information such as statuses, failed login attempts, last login date, password change date and security stamp.
◾ All tokens are persisted in the database and the active ones are reload at a server failure or in case of a restart. ◾ All tokens are persisted in the database and the active ones are reload at a server failure or in case of a restart.
@ -53,11 +53,74 @@
<Version>2.0.0</Version> <Version>2.0.0</Version>
<Content> <Content>
◾ Tuitio rebranding ◾ Tuitio rebranding
◾ .NET 6 upgrade ◾ .NET 6 upgrade
◾ Nuget packages upgrade ◾ Nuget packages upgrade
◾ Added Seq logging ◾ Added Seq logging
◾ Refactoring and code cleanup ◾ Refactoring and code cleanup
◾ Added README.md file ◾ Added README.md file
</Content>
</Note>
<Note>
<Version>2.1.0</Version>
<Content>
◾ Tuitio refactoring
◾ Added account logout method
◾ Tuitio performance optimizations
</Content>
</Note>
<Note>
<Version>2.2.0</Version>
<Content>
◾ Added unit testing with xunit
◾ Added some tests
</Content>
</Note>
<Note>
<Version>2.3.0</Version>
<Date>2023-03-27 19:20</Date>
<Content>
Added "user-info" method in API
◾ The "user-info" method returns the data of the authenticated user.
◾ Added http context accessor and authentication handler
◾ Added user contact options
◾ Published new versions of Tuitio's nuget packages
</Content>
</Note>
<Note>
<Version>2.4.0</Version>
<Date>2023-04-03 01:14</Date>
<Content>
Added user groups and roles
◾ From this version, any user can be assigned to groups and can have roles.
◾ Each user group can have roles that will be applied to all users who are part of the group.
</Content>
</Note>
<Note>
<Version>2.4.1</Version>
<Date>2023-04-07 19:12</Date>
<Content>
Authentication handler changes
◾ 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>
<Note>
<Version>2.4.3</Version>
<Date>2023-04-12 20:37</Date>
<Content>
Added IDs for user roles and groups in authorization result
◾ Based on the development from the previous version, the authorization result has been extended and will contain both IDs and codes for the groups and roles of the authenticated user.
◾ New versions of nuget packages have been released.
</Content> </Content>
</Note> </Note>
</ReleaseNotes> </ReleaseNotes>

View File

@ -30,6 +30,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tuitio.PublishedLanguage",
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tuitio.Wrapper", "src\Tuitio.Wrapper\Tuitio.Wrapper.csproj", "{F6FEC33B-C79E-4484-B356-9C7F1A5E5D95}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tuitio.Wrapper", "src\Tuitio.Wrapper\Tuitio.Wrapper.csproj", "{F6FEC33B-C79E-4484-B356-9C7F1A5E5D95}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{063D24C5-0823-48D6-A5DD-974CE38F8E71}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "UnitTests", "UnitTests", "{ECDF52C7-A2AA-4070-8FCF-A79A58CFA5F6}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tuitio.Application.Tests", "test\UnitTests\Tuitio.Application.Tests\Tuitio.Application.Tests.csproj", "{11F38E16-13BA-43FB-89FD-CD863FF06BA8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tuitio.Wrapper.Tests", "test\UnitTests\Tuitio.Wrapper.Tests\Tuitio.Wrapper.Tests.csproj", "{89D54F51-6ABB-4FE2-B060-A60826B6DE1E}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -60,6 +68,14 @@ Global
{F6FEC33B-C79E-4484-B356-9C7F1A5E5D95}.Debug|Any CPU.Build.0 = Debug|Any CPU {F6FEC33B-C79E-4484-B356-9C7F1A5E5D95}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F6FEC33B-C79E-4484-B356-9C7F1A5E5D95}.Release|Any CPU.ActiveCfg = Release|Any CPU {F6FEC33B-C79E-4484-B356-9C7F1A5E5D95}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F6FEC33B-C79E-4484-B356-9C7F1A5E5D95}.Release|Any CPU.Build.0 = Release|Any CPU {F6FEC33B-C79E-4484-B356-9C7F1A5E5D95}.Release|Any CPU.Build.0 = Release|Any CPU
{11F38E16-13BA-43FB-89FD-CD863FF06BA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{11F38E16-13BA-43FB-89FD-CD863FF06BA8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{11F38E16-13BA-43FB-89FD-CD863FF06BA8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{11F38E16-13BA-43FB-89FD-CD863FF06BA8}.Release|Any CPU.Build.0 = Release|Any CPU
{89D54F51-6ABB-4FE2-B060-A60826B6DE1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{89D54F51-6ABB-4FE2-B060-A60826B6DE1E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{89D54F51-6ABB-4FE2-B060-A60826B6DE1E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{89D54F51-6ABB-4FE2-B060-A60826B6DE1E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -71,6 +87,9 @@ Global
{CE81A435-49AC-4544-A381-FAC91BEB3C49} = {5A8FF505-3E4D-4258-BC3E-CACD74A7B98C} {CE81A435-49AC-4544-A381-FAC91BEB3C49} = {5A8FF505-3E4D-4258-BC3E-CACD74A7B98C}
{67B4D1FF-D02E-4DA6-9FB8-F71667360448} = {5A8FF505-3E4D-4258-BC3E-CACD74A7B98C} {67B4D1FF-D02E-4DA6-9FB8-F71667360448} = {5A8FF505-3E4D-4258-BC3E-CACD74A7B98C}
{F6FEC33B-C79E-4484-B356-9C7F1A5E5D95} = {5A8FF505-3E4D-4258-BC3E-CACD74A7B98C} {F6FEC33B-C79E-4484-B356-9C7F1A5E5D95} = {5A8FF505-3E4D-4258-BC3E-CACD74A7B98C}
{ECDF52C7-A2AA-4070-8FCF-A79A58CFA5F6} = {063D24C5-0823-48D6-A5DD-974CE38F8E71}
{11F38E16-13BA-43FB-89FD-CD863FF06BA8} = {ECDF52C7-A2AA-4070-8FCF-A79A58CFA5F6}
{89D54F51-6ABB-4FE2-B060-A60826B6DE1E} = {ECDF52C7-A2AA-4070-8FCF-A79A58CFA5F6}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E93DC46D-9C55-4A05-B299-497CDD90747E} SolutionGuid = {E93DC46D-9C55-4A05-B299-497CDD90747E}

View File

@ -9,7 +9,7 @@
<MediatRPackageVersion>9.0.0</MediatRPackageVersion> <MediatRPackageVersion>9.0.0</MediatRPackageVersion>
<EntityFrameworkCorePackageVersion>6.0.1</EntityFrameworkCorePackageVersion> <EntityFrameworkCorePackageVersion>6.0.1</EntityFrameworkCorePackageVersion>
<NewtonsoftJsonPackageVersion>13.0.1</NewtonsoftJsonPackageVersion> <NewtonsoftJsonPackageVersion>13.0.1</NewtonsoftJsonPackageVersion>
<NetmashExtensionsSwaggerPackageVersion>1.0.6</NetmashExtensionsSwaggerPackageVersion> <NetmashExtensionsSwaggerPackageVersion>1.0.7</NetmashExtensionsSwaggerPackageVersion>
<NetmashDatabaseMigrationPackageVersion>1.2.0</NetmashDatabaseMigrationPackageVersion> <NetmashDatabaseMigrationPackageVersion>1.2.0</NetmashDatabaseMigrationPackageVersion>
<NetmashExtensionsHttpPackageVersion>1.0.0</NetmashExtensionsHttpPackageVersion> <NetmashExtensionsHttpPackageVersion>1.0.0</NetmashExtensionsHttpPackageVersion>
</PropertyGroup> </PropertyGroup>

View File

@ -0,0 +1,7 @@
namespace Tuitio.Application.Abstractions
{
public interface IHttpContextService
{
int GetUserId();
}
}

View File

@ -0,0 +1,44 @@
// Copyright (c) 2020 Tudor Stanciu
using MediatR;
using Microsoft.Extensions.Logging;
using System.Threading;
using System.Threading.Tasks;
using Tuitio.Application.Services.Abstractions;
using Tuitio.PublishedLanguage.Constants;
using Tuitio.PublishedLanguage.Dto;
namespace Tuitio.Application.CommandHandlers
{
public class AccountLoginHandler
{
public record Command(string UserName, string Password) : IRequest<Envelope<AccountLoginResult>>;
public class CommandHandler : IRequestHandler<Command, Envelope<AccountLoginResult>>
{
private readonly IUserService _userService;
private readonly ILogger<AccountLoginHandler> _logger;
public CommandHandler(IUserService userService, ILogger<AccountLoginHandler> logger)
{
_userService = userService;
_logger = logger;
}
public async Task<Envelope<AccountLoginResult>> Handle(Command command, CancellationToken cancellationToken)
{
var loginResult = await _userService.Login(command.UserName, command.Password);
if (loginResult == null)
{
_logger.LogDebug($"Login failed for user '{command.UserName}'.");
return Envelope<AccountLoginResult>.Fail(EnvelopeError.BAD_CREDENTIALS);
}
_logger.LogDebug($"Login succeeded for user '{command.UserName}'.");
var result = new AccountLoginResult(loginResult.Raw, loginResult.Token.ExpiresIn);
return Envelope<AccountLoginResult>.Success(result);
}
}
}
}

View File

@ -0,0 +1,47 @@
// Copyright (c) 2020 Tudor Stanciu
using AutoMapper;
using MediatR;
using Microsoft.Extensions.Logging;
using System.Threading;
using System.Threading.Tasks;
using Tuitio.Application.Services.Abstractions;
using Tuitio.PublishedLanguage.Constants;
using Tuitio.PublishedLanguage.Dto;
namespace Tuitio.Application.CommandHandlers
{
public class AccountLogoutHandler
{
public record Command(string Token) : IRequest<Envelope<AccountLogoutResult>>;
public class CommandHandler : IRequestHandler<Command, Envelope<AccountLogoutResult>>
{
private readonly IUserService _userService;
private readonly IMapper _mapper;
private readonly ILogger<AccountLoginHandler> _logger;
public CommandHandler(IUserService userService, IMapper mapper, ILogger<AccountLoginHandler> logger)
{
_userService = userService;
_mapper = mapper;
_logger = logger;
}
public async Task<Envelope<AccountLogoutResult>> Handle(Command command, CancellationToken cancellationToken)
{
var logoutResult = await _userService.Logout(command.Token);
if (logoutResult == null)
{
_logger.LogDebug($"Logout failed for token '{command.Token}'.");
return Envelope<AccountLogoutResult>.Fail(EnvelopeError.UNAUTHENTICATED);
}
_logger.LogDebug($"Logout succeeded for user '{logoutResult.UserName}'.");
var result = _mapper.Map<AccountLogoutResult>(logoutResult);
return Envelope<AccountLogoutResult>.Success(result);
}
}
}
}

View File

@ -1,44 +0,0 @@
// Copyright (c) 2020 Tudor Stanciu
using AutoMapper;
using Tuitio.Application.Commands;
using Tuitio.Application.Services;
using Tuitio.PublishedLanguage.Constants;
using Tuitio.PublishedLanguage.Dto;
using Tuitio.PublishedLanguage.Events;
using MediatR;
using Microsoft.Extensions.Logging;
using System.Threading;
using System.Threading.Tasks;
namespace Tuitio.Application.CommandHandlers
{
public class AuthenticateUserHandler : IRequestHandler<AuthenticateUser, AuthenticateUserResult>
{
private readonly IUserService _userService;
private readonly IMapper _mapper;
private readonly ILogger<AuthenticateUserHandler> _logger;
public AuthenticateUserHandler(IUserService userService, IMapper mapper, ILogger<AuthenticateUserHandler> logger)
{
_userService = userService;
_mapper = mapper;
_logger = logger;
}
public async Task<AuthenticateUserResult> Handle(AuthenticateUser command, CancellationToken cancellationToken)
{
var internalToken = await _userService.Authenticate(command.UserName, command.Password);
if (internalToken == null)
{
_logger.LogDebug($"Authentication failed for user '{command.UserName}'.");
return new AuthenticateUserResult() { Status = AuthenticationStatus.BAD_CREDENTIALS };
}
_logger.LogDebug($"Authentication succeeded for user '{command.UserName}'.");
var token = _mapper.Map<Token>(internalToken);
return new AuthenticateUserResult() { Token = token, Status = AuthenticationStatus.SUCCESS };
}
}
}

View File

@ -0,0 +1,48 @@
// Copyright (c) 2020 Tudor Stanciu
using AutoMapper;
using MediatR;
using Microsoft.Extensions.Logging;
using System.Threading;
using System.Threading.Tasks;
using Tuitio.Application.Services.Abstractions;
using Tuitio.PublishedLanguage.Constants;
using Tuitio.PublishedLanguage.Dto;
namespace Tuitio.Application.CommandHandlers
{
public class AuthorizationHandler
{
public record Command(string Token) : IRequest<Envelope<AuthorizationResult>>;
public class CommandHandler : IRequestHandler<Command, Envelope<AuthorizationResult>>
{
private readonly IUserService _userService;
private readonly IMapper _mapper;
private readonly ILogger<AuthorizationHandler> _logger;
public CommandHandler(IUserService userService, IMapper mapper, ILogger<AuthorizationHandler> logger)
{
_userService = userService;
_mapper = mapper;
_logger = logger;
}
public Task<Envelope<AuthorizationResult>> Handle(Command command, CancellationToken cancellationToken)
{
var token = _userService.Authorize(command.Token);
if (token == null)
{
_logger.LogDebug($"Authorization failed for token '{command.Token}'.");
var result = Envelope<AuthorizationResult>.Fail(EnvelopeError.UNAUTHORIZED);
return Task.FromResult(result);
}
_logger.LogDebug($"Authorization succeeded for token '{command.Token}'.");
var authorizationResult = _mapper.Map<AuthorizationResult>(token);
var envelope = Envelope<AuthorizationResult>.Success(authorizationResult);
return Task.FromResult(envelope);
}
}
}
}

View File

@ -1,42 +0,0 @@
// Copyright (c) 2020 Tudor Stanciu
using AutoMapper;
using Tuitio.Application.Commands;
using Tuitio.Application.Services;
using Tuitio.PublishedLanguage.Dto;
using MediatR;
using Microsoft.Extensions.Logging;
using System.Threading;
using System.Threading.Tasks;
namespace Tuitio.Application.CommandHandlers
{
public class AuthorizeTokenHandler : IRequestHandler<AuthorizeToken, TokenCore>
{
private readonly IUserService _userService;
private readonly IMapper _mapper;
private readonly ILogger<AuthorizeTokenHandler> _logger;
public AuthorizeTokenHandler(IUserService userService, IMapper mapper, ILogger<AuthorizeTokenHandler> logger)
{
_userService = userService;
_mapper = mapper;
_logger = logger;
}
public Task<TokenCore> Handle(AuthorizeToken command, CancellationToken cancellationToken)
{
var tokenCore = _userService.Authorize(command.Token);
if (tokenCore == null)
{
_logger.LogDebug($"Authorization failed for token '{command.Token}'.");
return null;
}
_logger.LogDebug($"Authorization succeeded for token '{command.Token}'.");
var tokenCoreResult = _mapper.Map<TokenCore>(tokenCore);
return Task.FromResult(tokenCoreResult);
}
}
}

View File

@ -1,13 +0,0 @@
// Copyright (c) 2020 Tudor Stanciu
using MediatR;
using Tuitio.PublishedLanguage.Events;
namespace Tuitio.Application.Commands
{
public class AuthenticateUser : IRequest<AuthenticateUserResult>
{
public string UserName { get; set; }
public string Password { get; set; }
}
}

View File

@ -1,12 +0,0 @@
// Copyright (c) 2020 Tudor Stanciu
using MediatR;
using Tuitio.PublishedLanguage.Dto;
namespace Tuitio.Application.Commands
{
public class AuthorizeToken : IRequest<TokenCore>
{
public string Token { get; set; }
}
}

View File

@ -1,10 +1,10 @@
// Copyright (c) 2020 Tudor Stanciu // Copyright (c) 2020 Tudor Stanciu
using Microsoft.Extensions.DependencyInjection;
using Tuitio.Application.Services; using Tuitio.Application.Services;
using Tuitio.Application.Services.Abstractions; using Tuitio.Application.Services.Abstractions;
using Tuitio.Application.Stores; using Tuitio.Application.Stores;
using Tuitio.Domain.Abstractions; using Tuitio.Domain.Abstractions;
using Microsoft.Extensions.DependencyInjection;
namespace Tuitio.Application namespace Tuitio.Application
{ {

View File

@ -0,0 +1,43 @@
// Copyright (c) 2020 Tudor Stanciu
using System;
using System.Collections.Generic;
using System.Linq;
using Tuitio.Domain.Entities;
using Tuitio.Domain.Models;
namespace Tuitio.Application.Extensions
{
internal static class EntityConversions
{
public static Dictionary<string, string> ToDictionary(this ICollection<UserClaim> claims)
{
if (claims == null)
return null;
var result = new Dictionary<string, string>();
foreach (var claim in claims)
result.Add(claim.ClaimKey, claim.ClaimValue);
return result;
}
public static IEnumerable<RecordIdentifier> AsRecordIdentifiers(this IEnumerable<UserGroup> userGroups)
{
if (userGroups == null)
throw new ArgumentNullException(nameof(userGroups));
var result = userGroups.Select(z => new RecordIdentifier(z.UserGroupId, z.UserGroupCode));
return result;
}
public static IEnumerable<RecordIdentifier> AsRecordIdentifiers(this IEnumerable<UserRole> userRoles)
{
if (userRoles == null)
throw new ArgumentNullException(nameof(userRoles));
var result = userRoles.Select(z => new RecordIdentifier(z.UserRoleId, z.UserRoleCode));
return result;
}
}
}

View File

@ -0,0 +1,33 @@
// Copyright (c) 2020 Tudor Stanciu
using System.Collections.Generic;
using System.Linq;
using Tuitio.Domain.Entities;
namespace Tuitio.Application.Extensions
{
internal static class EntityExtensions
{
public static IEnumerable<UserRole> GetUserRoles(this AppUser user)
{
var roles = new List<UserRole>();
var groups = user.UserGroups?.Select(z => z.UserGroup);
if (groups != null)
{
foreach (var group in groups)
{
var groupRoles = group.GroupRoles?.Select(z => z.UserRole);
if (groupRoles == null)
continue;
roles.AddRange(groupRoles);
}
}
if (user.UserRoles != null)
roles.AddRange(user.UserRoles.Select(z => z.UserRole));
return roles.ToArray();
}
}
}

View File

@ -1,8 +1,6 @@
// Copyright (c) 2020 Tudor Stanciu // Copyright (c) 2020 Tudor Stanciu
using AutoMapper; using AutoMapper;
using Tuitio.Domain.Entities;
using System.Collections.Generic;
using dto = Tuitio.PublishedLanguage.Dto; using dto = Tuitio.PublishedLanguage.Dto;
using models = Tuitio.Domain.Models; using models = Tuitio.Domain.Models;
@ -12,22 +10,9 @@ namespace Tuitio.Application.Mappings
{ {
public MappingProfile() public MappingProfile()
{ {
CreateMap<models.Token, dto.Token>(); CreateMap<models.RecordIdentifier, dto.RecordIdentifier>();
CreateMap<models.TokenCore, dto.TokenCore>(); CreateMap<models.Token, dto.AuthorizationResult>();
CreateMap<AppUser, models.TokenCore>() CreateMap<models.Account.LogoutResult, dto.AccountLogoutResult>();
.ForMember(z => z.Claims, src => src.MapFrom(z => ComposeClaims(z.Claims)));
}
private Dictionary<string, string> ComposeClaims(ICollection<UserClaim> claims)
{
if (claims == null)
return null;
var result = new Dictionary<string, string>();
foreach (var claim in claims)
result.Add(claim.ClaimKey, claim.ClaimValue);
return result;
} }
} }
} }

View File

@ -0,0 +1,36 @@
// Copyright (c) 2020 Tudor Stanciu
using AutoMapper;
using System.Linq;
using Tuitio.Application.Extensions;
using Tuitio.Application.Queries;
using Tuitio.Domain.Entities;
namespace Tuitio.Application.Mappings
{
public class UserInfoMappings : Profile
{
public UserInfoMappings()
{
CreateMap<AppUser, GetUserInfo.Model>()
.ForMember(z => z.Claims, src => src.MapFrom(z => z.Claims.ToDictionary()))
.ForMember(z => z.UserGroups, src => src.MapFrom(z => z.UserGroups.Select(z => z.UserGroup)))
.ForMember(z => z.UserRoles, src => src.MapFrom(z => z.GetUserRoles()));
CreateMap<ContactOption, GetUserInfo.ContactOption>()
.ForMember(z => z.Id, src => src.MapFrom(z => z.ContactOptionId))
.ForMember(z => z.ContactTypeCode, src => src.MapFrom(z => z.ContactType.ContactTypeCode))
.ForMember(z => z.ContactTypeName, src => src.MapFrom(z => z.ContactType.ContactTypeName));
CreateMap<UserGroup, GetUserInfo.UserGroup>()
.ForMember(z => z.Id, src => src.MapFrom(z => z.UserGroupId))
.ForMember(z => z.Code, src => src.MapFrom(z => z.UserGroupCode))
.ForMember(z => z.Name, src => src.MapFrom(z => z.UserGroupName));
CreateMap<UserRole, GetUserInfo.UserRole>()
.ForMember(z => z.Id, src => src.MapFrom(z => z.UserRoleId))
.ForMember(z => z.Code, src => src.MapFrom(z => z.UserRoleCode))
.ForMember(z => z.Name, src => src.MapFrom(z => z.UserRoleName));
}
}
}

View File

@ -0,0 +1,5 @@
// Copyright (c) 2020 Tudor Stanciu
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Tuitio.Application.Tests")]

View File

@ -0,0 +1,80 @@
// Copyright (c) 2020 Tudor Stanciu
using AutoMapper;
using MediatR;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Tuitio.Application.Abstractions;
using Tuitio.Domain.Repositories;
namespace Tuitio.Application.Queries
{
public class GetUserInfo
{
public class Query : IRequest<Model> { }
public record Model
{
public int UserId { get; init; }
public string UserName { get; init; }
public string FirstName { get; init; }
public string LastName { get; init; }
public string Email { get; init; }
public string ProfilePictureUrl { get; init; }
public string SecurityStamp { get; init; }
public DateTime CreationDate { get; init; }
public int? FailedLoginAttempts { get; init; }
public DateTime? LastLoginDate { get; init; }
public Dictionary<string, string> Claims { get; init; }
public UserRole[] UserRoles { get; init; }
public UserGroup[] UserGroups { get; init; }
public ContactOption[] ContactOptions { get; init; }
}
public record ContactOption
{
public int Id { get; init; }
public string ContactTypeCode { get; init; }
public string ContactTypeName { get; init; }
public string ContactValue { get; init; }
}
public record UserGroup
{
public int Id { get; init; }
public string Code { get; init; }
public string Name { get; init; }
}
public record UserRole
{
public int Id { get; init; }
public string Code { get; init; }
public string Name { get; init; }
}
public class QueryHandler : IRequestHandler<Query, Model>
{
private readonly IUserRepository _userRepository;
private readonly IHttpContextService _httpContextService;
private readonly IMapper _mapper;
public QueryHandler(IUserRepository userRepository, IHttpContextService httpContextService, IMapper mapper)
{
_userRepository=userRepository;
_httpContextService=httpContextService;
_mapper=mapper;
}
public async Task<Model> Handle(Query request, CancellationToken cancellationToken)
{
var userId = _httpContextService.GetUserId();
var user = await _userRepository.GetFullUser(userId);
var info = _mapper.Map<Model>(user);
return info;
}
}
}
}

View File

@ -3,11 +3,10 @@
using Tuitio.Domain.Entities; using Tuitio.Domain.Entities;
using Tuitio.Domain.Models; using Tuitio.Domain.Models;
namespace Tuitio.Application.Services namespace Tuitio.Application.Services.Abstractions
{ {
internal interface ITokenService internal interface ITokenService
{ {
Token GenerateToken(AppUser user); Token GenerateToken(AppUser user);
TokenCore ExtractTokenCore(string tokenRaw);
} }
} }

View File

@ -0,0 +1,15 @@
// Copyright (c) 2020 Tudor Stanciu
using System.Threading.Tasks;
using Tuitio.Domain.Models;
using Tuitio.Domain.Models.Account;
namespace Tuitio.Application.Services.Abstractions
{
public interface IUserService
{
Task<LoginResult> Login(string userName, string password);
Task<LogoutResult> Logout(string tokenRaw);
Token Authorize(string tokenRaw);
}
}

View File

@ -1,14 +1,14 @@
// Copyright (c) 2020 Tudor Stanciu // Copyright (c) 2020 Tudor Stanciu
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;
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.Models;
using Tuitio.Domain.Repositories; using Tuitio.Domain.Repositories;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;
using System.Threading.Tasks;
namespace Tuitio.Application.Services namespace Tuitio.Application.Services
{ {
@ -20,9 +20,9 @@ namespace Tuitio.Application.Services
public BehaviorService(IServiceProvider serviceProvider, ILogger<BehaviorService> logger, ITokenStore securityStore) public BehaviorService(IServiceProvider serviceProvider, ILogger<BehaviorService> logger, ITokenStore securityStore)
{ {
_serviceProvider = serviceProvider; _serviceProvider=serviceProvider;
_logger = logger; _logger=logger;
_securityStore = securityStore; _securityStore=securityStore;
} }
public void FillTokenStore() public void FillTokenStore()
@ -33,7 +33,7 @@ namespace Tuitio.Application.Services
var activeTokens = Array.Empty<UserToken>(); var activeTokens = Array.Empty<UserToken>();
using (var scope = _serviceProvider.CreateScope()) using (var scope = _serviceProvider.CreateScope())
{ {
var _repository = scope.ServiceProvider.GetRequiredService<IIdentityRepository>(); var _repository = scope.ServiceProvider.GetRequiredService<IUserRepository>();
activeTokens = await _repository.GetActiveTokens(); activeTokens = await _repository.GetActiveTokens();
} }
@ -43,8 +43,8 @@ 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 = new Token() { Raw = token.Token, ValidFrom = token.ValidFrom, ValidUntil = token.ValidUntil }; var storeToken = Token.Import(token.Token);
_securityStore.SetToken(storeToken, token.UserId); _securityStore.Set(token.Token, storeToken);
} }
} }
} }

View File

@ -1,13 +0,0 @@
// Copyright (c) 2020 Tudor Stanciu
using Tuitio.Domain.Models;
using System.Threading.Tasks;
namespace Tuitio.Application.Services
{
public interface IUserService
{
Task<Token> Authenticate(string userName, string password);
TokenCore Authorize(string token);
}
}

View File

@ -1,78 +1,32 @@
// Copyright (c) 2020 Tudor Stanciu // Copyright (c) 2020 Tudor Stanciu
using AutoMapper; using System.Linq;
using Tuitio.Application.Extensions;
using Tuitio.Application.Services.Abstractions;
using Tuitio.Domain.Abstractions; using Tuitio.Domain.Abstractions;
using Tuitio.Domain.Entities; using Tuitio.Domain.Entities;
using Tuitio.Domain.Models; using Tuitio.Domain.Models;
using Newtonsoft.Json;
using System;
using System.Text;
using System.Text.RegularExpressions;
namespace Tuitio.Application.Services 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 tokenRaw = GenerateTokenRaw(user); var token = new Token(_configProvider.Token.ValidityInMinutes);
var currentDate = DateTime.Now; var claims = user.Claims?.ToDictionary();
var token = new Token() { Raw = tokenRaw, ValidFrom = currentDate, ValidUntil = currentDate.AddMinutes(_configProvider.Token.ValidityInMinutes) }; 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; return token;
} }
private string GenerateTokenRaw(AppUser user)
{
var tokenCore = GenerateTokenCore(user);
var tokenCoreString = JsonConvert.SerializeObject(tokenCore);
var tokenCoreBytes = Encoding.UTF8.GetBytes(tokenCoreString);
var tokenRaw = Convert.ToBase64String(tokenCoreBytes);
return tokenRaw;
}
private TokenCore GenerateTokenCore(AppUser user)
{
var tokenCore = _mapper.Map<TokenCore>(user);
tokenCore.LockStamp = Regex.Replace(Convert.ToBase64String(Guid.NewGuid().ToByteArray()), "[/+=]", "");
return tokenCore;
}
public TokenCore ExtractTokenCore(string tokenRaw)
{
var valid = ValidateTokenRaw(tokenRaw);
if (!valid)
return null;
var tokenCoreBytes = Convert.FromBase64String(tokenRaw);
var tokenCoreString = Encoding.UTF8.GetString(tokenCoreBytes);
var tokenCore = JsonConvert.DeserializeObject<TokenCore>(tokenCoreString);
return tokenCore;
}
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

@ -1,61 +1,89 @@
// Copyright (c) 2020 Tudor Stanciu // Copyright (c) 2020 Tudor Stanciu
using System;
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.Abstractions; using Tuitio.Domain.Abstractions;
using Tuitio.Domain.Entities; using Tuitio.Domain.Entities;
using Tuitio.Domain.Models; using Tuitio.Domain.Models;
using Tuitio.Domain.Models.Account;
using Tuitio.Domain.Repositories; using Tuitio.Domain.Repositories;
using System;
using System.Threading.Tasks;
namespace Tuitio.Application.Services namespace Tuitio.Application.Services
{ {
internal class UserService : IUserService internal class UserService : IUserService
{ {
private readonly ITokenStore _securityStore; private readonly ITokenStore _securityStore;
private readonly IIdentityRepository _identityRepository; private readonly IUserRepository _userRepository;
private readonly ITokenService _tokenService; private readonly ITokenService _tokenService;
private readonly IConfigProvider _configProvider; private readonly IConfigProvider _configProvider;
private readonly IHashingService _hashingService; private readonly IHashingService _hashingService;
public UserService(ITokenStore securityStore, IIdentityRepository identityRepository, ITokenService tokenService, IConfigProvider configProvider, IHashingService hashingService) public UserService(ITokenStore securityStore, IUserRepository userRepository, ITokenService tokenService, IConfigProvider configProvider, IHashingService hashingService)
{ {
_securityStore=securityStore; _securityStore=securityStore;
_identityRepository=identityRepository; _userRepository=userRepository;
_tokenService=tokenService; _tokenService=tokenService;
_configProvider=configProvider; _configProvider=configProvider;
_hashingService=hashingService; _hashingService=hashingService;
} }
public async Task<Token> Authenticate(string userName, string password) public async Task<LoginResult> Login(string userName, string password)
{ {
ValidateCredentials(userName, password);
var passwordHash = _hashingService.HashSha256(password); var passwordHash = _hashingService.HashSha256(password);
var user = await _identityRepository.GetUser(userName, passwordHash); var user = await _userRepository.GetUser(userName, passwordHash);
if (user == null)
return null;
var valid = ValidateUser(user); var valid = ValidateUser(user);
if (!valid) if (!valid)
return null; return null;
var token = _tokenService.GenerateToken(user); var token = _tokenService.GenerateToken(user);
_securityStore.SetToken(token, user.UserId); var raw = token.Export();
await _identityRepository.UpdateUserAfterAuthentication(user, token); _securityStore.Set(raw, token);
await _userRepository.UpdateUserAfterLogin(user, token, raw);
var result = new LoginResult(token, raw);
return result;
}
public async Task<LogoutResult> Logout(string tokenRaw)
{
var token = _securityStore.Get(tokenRaw);
if (token == null)
return null;
await _userRepository.RemoveToken(token.TokenId);
_securityStore.Remove(tokenRaw);
var result = new LogoutResult(token.UserId, token.UserName, DateTime.UtcNow);
return result;
}
public Token Authorize(string tokenRaw)
{
var token = _securityStore.Get(tokenRaw);
return token; return token;
} }
public TokenCore Authorize(string token) private void ValidateCredentials(string userName, string password)
{ {
var tokenCore = _securityStore.ValidateAndGetTokenCore(token); if (string.IsNullOrEmpty(userName))
if (tokenCore == null) throw new ArgumentException($"Value cannot be null or empty string.", nameof(userName));
return null;
return tokenCore; if (string.IsNullOrEmpty(password))
throw new ArgumentException($"Value cannot be null or empty string.", nameof(password));
} }
private bool ValidateUser(AppUser user) private bool ValidateUser(AppUser user)
{ {
if (user == null) if (user == null)
return false; throw new ArgumentNullException(nameof(user));
if (user.FailedLoginAttempts.HasValue && user.FailedLoginAttempts.Value > _configProvider.Restrictions.MaxFailedLoginAttempts) if (user.FailedLoginAttempts.HasValue && user.FailedLoginAttempts.Value > _configProvider.Restrictions.MaxFailedLoginAttempts)
return false; return false;

View File

@ -1,12 +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
{ {
void SetToken(Token token, int userId); Token Get(string key);
TokenCore ValidateAndGetTokenCore(string token); bool Set(string key, Token token);
void Remove(string key);
} }
} }

View File

@ -1,49 +1,39 @@
// Copyright (c) 2020 Tudor Stanciu // Copyright (c) 2020 Tudor Stanciu
using Tuitio.Application.Services;
using Tuitio.Domain.Models;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using Tuitio.Domain.Models;
namespace Tuitio.Application.Stores namespace Tuitio.Application.Stores
{ {
internal class TokenStore : ITokenStore internal class TokenStore : ITokenStore
{ {
private readonly ITokenService _tokenService; private ConcurrentDictionary<string, Token> Tokens { get; }
private ConcurrentDictionary<int, List<Token>> Tokens { get; }
public TokenStore(ITokenService tokenService) public TokenStore()
{ {
_tokenService = tokenService; Tokens = new ConcurrentDictionary<string, Token>();
Tokens = new ConcurrentDictionary<int, List<Token>>();
} }
public void SetToken(Token token, int userId) public Token Get(string key)
{ {
var registered = Tokens.TryGetValue(userId, out List<Token> list); var registered = Tokens.ContainsKey(key);
if (registered)
list.Add(token);
else
Tokens.TryAdd(userId, new List<Token>() { token });
}
public TokenCore ValidateAndGetTokenCore(string token)
{
var tokenCore = _tokenService.ExtractTokenCore(token);
if (tokenCore == null)
return null;
var registered = Tokens.TryGetValue(tokenCore.UserId, out List<Token> list);
if (!registered) if (!registered)
return null; return null;
var valid = list.FirstOrDefault(z => z.Raw == token); return Tokens[key];
if (valid == null)
return null;
return tokenCore;
} }
public bool Set(string key, Token token)
{
var registered = Tokens.ContainsKey(key);
if (registered)
return false;
Tokens.TryAdd(key, token);
return true;
}
public void Remove(string key) => Tokens.Remove(key, out _);
} }
} }

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

@ -6,12 +6,13 @@ using Microsoft.EntityFrameworkCore;
namespace Tuitio.Domain.Data.DbContexts namespace Tuitio.Domain.Data.DbContexts
{ {
public class IdentityDbContext : DbContext public class TuitioDbContext : DbContext
{ {
public DbSet<UserStatus> UserStatuses{ get; set; }
public DbSet<AppUser> Users { get; set; } public DbSet<AppUser> Users { get; set; }
public DbSet<UserToken> UserTokens { get; set; } public DbSet<UserToken> UserTokens { get; set; }
public IdentityDbContext(DbContextOptions<IdentityDbContext> options) public TuitioDbContext(DbContextOptions<TuitioDbContext> options)
: base(options) : base(options)
{ {
base.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.TrackAll; base.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.TrackAll;
@ -26,6 +27,13 @@ namespace Tuitio.Domain.Data.DbContexts
modelBuilder.ApplyConfiguration(new AppUserConfiguration()); modelBuilder.ApplyConfiguration(new AppUserConfiguration());
modelBuilder.ApplyConfiguration(new UserClaimConfiguration()); modelBuilder.ApplyConfiguration(new UserClaimConfiguration());
modelBuilder.ApplyConfiguration(new UserTokenConfiguration()); modelBuilder.ApplyConfiguration(new UserTokenConfiguration());
modelBuilder.ApplyConfiguration(new ContactTypeConfiguration());
modelBuilder.ApplyConfiguration(new ContactOptionConfiguration());
modelBuilder.ApplyConfiguration(new UserGroupConfiguration());
modelBuilder.ApplyConfiguration(new UserRoleConfiguration());
modelBuilder.ApplyConfiguration(new UserXUserGroupConfiguration());
modelBuilder.ApplyConfiguration(new UserGroupXUserRoleConfiguration());
modelBuilder.ApplyConfiguration(new UserXUserRoleConfiguration());
} }
} }
} }

View File

@ -1,22 +1,26 @@
// Copyright (c) 2020 Tudor Stanciu // Copyright (c) 2020 Tudor Stanciu
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Tuitio.Domain.Data.DbContexts; using Tuitio.Domain.Data.DbContexts;
using Tuitio.Domain.Data.Repositories; using Tuitio.Domain.Data.Repositories;
using Tuitio.Domain.Repositories; using Tuitio.Domain.Repositories;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
namespace Tuitio.Domain.Data namespace Tuitio.Domain.Data
{ {
public static class DependencyInjectionExtensions public static class DependencyInjectionExtensions
{ {
public static void AddDataAccessServices(this IServiceCollection services)
{
services.AddScoped<IUserRepository, UserRepository>();
}
public static void AddDataAccess(this IServiceCollection services) public static void AddDataAccess(this IServiceCollection services)
{ {
services.AddScoped<IIdentityRepository, IdentityRepository>(); services.AddDataAccessServices();
services services
.AddDbContextPool<IdentityDbContext>( .AddDbContextPool<TuitioDbContext>(
(serviceProvider, options) => (serviceProvider, options) =>
{ {
var configuration = serviceProvider.GetService<IConfiguration>(); var configuration = serviceProvider.GetService<IConfiguration>();

View File

@ -14,6 +14,9 @@ namespace Tuitio.Domain.Data.EntityTypeConfiguration
builder.Property(z => z.UserId).ValueGeneratedOnAdd(); builder.Property(z => z.UserId).ValueGeneratedOnAdd();
builder.HasOne(z => z.Status).WithMany().HasForeignKey(z => z.StatusId); builder.HasOne(z => z.Status).WithMany().HasForeignKey(z => z.StatusId);
builder.HasMany(z => z.Claims).WithOne().HasForeignKey(z => z.UserId); builder.HasMany(z => z.Claims).WithOne().HasForeignKey(z => z.UserId);
builder.HasMany(z => z.ContactOptions).WithOne().HasForeignKey(z => z.UserId);
builder.HasMany(z => z.UserGroups).WithOne().HasForeignKey(z => z.UserId);
builder.HasMany(z => z.UserRoles).WithOne().HasForeignKey(z => z.UserId);
} }
} }
} }

View File

@ -0,0 +1,18 @@
// Copyright (c) 2020 Tudor Stanciu
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tuitio.Domain.Entities;
namespace Tuitio.Domain.Data.EntityTypeConfiguration
{
class ContactOptionConfiguration : IEntityTypeConfiguration<ContactOption>
{
public void Configure(EntityTypeBuilder<ContactOption> builder)
{
builder.ToTable("ContactOption").HasKey(key => key.ContactOptionId);
builder.Property(z => z.ContactOptionId).ValueGeneratedOnAdd();
builder.HasOne(z => z.ContactType).WithMany().HasForeignKey(z => z.ContactTypeId);
}
}
}

View File

@ -0,0 +1,17 @@
// Copyright (c) 2020 Tudor Stanciu
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tuitio.Domain.Entities;
namespace Tuitio.Domain.Data.EntityTypeConfiguration
{
class ContactTypeConfiguration : IEntityTypeConfiguration<ContactType>
{
public void Configure(EntityTypeBuilder<ContactType> builder)
{
builder.ToTable("ContactType").HasKey(z => z.ContactTypeId);
builder.Property(z => z.ContactTypeId).ValueGeneratedOnAdd();
}
}
}

View File

@ -0,0 +1,18 @@
// Copyright (c) 2020 Tudor Stanciu
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tuitio.Domain.Entities;
namespace Tuitio.Domain.Data.EntityTypeConfiguration
{
class UserGroupConfiguration : IEntityTypeConfiguration<UserGroup>
{
public void Configure(EntityTypeBuilder<UserGroup> builder)
{
builder.ToTable("UserGroup").HasKey(z => z.UserGroupId);
builder.Property(z => z.UserGroupId).ValueGeneratedOnAdd();
builder.HasMany(z => z.GroupRoles).WithOne().HasForeignKey(z => z.UserGroupId);
}
}
}

View File

@ -0,0 +1,17 @@
// Copyright (c) 2020 Tudor Stanciu
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tuitio.Domain.Entities;
namespace Tuitio.Domain.Data.EntityTypeConfiguration
{
class UserGroupXUserRoleConfiguration : IEntityTypeConfiguration<UserGroupXUserRole>
{
public void Configure(EntityTypeBuilder<UserGroupXUserRole> builder)
{
builder.ToTable("UserGroupXUserRole").HasKey(z => new { z.UserGroupId, z.UserRoleId });
builder.HasOne(z => z.UserRole).WithMany().HasForeignKey(z => z.UserRoleId);
}
}
}

View File

@ -0,0 +1,17 @@
// Copyright (c) 2020 Tudor Stanciu
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tuitio.Domain.Entities;
namespace Tuitio.Domain.Data.EntityTypeConfiguration
{
class UserRoleConfiguration : IEntityTypeConfiguration<UserRole>
{
public void Configure(EntityTypeBuilder<UserRole> builder)
{
builder.ToTable("UserRole").HasKey(z => z.UserRoleId);
builder.Property(z => z.UserRoleId).ValueGeneratedOnAdd();
}
}
}

View File

@ -1,8 +1,8 @@
// Copyright (c) 2020 Tudor Stanciu // Copyright (c) 2020 Tudor Stanciu
using Tuitio.Domain.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tuitio.Domain.Entities;
namespace Tuitio.Domain.Data.EntityTypeConfiguration namespace Tuitio.Domain.Data.EntityTypeConfiguration
{ {
@ -11,7 +11,6 @@ namespace Tuitio.Domain.Data.EntityTypeConfiguration
public void Configure(EntityTypeBuilder<UserToken> builder) public void Configure(EntityTypeBuilder<UserToken> builder)
{ {
builder.ToTable("UserToken").HasKey(z => z.TokenId); builder.ToTable("UserToken").HasKey(z => z.TokenId);
builder.Property(z => z.TokenId).ValueGeneratedOnAdd();
} }
} }
} }

View File

@ -0,0 +1,17 @@
// Copyright (c) 2020 Tudor Stanciu
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tuitio.Domain.Entities;
namespace Tuitio.Domain.Data.EntityTypeConfiguration
{
class UserXUserGroupConfiguration : IEntityTypeConfiguration<UserXUserGroup>
{
public void Configure(EntityTypeBuilder<UserXUserGroup> builder)
{
builder.ToTable("UserXUserGroup").HasKey(z => new { z.UserId, z.UserGroupId });
builder.HasOne(z => z.UserGroup).WithMany().HasForeignKey(z => z.UserGroupId);
}
}
}

View File

@ -0,0 +1,17 @@
// Copyright (c) 2020 Tudor Stanciu
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tuitio.Domain.Entities;
namespace Tuitio.Domain.Data.EntityTypeConfiguration
{
class UserXUserRoleConfiguration : IEntityTypeConfiguration<UserXUserRole>
{
public void Configure(EntityTypeBuilder<UserXUserRole> builder)
{
builder.ToTable("UserXUserRole").HasKey(z => new { z.UserId, z.UserRoleId });
builder.HasOne(z => z.UserRole).WithMany().HasForeignKey(z => z.UserRoleId);
}
}
}

View File

@ -1,56 +0,0 @@
// Copyright (c) 2020 Tudor Stanciu
using Tuitio.Domain.Data.DbContexts;
using Tuitio.Domain.Entities;
using Tuitio.Domain.Models;
using Tuitio.Domain.Repositories;
using Microsoft.EntityFrameworkCore;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace Tuitio.Domain.Data.Repositories
{
class IdentityRepository : IIdentityRepository
{
private readonly IdentityDbContext _dbContext;
public IdentityRepository(IdentityDbContext dbContext)
{
_dbContext = dbContext;
}
public Task<AppUser> GetUser(string userName, string password)
{
return _dbContext.Users
.Include(z => z.Status)
.Include(z => z.Claims)
.FirstOrDefaultAsync(z => z.UserName == userName && z.Password == password);
}
public async Task UpdateUserAfterAuthentication(AppUser user, Token token)
{
var userToken = new UserToken()
{
UserId = user.UserId,
Token = token.Raw,
ValidFrom = token.ValidFrom,
ValidUntil = token.ValidUntil
};
await _dbContext.AddAsync(userToken);
user.LastLoginDate = DateTime.Now;
_dbContext.Update(user);
await _dbContext.SaveChangesAsync();
}
public Task<UserToken[]> GetActiveTokens()
{
var currentDate = DateTime.Now;
var query = _dbContext.UserTokens
.Where(z => z.ValidFrom <= currentDate && z.ValidUntil >= currentDate && (!z.Burnt.HasValue || z.Burnt.Value == false));
return query.ToArrayAsync();
}
}
}

View File

@ -0,0 +1,90 @@
// Copyright (c) 2020 Tudor Stanciu
using Microsoft.EntityFrameworkCore;
using System;
using System.Linq;
using System.Threading.Tasks;
using Tuitio.Domain.Data.DbContexts;
using Tuitio.Domain.Entities;
using Tuitio.Domain.Models;
using Tuitio.Domain.Repositories;
namespace Tuitio.Domain.Data.Repositories
{
class UserRepository : IUserRepository
{
private readonly TuitioDbContext _dbContext;
public UserRepository(TuitioDbContext dbContext)
{
_dbContext = dbContext;
}
public Task<AppUser> GetUser(string userName, string password)
{
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);
}
public Task<AppUser> GetFullUser(int userId)
{
return _dbContext.Users
.Include(z => z.Status)
.Include(z => z.Claims)
.Include(z => z.UserRoles).ThenInclude(z => z.UserRole)
.Include(z => z.ContactOptions).ThenInclude(z => z.ContactType)
.Include(z => z.UserGroups).ThenInclude(z => z.UserGroup).ThenInclude(z => z.GroupRoles).ThenInclude(z => z.UserRole)
.FirstOrDefaultAsync(z => z.UserId == userId);
}
public async Task UpdateUserAfterLogin(AppUser user, Token token, string tokenRaw)
{
var userToken = new UserToken()
{
TokenId = token.TokenId,
UserId = token.UserId,
Token = tokenRaw,
ValidFrom = token.CreatedAt,
ValidUntil = token.CreatedAt.AddSeconds(token.ExpiresIn)
};
await _dbContext.AddAsync(userToken);
user.LastLoginDate = DateTime.UtcNow;
await _dbContext.SaveChangesAsync();
}
public async Task<UserToken[]> GetActiveTokens()
{
var currentDate = DateTime.UtcNow;
// remove expired tokens
_dbContext.UserTokens.RemoveRange(_dbContext.UserTokens.Where(z => z.ValidUntil < currentDate));
await _dbContext.SaveChangesAsync();
// retrieve active tokens
var query = _dbContext.UserTokens
.Where(z => z.ValidFrom <= currentDate && z.ValidUntil >= currentDate);
var tokens = await query.ToArrayAsync();
return tokens;
}
public Task RemoveToken(Guid tokenId)
{
var token = new UserToken()
{
TokenId = tokenId
};
_dbContext.UserTokens.Attach(token);
_dbContext.UserTokens.Remove(token);
return _dbContext.SaveChangesAsync();
}
}
}

View File

@ -2,11 +2,10 @@ if not exists (select top 1 1 from sys.objects where name = 'UserToken' and type
begin begin
create table UserToken create table UserToken
( (
TokenId int identity(1, 1) 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,
ValidFrom datetime not null, ValidFrom datetime not null,
ValidUntil datetime not null, ValidUntil datetime not null
Burnt bit
) )
end end

View File

@ -0,0 +1,24 @@
if not exists (select top 1 1 from sys.objects where name = 'ContactType' and type = 'U')
begin
create table ContactType
(
ContactTypeId int identity(1, 1) constraint PK_ContactType primary key,
ContactTypeCode varchar(30) not null,
ContactTypeName varchar(50) not null
)
end
if not exists (select top 1 1 from ContactType)
begin
insert into ContactType(ContactTypeCode, ContactTypeName)
values ('EMAIL', 'Email'),
('PHONE', 'Phone'),
('WEBSITE', 'Website'),
('LINKEDIN', 'LinkedIn'),
('GITHUB', 'GitHub'),
('GITEA', 'Gitea'),
('PORTFOLIO', 'Portfolio'),
('CURRICULUM_VITAE', 'Curriculum vitae'),
('BLOG', 'Blog'),
('REDDIT', 'Reddit')
end

View File

@ -0,0 +1,10 @@
if not exists (select top 1 1 from sys.objects where name = 'ContactOption' and type = 'U')
begin
create table ContactOption
(
ContactOptionId int identity(1, 1) constraint PK_ContactOption primary key,
UserId int constraint FK_ContactOption_AppUser foreign key references AppUser(UserId),
ContactTypeId int constraint FK_ContactOption_ContactType foreign key references ContactType(ContactTypeId),
ContactValue varchar(150) not null
)
end

View File

@ -0,0 +1,9 @@
if not exists (select top 1 1 from sys.objects where name = 'UserGroup' and type = 'U')
begin
create table UserGroup
(
UserGroupId int identity(1, 1) constraint PK_UserGroup primary key,
UserGroupCode varchar(30) not null,
UserGroupName varchar(50) not null
)
end

View File

@ -0,0 +1,9 @@
if not exists (select top 1 1 from sys.objects where name = 'UserRole' and type = 'U')
begin
create table UserRole
(
UserRoleId int identity(1, 1) constraint PK_UserRole primary key,
UserRoleCode varchar(30) not null,
UserRoleName varchar(50) not null
)
end

View File

@ -0,0 +1,29 @@
if not exists (select top 1 1 from sys.objects where name = 'UserXUserGroup' and type = 'U')
begin
create table UserXUserGroup
(
UserId int not null constraint FK_UserXUserGroup_AppUser foreign key references AppUser(UserId),
UserGroupId int not null constraint FK_UserXUserGroup_UserGroup foreign key references UserGroup(UserGroupId),
constraint PK_UserXUserGroup primary key (UserId, UserGroupId)
)
end
if not exists (select top 1 1 from sys.objects where name = 'UserGroupXUserRole' and type = 'U')
begin
create table UserGroupXUserRole
(
UserGroupId int not null constraint FK_UserGroupXUserRole_UserGroup references UserGroup(UserGroupId),
UserRoleId int not null constraint FK_UserGroupXUserRole_UserRole references UserRole(UserRoleId),
constraint PK_UserGroupXUserRole primary key (UserGroupId, UserRoleId)
)
end
if not exists (select top 1 1 from sys.objects where name = 'UserXUserRole' and type = 'U')
begin
create table UserXUserRole
(
UserId int not null constraint FK_UserXUserRole_AppUser references AppUser(UserId),
UserRoleId int not null constraint FK_UserXUserRole_UserRole references UserRole(UserRoleId),
constraint PK_UserXUserRole primary key (UserId, UserRoleId)
)
end

View File

@ -28,6 +28,21 @@
<None Update="Scripts\1.0.1\02.UserToken table.sql"> <None Update="Scripts\1.0.1\02.UserToken table.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
<None Update="Scripts\2.3.0\01.ContactType table.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Scripts\2.3.0\02.ContactOption table.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Scripts\2.4.0\01.UserGroup table.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Scripts\2.4.0\02.UserRole table.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Scripts\2.4.0\03.New tables UserXUserGroup UserGroupXUserRole UserXUserRole.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -22,5 +22,8 @@ namespace Tuitio.Domain.Entities
public DateTime? PasswordChangeDate { get; set; } public DateTime? PasswordChangeDate { get; set; }
public UserStatus Status { get; set; } public UserStatus Status { get; set; }
public ICollection<UserClaim> Claims { get; set; } public ICollection<UserClaim> Claims { get; set; }
public ICollection<ContactOption> ContactOptions { get; set; }
public ICollection<UserXUserGroup> UserGroups { get; set; }
public ICollection<UserXUserRole> UserRoles { get; set; }
} }
} }

View File

@ -0,0 +1,13 @@
// Copyright (c) 2020 Tudor Stanciu
namespace Tuitio.Domain.Entities
{
public class ContactOption
{
public int ContactOptionId { get; set; }
public int UserId { get; set; }
public int ContactTypeId { get; set; }
public string ContactValue { get; set; }
public ContactType ContactType { get; set; }
}
}

View File

@ -0,0 +1,11 @@
// Copyright (c) 2020 Tudor Stanciu
namespace Tuitio.Domain.Entities
{
public class ContactType
{
public int ContactTypeId { get; set; }
public string ContactTypeCode { get; set; }
public string ContactTypeName { get; set; }
}
}

View File

@ -0,0 +1,14 @@
// Copyright (c) 2020 Tudor Stanciu
using System.Collections.Generic;
namespace Tuitio.Domain.Entities
{
public class UserGroup
{
public int UserGroupId { get; set; }
public string UserGroupCode { get; set; }
public string UserGroupName { get; set; }
public ICollection<UserGroupXUserRole> GroupRoles { get; set; }
}
}

View File

@ -0,0 +1,11 @@
// Copyright (c) 2020 Tudor Stanciu
namespace Tuitio.Domain.Entities
{
public class UserGroupXUserRole
{
public int UserGroupId { get; set; }
public int UserRoleId { get; set; }
public UserRole UserRole { get; set; }
}
}

View File

@ -0,0 +1,11 @@
// Copyright (c) 2020 Tudor Stanciu
namespace Tuitio.Domain.Entities
{
public class UserRole
{
public int UserRoleId { get; set; }
public string UserRoleCode { get; set; }
public string UserRoleName { get; set; }
}
}

View File

@ -6,11 +6,10 @@ namespace Tuitio.Domain.Entities
{ {
public class UserToken public class UserToken
{ {
public int 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 DateTime ValidFrom { get; set; } public DateTime ValidFrom { get; set; }
public DateTime ValidUntil { get; set; } public DateTime ValidUntil { get; set; }
public bool? Burnt { get; set; }
} }
} }

View File

@ -0,0 +1,11 @@
// Copyright (c) 2020 Tudor Stanciu
namespace Tuitio.Domain.Entities
{
public class UserXUserGroup
{
public int UserId { get; set; }
public int UserGroupId { get; set; }
public UserGroup UserGroup { get; set; }
}
}

View File

@ -0,0 +1,11 @@
// Copyright (c) 2020 Tudor Stanciu
namespace Tuitio.Domain.Entities
{
public class UserXUserRole
{
public int UserId { get; set; }
public int UserRoleId { get; set; }
public UserRole UserRole { get; set; }
}
}

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

@ -0,0 +1,9 @@
// Copyright (c) 2020 Tudor Stanciu
using System;
namespace Tuitio.Domain.Models.Account
{
public record LoginResult(Token Token, string Raw);
public record LogoutResult(int UserId, string UserName, DateTime LogoutDate);
}

View File

@ -0,0 +1,6 @@
// Copyright (c) 2020 Tudor Stanciu
namespace Tuitio.Domain.Models
{
public record RecordIdentifier(int Id, string Code);
}

View File

@ -1,13 +1,87 @@
// Copyright (c) 2020 Tudor Stanciu // Copyright (c) 2020 Tudor Stanciu
using Newtonsoft.Json;
using System; using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
using Tuitio.Domain.Helpers;
namespace Tuitio.Domain.Models namespace Tuitio.Domain.Models
{ {
public class Token public class Token
{ {
public string Raw { get; set; } public Guid TokenId { get; set; }
public DateTime ValidFrom { get; set; } public int UserId { get; set; }
public DateTime ValidUntil { get; set; } public string UserName { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string SecurityStamp { get; set; }
public string LockStamp { get; set; }
public DateTime CreatedAt { get; set; }
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)
{
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<RecordIdentifier> userRoles, IEnumerable<RecordIdentifier> 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,19 +0,0 @@
// Copyright (c) 2020 Tudor Stanciu
using System.Collections.Generic;
namespace Tuitio.Domain.Models
{
public class TokenCore
{
public int UserId { get; set; }
public string UserName { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string ProfilePictureUrl { get; set; }
public string SecurityStamp { get; set; }
public string LockStamp { get; set; }
public Dictionary<string, string> Claims { get; set; }
}
}

View File

@ -3,13 +3,16 @@
using Tuitio.Domain.Entities; using Tuitio.Domain.Entities;
using Tuitio.Domain.Models; using Tuitio.Domain.Models;
using System.Threading.Tasks; using System.Threading.Tasks;
using System;
namespace Tuitio.Domain.Repositories namespace Tuitio.Domain.Repositories
{ {
public interface IIdentityRepository public interface IUserRepository
{ {
Task<AppUser> GetUser(string userName, string password); Task<AppUser> GetUser(string userName, string password);
Task UpdateUserAfterAuthentication(AppUser user, Token token); Task<AppUser> GetFullUser(int userId);
Task UpdateUserAfterLogin(AppUser user, Token token, string tokenRaw);
Task<UserToken[]> GetActiveTokens(); Task<UserToken[]> GetActiveTokens();
Task RemoveToken(Guid tokenId);
} }
} }

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

@ -1,11 +0,0 @@
// Copyright (c) 2020 Tudor Stanciu
namespace Tuitio.PublishedLanguage.Constants
{
public struct AuthenticationStatus
{
public const string
SUCCESS = "SUCCESS",
BAD_CREDENTIALS = "BAD_CREDENTIALS";
}
}

View File

@ -0,0 +1,12 @@
// Copyright (c) 2020 Tudor Stanciu
namespace Tuitio.PublishedLanguage.Constants
{
public struct EnvelopeError
{
public const string
BAD_CREDENTIALS = "BAD_CREDENTIALS",
UNAUTHENTICATED = "UNAUTHENTICATED",
UNAUTHORIZED = "UNAUTHORIZED";
}
}

View File

@ -0,0 +1,26 @@
// Copyright (c) 2020 Tudor Stanciu
namespace Tuitio.PublishedLanguage.Dto
{
public class Envelope<T> where T : class
{
public T Result { get; }
public string Error { get; }
public Envelope(T result, string error)
{
Result=result;
Error=error;
}
public static Envelope<T> Success(T result)
{
return new Envelope<T>(result, null);
}
public static Envelope<T> Fail(string message)
{
return new Envelope<T>(null, message);
}
}
}

View File

@ -0,0 +1,27 @@
// Copyright (c) 2020 Tudor Stanciu
using System;
using System.Collections.Generic;
namespace Tuitio.PublishedLanguage.Dto
{
public record AccountLoginResult(string Token, double ExpiresIn);
public record AccountLogoutResult(int UserId, string UserName, DateTime LogoutDate);
public class AuthorizationResult
{
public Guid TokenId { get; init; }
public int UserId { get; init; }
public string UserName { get; init; }
public string FirstName { get; init; }
public string LastName { get; init; }
public string Email { get; init; }
public string SecurityStamp { get; init; }
public string LockStamp { get; init; }
public DateTime CreatedAt { get; init; }
public long ExpiresIn { get; init; }
public Dictionary<string, string> Claims { get; init; }
public RecordIdentifier[] UserRoles { get; init; }
public RecordIdentifier[] UserGroups { get; init; }
}
public record RecordIdentifier(int Id, string Code);
}

View File

@ -1,13 +0,0 @@
// Copyright (c) 2020 Tudor Stanciu
using System;
namespace Tuitio.PublishedLanguage.Dto
{
public class Token
{
public string Raw { get; set; }
public DateTime ValidFrom { get; set; }
public DateTime ValidUntil { get; set; }
}
}

View File

@ -1,19 +0,0 @@
// Copyright (c) 2020 Tudor Stanciu
using System.Collections.Generic;
namespace Tuitio.PublishedLanguage.Dto
{
public class TokenCore
{
public int UserId { get; set; }
public string UserName { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string ProfilePictureUrl { get; set; }
public string SecurityStamp { get; set; }
public string LockStamp { get; set; }
public Dictionary<string, string> Claims { get; set; }
}
}

View File

@ -1,12 +0,0 @@
// Copyright (c) 2020 Tudor Stanciu
using Tuitio.PublishedLanguage.Dto;
namespace Tuitio.PublishedLanguage.Events
{
public class AuthenticateUserResult
{
public Token Token { get; set; }
public string Status { get; set; }
}
}

View File

@ -1,3 +1,16 @@
2.0.0 release [2023-01-31 02:17] 2.2.2 release [2023-04-12 20:37]
◾ Added IDs for user roles and groups in authorization result
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]
◾ Tuitio refactoring
◾ Added account logout method
2.0.0 release [2023-01-31 02:17]
◾ Tuitio rebranding ◾ Tuitio rebranding
◾ Initial release of Tuitio's published language package ◾ Initial release of Tuitio's published language package

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.0.0</Version> <Version>2.2.2</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

@ -5,7 +5,8 @@ namespace Tuitio.Wrapper.Constants
internal struct ApiRoutes internal struct ApiRoutes
{ {
public const string public const string
Authentication = "identity/authenticate?UserName={0}&Password={1}", AccountLogin = "account/login?UserName={0}&Password={1}",
Authorization = "identity/authorize?Token={0}"; AccountLogout = "account/logout?Token={0}",
Authorization = "connect/authorize?Token={0}";
} }
} }

View File

@ -11,7 +11,7 @@ namespace Tuitio.Wrapper
public static void UseTuitioServices(this IServiceCollection services, string baseAddress) public static void UseTuitioServices(this IServiceCollection services, string baseAddress)
{ {
services.AddSingleton(new ServiceConfiguration(baseAddress)); services.AddSingleton(new ServiceConfiguration(baseAddress));
services.AddHttpClient<IIdentityService, IdentityService>(); services.AddHttpClient<ITuitioService, TuitioService>();
} }
} }
} }

View File

@ -0,0 +1,5 @@
// Copyright (c) 2020 Tudor Stanciu
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Tuitio.Wrapper.Tests")]

View File

@ -2,9 +2,9 @@
[Tuitio](https://lab.code-rove.com/gitea/tudor.stanciu/tuitio) is a simple identity server implementation focused strictly on the needs of my home lab. [Tuitio](https://lab.code-rove.com/gitea/tudor.stanciu/tuitio) is a simple identity server implementation focused strictly on the needs of my home lab.
***Tuitio.Wrapper*** is a NuGet package that facilitates integration with a Tuitio instance in a .NET environment by registering a service called IIdentityService in the application's service collection. ***Tuitio.Wrapper*** is a NuGet package that facilitates integration with a Tuitio instance in a .NET environment by registering a service called ITuitioService in the application's service collection.
It contains two methods, "Authenticate" and "Authorize", which are responsible for calling the appropriate methods from the API controller, ```/identity/authenticate``` or ```/identity/authorize```. These methods provide a simple and convenient way for developers to handle authentication and authorization when communicating with Tuitio's API. It contains three methods, "Login", "Logout" and "Authorize", which are responsible for calling the appropriate methods from the API controller, ```/account/login```, ```/account/logout``` or ```/connect/authorize```. These methods provide a simple and convenient way for developers to handle authentication and authorization when communicating with Tuitio's API.
Once the package is installed, all the developer has to do is call the ```UseTuitioServices``` method at application startup. After this step, IIdentityService can be injected into any class in the application. Once the package is installed, all the developer has to do is call the ```UseTuitioServices``` method at application startup. After this step, ITuitioService can be injected into any class in the application.
## Package repository ## Package repository

View File

@ -1,3 +1,16 @@
2.0.0 release [2023-01-31 02:17] 2.2.2 release [2023-04-12 20:37]
◾ Added IDs for user roles and groups in authorization result
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]
◾ Tuitio refactoring
◾ Added account logout method
2.0.0 release [2023-01-31 02:17]
◾ Tuitio rebranding ◾ Tuitio rebranding
◾ Initial release of Tuitio's API wrapper ◾ Initial release of Tuitio's API wrapper

View File

@ -1,13 +0,0 @@
// Copyright (c) 2020 Tudor Stanciu
using Tuitio.PublishedLanguage.Dto;
using System.Threading.Tasks;
namespace Tuitio.Wrapper.Services
{
public interface IIdentityService
{
Task<Token> Authenticate(string userName, string password);
Task<TokenCore> Authorize(string token);
}
}

View File

@ -0,0 +1,14 @@
// Copyright (c) 2020 Tudor Stanciu
using System.Threading.Tasks;
using Tuitio.PublishedLanguage.Dto;
namespace Tuitio.Wrapper.Services
{
public interface ITuitioService
{
Task<Envelope<AccountLoginResult>> Login(string userName, string password);
Task<Envelope<AccountLogoutResult>> Logout(string token);
Task<Envelope<AuthorizationResult>> Authorize(string token);
}
}

View File

@ -1,41 +0,0 @@
// Copyright (c) 2020 Tudor Stanciu
using Tuitio.PublishedLanguage.Dto;
using Tuitio.Wrapper.Constants;
using Tuitio.Wrapper.Models;
using Netmash.Extensions.Http;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
namespace Tuitio.Wrapper.Services
{
internal class IdentityService : IIdentityService
{
private const string _contentType = "application/json";
private readonly HttpClient _httpClient;
public IdentityService(HttpClient httpClient, ServiceConfiguration configuration)
{
httpClient.BaseAddress = new Uri(configuration.BaseAddress);
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(_contentType));
_httpClient = httpClient;
}
public async Task<Token> Authenticate(string userName, string password)
{
var route = string.Format(ApiRoutes.Authentication, userName, password);
var result = await _httpClient.ExecutePostRequest<Token>(route);
return result;
}
public async Task<TokenCore> Authorize(string token)
{
var route = string.Format(ApiRoutes.Authorization, token);
var result = await _httpClient.ExecutePostRequest<TokenCore>(route);
return result;
}
}
}

View File

@ -0,0 +1,48 @@
// Copyright (c) 2020 Tudor Stanciu
using Netmash.Extensions.Http;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Tuitio.PublishedLanguage.Dto;
using Tuitio.Wrapper.Constants;
using Tuitio.Wrapper.Models;
namespace Tuitio.Wrapper.Services
{
internal class TuitioService : ITuitioService
{
private const string _contentType = "application/json";
private readonly HttpClient _httpClient;
public TuitioService(HttpClient httpClient, ServiceConfiguration configuration)
{
httpClient.BaseAddress = new Uri(configuration.BaseAddress);
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(_contentType));
_httpClient = httpClient;
}
public async Task<Envelope<AccountLoginResult>> Login(string userName, string password)
{
var route = string.Format(ApiRoutes.AccountLogin, userName, password);
var result = await _httpClient.ExecutePostRequest<Envelope<AccountLoginResult>>(route);
return result;
}
public async Task<Envelope<AccountLogoutResult>> Logout(string token)
{
var route = string.Format(ApiRoutes.AccountLogout, token);
var result = await _httpClient.ExecutePostRequest<Envelope<AccountLogoutResult>>(route);
return result;
}
public async Task<Envelope<AuthorizationResult>> Authorize(string token)
{
var route = string.Format(ApiRoutes.Authorization, token);
var result = await _httpClient.ExecutePostRequest<Envelope<AuthorizationResult>>(route);
return result;
}
}
}

View File

@ -2,12 +2,12 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<Description>Tuitio.Wrapper facilitates integration with a Tuitio instance in a .NET environment by registering a service called IIdentityService in the applications service collection. It contains two methods that provide a simple and convenient way for developers to handle authentication and authorization when communicating with Tuitios API.</Description> <Description>Tuitio.Wrapper facilitates integration with a Tuitio instance in a .NET environment by registering a service called ITuitioService in the applications service collection. It contains three methods that provide a simple and convenient way for developers to handle authentication and authorization when communicating with Tuitios API.</Description>
<PackageProjectUrl>https://lab.code-rove.com/gitea/tudor.stanciu/tuitio</PackageProjectUrl> <PackageProjectUrl>https://lab.code-rove.com/gitea/tudor.stanciu/tuitio</PackageProjectUrl>
<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.0.0</Version> <Version>2.2.2</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

@ -0,0 +1,20 @@
// Copyright (c) 2020 Tudor Stanciu
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection;
using Tuitio.Application.Abstractions;
namespace Tuitio.Authentication
{
internal static class AuthenticationExtensions
{
public static IServiceCollection AddLocalAuthentication(this IServiceCollection services)
{
var scheme = "TuitioAuthentication";
services.AddAuthentication(scheme)
.AddScheme<AuthenticationSchemeOptions, AuthenticationHandler>(scheme, null);
services.AddScoped<IHttpContextService, HttpContextService>();
return services;
}
}
}

View File

@ -0,0 +1,101 @@
// Copyright (c) 2020 Tudor Stanciu
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Tuitio.Application.Services.Abstractions;
using Tuitio.Domain.Models;
namespace Tuitio.Authentication
{
public class AuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly IUserService _userService;
private readonly ILogger<AuthenticationHandler> _logger;
public AuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory loggerFactory, UrlEncoder encoder, ISystemClock clock, IUserService userService, ILogger<AuthenticationHandler> logger) : base(options, loggerFactory, encoder, clock)
{
_userService=userService;
_logger=logger;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var endpoint = Context.GetEndpoint();
if (endpoint?.Metadata?.GetMetadata<IAllowAnonymous>() != null)
{
return AuthenticateResult.NoResult();
}
var token = GetAuthorizationToken();
if (token == null)
return AuthenticateResult.Fail("AUTHORIZATION_HEADER_IS_MISSING");
var result = await Task.Run(() => _userService.Authorize(token));
if (result == null)
return AuthenticateResult.Fail("UNAUTHORIZED");
var ticket = GetAuthenticationTicket(result);
return AuthenticateResult.Success(ticket);
}
private string GetAuthorizationToken()
{
if (Request.Headers.ContainsKey("Authorization"))
{
var authorizationHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]);
var token = authorizationHeader.Parameter;
return token;
}
return null;
}
private AuthenticationTicket GetAuthenticationTicket(Token result)
{
var claimCollection = new Dictionary<string, string>()
{
{ ClaimTypes.NameIdentifier, result.UserId.ToString() },
{ ClaimTypes.Name, result.UserName },
};
if (result.FirstName != null)
claimCollection.Add(ClaimTypes.GivenName, result.FirstName);
if (result.LastName != null)
claimCollection.Add(ClaimTypes.Surname, result.FirstName);
if (result.Email != null)
claimCollection.Add(ClaimTypes.Email, result.Email);
if (result.Claims != null && result.Claims.Any())
{
foreach (var claim in result.Claims)
{
if (claimCollection.ContainsKey(claim.Key))
{
_logger.LogWarning($"There is already a claim with key {claim.Key} in the collection. The combination {claim.Key}:{claim.Value} will be ignored.");
continue;
}
claimCollection.Add(claim.Key, claim.Value);
}
}
var claims = claimCollection.Select(z => new Claim(z.Key, z.Value)).ToArray();
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return ticket;
}
}
}

View File

@ -0,0 +1,33 @@
// Copyright (c) 2020 Tudor Stanciu
using Microsoft.AspNetCore.Http;
using System.Linq;
using System;
using Tuitio.Application.Abstractions;
using System.Security.Claims;
namespace Tuitio.Authentication
{
public class HttpContextService : IHttpContextService
{
private readonly IHttpContextAccessor _httpAccessor;
public HttpContextService(IHttpContextAccessor httpAccessor)
{
_httpAccessor=httpAccessor;
}
public int GetUserId()
{
var userIdString = _httpAccessor.HttpContext.User?.Claims.FirstOrDefault(z => z.Type == ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdString))
throw new Exception("User id could not be retrieved from claims.");
if (!int.TryParse(userIdString, out int userId))
throw new Exception("User id is not a valid integer.");
return userId;
}
}
}

View File

@ -0,0 +1,40 @@
// Copyright (c) 2020 Tudor Stanciu
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
using Tuitio.Application.CommandHandlers;
namespace Tuitio.Controllers
{
[Authorize]
[ApiController]
[Route("account")]
public class AccountController : ControllerBase
{
private readonly IMediator _mediator;
public AccountController(IMediator mediator)
{
_mediator = mediator;
}
[AllowAnonymous]
[HttpPost("login")]
public async Task<IActionResult> Login([FromQuery] string userName, string password)
{
var command = new AccountLoginHandler.Command(userName, password);
var result = await _mediator.Send(command);
return Ok(result);
}
[HttpPost("logout")]
public async Task<IActionResult> Logout([FromQuery] string token)
{
var command = new AccountLogoutHandler.Command(token);
var result = await _mediator.Send(command);
return Ok(result);
}
}
}

View File

@ -0,0 +1,41 @@
// Copyright (c) 2020 Tudor Stanciu
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
using Tuitio.Application.CommandHandlers;
using Tuitio.Application.Queries;
namespace Tuitio.Api.Controllers
{
[Authorize]
[ApiController]
[Route("connect")]
public class ConnectController : ControllerBase
{
private readonly IMediator _mediator;
public ConnectController(IMediator mediator)
{
_mediator = mediator;
}
[AllowAnonymous]
[HttpPost("authorize")]
public async Task<IActionResult> AuthorizeToken([FromQuery] string token)
{
var command = new AuthorizationHandler.Command(token);
var result = await _mediator.Send(command);
return Ok(result);
}
[HttpGet("user-info")]
public async Task<IActionResult> GetUserInfo()
{
var command = new GetUserInfo.Query();
var result = await _mediator.Send(command);
return Ok(result);
}
}
}

View File

@ -1,43 +0,0 @@
// Copyright (c) 2020 Tudor Stanciu
using Tuitio.Application.Commands;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
namespace Tuitio.Api.Controllers
{
[ApiController]
[Route("identity")]
public class IdentityController : ControllerBase
{
private readonly IMediator _mediator;
public IdentityController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost("authenticate")]
public async Task<IActionResult> AuthenticateUser([FromQuery] AuthenticateUser authenticateUser)
{
var result = await _mediator.Send(authenticateUser);
if (result != null)
return Ok(result);
else
return BadRequest();
}
[HttpPost("authorize")]
public async Task<IActionResult> AuthorizeToken([FromQuery] AuthorizeToken authorizeToken)
{
var result = await _mediator.Send(authorizeToken);
if (result != null)
return Ok(result);
else
return BadRequest();
}
}
}

View File

@ -11,11 +11,8 @@ namespace Tuitio.Api.Controllers
[Route("system")] [Route("system")]
public class SystemController : ControllerBase public class SystemController : ControllerBase
{ {
private readonly IMediator _mediator;
public SystemController(IMediator mediator) public SystemController(IMediator mediator)
{ {
_mediator = mediator;
} }
[AllowAnonymous] [AllowAnonymous]
@ -24,12 +21,5 @@ namespace Tuitio.Api.Controllers
{ {
return Ok($"Ping success. System datetime: {DateTime.Now}"); return Ok($"Ping success. System datetime: {DateTime.Now}");
} }
/*
Methods:
/version
/burn-token
/burn-all-tokens
*/
} }
} }

View File

@ -12,8 +12,9 @@ COPY ["NuGet.config", "."]
COPY ["src/Tuitio/Tuitio.csproj", "src/Tuitio/"] COPY ["src/Tuitio/Tuitio.csproj", "src/Tuitio/"]
COPY ["src/Tuitio.Application/Tuitio.Application.csproj", "src/Tuitio.Application/"] COPY ["src/Tuitio.Application/Tuitio.Application.csproj", "src/Tuitio.Application/"]
COPY ["src/Tuitio.Domain/Tuitio.Domain.csproj", "src/Tuitio.Domain/"] COPY ["src/Tuitio.Domain/Tuitio.Domain.csproj", "src/Tuitio.Domain/"]
COPY ["src/Tuitio.PublishedLanguage/Tuitio.PublishedLanguage.csproj", "src/Tuitio.PublishedLanguage/"]
COPY ["src/Tuitio.Domain.Data/Tuitio.Domain.Data.csproj", "src/Tuitio.Domain.Data/"] COPY ["src/Tuitio.Domain.Data/Tuitio.Domain.Data.csproj", "src/Tuitio.Domain.Data/"]
COPY ["src/Tuitio.PublishedLanguage/Tuitio.PublishedLanguage.csproj", "src/Tuitio.PublishedLanguage/"]
COPY ["src/Tuitio.PublishedLanguage/ReleaseNotes.txt", "src/Tuitio.PublishedLanguage/"]
RUN dotnet restore "src/Tuitio/Tuitio.csproj" RUN dotnet restore "src/Tuitio/Tuitio.csproj"
COPY . . COPY . .
WORKDIR "/workspace/src/Tuitio" WORKDIR "/workspace/src/Tuitio"

View File

@ -11,6 +11,7 @@ using Netmash.Infrastructure.DatabaseMigration;
using Netmash.Infrastructure.DatabaseMigration.Constants; using Netmash.Infrastructure.DatabaseMigration.Constants;
using Tuitio.Application; using Tuitio.Application;
using Tuitio.Application.Services.Abstractions; using Tuitio.Application.Services.Abstractions;
using Tuitio.Authentication;
using Tuitio.Domain.Data; using Tuitio.Domain.Data;
namespace Tuitio.Extensions namespace Tuitio.Extensions
@ -20,18 +21,19 @@ namespace Tuitio.Extensions
public static void ConfigureServices(this IServiceCollection services, IConfiguration configuration) public static void ConfigureServices(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddControllers(); services.AddControllers();
services.AddLocalAuthentication();
services.AddHttpContextAccessor();
// MediatR // MediatR
services.AddMediatR(typeof(Application.Commands.AuthenticateUser).Assembly); services.AddMediatR(typeof(Application.CommandHandlers.AccountLoginHandler).Assembly);
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(RequestPreProcessorBehavior<,>)); services.AddScoped(typeof(IPipelineBehavior<,>), typeof(RequestPreProcessorBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(RequestPostProcessorBehavior<,>)); services.AddScoped(typeof(IPipelineBehavior<,>), typeof(RequestPostProcessorBehavior<,>));
// AutoMapper // AutoMapper
services.AddAutoMapper( services.AddAutoMapper(typeof(Application.Mappings.MappingProfile).Assembly);
typeof(Application.Mappings.MappingProfile).Assembly);
// Swagger // Swagger
services.AddSwagger("Tuitio API", AuthorizationType.None); services.AddSwagger("Tuitio API", AuthorizationType.Tuitio);
// Data access // Data access
services.AddMigration(DatabaseType.SQLServer, MetadataLocation.Database); services.AddMigration(DatabaseType.SQLServer, MetadataLocation.Database);
@ -48,6 +50,7 @@ namespace Tuitio.Extensions
app.UseCors(z => z.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); app.UseCors(z => z.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());
app.UseRouting(); app.UseRouting();
app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseEndpoints(endpoints => app.UseEndpoints(endpoints =>
{ {

View File

@ -5,10 +5,7 @@
}, },
"Serilog": { "Serilog": {
"MinimumLevel": { "MinimumLevel": {
"Default": "Information", "Default": "Information"
"Override": {
"Microsoft": "Information"
}
} }
}, },
"Logs": { "Logs": {

View File

@ -0,0 +1,157 @@
// Copyright (c) 2020 Tudor Stanciu
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Linq;
using System.Threading.Tasks;
using Tuitio.Application.CommandHandlers;
using Tuitio.Application.Tests.Fixtures;
using Tuitio.PublishedLanguage.Constants;
using Tuitio.PublishedLanguage.Dto;
using Xunit;
namespace Tuitio.Application.Tests
{
public class CommandHandlerTests : IClassFixture<DependencyInjectionFixture>, IDisposable
{
private readonly IServiceScope _tuitioScope;
private readonly IMediator _mediator;
public CommandHandlerTests(DependencyInjectionFixture fixture)
{
_tuitioScope = fixture.ServiceProvider.GetRequiredService<IServiceScopeFactory>().CreateScope();
_mediator = _tuitioScope.ServiceProvider.GetRequiredService<IMediator>();
}
public void Dispose()
{
_tuitioScope.Dispose();
}
[Fact]
public async Task AccountLoginHandler_Handle_Success()
{
// Arrange
var userName = "tuitio.user";
var password = "pass123";
var command = new AccountLoginHandler.Command(userName, password);
// Act
var loginResult = await _mediator.Send(command);
// Assert
Assert.NotNull(loginResult);
Assert.NotNull(loginResult.Result);
Assert.Null(loginResult.Error);
Assert.NotEmpty(loginResult.Result.Token);
Assert.True(loginResult.Result.ExpiresIn > 0, "Token expiration must be a positive number.");
}
[Fact]
public async Task AccountLoginHandler_Handle_Failed()
{
// Arrange
var userName = "tuitio.user";
var password = "wrong_password";
var command = new AccountLoginHandler.Command(userName, password);
// Act
var result = await _mediator.Send(command);
// Assert
Assert.NotNull(result);
Assert.Null(result.Result);
Assert.NotNull(result.Error);
Assert.NotEmpty(result.Error);
Assert.Equal(EnvelopeError.BAD_CREDENTIALS, result.Error);
}
[Fact]
public async Task AccountLogoutHandler_Handle_Success()
{
// Arrange
var userName = "tuitio.user";
var password = "pass123";
var loginCommand = new AccountLoginHandler.Command(userName, password);
var loginResult = await _mediator.Send(loginCommand);
var logoutCommand = new AccountLogoutHandler.Command(loginResult.Result.Token);
// Act
Envelope<AccountLogoutResult> logoutResult;
using (var scope = _tuitioScope.ServiceProvider.CreateScope())
{
var newMediator = scope.ServiceProvider.GetRequiredService<IMediator>();
logoutResult = await newMediator.Send(logoutCommand);
}
// Assert
Assert.NotNull(logoutResult);
Assert.NotNull(logoutResult.Result);
Assert.Null(logoutResult.Error);
Assert.Equal(userName, logoutResult.Result.UserName);
Assert.True((DateTime.UtcNow - logoutResult.Result.LogoutDate).TotalMinutes <= 1, "Logout date must be within the last minute.");
}
[Fact]
public async Task AccountLogoutHandler_Handle_Failed()
{
// Arrange
var unauthenticatedToken = "unauthenticated-token";
var command = new AccountLogoutHandler.Command(unauthenticatedToken);
// Act
var result = await _mediator.Send(command);
// Assert
Assert.NotNull(result);
Assert.Null(result.Result);
Assert.NotNull(result.Error);
Assert.NotEmpty(result.Error);
Assert.Equal(EnvelopeError.UNAUTHENTICATED, result.Error);
}
[Fact]
public async Task AuthorizationHandler_Handle_Success()
{
// Arrange
var userName = "tuitio.user";
var password = "pass123";
var loginCommand = new AccountLoginHandler.Command(userName, password);
var loginResult = await _mediator.Send(loginCommand);
var authorizationCommand = new AuthorizationHandler.Command(loginResult.Result.Token);
// Act
var authorizationResult = await _mediator.Send(authorizationCommand);
// Assert
Assert.NotNull(authorizationResult);
Assert.NotNull(authorizationResult.Result);
Assert.Null(authorizationResult.Error);
Assert.Equal(userName, authorizationResult.Result.UserName);
Assert.NotNull(authorizationResult.Result.SecurityStamp);
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]
public async Task AuthorizationHandler_Handle_Failed()
{
// Arrange
var unauthorizedToken = "unauthorized-token";
var command = new AuthorizationHandler.Command(unauthorizedToken);
// Act
var result = await _mediator.Send(command);
// Assert
Assert.NotNull(result);
Assert.Null(result.Result);
Assert.NotNull(result.Error);
Assert.NotEmpty(result.Error);
Assert.Equal(EnvelopeError.UNAUTHORIZED, result.Error);
}
}
}

View File

@ -0,0 +1,161 @@
// Copyright (c) 2020 Tudor Stanciu
using MediatR;
using MediatR.Pipeline;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Data.Common;
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
{
public class DependencyInjectionFixture : IAsyncLifetime
{
private readonly Guid _prefix;
private readonly List<SqliteConnection> _connections;
public readonly IServiceProvider ServiceProvider;
public DependencyInjectionFixture()
{
_prefix = Guid.NewGuid();
_connections = new List<SqliteConnection>();
ServiceProvider = BuildServiceProvider();
}
public async Task DisposeAsync()
{
foreach (var con in _connections)
await con.DisposeAsync();
}
public async Task InitializeAsync()
{
await PrepareDbAsync();
SeedData();
}
private IServiceProvider BuildServiceProvider()
{
var configurationBuilder = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.tuitio.json", optional: true, reloadOnChange: false);
var configuration = configurationBuilder.Build();
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(configuration);
services.AddLogging(builder =>
{
builder.SetMinimumLevel(LogLevel.Warning);
builder.AddFilter("Microsoft.*", LogLevel.Warning);
});
// MediatR
services.AddMediatR(typeof(Application.CommandHandlers.AccountLoginHandler).Assembly);
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(RequestPreProcessorBehavior<,>));
// AutoMapper
services.AddAutoMapper(typeof(Application.Mappings.MappingProfile).Assembly);
services.AddDbContext<TuitioDbContext>(options =>
{
options.UseSqlite(GetOrAddInMemoryDatabase("TuitioDbContext"));
options.EnableSensitiveDataLogging();
options.EnableDetailedErrors();
});
services.AddDataAccessServices();
services.AddApplicationServices();
var provider = services.BuildServiceProvider();
return provider;
}
private DbConnection GetOrAddInMemoryDatabase(string code)
{
var connection = new SqliteConnection($"Data Source={_prefix}{code};Mode=Memory;Cache=shared");
_connections.Add(connection);
connection.Open();
return connection;
}
private async Task PrepareDbAsync()
{
var result = await ServiceProvider.GetRequiredService<TuitioDbContext>().Database.EnsureCreatedAsync();
if (!result) throw new Exception("TuitioDbContext exists");
var script = await File.ReadAllTextAsync("seed.sql");
await ServiceProvider.GetRequiredService<TuitioDbContext>().Database.ExecuteSqlRawAsync(script);
}
private void SeedData()
{
using var dbContext = ServiceProvider.GetRequiredService<TuitioDbContext>();
#region UserStatus
dbContext.UserStatuses.AddRange(new Domain.Entities.UserStatus[]
{
new Domain.Entities.UserStatus()
{
StatusId = 1,
StatusCode = "ACTIVE",
StatusName = "Active"
},
new Domain.Entities.UserStatus()
{
StatusId = 2,
StatusCode = "INACTIVE",
StatusName = "Inactive"
},
new Domain.Entities.UserStatus()
{
StatusId = 3,
StatusCode = "BLOCKED",
StatusName = "Blocked"
}
});
#endregion
#region Users
dbContext.Users.Add(new Domain.Entities.AppUser()
{
UserId = 1,
UserName = "tuitio.user",
Password = "9B8769A4A742959A2D0298C36FB70623F2DFACDA8436237DF08D8DFD5B37374C", // pass123
FirstName = "Tuitio",
LastName = "User",
Email = "tuitio.user@tuitio.lab",
SecurityStamp = "A93650FF-1FC4-4999-BAB6-3EEB174F6892",
StatusId = 1,
CreationDate = DateTime.Now,
FailedLoginAttempts = 0,
UserRoles = new UserXUserRole[]
{
new UserXUserRole()
{
UserId = 1,
UserRoleId = 1,
UserRole = new UserRole()
{
UserRoleId = 1,
UserRoleCode = "MOCK_ROLE",
UserRoleName = "Mock role"
}
}
}
});
#endregion
dbContext.SaveChanges();
}
}
}

View File

@ -0,0 +1,24 @@
// Copyright (c) 2020 Tudor Stanciu
using Tuitio.Application.Services;
using Xunit;
namespace Tuitio.Application.Tests
{
public class HashingServiceTests
{
[Fact]
public void HashSha256_ShouldReturnCorrectHash()
{
// Arrange
var expected = "9B8769A4A742959A2D0298C36FB70623F2DFACDA8436237DF08D8DFD5B37374C";
var hashingService = new HashingService();
// Act
var actual = hashingService.HashSha256("pass123");
// Assert
Assert.Equal(expected, actual);
}
}
}

View File

@ -0,0 +1,43 @@
// Copyright (c) 2020 Tudor Stanciu
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Linq;
using System.Threading.Tasks;
using Tuitio.Application.Tests.Fixtures;
using Tuitio.Domain.Data.DbContexts;
using Xunit;
namespace Tuitio.Application.Tests
{
public class LocalSqliteDatabaseTests : IClassFixture<DependencyInjectionFixture>, IDisposable
{
private readonly IServiceScope _tuitioScope;
public LocalSqliteDatabaseTests(DependencyInjectionFixture fixture)
{
_tuitioScope = fixture.ServiceProvider.GetRequiredService<IServiceScopeFactory>().CreateScope();
}
public void Dispose()
{
_tuitioScope.Dispose();
}
[Fact]
public async Task EnsureExpectedDataInLocalDbTest()
{
// Arrange
var _dbContext = _tuitioScope.ServiceProvider.GetRequiredService<TuitioDbContext>();
// Act
var statuses = await _dbContext.UserStatuses.ToArrayAsync();
// Assert
Assert.NotEmpty(statuses);
Assert.True(statuses.All(z => !string.IsNullOrWhiteSpace(z.StatusCode)));
Assert.Contains(statuses, z => z.StatusCode == "ACTIVE");
}
}
}

View File

@ -0,0 +1,89 @@
// Copyright (c) 2020 Tudor Stanciu
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
{
public class TokenServiceTests : IClassFixture<DependencyInjectionFixture>, IDisposable
{
private readonly IServiceScope _tuitioScope;
private readonly AppUser _userMock;
public TokenServiceTests(DependencyInjectionFixture fixture)
{
_tuitioScope = fixture.ServiceProvider.GetRequiredService<IServiceScopeFactory>().CreateScope();
_userMock = MockAppUser();
}
private AppUser MockAppUser()
{
var user = new AppUser()
{
UserId = 1,
UserName = "tuitio.test",
Password = "9B8769A4A742959A2D0298C36FB70623F2DFACDA8436237DF08D8DFD5B37374C", //pass123
Email = "tuitio.test@test.test",
FirstName = "tuitio",
LastName = "test",
StatusId = 1,
FailedLoginAttempts = 0,
SecurityStamp = "A93650FF-1FC4-4999-BAB6-3EEB174F6892",
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;
}
public void Dispose()
{
_tuitioScope.Dispose();
}
[Fact]
public void TokenGenerationTest()
{
// Arrange
var _tokenService = _tuitioScope.ServiceProvider.GetRequiredService<ITokenService>();
// Act
var token = _tokenService.GenerateToken(_userMock);
var raw = token.Export();
var extracted = Token.Import(raw);
// Assert
Assert.NotNull(token);
Assert.NotNull(raw);
Assert.NotNull(extracted);
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

@ -0,0 +1,53 @@
// Copyright (c) 2020 Tudor Stanciu
using System;
using Tuitio.Application.Stores;
using Tuitio.Domain.Models;
using Xunit;
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 = GetMockedToken();
var store = new TokenStore();
// Act
store.Set(key, expected);
var actual = store.Get(key);
// Assert
Assert.Equal(expected, actual);
}
[Fact]
public void Remove_ShouldRemoveTokenFromStore()
{
// Arrange
var key = "user001";
var mock = GetMockedToken();
var store = new TokenStore();
// Act
store.Set(key, mock);
store.Remove(key);
var actual = store.Get(key);
// Assert
Assert.True(actual == null);
}
}
}

Some files were not shown because too many files have changed in this diff Show More