From 646f5b33fac15d93cacbc0b7a06b8cc90073a696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mertcan=20C=CC=A7ezik?= Date: Wed, 19 Aug 2026 15:45:45 +0300 Subject: [PATCH] feat: add JWT authentication and role authorization --- .../Authentication/AuthModels.cs | 42 +++++++ .../Authentication/JwtTokenService.cs | 36 ++++++ .../Authentication/SeedAdminService.cs | 55 +++++++++ .../Configuration/JwtOptions.cs | 25 ++++ .../Controllers/AuthController.cs | 107 ++++++++++++++++++ .../Controllers/UsersController.cs | 53 +++++++++ src/EventForge.Api/Data/MongoDocument.cs | 23 ++++ .../Data/MongoIndexInitializer.cs | 26 +++++ src/EventForge.Api/Data/MongoRepository.cs | 88 ++++++++++++++ src/EventForge.Api/Models/RoleNames.cs | 15 +++ src/EventForge.Api/Models/UserDocument.cs | 23 ++++ src/EventForge.Api/Program.cs | 45 ++++++++ src/EventForge.Api/appsettings.json | 8 ++ 13 files changed, 546 insertions(+) create mode 100644 src/EventForge.Api/Authentication/AuthModels.cs create mode 100644 src/EventForge.Api/Authentication/JwtTokenService.cs create mode 100644 src/EventForge.Api/Authentication/SeedAdminService.cs create mode 100644 src/EventForge.Api/Configuration/JwtOptions.cs create mode 100644 src/EventForge.Api/Controllers/AuthController.cs create mode 100644 src/EventForge.Api/Controllers/UsersController.cs create mode 100644 src/EventForge.Api/Data/MongoDocument.cs create mode 100644 src/EventForge.Api/Data/MongoIndexInitializer.cs create mode 100644 src/EventForge.Api/Data/MongoRepository.cs create mode 100644 src/EventForge.Api/Models/RoleNames.cs create mode 100644 src/EventForge.Api/Models/UserDocument.cs diff --git a/src/EventForge.Api/Authentication/AuthModels.cs b/src/EventForge.Api/Authentication/AuthModels.cs new file mode 100644 index 0000000..87f3b3b --- /dev/null +++ b/src/EventForge.Api/Authentication/AuthModels.cs @@ -0,0 +1,42 @@ +using EventForge.Api.Models; + +namespace EventForge.Api.Authentication; + +public sealed class RegisterRequest +{ + public string Email { get; init; } = string.Empty; + + public string Password { get; init; } = string.Empty; + + public string DisplayName { get; init; } = string.Empty; +} + +public sealed class LoginRequest +{ + public string Email { get; init; } = string.Empty; + + public string Password { get; init; } = string.Empty; +} + +public sealed class RoleUpdateRequest +{ + public string Role { get; init; } = string.Empty; +} + +public sealed record UserProfile( + string Id, + string Email, + string DisplayName, + string Role, + bool IsActive); + +public sealed record AuthResponse( + string AccessToken, + DateTime ExpiresAtUtc, + UserProfile User); + +public static class UserProfileMapper +{ + public static UserProfile ToProfile(this UserDocument user) + => new(user.Id, user.Email, user.DisplayName, user.Role, user.IsActive); +} diff --git a/src/EventForge.Api/Authentication/JwtTokenService.cs b/src/EventForge.Api/Authentication/JwtTokenService.cs new file mode 100644 index 0000000..d9262aa --- /dev/null +++ b/src/EventForge.Api/Authentication/JwtTokenService.cs @@ -0,0 +1,36 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using EventForge.Api.Configuration; +using EventForge.Api.Models; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace EventForge.Api.Authentication; + +public sealed class JwtTokenService(IOptions options) +{ + public (string Token, DateTime ExpiresAtUtc) CreateAccessToken(UserDocument user) + { + var settings = options.Value; + var expiresAtUtc = DateTime.UtcNow.AddMinutes(settings.AccessTokenMinutes); + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, user.Id), + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(ClaimTypes.Name, user.DisplayName), + new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.Role, user.Role) + }; + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.SigningKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var token = new JwtSecurityToken( + issuer: settings.Issuer, + audience: settings.Audience, + claims: claims, + expires: expiresAtUtc, + signingCredentials: credentials); + + return (new JwtSecurityTokenHandler().WriteToken(token), expiresAtUtc); + } +} diff --git a/src/EventForge.Api/Authentication/SeedAdminService.cs b/src/EventForge.Api/Authentication/SeedAdminService.cs new file mode 100644 index 0000000..36ffb51 --- /dev/null +++ b/src/EventForge.Api/Authentication/SeedAdminService.cs @@ -0,0 +1,55 @@ +using EventForge.Api.Configuration; +using EventForge.Api.Data; +using EventForge.Api.Models; +using Microsoft.Extensions.Options; +using MongoDB.Driver; + +namespace EventForge.Api.Authentication; + +public sealed class SeedAdminService( + IMongoRepository users, + IOptions options, + ILogger logger) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + var settings = options.Value; + if (string.IsNullOrWhiteSpace(settings.Email) && string.IsNullOrWhiteSpace(settings.Password)) + { + logger.LogInformation("Admin bootstrap is disabled because seed credentials are not configured."); + return; + } + + if (string.IsNullOrWhiteSpace(settings.Email) || string.IsNullOrWhiteSpace(settings.Password)) + { + throw new InvalidOperationException("SEED_ADMIN_EMAIL and SEED_ADMIN_PASSWORD must be configured together."); + } + + var email = settings.Email.Trim().ToLowerInvariant(); + var filter = Builders.Filter.Eq(user => user.Email, email); + var existing = await users.FindOneAsync(filter, cancellationToken); + if (existing is not null) + { + if (!string.Equals(existing.Role, RoleNames.Admin, StringComparison.Ordinal)) + { + existing.Role = RoleNames.Admin; + await users.ReplaceAsync(existing, cancellationToken); + } + + logger.LogInformation("Bootstrap admin {Email} is ready.", email); + return; + } + + await users.InsertAsync(new UserDocument + { + Email = email, + DisplayName = string.IsNullOrWhiteSpace(settings.DisplayName) ? "EventForge Admin" : settings.DisplayName.Trim(), + PasswordHash = BCrypt.Net.BCrypt.HashPassword(settings.Password), + Role = RoleNames.Admin + }, cancellationToken); + + logger.LogInformation("Bootstrap admin {Email} was created.", email); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/EventForge.Api/Configuration/JwtOptions.cs b/src/EventForge.Api/Configuration/JwtOptions.cs new file mode 100644 index 0000000..8a807cc --- /dev/null +++ b/src/EventForge.Api/Configuration/JwtOptions.cs @@ -0,0 +1,25 @@ +namespace EventForge.Api.Configuration; + +public sealed class JwtOptions +{ + public const string SectionName = "Jwt"; + + public string Issuer { get; set; } = "EventForge.Api"; + + public string Audience { get; set; } = "EventForge.Client"; + + public string SigningKey { get; set; } = string.Empty; + + public int AccessTokenMinutes { get; set; } = 30; +} + +public sealed class SeedAdminOptions +{ + public const string SectionName = "SeedAdmin"; + + public string Email { get; set; } = string.Empty; + + public string Password { get; set; } = string.Empty; + + public string DisplayName { get; set; } = "EventForge Admin"; +} diff --git a/src/EventForge.Api/Controllers/AuthController.cs b/src/EventForge.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..99bd6fb --- /dev/null +++ b/src/EventForge.Api/Controllers/AuthController.cs @@ -0,0 +1,107 @@ +using System.ComponentModel.DataAnnotations; +using System.Security.Claims; +using EventForge.Api.Authentication; +using EventForge.Api.Data; +using EventForge.Api.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MongoDB.Driver; + +namespace EventForge.Api.Controllers; + +[ApiController] +[Route("api/auth")] +public sealed class AuthController( + IMongoRepository users, + JwtTokenService tokenService) : ControllerBase +{ + [HttpPost("register")] + [AllowAnonymous] + public async Task> Register( + RegisterRequest request, + CancellationToken cancellationToken) + { + var email = NormalizeEmail(request.Email); + if (!new EmailAddressAttribute().IsValid(email)) + { + return Problem(statusCode: StatusCodes.Status400BadRequest, title: "Invalid email address."); + } + + if (request.Password.Length < 12) + { + return Problem(statusCode: StatusCodes.Status400BadRequest, title: "Password must contain at least 12 characters."); + } + + if (request.DisplayName.Trim().Length is < 2 or > 80) + { + return Problem(statusCode: StatusCodes.Status400BadRequest, title: "Display name must contain 2 to 80 characters."); + } + + var existing = await users.FindOneAsync( + Builders.Filter.Eq(user => user.Email, email), + cancellationToken); + if (existing is not null) + { + return Conflict(new ProblemDetails + { + Title = "Email is already registered.", + Status = StatusCodes.Status409Conflict + }); + } + + var user = await users.InsertAsync(new UserDocument + { + Email = email, + DisplayName = request.DisplayName.Trim(), + PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), + Role = RoleNames.Attendee + }, cancellationToken); + + return CreatedAtAction(nameof(Me), CreateResponse(user)); + } + + [HttpPost("login")] + [AllowAnonymous] + public async Task> Login( + LoginRequest request, + CancellationToken cancellationToken) + { + var email = NormalizeEmail(request.Email); + var user = await users.FindOneAsync( + Builders.Filter.Eq(candidate => candidate.Email, email), + cancellationToken); + + if (user is null || !user.IsActive || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) + { + return Unauthorized(new ProblemDetails + { + Title = "Invalid email or password.", + Status = StatusCodes.Status401Unauthorized + }); + } + + return Ok(CreateResponse(user)); + } + + [HttpGet("me")] + [Authorize] + public async Task> Me(CancellationToken cancellationToken) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) + { + return Unauthorized(); + } + + var user = await users.FindByIdAsync(userId, cancellationToken); + return user is null ? NotFound() : Ok(user.ToProfile()); + } + + private AuthResponse CreateResponse(UserDocument user) + { + var token = tokenService.CreateAccessToken(user); + return new AuthResponse(token.Token, token.ExpiresAtUtc, user.ToProfile()); + } + + private static string NormalizeEmail(string email) => email.Trim().ToLowerInvariant(); +} diff --git a/src/EventForge.Api/Controllers/UsersController.cs b/src/EventForge.Api/Controllers/UsersController.cs new file mode 100644 index 0000000..7a842c2 --- /dev/null +++ b/src/EventForge.Api/Controllers/UsersController.cs @@ -0,0 +1,53 @@ +using EventForge.Api.Authentication; +using EventForge.Api.Data; +using EventForge.Api.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MongoDB.Driver; + +namespace EventForge.Api.Controllers; + +[ApiController] +[Route("api/users")] +[Authorize(Roles = RoleNames.Admin)] +public sealed class UsersController(IMongoRepository users) : ControllerBase +{ + [HttpGet] + public async Task>> List(CancellationToken cancellationToken) + { + var documents = await users.ListAsync( + sort: Builders.Sort.Ascending(user => user.Email), + cancellationToken: cancellationToken); + + return Ok(documents.Select(user => user.ToProfile()).ToArray()); + } + + [HttpGet("{id}")] + public async Task> Get(string id, CancellationToken cancellationToken) + { + var user = await users.FindByIdAsync(id, cancellationToken); + return user is null ? NotFound() : Ok(user.ToProfile()); + } + + [HttpPatch("{id}/role")] + public async Task> UpdateRole( + string id, + RoleUpdateRequest request, + CancellationToken cancellationToken) + { + if (!RoleNames.All.Contains(request.Role)) + { + return Problem(statusCode: StatusCodes.Status400BadRequest, title: "Unknown role."); + } + + var user = await users.FindByIdAsync(id, cancellationToken); + if (user is null) + { + return NotFound(); + } + + user.Role = request.Role; + await users.ReplaceAsync(user, cancellationToken); + return Ok(user.ToProfile()); + } +} diff --git a/src/EventForge.Api/Data/MongoDocument.cs b/src/EventForge.Api/Data/MongoDocument.cs new file mode 100644 index 0000000..6aacc79 --- /dev/null +++ b/src/EventForge.Api/Data/MongoDocument.cs @@ -0,0 +1,23 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace EventForge.Api.Data; + +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class MongoCollectionAttribute(string name) : Attribute +{ + public string Name { get; } = name; +} + +public abstract class MongoDocument +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ObjectId.GenerateNewId().ToString(); + + [BsonElement("createdAtUtc")] + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + + [BsonElement("updatedAtUtc")] + public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/src/EventForge.Api/Data/MongoIndexInitializer.cs b/src/EventForge.Api/Data/MongoIndexInitializer.cs new file mode 100644 index 0000000..b00118e --- /dev/null +++ b/src/EventForge.Api/Data/MongoIndexInitializer.cs @@ -0,0 +1,26 @@ +using EventForge.Api.Models; +using MongoDB.Driver; + +namespace EventForge.Api.Data; + +public sealed class MongoIndexInitializer( + MongoDatabaseProvider databaseProvider, + ILogger logger) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + var users = databaseProvider.Database.GetCollection("users"); + var keys = Builders.IndexKeys.Ascending(user => user.Email); + await users.Indexes.CreateOneAsync( + new CreateIndexModel(keys, new CreateIndexOptions + { + Name = "ux_users_email", + Unique = true + }), + cancellationToken: cancellationToken); + + logger.LogInformation("MongoDB indexes initialized."); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/EventForge.Api/Data/MongoRepository.cs b/src/EventForge.Api/Data/MongoRepository.cs new file mode 100644 index 0000000..bcdc423 --- /dev/null +++ b/src/EventForge.Api/Data/MongoRepository.cs @@ -0,0 +1,88 @@ +using MongoDB.Driver; + +namespace EventForge.Api.Data; + +public interface IMongoRepository where T : MongoDocument +{ + IMongoCollection Collection { get; } + + Task FindByIdAsync(string id, CancellationToken cancellationToken = default); + + Task FindOneAsync(FilterDefinition filter, CancellationToken cancellationToken = default); + + Task> ListAsync( + FilterDefinition? filter = null, + SortDefinition? sort = null, + CancellationToken cancellationToken = default); + + Task InsertAsync(T document, CancellationToken cancellationToken = default); + + Task ReplaceAsync(T document, CancellationToken cancellationToken = default); + + Task DeleteAsync(string id, CancellationToken cancellationToken = default); +} + +public sealed class MongoRepository(MongoDatabaseProvider databaseProvider) : IMongoRepository + where T : MongoDocument +{ + private readonly FilterDefinitionBuilder _filters = Builders.Filter; + + public IMongoCollection Collection { get; } = databaseProvider.Database.GetCollection(GetCollectionName()); + + public Task FindByIdAsync(string id, CancellationToken cancellationToken = default) + => FindOneAsync(_filters.Eq(document => document.Id, id), cancellationToken); + + public async Task FindOneAsync(FilterDefinition filter, CancellationToken cancellationToken = default) + { + var result = await Collection.Find(filter).FirstOrDefaultAsync(cancellationToken); + return result; + } + + public async Task> ListAsync( + FilterDefinition? filter = null, + SortDefinition? sort = null, + CancellationToken cancellationToken = default) + { + var query = Collection.Find(filter ?? _filters.Empty); + if (sort is not null) + { + query = query.Sort(sort); + } + + return await query.ToListAsync(cancellationToken); + } + + public async Task InsertAsync(T document, CancellationToken cancellationToken = default) + { + document.CreatedAtUtc = DateTime.UtcNow; + document.UpdatedAtUtc = document.CreatedAtUtc; + await Collection.InsertOneAsync(document, cancellationToken: cancellationToken); + return document; + } + + public async Task ReplaceAsync(T document, CancellationToken cancellationToken = default) + { + document.UpdatedAtUtc = DateTime.UtcNow; + var result = await Collection.ReplaceOneAsync( + _filters.Eq(item => item.Id, document.Id), + document, + cancellationToken: cancellationToken); + + return result.MatchedCount > 0; + } + + public async Task DeleteAsync(string id, CancellationToken cancellationToken = default) + { + var result = await Collection.DeleteOneAsync(_filters.Eq(item => item.Id, id), cancellationToken); + return result.DeletedCount > 0; + } + + private static string GetCollectionName() + { + var attribute = typeof(T).GetCustomAttributes(typeof(MongoCollectionAttribute), false) + .OfType() + .SingleOrDefault(); + + return attribute?.Name ?? $"{typeof(T).Name.ToLowerInvariant()}s"; + } +} diff --git a/src/EventForge.Api/Models/RoleNames.cs b/src/EventForge.Api/Models/RoleNames.cs new file mode 100644 index 0000000..6b5ac9c --- /dev/null +++ b/src/EventForge.Api/Models/RoleNames.cs @@ -0,0 +1,15 @@ +namespace EventForge.Api.Models; + +public static class RoleNames +{ + public const string Admin = "Admin"; + public const string Organizer = "Organizer"; + public const string Attendee = "Attendee"; + + public static readonly IReadOnlySet All = new HashSet(StringComparer.OrdinalIgnoreCase) + { + Admin, + Organizer, + Attendee + }; +} diff --git a/src/EventForge.Api/Models/UserDocument.cs b/src/EventForge.Api/Models/UserDocument.cs new file mode 100644 index 0000000..1cc1c6d --- /dev/null +++ b/src/EventForge.Api/Models/UserDocument.cs @@ -0,0 +1,23 @@ +using EventForge.Api.Data; +using MongoDB.Bson.Serialization.Attributes; + +namespace EventForge.Api.Models; + +[MongoCollection("users")] +public sealed class UserDocument : MongoDocument +{ + [BsonElement("email")] + public string Email { get; set; } = string.Empty; + + [BsonElement("displayName")] + public string DisplayName { get; set; } = string.Empty; + + [BsonElement("passwordHash")] + public string PasswordHash { get; set; } = string.Empty; + + [BsonElement("role")] + public string Role { get; set; } = RoleNames.Attendee; + + [BsonElement("isActive")] + public bool IsActive { get; set; } = true; +} diff --git a/src/EventForge.Api/Program.cs b/src/EventForge.Api/Program.cs index 38de70b..46fc77c 100644 --- a/src/EventForge.Api/Program.cs +++ b/src/EventForge.Api/Program.cs @@ -1,10 +1,14 @@ +using System.Text; using DotNetEnv; +using EventForge.Api.Authentication; using EventForge.Api.Configuration; using EventForge.Api.Data; using EventForge.Api.Health; using EventForge.Api.Infrastructure; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; using StackExchange.Redis; Env.TraversePath().Load(); @@ -13,6 +17,11 @@ builder.Configuration.AddInMemoryCollection(EnvironmentConfiguration.BuildOverrides()); +var jwtSettings = builder.Configuration.GetSection(JwtOptions.SectionName).Get() ?? new JwtOptions(); +var signingKey = string.IsNullOrWhiteSpace(jwtSettings.SigningKey) + ? "invalid-signing-key-placeholder-for-options-validation-only" + : jwtSettings.SigningKey; + builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddOpenApi(); @@ -32,6 +41,16 @@ "Redis:ConnectionString is required.") .ValidateOnStart(); +builder.Services.AddOptions() + .BindConfiguration(JwtOptions.SectionName) + .Validate(options => !string.IsNullOrWhiteSpace(options.Issuer), "Jwt:Issuer is required.") + .Validate(options => !string.IsNullOrWhiteSpace(options.Audience), "Jwt:Audience is required.") + .Validate(options => options.SigningKey.Length >= 32, "Jwt:SigningKey must contain at least 32 characters.") + .Validate(options => options.AccessTokenMinutes is > 0 and <= 1440, "Jwt:AccessTokenMinutes must be between 1 and 1440.") + .ValidateOnStart(); + +builder.Services.AddOptions().BindConfiguration(SeedAdminOptions.SectionName); + builder.Services.AddSingleton(); builder.Services.AddSingleton(serviceProvider => { @@ -42,6 +61,29 @@ return ConnectionMultiplexer.Connect(configuration); }); builder.Services.AddSingleton(); +builder.Services.AddSingleton(typeof(IMongoRepository<>), typeof(MongoRepository<>)); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.RequireHttpsMetadata = !builder.Environment.IsDevelopment(); + options.SaveToken = false; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwtSettings.Issuer, + ValidateAudience = true, + ValidAudience = jwtSettings.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30) + }; + }); +builder.Services.AddAuthorization(); builder.Services.AddHealthChecks() .AddCheck("mongodb") .AddCheck("redis"); @@ -66,6 +108,9 @@ }); app.MapHealthChecks("/health/ready"); +app.UseAuthentication(); +app.UseAuthorization(); + app.MapControllers(); app.Run(); diff --git a/src/EventForge.Api/appsettings.json b/src/EventForge.Api/appsettings.json index 79250f4..9fa2418 100644 --- a/src/EventForge.Api/appsettings.json +++ b/src/EventForge.Api/appsettings.json @@ -13,5 +13,13 @@ "ConnectionString": "localhost:6379", "InstanceName": "eventforge:" }, + "Jwt": { + "Issuer": "EventForge.Api", + "Audience": "EventForge.Client", + "AccessTokenMinutes": 30 + }, + "SeedAdmin": { + "DisplayName": "EventForge Admin" + }, "AllowedHosts": "*" }