chore: moving towards agentic development
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
namespace Socialize.Modules.Identity.Configuration;
|
||||
|
||||
public record JwtOptions
|
||||
{
|
||||
public const string SectionName = "Authentication:Jwt";
|
||||
|
||||
public required TimeSpan Lifetime { get; init; }
|
||||
public required string Issuer { get; init; }
|
||||
public required string Audience { get; init; }
|
||||
public required string Key { get; init; }
|
||||
|
||||
public TimeSpan RefreshTokenLifetime { get; init; }
|
||||
public bool RefreshTokenRequireRotation { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Socialize.Modules.Identity.Contracts;
|
||||
|
||||
public interface IUserLookup
|
||||
{
|
||||
Task<UserReference?> GetUserAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Socialize.Modules.Identity.Contracts;
|
||||
|
||||
public static class KnownRoles
|
||||
{
|
||||
public const string Administrator = nameof(Administrator);
|
||||
public const string Manager = nameof(Manager);
|
||||
public const string Client = nameof(Client);
|
||||
public const string Provider = nameof(Provider);
|
||||
public const string WorkspaceMember = nameof(WorkspaceMember);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Socialize.Modules.Identity.Contracts;
|
||||
|
||||
public record UserReference(
|
||||
Guid Id,
|
||||
string Fullname,
|
||||
string? PortraitUrl);
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Security.Claims;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Models;
|
||||
|
||||
namespace Socialize.Modules.Identity.Data;
|
||||
|
||||
public class IdentityService(
|
||||
UserManager userManager,
|
||||
IHttpContextAccessor contextAccessor
|
||||
)
|
||||
{
|
||||
public async Task<UserModel?> GetCurrentUserAsync()
|
||||
{
|
||||
string? currentUserId = contextAccessor.HttpContext?.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (string.IsNullOrEmpty(currentUserId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
UserModel? ret;
|
||||
User? user = await userManager.FindByIdAsync(currentUserId);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
ret = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
UserModel userModel = new()
|
||||
{
|
||||
Id = user.Id,
|
||||
Username = user.UserName ?? string.Empty,
|
||||
PhoneNumber = user.PhoneNumber ?? string.Empty,
|
||||
Email = user.Email ?? string.Empty,
|
||||
PortraitUrl = user.PortraitUrl,
|
||||
Alias = user.Alias,
|
||||
Firstname = user.Firstname,
|
||||
Lastname = user.Lastname,
|
||||
BirthDate = user.BirthDate,
|
||||
Address = user.Address
|
||||
};
|
||||
|
||||
ret = userModel;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
public async Task<IList<string>> GetCurrentUserRolesAsync()
|
||||
{
|
||||
UserModel? currentUserModel = await GetCurrentUserAsync();
|
||||
|
||||
if (currentUserModel is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
User? currentUser = await userManager.FindByIdAsync(currentUserModel.Id.ToString());
|
||||
|
||||
if (currentUser is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
IList<string> userRoles = await userManager.GetRolesAsync(currentUser);
|
||||
|
||||
return userRoles;
|
||||
}
|
||||
|
||||
public async Task<IList<Claim>> GetCurrentUserClaimsAsync()
|
||||
{
|
||||
UserModel? currentUserModel = await GetCurrentUserAsync();
|
||||
|
||||
if (currentUserModel is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
User? currentUser = await userManager.FindByIdAsync(currentUserModel.Id.ToString());
|
||||
|
||||
if (currentUser is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await userManager.GetClaimsAsync(currentUser);
|
||||
}
|
||||
}
|
||||
9
backend/src/Socialize.Api/Modules/Identity/Data/Role.cs
Normal file
9
backend/src/Socialize.Api/Modules/Identity/Data/Role.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Socialize.Modules.Identity.Data;
|
||||
|
||||
public class Role : IdentityRole<Guid>
|
||||
{
|
||||
public Role() { }
|
||||
public Role(string roleName) : base(roleName) { }
|
||||
}
|
||||
19
backend/src/Socialize.Api/Modules/Identity/Data/User.cs
Normal file
19
backend/src/Socialize.Api/Modules/Identity/Data/User.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Socialize.Modules.Identity.Data;
|
||||
|
||||
public class User : IdentityUser<Guid>
|
||||
{
|
||||
[MaxLength(256)] public string? Alias { get; set; }
|
||||
[MaxLength(256)] public string? Firstname { get; set; }
|
||||
[MaxLength(256)] public string? Lastname { get; set; }
|
||||
public DateTime? BirthDate { get; set; }
|
||||
[MaxLength(256)] public string? Address { get; set; }
|
||||
[MaxLength(2048)] public string? PortraitUrl { get; set; }
|
||||
[MaxLength(256)] public string? GoogleId { get; set; }
|
||||
[MaxLength(256)] public string? FacebookId { get; set; }
|
||||
[MaxLength(44)] public string? RefreshToken { get; set; }
|
||||
public DateTime RefreshTokenExpiryTime { get; set; }
|
||||
public string Fullname => $"{Lastname}, {Firstname}";
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Socialize.Modules.Identity.Data;
|
||||
|
||||
public sealed class UserManager(
|
||||
IUserStore<User> store,
|
||||
IOptions<IdentityOptions> optionsAccessor,
|
||||
IPasswordHasher<User> passwordHasher,
|
||||
IEnumerable<IUserValidator<User>> userValidators,
|
||||
IEnumerable<IPasswordValidator<User>> passwordValidators,
|
||||
ILookupNormalizer keyNormalizer,
|
||||
IdentityErrorDescriber errors,
|
||||
IServiceProvider services,
|
||||
ILogger<UserManager<User>> logger)
|
||||
: UserManager<User>(
|
||||
store,
|
||||
optionsAccessor,
|
||||
passwordHasher,
|
||||
userValidators,
|
||||
passwordValidators,
|
||||
keyNormalizer,
|
||||
errors,
|
||||
services,
|
||||
logger)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using Socialize.Data;
|
||||
using Socialize.Modules.Identity.Configuration;
|
||||
using Socialize.Modules.Identity.Contracts;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Socialize.Modules.Identity.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Socialize.Modules.Identity;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static WebApplicationBuilder AddIdentityModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.Configure<JwtOptions>(
|
||||
builder.Configuration.GetRequiredSection(JwtOptions.SectionName));
|
||||
|
||||
builder.Services.AddAuthentication()
|
||||
.AddBearerToken(IdentityConstants.BearerScheme);
|
||||
|
||||
builder.Services.AddAuthorizationBuilder();
|
||||
|
||||
builder.Services
|
||||
.Configure<IdentityOptions>(options =>
|
||||
{
|
||||
if (!builder.Environment.IsDevelopment())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
options.Password.RequireDigit = false;
|
||||
options.Password.RequireLowercase = false;
|
||||
options.Password.RequireUppercase = false;
|
||||
options.Password.RequireNonAlphanumeric = false;
|
||||
options.Password.RequiredLength = 3;
|
||||
options.Password.RequiredUniqueChars = 1;
|
||||
})
|
||||
.AddIdentityCore<User>()
|
||||
.AddUserManager<UserManager>()
|
||||
.AddRoles<Role>()
|
||||
.AddEntityFrameworkStores<AppDbContext>()
|
||||
.AddApiEndpoints()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
// Singleton services
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// Scoped services
|
||||
builder.Services.AddScoped<IdentityService>();
|
||||
builder.Services.AddScoped<EmailVerificationService>();
|
||||
builder.Services.AddScoped<AccessTokenFactory>();
|
||||
builder.Services.AddScoped<IUserLookup, UserLookup>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static async Task<IApplicationBuilder> UseIdentityModuleAsync(
|
||||
this IApplicationBuilder app,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IServiceScopeFactory scopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
|
||||
using IServiceScope scope = scopeFactory.CreateScope();
|
||||
RoleManager<Role> roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<Role>>();
|
||||
await TrySeedAsync(roleManager);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task TrySeedAsync(RoleManager<Role> roleManager)
|
||||
{
|
||||
Role administratorRole = new(KnownRoles.Administrator);
|
||||
if (roleManager.Roles.All(r => r.Name != administratorRole.Name))
|
||||
{
|
||||
await roleManager.CreateAsync(administratorRole);
|
||||
}
|
||||
|
||||
Role managerRole = new(KnownRoles.Manager);
|
||||
if (roleManager.Roles.All(r => r.Name != managerRole.Name))
|
||||
{
|
||||
await roleManager.CreateAsync(managerRole);
|
||||
}
|
||||
|
||||
Role clientRole = new(KnownRoles.Client);
|
||||
if (roleManager.Roles.All(r => r.Name != clientRole.Name))
|
||||
{
|
||||
await roleManager.CreateAsync(clientRole);
|
||||
}
|
||||
|
||||
Role providerRole = new(KnownRoles.Provider);
|
||||
if (roleManager.Roles.All(r => r.Name != providerRole.Name))
|
||||
{
|
||||
await roleManager.CreateAsync(providerRole);
|
||||
}
|
||||
|
||||
Role workspaceMemberRole = new(KnownRoles.WorkspaceMember);
|
||||
if (roleManager.Roles.All(r => r.Name != workspaceMemberRole.Name))
|
||||
{
|
||||
await roleManager.CreateAsync(workspaceMemberRole);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeAddressRequest(
|
||||
string? Address);
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangeAddressHandler(
|
||||
UserManager userManager)
|
||||
: Endpoint<ChangeAddressRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/users/address");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangeAddressRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
user.Address = request.Address;
|
||||
|
||||
IdentityResult result = await userManager.UpdateAsync(user);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeAliasRequest(
|
||||
string? Alias);
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangeAliasHandler(
|
||||
UserManager userManager)
|
||||
: Endpoint<ChangeAliasRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/users/alias");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangeAliasRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
user.Alias = request.Alias;
|
||||
|
||||
IdentityResult result = await userManager.UpdateAsync(user);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeBirthDateRequest(
|
||||
DateTime BirthDate);
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangeBirthDateHandler(
|
||||
UserManager userManager)
|
||||
: Endpoint<ChangeBirthDateRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/users/birthdate");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangeBirthDateRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
user.BirthDate = request.BirthDate;
|
||||
|
||||
IdentityResult result = await userManager.UpdateAsync(user);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeEmailRequest(
|
||||
string? Email);
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangeEmailHandler(
|
||||
UserManager userManager)
|
||||
: Endpoint<ChangeEmailRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/users/email");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangeEmailRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
user.Email = request.Email;
|
||||
|
||||
// TODO: check to see if identity resets the `email confirmed` flag - @jonathan
|
||||
IdentityResult result = await userManager.UpdateAsync(user);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangeFullnameRequest(
|
||||
string? Firstname,
|
||||
string? Lastname);
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangeFullnameHandler(
|
||||
UserManager userManager)
|
||||
: Endpoint<ChangeFullnameRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/users/fullname");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangeFullnameRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
user.Firstname = request.Firstname;
|
||||
user.Lastname = request.Lastname;
|
||||
|
||||
IdentityResult result = await userManager.UpdateAsync(user);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangePhoneRequest(
|
||||
string? PhoneNumber);
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangePhoneHandler(
|
||||
UserManager userManager)
|
||||
: Endpoint<ChangePhoneRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/users/phone");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangePhoneRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
user.PhoneNumber = request.PhoneNumber;
|
||||
// TODO: check to see if identity resets the `phone confirmed` flag - @jonathan
|
||||
|
||||
IdentityResult result = await userManager.UpdateAsync(user);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Socialize.Infrastructure.BlobStorage.Contracts;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangePortraitRequest(
|
||||
IFormFile File);
|
||||
|
||||
[PublicAPI]
|
||||
public record ChangePortraitResponse(
|
||||
string BlobUrl);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class ChangePortraitRequestValidator : Validator<ChangePortraitRequest>
|
||||
{
|
||||
public ChangePortraitRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.File)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class ChangePortraitHandler(
|
||||
UserManager userManager,
|
||||
IBlobStorage blobStorage)
|
||||
: Endpoint<ChangePortraitRequest, ChangePortraitResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/users/portrait");
|
||||
Options(o => o.WithTags("Users"));
|
||||
AllowFileUploads();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ChangePortraitRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string blobUrl = await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Users,
|
||||
$"{user.Id}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}",
|
||||
request.File.OpenReadStream(),
|
||||
request.File.ContentType,
|
||||
ct);
|
||||
|
||||
user.PortraitUrl = blobUrl;
|
||||
|
||||
IdentityResult result = await userManager.UpdateAsync(user);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await SendOkAsync(
|
||||
new ChangePortraitResponse(blobUrl),
|
||||
ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Web;
|
||||
using Socialize.Infrastructure.Configuration;
|
||||
using Socialize.Infrastructure.Emailer.Contracts;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ForgotPasswordRequest(
|
||||
string Email);
|
||||
|
||||
[PublicAPI]
|
||||
public class ForgotPasswordHandler(
|
||||
UserManager userManager,
|
||||
IEmailSender emailSender,
|
||||
IOptionsSnapshot<WebsiteOptions> options)
|
||||
: Endpoint<ForgotPasswordRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
AllowAnonymous();
|
||||
Post("/api/users/forgot-password");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ForgotPasswordRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Find user by email
|
||||
User? user = await userManager.FindByEmailAsync(request.Email);
|
||||
|
||||
// Always return OK even if user not found to prevent email enumeration
|
||||
if (user is null)
|
||||
{
|
||||
await SendOkAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate password reset token
|
||||
string token = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
|
||||
// URL encode the token as it may contain characters that are not URL safe
|
||||
string encodedToken = HttpUtility.UrlEncode(token);
|
||||
|
||||
// Build reset link
|
||||
string resetLink =
|
||||
$"{options.Value.FrontendBaseUrl}/reset-password?email={HttpUtility.UrlEncode(request.Email)}&token={encodedToken}";
|
||||
|
||||
// Create a styled email message
|
||||
string subject = "Reset your Socialize password";
|
||||
string message = $"""
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
|
||||
<h1 style="color: #2c3e50; margin-bottom: 20px;">Reset Your Socialize Password</h1>
|
||||
|
||||
<p style="font-size: 16px; line-height: 1.5; margin-bottom: 25px;">
|
||||
Please click the button below to reset your password:
|
||||
</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href='{resetLink}'
|
||||
style="background-color: #3498db;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);">
|
||||
Reset Password
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #7f8c8d; margin-top: 30px;">
|
||||
If you did not request a password reset, please ignore this email.
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px; color: #7f8c8d; margin-top: 20px;">
|
||||
If the button doesn't work, you can copy and paste this link into your browser:
|
||||
<br>
|
||||
<a href='{resetLink}' style="color: #3498db; word-break: break-all;">{resetLink}</a>
|
||||
</p>
|
||||
</div>
|
||||
""";
|
||||
|
||||
// Send email
|
||||
await emailSender.SendEmailAsync(request.Email, subject, message);
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Security.Claims;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Socialize.Modules.Identity.Models;
|
||||
using Socialize.Infrastructure.Security;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public class GetCurrentUserQueryHandler(
|
||||
IdentityService identityService)
|
||||
: EndpointWithoutRequest<UserDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/users/profile");
|
||||
Options(o => o.WithTags("Memberships"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
UserModel? userModel = await identityService.GetCurrentUserAsync();
|
||||
|
||||
if (userModel is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
IList<string> roles = await identityService.GetCurrentUserRolesAsync();
|
||||
IList<Claim> claims = await identityService.GetCurrentUserClaimsAsync();
|
||||
|
||||
string? persona = claims
|
||||
.Where(claim => claim.Type == KnownClaims.Persona)
|
||||
.Select(claim => claim.Value)
|
||||
.LastOrDefault();
|
||||
|
||||
List<Guid> workspaceIds = claims
|
||||
.Where(claim => claim.Type == KnownClaims.WorkspaceScope)
|
||||
.Select(claim => Guid.TryParse(claim.Value, out Guid id) ? id : Guid.Empty)
|
||||
.Where(id => id != Guid.Empty)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
List<Guid> clientIds = claims
|
||||
.Where(claim => claim.Type == KnownClaims.ClientScope)
|
||||
.Select(claim => Guid.TryParse(claim.Value, out Guid id) ? id : Guid.Empty)
|
||||
.Where(id => id != Guid.Empty)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
List<Guid> projectIds = claims
|
||||
.Where(claim => claim.Type == KnownClaims.ProjectScope)
|
||||
.Select(claim => Guid.TryParse(claim.Value, out Guid id) ? id : Guid.Empty)
|
||||
.Where(id => id != Guid.Empty)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
await SendOkAsync(
|
||||
new UserDto
|
||||
{
|
||||
Id = userModel.Id,
|
||||
Persona = persona,
|
||||
AuthorizedWorkspaceIds = workspaceIds,
|
||||
AuthorizedClientIds = clientIds,
|
||||
AuthorizedProjectIds = projectIds,
|
||||
Alias = userModel.Alias,
|
||||
PortraitUrl = userModel.PortraitUrl,
|
||||
Firstname = userModel.Firstname,
|
||||
Lastname = userModel.Lastname,
|
||||
Username = userModel.Username,
|
||||
PhoneNumber = userModel.PhoneNumber,
|
||||
Email = userModel.Email,
|
||||
BirthDate = userModel.BirthDate,
|
||||
Address = userModel.Address,
|
||||
UserRoles = roles
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Socialize.Infrastructure.BlobStorage.Contracts;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Socialize.Modules.Identity.Models;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public class GetCurrentUserPortraitHandler(
|
||||
IdentityService identityService,
|
||||
IBlobStorage blobStorage
|
||||
)
|
||||
: EndpointWithoutRequest<Stream>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/users/portrait");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
UserModel? identityUser = await identityService.GetCurrentUserAsync();
|
||||
|
||||
if (identityUser is null)
|
||||
{
|
||||
await SendNotFoundAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
MemoryStream stream = await blobStorage.DownloadFileAsync(
|
||||
ContainerNames.Users,
|
||||
$"{identityUser.Id.ToString()}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}",
|
||||
cancellationToken);
|
||||
|
||||
await SendOkAsync(stream, cancellationToken);
|
||||
}
|
||||
}
|
||||
82
backend/src/Socialize.Api/Modules/Identity/Handlers/Login.cs
Normal file
82
backend/src/Socialize.Api/Modules/Identity/Handlers/Login.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Socialize.Modules.Identity.Configuration;
|
||||
using Socialize.Modules.Identity.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record LoginRequest(
|
||||
string Email,
|
||||
string Password);
|
||||
|
||||
[PublicAPI]
|
||||
public record LoginResponse(
|
||||
string AccessToken,
|
||||
string RefreshToken);
|
||||
|
||||
[PublicAPI]
|
||||
public class LoginHandler(
|
||||
UserManager userManager,
|
||||
IOptionsSnapshot<JwtOptions> jwtOptions,
|
||||
AccessTokenFactory accessTokenFactory)
|
||||
: Endpoint<LoginRequest, LoginResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
AllowAnonymous();
|
||||
Post("/api/users/login");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
LoginRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Find the user by email
|
||||
User? user = await userManager.FindByEmailAsync(request.Email);
|
||||
user ??= await userManager.FindByNameAsync(request.Email);
|
||||
if (user is null)
|
||||
{
|
||||
await SendStringAsync(
|
||||
"Invalid email or password",
|
||||
401,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify password
|
||||
bool isPasswordValid = await userManager.CheckPasswordAsync(user, request.Password);
|
||||
if (!isPasswordValid)
|
||||
{
|
||||
await SendStringAsync(
|
||||
"Invalid email or password",
|
||||
401,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the email is confirmed
|
||||
if (!user.EmailConfirmed)
|
||||
{
|
||||
await SendStringAsync(
|
||||
"Email not verified. Please check your email for verification instructions.",
|
||||
401,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate a new refresh token
|
||||
user.RefreshToken = RefreshTokenGenerator.Next();
|
||||
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
|
||||
await userManager.UpdateAsync(user);
|
||||
|
||||
// Generate JWT token
|
||||
string accessToken = await accessTokenFactory.CreateAsync(user);
|
||||
|
||||
await SendOkAsync(
|
||||
new LoginResponse(accessToken, user.RefreshToken),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Configuration;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Socialize.Modules.Identity.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public class FacebookUserInfo
|
||||
{
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("email")] public string? Email { get; init; } // Email might be null if not granted
|
||||
[JsonPropertyName("name")] public required string Name { get; init; }
|
||||
[JsonPropertyName("picture")] public required FacebookPictureData Picture { get; init; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class FacebookPictureData
|
||||
{
|
||||
[JsonPropertyName("data")] public required FacebookPicture Picture { get; init; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class FacebookPicture
|
||||
{
|
||||
[JsonPropertyName("url")] public required string Url { get; init; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public record LoginWithFacebookRequest(
|
||||
string Token);
|
||||
|
||||
[PublicAPI]
|
||||
public record LoginWithFacebookResponse(
|
||||
string AccessToken,
|
||||
string RefreshToken);
|
||||
|
||||
[PublicAPI]
|
||||
public class LoginWithFacebookHandler(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
UserManager userManager,
|
||||
IOptionsSnapshot<JwtOptions> jwtOptions,
|
||||
AccessTokenFactory accessTokenFactory)
|
||||
: Endpoint<LoginWithFacebookRequest, LoginWithFacebookResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
AllowAnonymous();
|
||||
Post("/api/users/login-with-facebook");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
LoginWithFacebookRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Verify the token with Facebook
|
||||
using HttpClient httpClient = httpClientFactory.CreateClient();
|
||||
using HttpResponseMessage response = await httpClient.GetAsync(
|
||||
$"https://graph.facebook.com/me?access_token={request.Token}&fields=id,name,email,picture.width(200).height(200)",
|
||||
ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await SendStringAsync(
|
||||
"The token is not valid",
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the user info (email, name, profile picture)
|
||||
string content = await response.Content.ReadAsStringAsync(ct);
|
||||
FacebookUserInfo? userInfo = JsonSerializer.Deserialize<FacebookUserInfo>(content);
|
||||
if (userInfo is null || string.IsNullOrEmpty(userInfo.Id))
|
||||
{
|
||||
await SendStringAsync(
|
||||
"Failed to retrieve user information from Facebook",
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user exists or create a new one
|
||||
User? user = await userManager.FindByEmailAsync(userInfo.Email!);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
string generatedPassword = PasswordGenerator.Next();
|
||||
User generatedUser = new()
|
||||
{
|
||||
UserName = userInfo.Email ?? $"fb_{userInfo.Id}",
|
||||
Email = userInfo.Email,
|
||||
EmailConfirmed = true,
|
||||
Firstname = userInfo.Name.Split(' ').FirstOrDefault() ?? "",
|
||||
Lastname = userInfo.Name.Split(' ').Skip(1).FirstOrDefault() ?? "",
|
||||
Alias = userInfo.Name,
|
||||
PortraitUrl = userInfo.Picture.Picture.Url,
|
||||
FacebookId = userInfo.Id // Storing Facebook ID
|
||||
};
|
||||
|
||||
IdentityResult result = await userManager.CreateAsync(
|
||||
generatedUser,
|
||||
generatedPassword);
|
||||
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
await SendStringAsync(
|
||||
result.Errors.First().Description,
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
user = generatedUser;
|
||||
}
|
||||
|
||||
// Generate refresh token
|
||||
string refreshToken = RefreshTokenGenerator.Next();
|
||||
|
||||
// Store refresh token in user's properties
|
||||
user.RefreshToken = refreshToken;
|
||||
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
|
||||
await userManager.UpdateAsync(user);
|
||||
|
||||
string accessToken = await accessTokenFactory.CreateAsync(user);
|
||||
|
||||
await SendOkAsync(
|
||||
new LoginWithFacebookResponse(accessToken, refreshToken),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Configuration;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Socialize.Modules.Identity.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
internal class GoogleToken
|
||||
{
|
||||
[JsonPropertyName("access_token")] public required string AccessToken { get; init; }
|
||||
[JsonPropertyName("token_type")] public required string TokenType { get; init; }
|
||||
[JsonPropertyName("expires_in")] public required int ExpiresIn { get; init; }
|
||||
[JsonPropertyName("scope")] public required string Scope { get; init; }
|
||||
[JsonPropertyName("authuser")] public required string AuthUser { get; init; }
|
||||
[JsonPropertyName("prompt")] public required string Prompt { get; init; }
|
||||
}
|
||||
|
||||
public class GoogleUserInfo
|
||||
{
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("email")] public required string Email { get; init; }
|
||||
[JsonPropertyName("verified_email")] public required bool VerifiedEmail { get; init; }
|
||||
[JsonPropertyName("name")] public required string Name { get; init; }
|
||||
[JsonPropertyName("given_name")] public required string GivenName { get; init; }
|
||||
[JsonPropertyName("family_name")] public string FamilyName { get; init; } = string.Empty;
|
||||
[JsonPropertyName("picture")] public required string Picture { get; init; }
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public record LoginWithGoogleRequest(
|
||||
string Token);
|
||||
|
||||
[PublicAPI]
|
||||
public record LoginWithGoogleResponse(
|
||||
string AccessToken,
|
||||
string RefreshToken);
|
||||
|
||||
[PublicAPI]
|
||||
public class LoginWithGoogleHandler(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
UserManager userManager,
|
||||
IOptionsSnapshot<JwtOptions> jwtOptions,
|
||||
AccessTokenFactory accessTokenFactory)
|
||||
: Endpoint<LoginWithGoogleRequest, LoginWithGoogleResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
AllowAnonymous();
|
||||
Post("/api/users/login-with-google");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
LoginWithGoogleRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
GoogleToken googleToken = JsonSerializer.Deserialize<GoogleToken>(request.Token)!;
|
||||
|
||||
// Verify the token with Google
|
||||
using HttpClient httpClient = httpClientFactory.CreateClient();
|
||||
using HttpResponseMessage response = await httpClient.GetAsync(
|
||||
$"https://www.googleapis.com/oauth2/v1/userinfo?access_token={googleToken.AccessToken}",
|
||||
ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await SendStringAsync(
|
||||
"The token is not valid",
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the user info (email, name, etc.).
|
||||
string content = await response.Content.ReadAsStringAsync(ct);
|
||||
GoogleUserInfo? userInfo = JsonSerializer.Deserialize<GoogleUserInfo>(content);
|
||||
if (userInfo is null
|
||||
|| !userInfo.VerifiedEmail
|
||||
|| string.IsNullOrEmpty(userInfo.Email))
|
||||
{
|
||||
await SendStringAsync(
|
||||
"The token does not contain an email",
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the user exists or create a new one
|
||||
User? user = await userManager.FindByEmailAsync(userInfo.Email);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
string generatedPassword = PasswordGenerator.Next();
|
||||
string refreshToken = RefreshTokenGenerator.Next();
|
||||
User generatedUser = new()
|
||||
{
|
||||
UserName = userInfo.Email,
|
||||
Email = userInfo.Email,
|
||||
EmailConfirmed = true,
|
||||
Firstname = userInfo.GivenName,
|
||||
Lastname = userInfo.FamilyName,
|
||||
Alias = userInfo.Name,
|
||||
PortraitUrl = userInfo.Picture,
|
||||
GoogleId = userInfo.Id,
|
||||
RefreshToken = refreshToken,
|
||||
RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime)
|
||||
};
|
||||
|
||||
IdentityResult result = await userManager.CreateAsync(
|
||||
generatedUser,
|
||||
generatedPassword);
|
||||
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
await SendStringAsync(
|
||||
result.Errors.First().Description,
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
user = generatedUser;
|
||||
}
|
||||
|
||||
// Generate the new refresh token
|
||||
user.RefreshToken = RefreshTokenGenerator.Next();
|
||||
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
|
||||
await userManager.UpdateAsync(user);
|
||||
|
||||
string accessToken = await accessTokenFactory.CreateAsync(user);
|
||||
|
||||
await SendOkAsync(
|
||||
new LoginWithGoogleResponse(accessToken, user.RefreshToken),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Configuration;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Socialize.Modules.Identity.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record RefreshTokenRequest(
|
||||
string RefreshToken);
|
||||
|
||||
[PublicAPI]
|
||||
public record RefreshTokenResponse(
|
||||
string AccessToken,
|
||||
string RefreshToken);
|
||||
|
||||
[PublicAPI]
|
||||
public class RefreshTokenHandler(
|
||||
UserManager userManager,
|
||||
IOptionsSnapshot<JwtOptions> jwtOptions,
|
||||
AccessTokenFactory accessTokenFactory)
|
||||
: Endpoint<RefreshTokenRequest, RefreshTokenResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
AllowAnonymous();
|
||||
Post("/api/users/refresh");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
RefreshTokenRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Find the user using the refresh token
|
||||
User? user = await userManager.Users
|
||||
.FirstOrDefaultAsync(u => u.RefreshToken == request.RefreshToken, ct);
|
||||
|
||||
if (user == null || user.RefreshTokenExpiryTime <= DateTime.UtcNow)
|
||||
{
|
||||
await SendUnauthorizedAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate a new refresh token if rotation is required
|
||||
if (jwtOptions.Value.RefreshTokenRequireRotation || user.RefreshToken is null)
|
||||
{
|
||||
user.RefreshToken = RefreshTokenGenerator.Next();
|
||||
}
|
||||
|
||||
// Update refresh token expiry time
|
||||
user.RefreshTokenExpiryTime = DateTime.UtcNow.Add(jwtOptions.Value.RefreshTokenLifetime);
|
||||
await userManager.UpdateAsync(user);
|
||||
|
||||
// Generate a new access token
|
||||
string accessToken = await accessTokenFactory.CreateAsync(user);
|
||||
|
||||
await SendOkAsync(
|
||||
new RefreshTokenResponse(accessToken, user.RefreshToken),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Socialize.Modules.Identity.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record RegisterRequest(
|
||||
string Email,
|
||||
string Password,
|
||||
string Name);
|
||||
|
||||
[PublicAPI]
|
||||
public record RegisterResponse(
|
||||
string Message);
|
||||
|
||||
[PublicAPI]
|
||||
public class RegisterHandler(
|
||||
UserManager userManager,
|
||||
EmailVerificationService emailVerificationService)
|
||||
: Endpoint<RegisterRequest, RegisterResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
AllowAnonymous();
|
||||
Post("/api/users/register");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
RegisterRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Check if the user already exists
|
||||
User? existingUser = await userManager.FindByEmailAsync(request.Email);
|
||||
if (existingUser is not null)
|
||||
{
|
||||
await SendStringAsync(
|
||||
"A user with this email already exists",
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Split the name into firstname and lastname (if provided)
|
||||
string[] nameParts = request.Name.Split(' ', 2);
|
||||
string firstname = nameParts[0];
|
||||
string lastname = nameParts.Length > 1 ? nameParts[1] : string.Empty;
|
||||
|
||||
// Create a new user
|
||||
User user = new()
|
||||
{
|
||||
UserName = request.Email,
|
||||
Email = request.Email,
|
||||
Firstname = firstname,
|
||||
Lastname = lastname,
|
||||
Alias = request.Name
|
||||
};
|
||||
|
||||
IdentityResult result = await userManager.CreateAsync(
|
||||
user,
|
||||
request.Password);
|
||||
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
await SendStringAsync(
|
||||
result.Errors.First().Description,
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await emailVerificationService.SendVerificationEmailAsync(user);
|
||||
|
||||
await SendOkAsync(
|
||||
new RegisterResponse("Registration successful! Please check your email to verify your account."),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Socialize.Modules.Identity.Services;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ResendVerificationRequest(
|
||||
string Email);
|
||||
|
||||
[PublicAPI]
|
||||
public record ResendVerificationResponse(
|
||||
string Message);
|
||||
|
||||
[PublicAPI]
|
||||
public class ResendVerificationHandler(
|
||||
EmailVerificationService emailWriter,
|
||||
UserManager userManager)
|
||||
: Endpoint<ResendVerificationRequest, ResendVerificationResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
AllowAnonymous();
|
||||
Post("/api/users/resend-verification");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ResendVerificationRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Find a user by email
|
||||
User? user = await userManager.FindByEmailAsync(request.Email);
|
||||
if (user is null)
|
||||
{
|
||||
// Don't reveal that the user doesn't exist
|
||||
await SendOkAsync(
|
||||
new ResendVerificationResponse(
|
||||
"If your email exists in our system, a verification link has been sent."),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the email is already confirmed
|
||||
if (user.EmailConfirmed)
|
||||
{
|
||||
await SendOkAsync(
|
||||
new ResendVerificationResponse("Your email is already verified. You can log in."),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await emailWriter.SendVerificationEmailAsync(user);
|
||||
|
||||
await SendOkAsync(
|
||||
new ResendVerificationResponse("If your email exists in our system, a verification link has been sent."),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record ResetPasswordRequest(
|
||||
string Email,
|
||||
string Token,
|
||||
string NewPassword);
|
||||
|
||||
[PublicAPI]
|
||||
public class ResetPasswordHandler(
|
||||
UserManager userManager)
|
||||
: Endpoint<ResetPasswordRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
AllowAnonymous();
|
||||
Post("/api/users/reset-password");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
ResetPasswordRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Find user by email
|
||||
User? user = await userManager.FindByEmailAsync(request.Email);
|
||||
if (user is null)
|
||||
{
|
||||
await SendStringAsync(
|
||||
"Invalid request",
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset password with token
|
||||
IdentityResult result = await userManager.ResetPasswordAsync(
|
||||
user,
|
||||
request.Token,
|
||||
request.NewPassword);
|
||||
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
await SendStringAsync(
|
||||
"Invalid or expired token",
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record SetPasswordRequest(
|
||||
string NewPassword);
|
||||
|
||||
[PublicAPI]
|
||||
public class SetPasswordHandler(
|
||||
UserManager userManager)
|
||||
: Endpoint<SetPasswordRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/users/set-password");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
SetPasswordRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Get current user id from claims
|
||||
string userId = User.GetUserId().ToString();
|
||||
|
||||
// Get user from database
|
||||
User? user = await userManager.FindByIdAsync(userId);
|
||||
if (user is null)
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string resetToken = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
IdentityResult result = await userManager.ResetPasswordAsync(user, resetToken, request.NewPassword);
|
||||
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
await SendStringAsync(
|
||||
result.Errors.First().Description,
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Web;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Socialize.Modules.Identity.Handlers;
|
||||
|
||||
[PublicAPI]
|
||||
public record VerifyEmailRequest(
|
||||
string UserId,
|
||||
string Token);
|
||||
|
||||
[PublicAPI]
|
||||
public record VerifyEmailResponse(
|
||||
string Message);
|
||||
|
||||
[PublicAPI]
|
||||
public class VerifyEmailHandler(
|
||||
UserManager userManager)
|
||||
: Endpoint<VerifyEmailRequest, VerifyEmailResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
AllowAnonymous();
|
||||
Get("/api/users/verify-email");
|
||||
Options(o => o.WithTags("Users"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
VerifyEmailRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Find user by ID
|
||||
User? user = await userManager.FindByIdAsync(request.UserId);
|
||||
if (user is null)
|
||||
{
|
||||
await SendStringAsync(
|
||||
"Invalid verification link",
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the token and confirm email
|
||||
string decoded = HttpUtility.UrlDecode(request.Token);
|
||||
string decodedWithPlus = request.Token.Replace(" ", "+");
|
||||
IdentityResult result = await userManager.ConfirmEmailAsync(user, decodedWithPlus);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
await SendStringAsync(
|
||||
"Invalid verification link or the link has expired",
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendOkAsync(
|
||||
new VerifyEmailResponse("Email verification successful! You can now log in."),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Socialize.Modules.Identity.Models;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Socialize.Modules.Identity;
|
||||
|
||||
public static class IdentityResultExtensions
|
||||
{
|
||||
public static Result ToApplicationResult(this IdentityResult result)
|
||||
{
|
||||
return result.Succeeded
|
||||
? Result.Success()
|
||||
: Result.Failure(result.Errors.Select(e => e.Description));
|
||||
}
|
||||
}
|
||||
49
backend/src/Socialize.Api/Modules/Identity/Models/Result.cs
Normal file
49
backend/src/Socialize.Api/Modules/Identity/Models/Result.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
namespace Socialize.Modules.Identity.Models;
|
||||
|
||||
public class Result(
|
||||
bool succeeded,
|
||||
IEnumerable<string> errors)
|
||||
{
|
||||
public bool Succeeded { get; init; } = succeeded;
|
||||
public string[] Errors { get; init; } = errors.ToArray();
|
||||
|
||||
public static Result Success()
|
||||
{
|
||||
return new Result(true, Array.Empty<string>());
|
||||
}
|
||||
|
||||
public static Result Failure(IEnumerable<string> errors)
|
||||
{
|
||||
return new Result(false, errors);
|
||||
}
|
||||
}
|
||||
|
||||
public class Result<T>(
|
||||
T? value,
|
||||
bool succeeded,
|
||||
IEnumerable<string> errors)
|
||||
{
|
||||
public bool Succeeded { get; init; } = succeeded;
|
||||
public string[] Errors { get; init; } = errors.ToArray();
|
||||
public T? Value { get; set; } = value;
|
||||
|
||||
public T GetValueOrDefault()
|
||||
{
|
||||
return Value ?? default(T)!;
|
||||
}
|
||||
|
||||
public string GetErrorsAsString()
|
||||
{
|
||||
return Errors.Length == 0 ? string.Empty : string.Join(", ", Errors);
|
||||
}
|
||||
|
||||
public static Result<T> Success(T value)
|
||||
{
|
||||
return new Result<T>(value, true, Array.Empty<string>());
|
||||
}
|
||||
|
||||
public static Result<T> Failure(T value, IEnumerable<string> errors)
|
||||
{
|
||||
return new Result<T>(value, false, errors);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Socialize.Modules.Identity.Models;
|
||||
|
||||
public class RoleModel
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
20
backend/src/Socialize.Api/Modules/Identity/Models/UserDto.cs
Normal file
20
backend/src/Socialize.Api/Modules/Identity/Models/UserDto.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace Socialize.Modules.Identity.Models;
|
||||
|
||||
public class UserDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public IList<string> UserRoles { get; init; } = [];
|
||||
public string? Persona { get; init; }
|
||||
public IList<Guid> AuthorizedWorkspaceIds { get; init; } = [];
|
||||
public IList<Guid> AuthorizedClientIds { get; init; } = [];
|
||||
public IList<Guid> AuthorizedProjectIds { get; init; } = [];
|
||||
public string Username { get; init; } = null!;
|
||||
public string? Alias { get; init; }
|
||||
public string? PortraitUrl { get; init; }
|
||||
public string? Firstname { get; init; }
|
||||
public string? Lastname { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public string? PhoneNumber { get; init; }
|
||||
public DateTime? BirthDate { get; init; }
|
||||
public string? Address { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Socialize.Modules.Identity.Models;
|
||||
|
||||
public class UserModel
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Username { get; init; } = null!;
|
||||
public string? Alias { get; init; }
|
||||
public string? PortraitUrl { get; init; }
|
||||
public string? Firstname { get; init; }
|
||||
public string? Lastname { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public string? PhoneNumber { get; init; }
|
||||
public DateTime? BirthDate { get; init; }
|
||||
public string? Address { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Security.Claims;
|
||||
using Socialize.Infrastructure.Security;
|
||||
using Socialize.Modules.Identity.Configuration;
|
||||
using Socialize.Modules.Identity.Contracts;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Socialize.Modules.Identity.Services;
|
||||
|
||||
public sealed class AccessTokenFactory(
|
||||
UserManager userManager,
|
||||
IOptionsSnapshot<JwtOptions> jwtOptions)
|
||||
{
|
||||
public async Task<string> CreateAsync(User user)
|
||||
{
|
||||
IList<string> roles = await userManager.GetRolesAsync(user);
|
||||
IList<Claim> claims = await userManager.GetClaimsAsync(user);
|
||||
|
||||
string persona = roles.Contains(KnownRoles.Manager, StringComparer.Ordinal)
|
||||
? KnownRoles.Manager
|
||||
: roles.Contains(KnownRoles.Client, StringComparer.Ordinal)
|
||||
? KnownRoles.Client
|
||||
: roles.Contains(KnownRoles.Provider, StringComparer.Ordinal)
|
||||
? KnownRoles.Provider
|
||||
: KnownRoles.WorkspaceMember;
|
||||
|
||||
List<Claim> tokenClaims = [.. claims, new Claim(KnownClaims.Persona, persona)];
|
||||
|
||||
return JwtTokenHelper.GenerateJwtToken(
|
||||
jwtOptions.Value.Lifetime,
|
||||
jwtOptions.Value.Issuer,
|
||||
jwtOptions.Value.Audience,
|
||||
jwtOptions.Value.Key,
|
||||
user.Id.ToString(),
|
||||
user.Email ?? string.Empty,
|
||||
user.Alias,
|
||||
user.Firstname ?? string.Empty,
|
||||
user.Lastname ?? string.Empty,
|
||||
user.PortraitUrl,
|
||||
roles,
|
||||
tokenClaims);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Web;
|
||||
using Socialize.Infrastructure.Configuration;
|
||||
using Socialize.Infrastructure.Emailer.Contracts;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Socialize.Modules.Identity.Services;
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class EmailVerificationService(
|
||||
IOptionsSnapshot<WebsiteOptions> options,
|
||||
UserManager userManager,
|
||||
IEmailSender emailSender)
|
||||
{
|
||||
public async Task SendVerificationEmailAsync(
|
||||
User user)
|
||||
{
|
||||
// Generate email confirmation token
|
||||
string token = await userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
string encodedToken = HttpUtility.UrlEncode(token);
|
||||
string verificationLink = $"{options.Value.FrontendBaseUrl}/verify-email?userId={user.Id}&token={encodedToken}";
|
||||
|
||||
// Send verification email
|
||||
await emailSender.SendEmailAsync(
|
||||
user.Email!,
|
||||
"Verify your email address",
|
||||
$"""
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
|
||||
<h1 style="color: #2c3e50; margin-bottom: 20px;">Welcome to Socialize!</h1>
|
||||
|
||||
<p style="font-size: 16px; line-height: 1.5; margin-bottom: 25px;">
|
||||
Please verify your email address by clicking the button below:
|
||||
</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href='{verificationLink}'
|
||||
style="background-color: #3498db;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);">
|
||||
Verify Email Address
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #7f8c8d; margin-top: 30px;">
|
||||
If you did not request this, please ignore this email.
|
||||
</p>
|
||||
|
||||
<p style="font-size: 14px; color: #7f8c8d; margin-top: 20px;">
|
||||
If the button doesn't work, you can copy and paste this link into your browser:
|
||||
<br>
|
||||
<a href='{verificationLink}' style="color: #3498db; word-break: break-all;">{verificationLink}</a>
|
||||
</p>
|
||||
</div>
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Socialize.Modules.Identity.Contracts;
|
||||
using Socialize.Modules.Identity.Data;
|
||||
|
||||
namespace Socialize.Modules.Identity.Services;
|
||||
|
||||
public sealed class UserLookup(
|
||||
UserManager userManager)
|
||||
: IUserLookup
|
||||
{
|
||||
public async Task<UserReference?> GetUserAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
User? user = await userManager.FindByIdAsync(userId.ToString());
|
||||
|
||||
return user is null
|
||||
? null
|
||||
: new UserReference(
|
||||
user.Id,
|
||||
user.Fullname,
|
||||
user.PortraitUrl);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user