From 6a8c2ebd07b8e9a8b2134e41788ca32fda2eed61 Mon Sep 17 00:00:00 2001 From: "milija.ivovic" Date: Sat, 24 Aug 2024 22:06:47 +0200 Subject: [PATCH] Started development of "User management" feature. The main endpoint is "user" add: register, login, profile. Added authentication, based on JWT. Added Identity seeder for MongoDB. --- .../DAL/Mongo/Seeders/RolesSeeder.cs | 30 ++++++++ .../DAL/Mongo/Seeders/UsersSeeder.cs | 35 +++++++++ .../StellarChat.Server.Api/Extensions.cs | 10 ++- .../Idenitity/User/GetProfile/GetProfile.cs | 5 ++ .../User/GetProfile/GetProfileEndpoint.cs | 30 ++++++++ .../User/GetProfile/GetProfileHandler.cs | 43 +++++++++++ .../Idenitity/User/LoginUser/LoginUser.cs | 11 +++ .../User/LoginUser/LoginUserEndpoint.cs | 31 ++++++++ .../User/LoginUser/LoginUserHandler.cs | 73 +++++++++++++++++++ .../User/RegistrationUser/RegistrationUser.cs | 14 ++++ .../RegistrationUserEndpoint.cs | 31 ++++++++ .../RegistrationUserHandler.cs | 45 ++++++++++++ .../StellarChat.Server.Api/GlobalUsings.cs | 24 ++++-- src/Server/StellarChat.Server.Api/Program.cs | 2 + .../StellarChat.Server.Api.csproj | 10 +++ .../StellarChat.Server.Api/appsettings.json | 6 ++ .../Common/Mailing/IEmailService.cs | 8 ++ .../User/GetProfile/GetProfileResponse.cs | 15 ++++ .../User/LoginUser/LoginUserRequest.cs | 13 ++++ .../User/LoginUser/LoginUserResponse.cs | 4 + .../RegistrationUserRequest.cs | 16 ++++ .../RegistrationUserResponse.cs | 4 + .../StellarChat.Shared.Contracts.csproj | 10 +++ .../API/Auth/Extensions.cs | 43 +++++++++++ .../API/Auth/Jwt/JwtOptions.cs | 9 +++ .../Common/Mailing/EmailService.cs | 32 ++++++++ .../Extensions.cs | 8 +- .../Identity/ApplicationRole.cs | 10 +++ .../Identity/ApplicationUser.cs | 16 ++++ .../Identity/Extensions.cs | 60 +++++++++++++++ .../Identity/Seeders/IMongoIdentitySeeder.cs | 6 ++ .../Identity/Seeders/IRolesSeeder.cs | 6 ++ .../Identity/Seeders/IUsersSeeder.cs | 6 ++ .../Identity/Seeders/MongoIdentitySeeder.cs | 32 ++++++++ .../Identity/StellarRoles.cs | 15 ++++ .../StellarChat.Shared.Infrastructure.csproj | 2 + 36 files changed, 705 insertions(+), 10 deletions(-) create mode 100644 src/Server/StellarChat.Server.Api/DAL/Mongo/Seeders/RolesSeeder.cs create mode 100644 src/Server/StellarChat.Server.Api/DAL/Mongo/Seeders/UsersSeeder.cs create mode 100644 src/Server/StellarChat.Server.Api/Features/Idenitity/User/GetProfile/GetProfile.cs create mode 100644 src/Server/StellarChat.Server.Api/Features/Idenitity/User/GetProfile/GetProfileEndpoint.cs create mode 100644 src/Server/StellarChat.Server.Api/Features/Idenitity/User/GetProfile/GetProfileHandler.cs create mode 100644 src/Server/StellarChat.Server.Api/Features/Idenitity/User/LoginUser/LoginUser.cs create mode 100644 src/Server/StellarChat.Server.Api/Features/Idenitity/User/LoginUser/LoginUserEndpoint.cs create mode 100644 src/Server/StellarChat.Server.Api/Features/Idenitity/User/LoginUser/LoginUserHandler.cs create mode 100644 src/Server/StellarChat.Server.Api/Features/Idenitity/User/RegistrationUser/RegistrationUser.cs create mode 100644 src/Server/StellarChat.Server.Api/Features/Idenitity/User/RegistrationUser/RegistrationUserEndpoint.cs create mode 100644 src/Server/StellarChat.Server.Api/Features/Idenitity/User/RegistrationUser/RegistrationUserHandler.cs create mode 100644 src/Shared/StellarChat.Shared.Abstractions/Common/Mailing/IEmailService.cs create mode 100644 src/Shared/StellarChat.Shared.Contracts/Identity/User/GetProfile/GetProfileResponse.cs create mode 100644 src/Shared/StellarChat.Shared.Contracts/Identity/User/LoginUser/LoginUserRequest.cs create mode 100644 src/Shared/StellarChat.Shared.Contracts/Identity/User/LoginUser/LoginUserResponse.cs create mode 100644 src/Shared/StellarChat.Shared.Contracts/Identity/User/RegistrationUser/RegistrationUserRequest.cs create mode 100644 src/Shared/StellarChat.Shared.Contracts/Identity/User/RegistrationUser/RegistrationUserResponse.cs create mode 100644 src/Shared/StellarChat.Shared.Infrastructure/API/Auth/Extensions.cs create mode 100644 src/Shared/StellarChat.Shared.Infrastructure/API/Auth/Jwt/JwtOptions.cs create mode 100644 src/Shared/StellarChat.Shared.Infrastructure/Common/Mailing/EmailService.cs create mode 100644 src/Shared/StellarChat.Shared.Infrastructure/Identity/ApplicationRole.cs create mode 100644 src/Shared/StellarChat.Shared.Infrastructure/Identity/ApplicationUser.cs create mode 100644 src/Shared/StellarChat.Shared.Infrastructure/Identity/Extensions.cs create mode 100644 src/Shared/StellarChat.Shared.Infrastructure/Identity/Seeders/IMongoIdentitySeeder.cs create mode 100644 src/Shared/StellarChat.Shared.Infrastructure/Identity/Seeders/IRolesSeeder.cs create mode 100644 src/Shared/StellarChat.Shared.Infrastructure/Identity/Seeders/IUsersSeeder.cs create mode 100644 src/Shared/StellarChat.Shared.Infrastructure/Identity/Seeders/MongoIdentitySeeder.cs create mode 100644 src/Shared/StellarChat.Shared.Infrastructure/Identity/StellarRoles.cs diff --git a/src/Server/StellarChat.Server.Api/DAL/Mongo/Seeders/RolesSeeder.cs b/src/Server/StellarChat.Server.Api/DAL/Mongo/Seeders/RolesSeeder.cs new file mode 100644 index 0000000..1228da2 --- /dev/null +++ b/src/Server/StellarChat.Server.Api/DAL/Mongo/Seeders/RolesSeeder.cs @@ -0,0 +1,30 @@ +using StellarChat.Shared.Infrastructure.Identity; +using StellarChat.Shared.Infrastructure.Identity.Seeders; + +namespace StellarChat.Server.Api.DAL.Mongo.Seeders; + +internal sealed class RolesSeeder(RoleManager roleManager, ILogger logger) : IRolesSeeder +{ + private readonly RoleManager _roleManager = roleManager; + private readonly ILogger _logger = logger; + + public async Task SeedAsync() + { + var role1 = new ApplicationRole + { + Name = StellarRoles.Basic + }; + + _logger.LogInformation("Started seeding 'roles' collection."); + + if (!await _roleManager.RoleExistsAsync(role1.Name)) + { + var result1 = await _roleManager.CreateAsync(role1); + + if (result1.Succeeded) + _logger.LogInformation($"Added a role to the database with 'ID': {role1.Id}, and 'Name': {role1.Name}."); + } + + _logger.LogInformation("Finished seeding 'roles' collection."); + } +} \ No newline at end of file diff --git a/src/Server/StellarChat.Server.Api/DAL/Mongo/Seeders/UsersSeeder.cs b/src/Server/StellarChat.Server.Api/DAL/Mongo/Seeders/UsersSeeder.cs new file mode 100644 index 0000000..88d3606 --- /dev/null +++ b/src/Server/StellarChat.Server.Api/DAL/Mongo/Seeders/UsersSeeder.cs @@ -0,0 +1,35 @@ +using StellarChat.Shared.Infrastructure.Identity.Seeders; + +namespace StellarChat.Server.Api.DAL.Mongo.Seeders; + +internal sealed class UsersSeeder(UserManager userManager,ILogger logger) : IUsersSeeder +{ + private readonly UserManager _userManager = userManager; + private readonly ILogger _logger = logger; + + public async Task SeedAsync() + { + string passwordUserBasic = "Test123!"; + + var user1 = new ApplicationUser + { + UserName = "user1", + Email = "user@demo.com", + FirstName = "User", + LastName = "Demo", + EmailConfirmed = true + }; + + _logger.LogInformation("Started seeding 'users' collection."); + + var result1 = await _userManager.CreateAsync(user1, passwordUserBasic); + + if (result1.Succeeded) + { + await _userManager.AddToRoleAsync(user1, StellarRoles.Basic); + _logger.LogInformation($"Added a user to the database with 'ID': {user1.Id}, and 'UserName': {user1.UserName}."); + } + + _logger.LogInformation("Finished seeding 'users' collection."); + } +} diff --git a/src/Server/StellarChat.Server.Api/Extensions.cs b/src/Server/StellarChat.Server.Api/Extensions.cs index 7af1ae5..6a479d5 100644 --- a/src/Server/StellarChat.Server.Api/Extensions.cs +++ b/src/Server/StellarChat.Server.Api/Extensions.cs @@ -10,6 +10,8 @@ using StellarChat.Server.Api.Features.Models.Connectors.Providers; using StellarChat.Server.Api.Features.Models.Connectors; using StellarChat.Server.Api.Options; +using StellarChat.Shared.Infrastructure.Common.Mailing; +using StellarChat.Shared.Infrastructure.Identity.Seeders; namespace StellarChat.Server.Api; @@ -47,7 +49,7 @@ public static void AddInfrastructure(this WebApplicationBuilder builder) builder.Services.TryAddSingleton(TimeProvider.System); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -76,7 +78,11 @@ public static void AddInfrastructure(this WebApplicationBuilder builder) .AddMongoRepository("chat-sessions") .AddMongoRepository("assistants") .AddMongoRepository("actions") - .AddMongoRepository("settings"); + .AddMongoRepository("settings") + .AddScoped() + .AddScoped() + .AddTransient() + .AddTransient(); builder.Services.AddMediator(options => { diff --git a/src/Server/StellarChat.Server.Api/Features/Idenitity/User/GetProfile/GetProfile.cs b/src/Server/StellarChat.Server.Api/Features/Idenitity/User/GetProfile/GetProfile.cs new file mode 100644 index 0000000..f9abbc4 --- /dev/null +++ b/src/Server/StellarChat.Server.Api/Features/Idenitity/User/GetProfile/GetProfile.cs @@ -0,0 +1,5 @@ +namespace StellarChat.Server.Api.Features.Idenitity.User.GetProfile; + +public class GetProfile : ICommand +{ +} diff --git a/src/Server/StellarChat.Server.Api/Features/Idenitity/User/GetProfile/GetProfileEndpoint.cs b/src/Server/StellarChat.Server.Api/Features/Idenitity/User/GetProfile/GetProfileEndpoint.cs new file mode 100644 index 0000000..aef0d90 --- /dev/null +++ b/src/Server/StellarChat.Server.Api/Features/Idenitity/User/GetProfile/GetProfileEndpoint.cs @@ -0,0 +1,30 @@ +namespace StellarChat.Server.Api.Features.Idenitity.User.GetProfile; + +internal sealed class GetProfileEndpoint : IEndpoint +{ + public void Expose(IEndpointRouteBuilder endpoints) + { + var userManagement = endpoints.MapGroup("/user").WithTags("User Management"); + + userManagement.MapGet("/profile", [Authorize] async (IMediator mediator) => + { + var response = await mediator.Send(new GetProfile()); + + if (!response.Success) + return Results.BadRequest(response); + + return Results.Ok(response); + }) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .WithName("GetUserProfile") + .WithOpenApi(operation => new(operation) + { + Summary = "Retrieves the user profile of the logged in user." + }); + } + + public void Register(IServiceCollection services, IConfiguration configuration) { } + + public void Use(IApplicationBuilder app) { } +} diff --git a/src/Server/StellarChat.Server.Api/Features/Idenitity/User/GetProfile/GetProfileHandler.cs b/src/Server/StellarChat.Server.Api/Features/Idenitity/User/GetProfile/GetProfileHandler.cs new file mode 100644 index 0000000..6070eee --- /dev/null +++ b/src/Server/StellarChat.Server.Api/Features/Idenitity/User/GetProfile/GetProfileHandler.cs @@ -0,0 +1,43 @@ +namespace StellarChat.Server.Api.Features.Idenitity.User.GetProfile; + +internal class GetProfileHandler : ICommandHandler +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly UserManager _userManager; + private readonly JwtOptions _jwtOptions; + + public GetProfileHandler( + IHttpContextAccessor httpContextAccessor, + UserManager userManager, + IOptions jwtOptions) + { + _httpContextAccessor = httpContextAccessor; + _userManager = userManager; + _jwtOptions = jwtOptions.Value; + } + + public async ValueTask Handle(GetProfile command, CancellationToken cancellationToken) + { + var userId = _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier); + var user = await _userManager.FindByIdAsync(userId); + + if (user == null) + return new GetProfileResponse { Success = false, Message = "User not found" }; + + var userRoles = await _userManager.GetRolesAsync(user); + + return new GetProfileResponse + { + Success = true, + UserName = user.UserName, + Email = user.Email, + FirstName = user.FirstName, + LastName = user.LastName, + PhoneNumber = user.PhoneNumber, + IsPhoneNumberConfirmed = user.PhoneNumberConfirmed, + Role = userRoles.FirstOrDefault() + }; + } + + +} diff --git a/src/Server/StellarChat.Server.Api/Features/Idenitity/User/LoginUser/LoginUser.cs b/src/Server/StellarChat.Server.Api/Features/Idenitity/User/LoginUser/LoginUser.cs new file mode 100644 index 0000000..256b6fa --- /dev/null +++ b/src/Server/StellarChat.Server.Api/Features/Idenitity/User/LoginUser/LoginUser.cs @@ -0,0 +1,11 @@ +namespace StellarChat.Server.Api.Features.Idenitity.User.LoginUser; + +public class LoginUser : ICommand +{ + + [Required, EmailAddress] + public string Email { get; set; } = default!; + [Required, DataType(DataType.Password)] + public string Password { get; set; } = default!; + +} diff --git a/src/Server/StellarChat.Server.Api/Features/Idenitity/User/LoginUser/LoginUserEndpoint.cs b/src/Server/StellarChat.Server.Api/Features/Idenitity/User/LoginUser/LoginUserEndpoint.cs new file mode 100644 index 0000000..41f4d96 --- /dev/null +++ b/src/Server/StellarChat.Server.Api/Features/Idenitity/User/LoginUser/LoginUserEndpoint.cs @@ -0,0 +1,31 @@ +namespace StellarChat.Server.Api.Features.Idenitity.User.LoginUser; + +internal sealed class LoginUserEndpoint : IEndpoint +{ + public void Expose(IEndpointRouteBuilder endpoints) + { + var userManagement = endpoints.MapGroup("/user").WithTags("User Management"); + + userManagement.MapPost("/login", [AllowAnonymous] async ([FromBody] LoginUserRequest request, IMediator mediator) => + { + var command = request.Adapt(); + + var response = await mediator.Send(command); + + if (!response.Success) + return Results.BadRequest(response); + + return Results.Ok(response); + }) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .WithOpenApi(operation => new(operation) + { + Summary = "User login." + }); + } + + public void Register(IServiceCollection services, IConfiguration configuration) { } + + public void Use(IApplicationBuilder app) { } +} diff --git a/src/Server/StellarChat.Server.Api/Features/Idenitity/User/LoginUser/LoginUserHandler.cs b/src/Server/StellarChat.Server.Api/Features/Idenitity/User/LoginUser/LoginUserHandler.cs new file mode 100644 index 0000000..891a3b2 --- /dev/null +++ b/src/Server/StellarChat.Server.Api/Features/Idenitity/User/LoginUser/LoginUserHandler.cs @@ -0,0 +1,73 @@ +namespace StellarChat.Server.Api.Features.Idenitity.User.LoginUser; + +internal class LoginUserHandler : ICommandHandler +{ + private readonly UserManager _userManager; + private readonly JwtOptions _jwtOptions; + + public LoginUserHandler( + UserManager userManager, + IOptions jwtOptions) + { + _userManager = userManager; + _jwtOptions = jwtOptions.Value; + } + + public async ValueTask Handle(LoginUser command, CancellationToken cancellationToken) + { + var user = await _userManager.FindByEmailAsync(command.Email); + if (user != null) + { + if (await _userManager.IsLockedOutAsync(user)) + return new LoginUserResponse(false, "Your account is locked out. Please try again later.", null); + + if (await _userManager.CheckPasswordAsync(user, command.Password)) + { + await _userManager.ResetAccessFailedCountAsync(user); + + var roles = await _userManager.GetRolesAsync(user); + var userRole = roles.FirstOrDefault(); + + var tokenHandler = new JwtSecurityTokenHandler(); + var secretKey = _jwtOptions.SECRET_KEY; + + if (string.IsNullOrEmpty(secretKey)) + return new LoginUserResponse(false, "Internal server error", null); + + var key = Convert.FromBase64String(secretKey); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.UserName ?? string.Empty), + new Claim(ClaimTypes.Role, userRole ?? string.Empty), + new Claim("scope", "api1") + ]), + + Expires = DateTime.UtcNow.AddHours(1), + Issuer = _jwtOptions.ISSUER, + Audience = _jwtOptions.ISSUER, + SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) + }; + var token = tokenHandler.CreateToken(tokenDescriptor); + var tokenString = tokenHandler.WriteToken(token); + + return new LoginUserResponse(true, string.Empty, tokenString); + } + else + { + await _userManager.AccessFailedAsync(user); + + if (await _userManager.IsLockedOutAsync(user)) + return new LoginUserResponse(false, "Your account is locked out. Please try again later.", null); + + return new LoginUserResponse(false, "Invalid login attempt.", null); + } + } + + return new LoginUserResponse(false, "Unauthorized", null); + } + + +} diff --git a/src/Server/StellarChat.Server.Api/Features/Idenitity/User/RegistrationUser/RegistrationUser.cs b/src/Server/StellarChat.Server.Api/Features/Idenitity/User/RegistrationUser/RegistrationUser.cs new file mode 100644 index 0000000..f9ea8fc --- /dev/null +++ b/src/Server/StellarChat.Server.Api/Features/Idenitity/User/RegistrationUser/RegistrationUser.cs @@ -0,0 +1,14 @@ +namespace StellarChat.Server.Api.Features.Idenitity.User.RegistrationUser; + +public class RegistrationUser : ICommand +{ + public string Username { get; set; } = default!; + + [Required, EmailAddress] + public string Email { get; set; } = default!; + [Required, DataType(DataType.Password)] + public string Password { get; set; } = default!; + [Required, DataType(DataType.Password), Compare(nameof(Password), ErrorMessage = "Passwords do not match")] + public string ConfirmPassword { get; set; } = default!; + +} diff --git a/src/Server/StellarChat.Server.Api/Features/Idenitity/User/RegistrationUser/RegistrationUserEndpoint.cs b/src/Server/StellarChat.Server.Api/Features/Idenitity/User/RegistrationUser/RegistrationUserEndpoint.cs new file mode 100644 index 0000000..91b3e4d --- /dev/null +++ b/src/Server/StellarChat.Server.Api/Features/Idenitity/User/RegistrationUser/RegistrationUserEndpoint.cs @@ -0,0 +1,31 @@ +namespace StellarChat.Server.Api.Features.Idenitity.User.RegistrationUser; + +internal sealed class RegistrationUserEndpoint : IEndpoint +{ + public void Expose(IEndpointRouteBuilder endpoints) + { + var userManagement = endpoints.MapGroup("/user").WithTags("User Management"); + + userManagement.MapPost("/register", [AllowAnonymous] async ([FromBody] RegistrationUserRequest request, IMediator mediator) => + { + var command = request.Adapt(); + + var response = await mediator.Send(command); + + if (!response.Success) + return Results.BadRequest(response); + + return Results.Ok(response); + }) + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .WithOpenApi(operation => new(operation) + { + Summary = "Registration new anonimus user." + }); + } + + public void Register(IServiceCollection services, IConfiguration configuration) { } + + public void Use(IApplicationBuilder app) { } +} diff --git a/src/Server/StellarChat.Server.Api/Features/Idenitity/User/RegistrationUser/RegistrationUserHandler.cs b/src/Server/StellarChat.Server.Api/Features/Idenitity/User/RegistrationUser/RegistrationUserHandler.cs new file mode 100644 index 0000000..ffc4a82 --- /dev/null +++ b/src/Server/StellarChat.Server.Api/Features/Idenitity/User/RegistrationUser/RegistrationUserHandler.cs @@ -0,0 +1,45 @@ +namespace StellarChat.Server.Api.Features.Idenitity.User.RegistrationUser; + +internal class RegistrationUserHandler : ICommandHandler +{ + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public RegistrationUserHandler( + UserManager userManager, + ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + public async ValueTask Handle(RegistrationUser command, CancellationToken cancellationToken) + { + _logger.LogInformation("Registering a user!"); + + var user = new ApplicationUser { UserName = command.Username, Email = command.Email, RegistredOn = DateTime.Now }; + var result = await _userManager.CreateAsync(user, command.Password); + + if (result.Succeeded) + { + await _userManager.AddToRoleAsync(user, StellarRoles.Basic); + + return new RegistrationUserResponse( + Success: true, + "User registered successfully", + user.UserName, + user.Email); + } + var errorDescriptions = string.Join(", ", result.Errors.Select(e => e.Description)); + + _logger.LogWarning($"User registration failed. {errorDescriptions}"); + + return new RegistrationUserResponse( + Success: false, + $"User registration failed. {errorDescriptions}", + null, + null); + } + + +} diff --git a/src/Server/StellarChat.Server.Api/GlobalUsings.cs b/src/Server/StellarChat.Server.Api/GlobalUsings.cs index 3cf38fc..b88e996 100644 --- a/src/Server/StellarChat.Server.Api/GlobalUsings.cs +++ b/src/Server/StellarChat.Server.Api/GlobalUsings.cs @@ -1,15 +1,12 @@ global using Mapster; global using MapsterMapper; global using Mediator; -global using MongoDB.Driver; -global using MongoDB.Driver.Linq; - +global using Microsoft.AspNetCore.Identity; global using Microsoft.AspNetCore.Mvc; global using Microsoft.AspNetCore.SignalR; -global using System.ComponentModel.DataAnnotations; global using Microsoft.Extensions.DependencyInjection.Extensions; -global using System.Reflection; - +global using MongoDB.Driver; +global using MongoDB.Driver.Linq; global using StellarChat.Server.Api; global using StellarChat.Server.Api.DAL.Mongo.Documents.Actions; global using StellarChat.Server.Api.DAL.Mongo.Documents.Assistants; @@ -20,7 +17,6 @@ global using StellarChat.Server.Api.DAL.Mongo.Exceptions.Chat; global using StellarChat.Server.Api.DAL.Mongo.Repositories.Assistants; global using StellarChat.Server.Api.DAL.Mongo.Repositories.Chat; -global using StellarChat.Server.Api.Domain.Settings.Repositories; global using StellarChat.Server.Api.Domain.Actions.Models; global using StellarChat.Server.Api.Domain.Actions.Repositories; global using StellarChat.Server.Api.Domain.Assistants.Models; @@ -28,15 +24,29 @@ global using StellarChat.Server.Api.Domain.Chat.Models; global using StellarChat.Server.Api.Domain.Chat.Repositories; global using StellarChat.Server.Api.Domain.Settings.Models; +global using StellarChat.Server.Api.Domain.Settings.Repositories; global using StellarChat.Server.Api.Features.Assistants.DefaultAssistant.Services; global using StellarChat.Server.Api.Hubs; global using StellarChat.Shared.Abstractions.API.Endpoints; +global using StellarChat.Shared.Abstractions.Common.Mailing; global using StellarChat.Shared.Abstractions.Exceptions; global using StellarChat.Shared.Abstractions.Pagination; global using StellarChat.Shared.Contracts.Actions; global using StellarChat.Shared.Contracts.Assistants; global using StellarChat.Shared.Contracts.Chat; +global using StellarChat.Shared.Contracts.Identity.User.RegistrationUser; global using StellarChat.Shared.Contracts.Models; global using StellarChat.Shared.Contracts.Settings; global using StellarChat.Shared.Infrastructure; global using StellarChat.Shared.Infrastructure.DAL.Mongo; +global using System.ComponentModel.DataAnnotations; +global using System.Reflection; +global using StellarChat.Shared.Infrastructure.Identity; +global using Microsoft.AspNetCore.Authorization; +global using StellarChat.Shared.Contracts.Identity.User.LoginUser; +global using Microsoft.IdentityModel.Tokens; +global using StellarChat.Shared.Infrastructure.API.Auth.Jwt; +global using System.IdentityModel.Tokens.Jwt; +global using System.Security.Claims; +global using Microsoft.Extensions.Options; +global using StellarChat.Shared.Contracts.Identity.User.GetProfile; diff --git a/src/Server/StellarChat.Server.Api/Program.cs b/src/Server/StellarChat.Server.Api/Program.cs index 0721120..a3f21e4 100644 --- a/src/Server/StellarChat.Server.Api/Program.cs +++ b/src/Server/StellarChat.Server.Api/Program.cs @@ -5,6 +5,8 @@ var app = builder.Build(); +await app.Services.SeedMongoIdentityAsync(); + app.UseInfrastructure(); app.Run(); diff --git a/src/Server/StellarChat.Server.Api/StellarChat.Server.Api.csproj b/src/Server/StellarChat.Server.Api/StellarChat.Server.Api.csproj index fab50ed..5c805a3 100644 --- a/src/Server/StellarChat.Server.Api/StellarChat.Server.Api.csproj +++ b/src/Server/StellarChat.Server.Api/StellarChat.Server.Api.csproj @@ -25,4 +25,14 @@ + + + + + + + + + + diff --git a/src/Server/StellarChat.Server.Api/appsettings.json b/src/Server/StellarChat.Server.Api/appsettings.json index 50a2707..c5c5988 100644 --- a/src/Server/StellarChat.Server.Api/appsettings.json +++ b/src/Server/StellarChat.Server.Api/appsettings.json @@ -4,6 +4,12 @@ "version": "v0.2.3-alpha" }, "AllowedHosts": "*", + "jwt": { + "secret_key": "uC2wI0K7opML123456ptc5qL+aPEv4Efu5NnO0mc94A=", + "issuer": "https://localhost:7057", + "token_expiration_minutes": 60, + "refresh_token_expiration_days": 7 + }, "openAI": { "api_key": "", "text_model": "gpt-3.5-turbo", diff --git a/src/Shared/StellarChat.Shared.Abstractions/Common/Mailing/IEmailService.cs b/src/Shared/StellarChat.Shared.Abstractions/Common/Mailing/IEmailService.cs new file mode 100644 index 0000000..e4d135a --- /dev/null +++ b/src/Shared/StellarChat.Shared.Abstractions/Common/Mailing/IEmailService.cs @@ -0,0 +1,8 @@ +namespace StellarChat.Shared.Abstractions.Common.Mailing; + +public interface IEmailService +{ + Task SendAccountUnlockEmailAsync(string? email, string callbackUrl); + Task SendPasswordResetEmailAsync(string email, string resetLink); + Task SendAppointmentCancellationEmailAsync(string email, DateTime appointmentDate, string reason); +} diff --git a/src/Shared/StellarChat.Shared.Contracts/Identity/User/GetProfile/GetProfileResponse.cs b/src/Shared/StellarChat.Shared.Contracts/Identity/User/GetProfile/GetProfileResponse.cs new file mode 100644 index 0000000..69efb8b --- /dev/null +++ b/src/Shared/StellarChat.Shared.Contracts/Identity/User/GetProfile/GetProfileResponse.cs @@ -0,0 +1,15 @@ +namespace StellarChat.Shared.Contracts.Identity.User.GetProfile; + +public class GetProfileResponse +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public string? UserName { get; set; } + public string? Email { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? PhoneNumber { get; set; } + public string? Address { get; set; } + public bool IsPhoneNumberConfirmed { get; set; } + public string? Role { get; set; } +} diff --git a/src/Shared/StellarChat.Shared.Contracts/Identity/User/LoginUser/LoginUserRequest.cs b/src/Shared/StellarChat.Shared.Contracts/Identity/User/LoginUser/LoginUserRequest.cs new file mode 100644 index 0000000..4c173b3 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Contracts/Identity/User/LoginUser/LoginUserRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace StellarChat.Shared.Contracts.Identity.User.LoginUser; + +public class LoginUserRequest +{ + + [Required, EmailAddress] + public string Email { get; set; } = default!; + [Required, DataType(DataType.Password)] + public string Password { get; set; } = default!; + +} diff --git a/src/Shared/StellarChat.Shared.Contracts/Identity/User/LoginUser/LoginUserResponse.cs b/src/Shared/StellarChat.Shared.Contracts/Identity/User/LoginUser/LoginUserResponse.cs new file mode 100644 index 0000000..dfb6f05 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Contracts/Identity/User/LoginUser/LoginUserResponse.cs @@ -0,0 +1,4 @@ +namespace StellarChat.Shared.Contracts.Identity.User.LoginUser; + +public sealed record LoginUserResponse(bool Success, string Message, string? Token); + \ No newline at end of file diff --git a/src/Shared/StellarChat.Shared.Contracts/Identity/User/RegistrationUser/RegistrationUserRequest.cs b/src/Shared/StellarChat.Shared.Contracts/Identity/User/RegistrationUser/RegistrationUserRequest.cs new file mode 100644 index 0000000..91e0197 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Contracts/Identity/User/RegistrationUser/RegistrationUserRequest.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace StellarChat.Shared.Contracts.Identity.User.RegistrationUser; + +public class RegistrationUserRequest +{ + public string Username { get; set; } = default!; + + [Required, EmailAddress] + public string Email { get; set; } = default!; + [Required, DataType(DataType.Password)] + public string Password { get; set; } = default!; + [Required, DataType(DataType.Password), Compare(nameof(Password), ErrorMessage = "Passwords do not match")] + public string ConfirmPassword { get; set; } = default!; + +} diff --git a/src/Shared/StellarChat.Shared.Contracts/Identity/User/RegistrationUser/RegistrationUserResponse.cs b/src/Shared/StellarChat.Shared.Contracts/Identity/User/RegistrationUser/RegistrationUserResponse.cs new file mode 100644 index 0000000..153a989 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Contracts/Identity/User/RegistrationUser/RegistrationUserResponse.cs @@ -0,0 +1,4 @@ +namespace StellarChat.Shared.Contracts.Identity.User.RegistrationUser; + +public sealed record RegistrationUserResponse(bool Success, string Message, string? Username, string? Email); + \ No newline at end of file diff --git a/src/Shared/StellarChat.Shared.Contracts/StellarChat.Shared.Contracts.csproj b/src/Shared/StellarChat.Shared.Contracts/StellarChat.Shared.Contracts.csproj index 9ac6fce..d3cfa8a 100644 --- a/src/Shared/StellarChat.Shared.Contracts/StellarChat.Shared.Contracts.csproj +++ b/src/Shared/StellarChat.Shared.Contracts/StellarChat.Shared.Contracts.csproj @@ -10,4 +10,14 @@ + + + + + + + + + + diff --git a/src/Shared/StellarChat.Shared.Infrastructure/API/Auth/Extensions.cs b/src/Shared/StellarChat.Shared.Infrastructure/API/Auth/Extensions.cs new file mode 100644 index 0000000..68cbcb8 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/API/Auth/Extensions.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using StellarChat.Shared.Infrastructure.API.Auth.Jwt; + +namespace StellarChat.Shared.Infrastructure.API.Auth; + +public static class Extensions +{ + private const string SectionName = "jwt"; + + public static IServiceCollection AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration) + { + var section = configuration.GetSection(SectionName); + services.Configure(section); + var options = section.BindOptions(); + + services.AddAuthentication(x => + { + x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }).AddJwtBearer(x => + { + x.RequireHttpsMetadata = true; + x.SaveToken = true; + x.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidIssuer = options.ISSUER, + ValidAudience = options.ISSUER, + IssuerSigningKey = new SymmetricSecurityKey(Convert.FromBase64String(options.SECRET_KEY)), + ClockSkew = TimeSpan.Zero + }; + }); + + return services; + } + +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/API/Auth/Jwt/JwtOptions.cs b/src/Shared/StellarChat.Shared.Infrastructure/API/Auth/Jwt/JwtOptions.cs new file mode 100644 index 0000000..a6a806a --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/API/Auth/Jwt/JwtOptions.cs @@ -0,0 +1,9 @@ +namespace StellarChat.Shared.Infrastructure.API.Auth.Jwt; + +public class JwtOptions +{ + public string SECRET_KEY { get; set; } = string.Empty; + public string ISSUER { get; set; } = string.Empty; + public int TOKEN_EXPIRATION_MINUTES { get; set; } + public int REFRESH_TOKEN_EXPIRATION_DAYS { get; set; } +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/Common/Mailing/EmailService.cs b/src/Shared/StellarChat.Shared.Infrastructure/Common/Mailing/EmailService.cs new file mode 100644 index 0000000..e5e20d1 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/Common/Mailing/EmailService.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Identity; +using StellarChat.Shared.Abstractions.Common.Mailing; +using StellarChat.Shared.Infrastructure.Identity; + +namespace StellarChat.Shared.Infrastructure.Common.Mailing; + +public class EmailService : IEmailService +{ + private readonly UserManager _userManager; + + public EmailService(UserManager userManager) + { + _userManager = userManager; + } + public async Task SendPasswordResetEmailAsync(string email, string resetLink) + { + string subject = "Reset Password"; + //TODO:M SendPasswordResetEmailAsync + } + + public async Task SendAccountUnlockEmailAsync(string? email, string callbackUrl) + { + var subject = "Unlock your account"; + //TODO:M SendAccountUnlockEmailAsync + } + + public async Task SendAppointmentCancellationEmailAsync(string email, DateTime appointmentDate, string reason) + { + var subject = "Appointment Cancelled"; + //TODO:M SendAppointmentCancellationEmailAsync + } +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/Extensions.cs b/src/Shared/StellarChat.Shared.Infrastructure/Extensions.cs index c54b8e0..ca7913d 100644 --- a/src/Shared/StellarChat.Shared.Infrastructure/Extensions.cs +++ b/src/Shared/StellarChat.Shared.Infrastructure/Extensions.cs @@ -3,11 +3,13 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using StellarChat.Shared.Infrastructure.API.Auth; using StellarChat.Shared.Infrastructure.API.CORS; using StellarChat.Shared.Infrastructure.API.Endpoints; using StellarChat.Shared.Infrastructure.Contexts; using StellarChat.Shared.Infrastructure.DAL.Mongo; using StellarChat.Shared.Infrastructure.Exceptions; +using StellarChat.Shared.Infrastructure.Identity; using StellarChat.Shared.Infrastructure.Observability.Logging; using System.Text.RegularExpressions; @@ -27,8 +29,10 @@ public static WebApplicationBuilder AddSharedInfrastructure(this WebApplicationB .AddSwaggerGen() .AddErrorHandling() .AddContext() - .AddCorsPolicy(builder.Configuration) + .AddCorsPolicy(builder.Configuration) .AddMongo(builder.Configuration) + .AddMongoIdentity(builder.Configuration) + .AddJwtAuthentication(builder.Configuration) .RegisterEndpoints(builder.Configuration); return builder; @@ -46,6 +50,8 @@ public static WebApplication UseSharedInfrastructure(this WebApplication app) .UseCorsPolicy() .UseErrorHandling() .UseContext() + .UseAuthentication() + .UseAuthorization() .UseLogging() .UseEndpoints(); diff --git a/src/Shared/StellarChat.Shared.Infrastructure/Identity/ApplicationRole.cs b/src/Shared/StellarChat.Shared.Infrastructure/Identity/ApplicationRole.cs new file mode 100644 index 0000000..47e4dfc --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/Identity/ApplicationRole.cs @@ -0,0 +1,10 @@ +using AspNetCore.Identity.MongoDbCore.Models; +using MongoDbGenericRepository.Attributes; + +namespace StellarChat.Shared.Infrastructure.Identity; + +[CollectionName("roles")] +public class ApplicationRole : MongoIdentityRole +{ + +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/Identity/ApplicationUser.cs b/src/Shared/StellarChat.Shared.Infrastructure/Identity/ApplicationUser.cs new file mode 100644 index 0000000..e2a7243 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/Identity/ApplicationUser.cs @@ -0,0 +1,16 @@ +using AspNetCore.Identity.MongoDbCore.Models; +using MongoDbGenericRepository.Attributes; + +namespace StellarChat.Shared.Infrastructure.Identity; + +[CollectionName("users")] +public class ApplicationUser : MongoIdentityUser +{ + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? RefreshToken { get; set; } + public DateTime? RefreshTokenExpiryTime { get; set; } + public DateTime RegistredOn { get; set; } + public DateTime ModifiedOn { get; set; } +} + diff --git a/src/Shared/StellarChat.Shared.Infrastructure/Identity/Extensions.cs b/src/Shared/StellarChat.Shared.Infrastructure/Identity/Extensions.cs new file mode 100644 index 0000000..25cdc4f --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/Identity/Extensions.cs @@ -0,0 +1,60 @@ +using AspNetCore.Identity.MongoDbCore.Extensions; +using AspNetCore.Identity.MongoDbCore.Infrastructure; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using StellarChat.Shared.Infrastructure.DAL.Mongo; +using StellarChat.Shared.Infrastructure.Identity.Seeders; + +namespace StellarChat.Shared.Infrastructure.Identity; + +public static class Extensions +{ + public static IServiceCollection AddMongoIdentity(this IServiceCollection services, IConfiguration configuration) + { + var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + if (options == null) + return services; + + var mongoDbIdentityConfig = new MongoDbIdentityConfiguration + { + MongoDbSettings = new MongoDbSettings + { + ConnectionString = options.CONNECTION_STRING, + DatabaseName = "StellarChat" + }, + IdentityOptionsAction = options => + { + options.Password.RequireDigit = false; + options.Password.RequiredLength = 8; + options.Password.RequireNonAlphanumeric = true; + options.Password.RequireLowercase = false; + + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(10); + options.Lockout.MaxFailedAccessAttempts = 5; + + options.User.RequireUniqueEmail = true; + } + }; + + services.ConfigureMongoDbIdentity(mongoDbIdentityConfig) + .AddUserManager>() + .AddSignInManager>() + .AddRoleManager>() + .AddDefaultTokenProviders(); + + services.AddTransient(); + + return services; + } + + public static async Task SeedMongoIdentityAsync(this IServiceProvider services) + { + using var scope = services.CreateScope(); + await scope.ServiceProvider.GetRequiredService() + .SeedIdentityAsync(); + } +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/Identity/Seeders/IMongoIdentitySeeder.cs b/src/Shared/StellarChat.Shared.Infrastructure/Identity/Seeders/IMongoIdentitySeeder.cs new file mode 100644 index 0000000..aed1b3e --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/Identity/Seeders/IMongoIdentitySeeder.cs @@ -0,0 +1,6 @@ +namespace StellarChat.Shared.Infrastructure.Identity.Seeders; + +public interface IMongoIdentitySeeder +{ + Task SeedIdentityAsync(); +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/Identity/Seeders/IRolesSeeder.cs b/src/Shared/StellarChat.Shared.Infrastructure/Identity/Seeders/IRolesSeeder.cs new file mode 100644 index 0000000..4b2caa4 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/Identity/Seeders/IRolesSeeder.cs @@ -0,0 +1,6 @@ +namespace StellarChat.Shared.Infrastructure.Identity.Seeders; + +public interface IRolesSeeder +{ + Task SeedAsync(); +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/Identity/Seeders/IUsersSeeder.cs b/src/Shared/StellarChat.Shared.Infrastructure/Identity/Seeders/IUsersSeeder.cs new file mode 100644 index 0000000..d6794b1 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/Identity/Seeders/IUsersSeeder.cs @@ -0,0 +1,6 @@ +namespace StellarChat.Shared.Infrastructure.Identity.Seeders; + +public interface IUsersSeeder +{ + Task SeedAsync(); +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/Identity/Seeders/MongoIdentitySeeder.cs b/src/Shared/StellarChat.Shared.Infrastructure/Identity/Seeders/MongoIdentitySeeder.cs new file mode 100644 index 0000000..1254bb7 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/Identity/Seeders/MongoIdentitySeeder.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Logging; + +namespace StellarChat.Shared.Infrastructure.Identity.Seeders; + +public class MongoIdentitySeeder : IMongoIdentitySeeder +{ + private readonly IRolesSeeder _rolesSeeder; + private readonly IUsersSeeder _usersSeeder; + private readonly ILogger _logger; + + public MongoIdentitySeeder(IRolesSeeder rolesSeeder, IUsersSeeder usersSeeder, ILogger logger) + { + _usersSeeder = usersSeeder; + _rolesSeeder = rolesSeeder; + _logger = logger; + } + /// + /// Seed Identity entities: roles, users.. + /// !!! This method must be called after AddMongoIdentity, actually after registration identity manager services. + /// + /// + public async Task SeedIdentityAsync() + { + _logger.LogInformation("Started Identity seeding the database."); + + await _rolesSeeder.SeedAsync(); + await _usersSeeder.SeedAsync(); + + _logger.LogInformation("Finished Identity seeding the database."); + } + +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/Identity/StellarRoles.cs b/src/Shared/StellarChat.Shared.Infrastructure/Identity/StellarRoles.cs new file mode 100644 index 0000000..07faac5 --- /dev/null +++ b/src/Shared/StellarChat.Shared.Infrastructure/Identity/StellarRoles.cs @@ -0,0 +1,15 @@ +using System.Collections.ObjectModel; + +namespace StellarChat.Shared.Infrastructure.Identity; + +public static class StellarRoles +{ + public const string Basic = nameof(Basic); + + public static IReadOnlyList DefaultRoles { get; } = new ReadOnlyCollection(new[] + { + Basic + }); + + public static bool IsDefault(string roleName) => DefaultRoles.Any(r => r == roleName); +} diff --git a/src/Shared/StellarChat.Shared.Infrastructure/StellarChat.Shared.Infrastructure.csproj b/src/Shared/StellarChat.Shared.Infrastructure/StellarChat.Shared.Infrastructure.csproj index aa6d267..da2357f 100644 --- a/src/Shared/StellarChat.Shared.Infrastructure/StellarChat.Shared.Infrastructure.csproj +++ b/src/Shared/StellarChat.Shared.Infrastructure/StellarChat.Shared.Infrastructure.csproj @@ -11,6 +11,8 @@ + +