Skip to content
Merged
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
42 changes: 42 additions & 0 deletions src/EventForge.Api/Authentication/AuthModels.cs
Original file line number Diff line number Diff line change
@@ -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);
}
36 changes: 36 additions & 0 deletions src/EventForge.Api/Authentication/JwtTokenService.cs
Original file line number Diff line number Diff line change
@@ -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<JwtOptions> 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);
}
}
55 changes: 55 additions & 0 deletions src/EventForge.Api/Authentication/SeedAdminService.cs
Original file line number Diff line number Diff line change
@@ -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<UserDocument> users,
IOptions<SeedAdminOptions> options,
ILogger<SeedAdminService> 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<UserDocument>.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;
}
25 changes: 25 additions & 0 deletions src/EventForge.Api/Configuration/JwtOptions.cs
Original file line number Diff line number Diff line change
@@ -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";
}
107 changes: 107 additions & 0 deletions src/EventForge.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
@@ -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<UserDocument> users,
JwtTokenService tokenService) : ControllerBase
{
[HttpPost("register")]
[AllowAnonymous]
public async Task<ActionResult<AuthResponse>> 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<UserDocument>.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<ActionResult<AuthResponse>> Login(
LoginRequest request,
CancellationToken cancellationToken)
{
var email = NormalizeEmail(request.Email);
var user = await users.FindOneAsync(
Builders<UserDocument>.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<ActionResult<UserProfile>> 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();
}
53 changes: 53 additions & 0 deletions src/EventForge.Api/Controllers/UsersController.cs
Original file line number Diff line number Diff line change
@@ -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<UserDocument> users) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<IReadOnlyList<UserProfile>>> List(CancellationToken cancellationToken)
{
var documents = await users.ListAsync(
sort: Builders<UserDocument>.Sort.Ascending(user => user.Email),
cancellationToken: cancellationToken);

return Ok(documents.Select(user => user.ToProfile()).ToArray());
}

[HttpGet("{id}")]
public async Task<ActionResult<UserProfile>> 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<ActionResult<UserProfile>> 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());
}
}
23 changes: 23 additions & 0 deletions src/EventForge.Api/Data/MongoDocument.cs
Original file line number Diff line number Diff line change
@@ -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;
}
26 changes: 26 additions & 0 deletions src/EventForge.Api/Data/MongoIndexInitializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using EventForge.Api.Models;
using MongoDB.Driver;

namespace EventForge.Api.Data;

public sealed class MongoIndexInitializer(
MongoDatabaseProvider databaseProvider,
ILogger<MongoIndexInitializer> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
var users = databaseProvider.Database.GetCollection<UserDocument>("users");
var keys = Builders<UserDocument>.IndexKeys.Ascending(user => user.Email);
await users.Indexes.CreateOneAsync(
new CreateIndexModel<UserDocument>(keys, new CreateIndexOptions
{
Name = "ux_users_email",
Unique = true
}),
cancellationToken: cancellationToken);

logger.LogInformation("MongoDB indexes initialized.");
}

public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
Loading