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
master
Tudor Stanciu 2023-03-28 17:01:50 +00:00
parent 55a9cc002d
commit 7d7bc9e82f
25 changed files with 365 additions and 8 deletions

View File

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

View File

@ -53,11 +53,11 @@
<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> </Content>
</Note> </Note>
<Note> <Note>
@ -75,4 +75,14 @@
◾ Added some tests ◾ Added some tests
</Content> </Content>
</Note> </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
</Content>
</Note>
</ReleaseNotes> </ReleaseNotes>

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

@ -5,6 +5,7 @@ using Tuitio.Domain.Entities;
using System.Collections.Generic; using System.Collections.Generic;
using dto = Tuitio.PublishedLanguage.Dto; using dto = Tuitio.PublishedLanguage.Dto;
using models = Tuitio.Domain.Models; using models = Tuitio.Domain.Models;
using Tuitio.Application.Queries;
namespace Tuitio.Application.Mappings namespace Tuitio.Application.Mappings
{ {
@ -13,6 +14,14 @@ namespace Tuitio.Application.Mappings
public MappingProfile() public MappingProfile()
{ {
CreateMap<models.Token, dto.AuthorizationResult>(); CreateMap<models.Token, dto.AuthorizationResult>();
CreateMap<AppUser, GetUserInfo.Model>()
.ForMember(z => z.Claims, src => src.MapFrom(z => ComposeClaims(z.Claims)));
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<AppUser, models.Token>() CreateMap<AppUser, models.Token>()
.ForMember(z => z.Claims, src => src.MapFrom(z => ComposeClaims(z.Claims))); .ForMember(z => z.Claims, src => src.MapFrom(z => ComposeClaims(z.Claims)));

View File

@ -0,0 +1,64 @@
// 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; 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 DateTime CreationDate { get; set; }
public int? FailedLoginAttempts { get; set; }
public DateTime? LastLoginDate { get; set; }
public Dictionary<string, string> Claims { get; init; }
public ContactOption[] ContactOptions { get; set; }
}
public record ContactOption
{
public int Id { get; set; }
public string ContactTypeCode { get; set; }
public string ContactTypeName { get; set; }
public string ContactValue { get; set; }
}
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

@ -27,6 +27,8 @@ 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());
} }
} }
} }

View File

@ -14,6 +14,7 @@ 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);
} }
} }
} }

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

@ -28,6 +28,15 @@ namespace Tuitio.Domain.Data.Repositories
.FirstOrDefaultAsync(z => z.UserName == userName && z.Password == password); .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.ContactOptions).ThenInclude(z => z.ContactType)
.FirstOrDefaultAsync(z => z.UserId == userId);
}
public async Task UpdateUserAfterLogin(AppUser user, Token token, string tokenRaw) public async Task UpdateUserAfterLogin(AppUser user, Token token, string tokenRaw)
{ {
var userToken = new UserToken() var userToken = new UserToken()

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

@ -28,6 +28,12 @@
<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>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -22,5 +22,6 @@ 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; }
} }
} }

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

@ -13,7 +13,6 @@ namespace Tuitio.Domain.Models
public string FirstName { get; set; } public string FirstName { get; set; }
public string LastName { get; set; } public string LastName { get; set; }
public string Email { get; set; } public string Email { get; set; }
public string ProfilePictureUrl { get; set; }
public string SecurityStamp { get; set; } public string SecurityStamp { get; set; }
public string LockStamp { get; set; } public string LockStamp { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }

View File

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

View File

@ -15,7 +15,6 @@ namespace Tuitio.PublishedLanguage.Dto
public string FirstName { get; init; } public string FirstName { get; init; }
public string LastName { get; init; } public string LastName { get; init; }
public string Email { get; init; } public string Email { get; init; }
public string ProfilePictureUrl { get; init; }
public string SecurityStamp { get; init; } public string SecurityStamp { get; init; }
public string LockStamp { get; init; } public string LockStamp { get; init; }
public DateTime CreatedAt { get; init; } public DateTime CreatedAt { get; init; }

View File

@ -0,0 +1,18 @@
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,91 @@
using Microsoft.AspNetCore.Authentication;
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 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,31 @@
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

@ -1,12 +1,15 @@
// Copyright (c) 2020 Tudor Stanciu // Copyright (c) 2020 Tudor Stanciu
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks; using System.Threading.Tasks;
using Tuitio.Application.CommandHandlers; using Tuitio.Application.CommandHandlers;
using Tuitio.Application.Queries;
namespace Tuitio.Api.Controllers namespace Tuitio.Api.Controllers
{ {
[Authorize]
[ApiController] [ApiController]
[Route("connect")] [Route("connect")]
public class ConnectController : ControllerBase public class ConnectController : ControllerBase
@ -18,6 +21,7 @@ namespace Tuitio.Api.Controllers
_mediator = mediator; _mediator = mediator;
} }
[AllowAnonymous]
[HttpPost("authorize")] [HttpPost("authorize")]
public async Task<IActionResult> AuthorizeToken([FromQuery] string token) public async Task<IActionResult> AuthorizeToken([FromQuery] string token)
{ {
@ -25,5 +29,13 @@ namespace Tuitio.Api.Controllers
var result = await _mediator.Send(command); var result = await _mediator.Send(command);
return Ok(result); 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

@ -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,6 +21,8 @@ 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.CommandHandlers.AccountLoginHandler).Assembly); services.AddMediatR(typeof(Application.CommandHandlers.AccountLoginHandler).Assembly);
@ -30,7 +33,7 @@ namespace Tuitio.Extensions
services.AddAutoMapper(typeof(Application.Mappings.MappingProfile).Assembly); services.AddAutoMapper(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);
@ -47,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 =>
{ {