Initial commit

This commit is contained in:
2026-06-04 06:24:56 +02:00
commit 282f4864c4
111 changed files with 8083 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
using MealPlan.Api.DTOs.Auth;
namespace MealPlan.Api.Services;
/// <summary>Outcome of an auth operation. Avoids throwing for expected failures (bad credentials, etc.).</summary>
public class AuthResult
{
public bool Succeeded { get; private init; }
public string? Error { get; private init; }
public TokenResponseDto? Tokens { get; private init; }
public static AuthResult Success(TokenResponseDto tokens) =>
new() { Succeeded = true, Tokens = tokens };
public static AuthResult Fail(string error) =>
new() { Succeeded = false, Error = error };
}

View File

@@ -0,0 +1,172 @@
using MealPlan.Api.Data;
using MealPlan.Api.DTOs.Auth;
using MealPlan.Api.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace MealPlan.Api.Services;
/// <summary>
/// Read-only authentication against the existing "AspNetUsers" table.
/// Password verification uses ASP.NET Core Identity's <see cref="IPasswordHasher{TUser}"/> so
/// it is compatible with hashes produced by ASP.NET Core Identity.
/// </summary>
public class AuthService : IAuthService
{
private readonly AppDbContext _db;
private readonly ITokenService _tokenService;
private readonly IRefreshTokenStore _refreshTokenStore;
private readonly IPasswordHasher<ApplicationUser> _passwordHasher;
private readonly ILogger<AuthService> _logger;
public AuthService(
AppDbContext db,
ITokenService tokenService,
IRefreshTokenStore refreshTokenStore,
IPasswordHasher<ApplicationUser> passwordHasher,
ILogger<AuthService> logger)
{
_db = db;
_tokenService = tokenService;
_refreshTokenStore = refreshTokenStore;
_passwordHasher = passwordHasher;
_logger = logger;
}
public async Task<AuthResult> RegisterAsync(RegisterRequestDto request, CancellationToken ct = default)
{
var normalizedEmail = request.Email.Trim().ToLowerInvariant();
var emailTaken = await _db.Users
.AsNoTracking()
.AnyAsync(u => u.Email != null && u.Email.ToLower() == normalizedEmail, ct);
if (emailTaken)
{
return AuthResult.Fail("An account with this email already exists.");
}
var user = new ApplicationUser
{
Id = Guid.NewGuid(),
Email = request.Email.Trim(),
UserName = string.IsNullOrWhiteSpace(request.UserName)
? request.Email.Trim()
: request.UserName.Trim(),
CreatedAt = DateTime.UtcNow,
IsActive = true,
};
user.PasswordHash = _passwordHasher.HashPassword(user, request.Password);
_db.Users.Add(user);
await _db.SaveChangesAsync(ct);
_logger.LogInformation("User {UserId} registered with email {Email}", user.Id, user.Email);
return AuthResult.Success(IssueTokens(user));
}
public async Task<AuthResult> LoginAsync(LoginRequestDto request, CancellationToken ct = default)
{
var normalizedEmail = request.Email.Trim();
var user = await _db.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Email != null && u.Email.ToLower() == normalizedEmail.ToLower(), ct);
// Uniform failure message to avoid user enumeration.
const string invalidCredentials = "Invalid email or password.";
if (user is null || string.IsNullOrEmpty(user.PasswordHash))
{
_logger.LogWarning("Login failed: user not found for {Email}", normalizedEmail);
return AuthResult.Fail(invalidCredentials);
}
if (!user.IsActive)
{
_logger.LogWarning("Login failed: inactive account {UserId}", user.Id);
return AuthResult.Fail("This account is disabled.");
}
var verification = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
if (verification == PasswordVerificationResult.Failed)
{
_logger.LogWarning("Login failed: bad password for {UserId}", user.Id);
return AuthResult.Fail(invalidCredentials);
}
_logger.LogInformation("User {UserId} logged in", user.Id);
return AuthResult.Success(IssueTokens(user));
}
public async Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct = default)
{
var existing = _refreshTokenStore.Get(refreshToken);
if (existing is null || !existing.IsActive)
{
_logger.LogWarning("Refresh failed: token missing or inactive");
return AuthResult.Fail("Invalid or expired refresh token.");
}
// Rotation: immediately invalidate the presented token.
_refreshTokenStore.Revoke(refreshToken);
if (!Guid.TryParse(existing.UserId, out var refreshUserId))
{
return AuthResult.Fail("Invalid or expired refresh token.");
}
var user = await _db.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == refreshUserId, ct);
if (user is null || !user.IsActive)
{
_logger.LogWarning("Refresh failed: user {UserId} missing or inactive", existing.UserId);
return AuthResult.Fail("Invalid or expired refresh token.");
}
return AuthResult.Success(IssueTokens(user));
}
public void Logout(string refreshToken) => _refreshTokenStore.Revoke(refreshToken);
public async Task<UserInfoDto?> GetUserInfoAsync(string userId, CancellationToken ct = default)
{
if (!Guid.TryParse(userId, out var parsedUserId))
{
return null;
}
var user = await _db.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == parsedUserId, ct);
if (user is null)
{
return null;
}
return new UserInfoDto
{
Id = user.Id.ToString(),
UserName = user.UserName,
Email = user.Email,
};
}
private TokenResponseDto IssueTokens(ApplicationUser user)
{
var accessToken = _tokenService.CreateAccessToken(user);
var refreshToken = _tokenService.CreateRefreshToken(user.Id.ToString());
return new TokenResponseDto
{
AccessToken = accessToken,
RefreshToken = refreshToken.Token,
ExpiresInSeconds = _tokenService.AccessTokenLifetimeSeconds,
TokenType = "Bearer",
};
}
}

View File

@@ -0,0 +1,18 @@
using MealPlan.Api.DTOs.Auth;
namespace MealPlan.Api.Services;
public interface IAuthService
{
Task<AuthResult> RegisterAsync(RegisterRequestDto request, CancellationToken ct = default);
Task<AuthResult> LoginAsync(LoginRequestDto request, CancellationToken ct = default);
/// <summary>Validates and rotates a refresh token, issuing a fresh token pair.</summary>
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken ct = default);
/// <summary>Revokes the supplied refresh token. Idempotent.</summary>
void Logout(string refreshToken);
Task<UserInfoDto?> GetUserInfoAsync(string userId, CancellationToken ct = default);
}

View File

@@ -0,0 +1,12 @@
using MealPlan.Api.DTOs.Recipe;
namespace MealPlan.Api.Services;
public interface IRecipeService
{
Task<IReadOnlyList<RecipeListItemDto>> GetRecipesAsync(int? category, string? search, CancellationToken ct = default);
Task<RecipeDetailDto?> GetRecipeByIdAsync(int id, CancellationToken ct = default);
Task<IReadOnlyList<CategoryCountDto>> GetCategoriesAsync(CancellationToken ct = default);
}

View File

@@ -0,0 +1,20 @@
using MealPlan.Api.Models;
namespace MealPlan.Api.Services;
/// <summary>
/// Stores issued refresh tokens. Backed by an in-memory implementation because the
/// existing database schema must not be modified and no migrations may be added.
/// </summary>
public interface IRefreshTokenStore
{
void Add(RefreshToken token);
RefreshToken? Get(string token);
/// <summary>Marks a token revoked. Returns the revoked token if it existed and was active.</summary>
RefreshToken? Revoke(string token);
/// <summary>Removes expired/revoked tokens to bound memory usage.</summary>
void RemoveExpired();
}

View File

@@ -0,0 +1,16 @@
using System.Security.Claims;
using MealPlan.Api.Models;
namespace MealPlan.Api.Services;
public interface ITokenService
{
/// <summary>Creates a signed JWT access token for the given user.</summary>
string CreateAccessToken(ApplicationUser user);
/// <summary>Generates and persists a new refresh token for the given user id.</summary>
RefreshToken CreateRefreshToken(string userId);
/// <summary>Access-token lifetime in seconds (exposed to clients for scheduling refresh).</summary>
int AccessTokenLifetimeSeconds { get; }
}

View File

@@ -0,0 +1,43 @@
using System.Collections.Concurrent;
using MealPlan.Api.Models;
namespace MealPlan.Api.Services;
/// <summary>
/// Thread-safe in-memory refresh token store registered as a singleton.
/// Note: tokens live only for the lifetime of the process. For multi-instance
/// deployments this should be swapped for a shared store (e.g. Redis), but it
/// satisfies the "do not modify the database" constraint of this project.
/// </summary>
public class InMemoryRefreshTokenStore : IRefreshTokenStore
{
private readonly ConcurrentDictionary<string, RefreshToken> _tokens = new();
public void Add(RefreshToken token) => _tokens[token.Token] = token;
public RefreshToken? Get(string token) =>
_tokens.TryGetValue(token, out var found) ? found : null;
public RefreshToken? Revoke(string token)
{
if (!_tokens.TryGetValue(token, out var found))
{
return null;
}
var wasActive = found.IsActive;
found.IsRevoked = true;
return wasActive ? found : null;
}
public void RemoveExpired()
{
foreach (var kvp in _tokens)
{
if (kvp.Value.IsRevoked || DateTime.UtcNow >= kvp.Value.ExpiresAtUtc)
{
_tokens.TryRemove(kvp.Key, out _);
}
}
}
}

View File

@@ -0,0 +1,17 @@
namespace MealPlan.Api.Services;
/// <summary>Strongly-typed JWT settings bound from the "Jwt" configuration section / env vars.</summary>
public class JwtOptions
{
public const string SectionName = "Jwt";
public string Secret { get; set; } = string.Empty;
public string Issuer { get; set; } = string.Empty;
public string Audience { get; set; } = string.Empty;
/// <summary>Access-token lifetime in minutes. Defaults to 15 per the security spec.</summary>
public int AccessTokenMinutes { get; set; } = 15;
/// <summary>Refresh-token lifetime in days. Defaults to 7 per the security spec.</summary>
public int RefreshTokenDays { get; set; } = 7;
}

View File

@@ -0,0 +1,107 @@
using MealPlan.Api.Data;
using MealPlan.Api.DTOs.Recipe;
using MealPlan.Api.Models;
using Microsoft.EntityFrameworkCore;
namespace MealPlan.Api.Services;
/// <summary>
/// Read-only recipe queries. All filtering is done with EF Core LINQ (parameterized);
/// no raw SQL is used anywhere.
/// </summary>
public class RecipeService : IRecipeService
{
private readonly AppDbContext _db;
public RecipeService(AppDbContext db) => _db = db;
public async Task<IReadOnlyList<RecipeListItemDto>> GetRecipesAsync(
int? category, string? search, CancellationToken ct = default)
{
var query = _db.Recipes.AsNoTracking().Where(r => r.IsActive);
if (category is >= 0 and <= 3)
{
query = query.Where(r => r.MealCategory == category);
}
if (!string.IsNullOrWhiteSpace(search))
{
var term = search.Trim();
query = query.Where(r => EF.Functions.ILike(r.Name, $"%{term}%"));
}
return await query
.OrderBy(r => r.Name)
.Select(r => new RecipeListItemDto
{
Id = r.Id,
Name = r.Name,
MealCategory = r.MealCategory,
Calories = r.Calories,
PrepTimeMinutes = r.PrepTimeMinutes,
})
.ToListAsync(ct);
}
public async Task<RecipeDetailDto?> GetRecipeByIdAsync(int id, CancellationToken ct = default)
{
return await _db.Recipes
.AsNoTracking()
.Where(r => r.Id == id && r.IsActive)
.Select(r => new RecipeDetailDto
{
Id = r.Id,
Name = r.Name,
MealCategory = r.MealCategory,
Calories = r.Calories,
Protein = r.Protein,
Fat = r.Fat,
Carbs = r.Carbs,
PrepTimeMinutes = r.PrepTimeMinutes,
Ingredients = r.Ingredients
.OrderBy(i => i.SortOrder)
.ThenBy(i => i.Id)
.Select(i => new IngredientDto
{
Id = i.Id,
Name = i.Name,
AmountGrams = i.AmountGrams,
Unit = i.Unit,
SortOrder = i.SortOrder,
})
.ToList(),
Steps = r.Steps
.OrderBy(s => s.StepNumber)
.Select(s => new RecipeStepDto
{
Id = s.Id,
StepNumber = s.StepNumber,
Description = s.Description,
})
.ToList(),
})
.FirstOrDefaultAsync(ct);
}
public async Task<IReadOnlyList<CategoryCountDto>> GetCategoriesAsync(CancellationToken ct = default)
{
var counts = await _db.Recipes
.AsNoTracking()
.Where(r => r.IsActive)
.GroupBy(r => r.MealCategory)
.Select(g => new { Category = g.Key, Count = g.Count() })
.ToListAsync(ct);
// Always return all four categories, even when a category has zero recipes.
return Enum.GetValues<MealCategory>()
.Select(category => new CategoryCountDto
{
Category = (int)category,
Name = category.ToString(),
Count = counts.FirstOrDefault(c => c.Category == (int)category)?.Count ?? 0,
})
.OrderBy(c => c.Category)
.ToList();
}
}

View File

@@ -0,0 +1,35 @@
namespace MealPlan.Api.Services;
/// <summary>
/// Periodically purges expired and revoked refresh tokens from the in-memory store.
/// </summary>
public class RefreshTokenCleanupService : BackgroundService
{
private readonly IRefreshTokenStore _store;
private readonly ILogger<RefreshTokenCleanupService> _logger;
public RefreshTokenCleanupService(
IRefreshTokenStore store,
ILogger<RefreshTokenCleanupService> logger)
{
_store = store;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
_store.RemoveExpired();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Refresh token cleanup failed");
}
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}
}

View File

@@ -0,0 +1,82 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using MealPlan.Api.Models;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace MealPlan.Api.Services;
/// <summary>
/// Issues JWT access tokens and rotating refresh tokens.
/// Refresh tokens are cryptographically random opaque strings tracked in <see cref="IRefreshTokenStore"/>.
/// </summary>
public class TokenService : ITokenService
{
private readonly JwtOptions _options;
private readonly IRefreshTokenStore _refreshTokenStore;
public TokenService(IOptions<JwtOptions> options, IRefreshTokenStore refreshTokenStore)
{
_options = options.Value;
_refreshTokenStore = refreshTokenStore;
}
public int AccessTokenLifetimeSeconds => _options.AccessTokenMinutes * 60;
public string CreateAccessToken(ApplicationUser user)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var userId = user.Id.ToString();
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, userId),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new(ClaimTypes.NameIdentifier, userId),
};
if (!string.IsNullOrWhiteSpace(user.Email))
{
claims.Add(new Claim(JwtRegisteredClaimNames.Email, user.Email));
}
if (!string.IsNullOrWhiteSpace(user.UserName))
{
claims.Add(new Claim(ClaimTypes.Name, user.UserName));
}
var token = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
notBefore: DateTime.UtcNow,
expires: DateTime.UtcNow.AddMinutes(_options.AccessTokenMinutes),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public RefreshToken CreateRefreshToken(string userId)
{
var token = new RefreshToken
{
Token = GenerateSecureToken(),
UserId = userId,
CreatedAtUtc = DateTime.UtcNow,
ExpiresAtUtc = DateTime.UtcNow.AddDays(_options.RefreshTokenDays),
IsRevoked = false,
};
_refreshTokenStore.Add(token);
return token;
}
private static string GenerateSecureToken()
{
var bytes = RandomNumberGenerator.GetBytes(64);
return Convert.ToBase64String(bytes);
}
}