Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Development feature - User Management #19

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/Server/StellarChat.Server.Api/DAL/Mongo/Seeders/RolesSeeder.cs
Original file line number Diff line number Diff line change
@@ -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<ApplicationRole> roleManager, ILogger<RolesSeeder> logger) : IRolesSeeder
{
private readonly RoleManager<ApplicationRole> _roleManager = roleManager;
private readonly ILogger<RolesSeeder> _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.");
}
}
35 changes: 35 additions & 0 deletions src/Server/StellarChat.Server.Api/DAL/Mongo/Seeders/UsersSeeder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
ο»Ώusing StellarChat.Shared.Infrastructure.Identity.Seeders;

namespace StellarChat.Server.Api.DAL.Mongo.Seeders;

internal sealed class UsersSeeder(UserManager<ApplicationUser> userManager,ILogger<UsersSeeder> logger) : IUsersSeeder
{
private readonly UserManager<ApplicationUser> _userManager = userManager;
private readonly ILogger<UsersSeeder> _logger = logger;

public async Task SeedAsync()
{
string passwordUserBasic = "Test123!";

var user1 = new ApplicationUser
{
UserName = "user1",
Email = "[email protected]",
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.");
}
}
10 changes: 8 additions & 2 deletions src/Server/StellarChat.Server.Api/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -47,7 +49,7 @@ public static void AddInfrastructure(this WebApplicationBuilder builder)
builder.Services.TryAddSingleton(TimeProvider.System);
builder.Services.AddScoped<IAppSettingsSeeder, AppSettingsSeeder>();
builder.Services.AddScoped<IAssistantsSeeder, AssistantsSeeder>();
builder.Services.AddScoped<IActionsSeeder, ActionsSeeder>();
builder.Services.AddScoped<IActionsSeeder, ActionsSeeder>();
builder.Services.AddScoped<IConnectorStrategy, ConnectorStrategy>();
builder.Services.AddScoped<IConnector, OpenAiProvider>();
builder.Services.AddScoped<IConnector, OllamaProvider>();
Expand Down Expand Up @@ -76,7 +78,11 @@ public static void AddInfrastructure(this WebApplicationBuilder builder)
.AddMongoRepository<ChatSessionDocument, Guid>("chat-sessions")
.AddMongoRepository<AssistantDocument, Guid>("assistants")
.AddMongoRepository<NativeActionDocument, Guid>("actions")
.AddMongoRepository<AppSettingsDocument, Guid>("settings");
.AddMongoRepository<AppSettingsDocument, Guid>("settings")
.AddScoped<IRolesSeeder, RolesSeeder>()
.AddScoped<IUsersSeeder, UsersSeeder>()
.AddTransient<IMongoIdentitySeeder, MongoIdentitySeeder>()
.AddTransient<IEmailService, EmailService>();

builder.Services.AddMediator(options =>
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ο»Ώnamespace StellarChat.Server.Api.Features.Idenitity.User.GetProfile;

public class GetProfile : ICommand<GetProfileResponse>
{
}
Original file line number Diff line number Diff line change
@@ -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) { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
ο»Ώnamespace StellarChat.Server.Api.Features.Idenitity.User.GetProfile;

internal class GetProfileHandler : ICommandHandler<GetProfile, GetProfileResponse>
{
private readonly IHttpContextAccessor _httpContextAccessor;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using IContext interface from the Shared project instead of IHttpContextAccessor. IContext is already designed to handle request information and the authenticated user, so it might be a more consistent approach across the codebase.

private readonly UserManager<ApplicationUser> _userManager;
private readonly JwtOptions _jwtOptions;

public GetProfileHandler(
IHttpContextAccessor httpContextAccessor,
UserManager<ApplicationUser> userManager,
IOptions<JwtOptions> jwtOptions)
{
_httpContextAccessor = httpContextAccessor;
_userManager = userManager;
_jwtOptions = jwtOptions.Value;
}

public async ValueTask<GetProfileResponse> 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()
};
}


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
ο»Ώnamespace StellarChat.Server.Api.Features.Idenitity.User.LoginUser;

public class LoginUser : ICommand<LoginUserResponse>
{

[Required, EmailAddress]
public string Email { get; set; } = default!;
[Required, DataType(DataType.Password)]
public string Password { get; set; } = default!;

}
Original file line number Diff line number Diff line change
@@ -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<LoginUser>();

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) { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
ο»Ώnamespace StellarChat.Server.Api.Features.Idenitity.User.LoginUser;

internal class LoginUserHandler : ICommandHandler<LoginUser, LoginUserResponse>
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly JwtOptions _jwtOptions;

public LoginUserHandler(
UserManager<ApplicationUser> userManager,
IOptions<JwtOptions> jwtOptions)
{
_userManager = userManager;
_jwtOptions = jwtOptions.Value;
}

public async ValueTask<LoginUserResponse> 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);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of returning a record for error handling, consider using custom exceptions. This approach will help to standardize error handling across the application and make it easier to manage and trace exceptions.


return new LoginUserResponse(false, "Invalid login attempt.", null);
}
}

return new LoginUserResponse(false, "Unauthorized", null);
}


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
ο»Ώnamespace StellarChat.Server.Api.Features.Idenitity.User.RegistrationUser;

public class RegistrationUser : ICommand<RegistrationUserResponse>
{
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!;

}
Original file line number Diff line number Diff line change
@@ -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<RegistrationUser>();

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) { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
ο»Ώnamespace StellarChat.Server.Api.Features.Idenitity.User.RegistrationUser;

internal class RegistrationUserHandler : ICommandHandler<RegistrationUser, RegistrationUserResponse>
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<RegistrationUserHandler> _logger;

public RegistrationUserHandler(
UserManager<ApplicationUser> userManager,
ILogger<RegistrationUserHandler> logger)
{
_userManager = userManager;
_logger = logger;
}

public async ValueTask<RegistrationUserResponse> 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);
}


}
Loading