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 fixmaster
parent
55a9cc002d
commit
7d7bc9e82f
|
@ -1,7 +1,7 @@
|
|||
<Project>
|
||||
<Import Project="dependencies.props" />
|
||||
<PropertyGroup>
|
||||
<Version>2.2.0</Version>
|
||||
<Version>2.3.0</Version>
|
||||
<Authors>Tudor Stanciu</Authors>
|
||||
<Company>STA</Company>
|
||||
<PackageTags>Tuitio</PackageTags>
|
||||
|
|
|
@ -75,4 +75,14 @@
|
|||
◾ 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
|
||||
</Content>
|
||||
</Note>
|
||||
</ReleaseNotes>
|
|
@ -9,7 +9,7 @@
|
|||
<MediatRPackageVersion>9.0.0</MediatRPackageVersion>
|
||||
<EntityFrameworkCorePackageVersion>6.0.1</EntityFrameworkCorePackageVersion>
|
||||
<NewtonsoftJsonPackageVersion>13.0.1</NewtonsoftJsonPackageVersion>
|
||||
<NetmashExtensionsSwaggerPackageVersion>1.0.6</NetmashExtensionsSwaggerPackageVersion>
|
||||
<NetmashExtensionsSwaggerPackageVersion>1.0.7</NetmashExtensionsSwaggerPackageVersion>
|
||||
<NetmashDatabaseMigrationPackageVersion>1.2.0</NetmashDatabaseMigrationPackageVersion>
|
||||
<NetmashExtensionsHttpPackageVersion>1.0.0</NetmashExtensionsHttpPackageVersion>
|
||||
</PropertyGroup>
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
namespace Tuitio.Application.Abstractions
|
||||
{
|
||||
public interface IHttpContextService
|
||||
{
|
||||
int GetUserId();
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ using Tuitio.Domain.Entities;
|
|||
using System.Collections.Generic;
|
||||
using dto = Tuitio.PublishedLanguage.Dto;
|
||||
using models = Tuitio.Domain.Models;
|
||||
using Tuitio.Application.Queries;
|
||||
|
||||
namespace Tuitio.Application.Mappings
|
||||
{
|
||||
|
@ -13,6 +14,14 @@ namespace Tuitio.Application.Mappings
|
|||
public MappingProfile()
|
||||
{
|
||||
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>()
|
||||
.ForMember(z => z.Claims, src => src.MapFrom(z => ComposeClaims(z.Claims)));
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -27,6 +27,8 @@ namespace Tuitio.Domain.Data.DbContexts
|
|||
modelBuilder.ApplyConfiguration(new AppUserConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new UserClaimConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new UserTokenConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new ContactTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new ContactOptionConfiguration());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ namespace Tuitio.Domain.Data.EntityTypeConfiguration
|
|||
builder.Property(z => z.UserId).ValueGeneratedOnAdd();
|
||||
builder.HasOne(z => z.Status).WithMany().HasForeignKey(z => z.StatusId);
|
||||
builder.HasMany(z => z.Claims).WithOne().HasForeignKey(z => z.UserId);
|
||||
builder.HasMany(z => z.ContactOptions).WithOne().HasForeignKey(z => z.UserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -28,6 +28,15 @@ namespace Tuitio.Domain.Data.Repositories
|
|||
.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)
|
||||
{
|
||||
var userToken = new UserToken()
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -28,6 +28,12 @@
|
|||
<None Update="Scripts\1.0.1\02.UserToken table.sql">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</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>
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -22,5 +22,6 @@ namespace Tuitio.Domain.Entities
|
|||
public DateTime? PasswordChangeDate { get; set; }
|
||||
public UserStatus Status { get; set; }
|
||||
public ICollection<UserClaim> Claims { get; set; }
|
||||
public ICollection<ContactOption> ContactOptions { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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; }
|
||||
}
|
||||
}
|
|
@ -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; }
|
||||
}
|
||||
}
|
|
@ -13,7 +13,6 @@ namespace Tuitio.Domain.Models
|
|||
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 DateTime CreatedAt { get; set; }
|
||||
|
|
|
@ -10,6 +10,7 @@ namespace Tuitio.Domain.Repositories
|
|||
public interface IUserRepository
|
||||
{
|
||||
Task<AppUser> GetUser(string userName, string password);
|
||||
Task<AppUser> GetFullUser(int userId);
|
||||
Task UpdateUserAfterLogin(AppUser user, Token token, string tokenRaw);
|
||||
Task<UserToken[]> GetActiveTokens();
|
||||
Task RemoveToken(Guid tokenId);
|
||||
|
|
|
@ -15,7 +15,6 @@ namespace Tuitio.PublishedLanguage.Dto
|
|||
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 string LockStamp { get; init; }
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,12 +1,15 @@
|
|||
// 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
|
||||
|
@ -18,6 +21,7 @@ namespace Tuitio.Api.Controllers
|
|||
_mediator = mediator;
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("authorize")]
|
||||
public async Task<IActionResult> AuthorizeToken([FromQuery] string token)
|
||||
{
|
||||
|
@ -25,5 +29,13 @@ namespace Tuitio.Api.Controllers
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ using Netmash.Infrastructure.DatabaseMigration;
|
|||
using Netmash.Infrastructure.DatabaseMigration.Constants;
|
||||
using Tuitio.Application;
|
||||
using Tuitio.Application.Services.Abstractions;
|
||||
using Tuitio.Authentication;
|
||||
using Tuitio.Domain.Data;
|
||||
|
||||
namespace Tuitio.Extensions
|
||||
|
@ -20,6 +21,8 @@ namespace Tuitio.Extensions
|
|||
public static void ConfigureServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddControllers();
|
||||
services.AddLocalAuthentication();
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
// MediatR
|
||||
services.AddMediatR(typeof(Application.CommandHandlers.AccountLoginHandler).Assembly);
|
||||
|
@ -30,7 +33,7 @@ namespace Tuitio.Extensions
|
|||
services.AddAutoMapper(typeof(Application.Mappings.MappingProfile).Assembly);
|
||||
|
||||
// Swagger
|
||||
services.AddSwagger("Tuitio API", AuthorizationType.None);
|
||||
services.AddSwagger("Tuitio API", AuthorizationType.Tuitio);
|
||||
|
||||
// Data access
|
||||
services.AddMigration(DatabaseType.SQLServer, MetadataLocation.Database);
|
||||
|
@ -47,6 +50,7 @@ namespace Tuitio.Extensions
|
|||
app.UseCors(z => z.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());
|
||||
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseEndpoints(endpoints =>
|
||||
{
|
||||
|
|
Loading…
Reference in New Issue