Added components, authentication and authorization
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace Htmx.ApiDemo.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Simple user document stored in MongoDB.
|
||||
/// All property→field name mappings are declared explicitly via [BsonElement]
|
||||
/// and registered in Program.cs via BsonClassMap — no AutoMap() reflection.
|
||||
/// </summary>
|
||||
public sealed class AppUser
|
||||
{
|
||||
[BsonId]
|
||||
public ObjectId Id { get; set; } = ObjectId.GenerateNewId();
|
||||
|
||||
[BsonElement("email")]
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
/// <summary>Email.ToUpperInvariant() — used for case-insensitive lookups.</summary>
|
||||
[BsonElement("normalizedEmail")]
|
||||
public string NormalizedEmail { get; set; } = "";
|
||||
|
||||
[BsonElement("passwordHash")]
|
||||
public string PasswordHash { get; set; } = "";
|
||||
|
||||
[BsonElement("displayName")]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
[BsonElement("createdAtUtc")]
|
||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Htmx.ApiDemo.Data;
|
||||
|
||||
/// <summary>
|
||||
/// AOT-safe authentication service backed by MongoDB.
|
||||
/// No EF Core, no LINQ-to-SQL, no RelationalModel fully NativeAOT safe.
|
||||
/// IPasswordHasher is pure PBKDF2 crypto with no dynamic IL.
|
||||
/// </summary>
|
||||
public sealed class AuthService(
|
||||
MongoDbService mongo,
|
||||
IPasswordHasher<AppUser> passwordHasher,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
public async Task<(bool Success, string? Error)> RegisterAsync(
|
||||
string email, string password, string? displayName, CancellationToken ct = default)
|
||||
{
|
||||
var normalized = email.ToUpperInvariant();
|
||||
|
||||
if (await mongo.EmailExistsAsync(normalized, ct))
|
||||
return (false, "That email address is already registered.");
|
||||
|
||||
var user = new AppUser
|
||||
{
|
||||
Email = email,
|
||||
NormalizedEmail = normalized,
|
||||
DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(),
|
||||
CreatedAtUtc = DateTime.UtcNow,
|
||||
};
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, password);
|
||||
|
||||
await mongo.InsertAsync(user, ct);
|
||||
await SignInUserAsync(user);
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string? Error)> LoginAsync(
|
||||
string email, string password, CancellationToken ct = default)
|
||||
{
|
||||
var normalized = email.ToUpperInvariant();
|
||||
var user = await mongo.FindByNormalizedEmailAsync(normalized, ct);
|
||||
|
||||
if (user is null)
|
||||
return (false, "Invalid email or password.");
|
||||
|
||||
var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
||||
if (result == PasswordVerificationResult.Failed)
|
||||
return (false, "Invalid email or password.");
|
||||
|
||||
await SignInUserAsync(user);
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
public async Task SignOutAsync()
|
||||
{
|
||||
var ctx = httpContextAccessor.HttpContext
|
||||
?? throw new InvalidOperationException("HttpContext is not available.");
|
||||
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
private async Task SignInUserAsync(AppUser user)
|
||||
{
|
||||
var ctx = httpContextAccessor.HttpContext
|
||||
?? throw new InvalidOperationException("HttpContext is not available.");
|
||||
|
||||
List<Claim> claims =
|
||||
[
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Email),
|
||||
new Claim(ClaimTypes.Email, user.Email),
|
||||
];
|
||||
|
||||
if (!string.IsNullOrEmpty(user.DisplayName))
|
||||
claims.Add(new Claim("DisplayName", user.DisplayName));
|
||||
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace Htmx.ApiDemo.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Scoped service wrapping the AppUser MongoDB collection.
|
||||
/// All operations use MongoDB's native async API — no EF, no LINQ-to-SQL
|
||||
/// translation, no RelationalModel, fully NativeAOT safe.
|
||||
/// </summary>
|
||||
public sealed class MongoDbService
|
||||
{
|
||||
private readonly IMongoCollection<AppUser> _users;
|
||||
|
||||
public MongoDbService(IMongoClient client, IConfiguration configuration)
|
||||
{
|
||||
var db = client.GetDatabase(configuration["MongoDbName"] ?? "HtmxAppDb");
|
||||
_users = db.GetCollection<AppUser>("users");
|
||||
}
|
||||
|
||||
/// <summary>Ensures the unique index on NormalizedEmail exists (idempotent).</summary>
|
||||
public async Task EnsureIndexesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var indexKeys = Builders<AppUser>.IndexKeys.Ascending(u => u.NormalizedEmail);
|
||||
var indexOptions = new CreateIndexOptions { Unique = true, Name = "uq_normalizedEmail" };
|
||||
var model = new CreateIndexModel<AppUser>(indexKeys, indexOptions);
|
||||
await _users.Indexes.CreateOneAsync(model, cancellationToken: ct);
|
||||
}
|
||||
|
||||
/// <summary>Returns true if a user with the given normalised email already exists.</summary>
|
||||
public async Task<bool> EmailExistsAsync(string normalizedEmail, CancellationToken ct = default)
|
||||
{
|
||||
var filter = Builders<AppUser>.Filter.Eq(u => u.NormalizedEmail, normalizedEmail);
|
||||
return await _users.Find(filter).AnyAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Returns the user matching the normalised email, or null.</summary>
|
||||
public async Task<AppUser?> FindByNormalizedEmailAsync(string normalizedEmail, CancellationToken ct = default)
|
||||
{
|
||||
var filter = Builders<AppUser>.Filter.Eq(u => u.NormalizedEmail, normalizedEmail);
|
||||
return await _users.Find(filter).FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Inserts a new user document.</summary>
|
||||
public Task InsertAsync(AppUser user, CancellationToken ct = default) =>
|
||||
_users.InsertOneAsync(user, cancellationToken: ct);
|
||||
}
|
||||
Reference in New Issue
Block a user