Reorganize into monorepo layout, move mitarbeiter-app to legacy reference

Consolidates the previously separate omsorgapp and omsorgCore repos
(each had their own nested .git with GitHub history) plus the old
root-level website/mitarbeiter-app into a single monorepo, matching
the structure already documented in the root CLAUDE.md. Also moves
the PHP employee app aside as omsorgWeb/mitarbeiter-app-legacy/ to
serve as a template for a ground-up rewrite.

Fixes .gitignore in the same pass: the config-secrets/uploads/data
patterns were unanchored (relative to repo root, not depth-agnostic),
so they silently stopped matching once the app moved under omsorgWeb/.
Patterns are now **/-prefixed and cover both mitarbeiter-app and
mitarbeiter-app-legacy, keeping DB/SMTP credentials and uploaded
employee documents out of version control.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix Kemmler
2026-08-07 14:21:37 +02:00
co-authored by Claude Sonnet 5
parent 8beb0fcf52
commit b6c1389c55
355 changed files with 24348 additions and 361 deletions
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
@@ -0,0 +1,19 @@
namespace OmsorgCore.Api.Contracts;
public record CreateEmployeeRequest(
string FirstName,
string LastName,
DateOnly? DateOfBirth,
string? Street,
string? PostalCode,
string? City,
string? Country,
string? PhoneNumber,
string? Email,
DateOnly? EntryDate,
DateOnly? ExitDate,
string? EmergencyContactName,
string? EmergencyContactPhone,
string? EmergencyContactRelation,
string? EmploymentType,
string? Qualification);
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record CreateRoleRequest(string Name);
@@ -0,0 +1,10 @@
namespace OmsorgCore.Api.Contracts;
/// <summary>Mode: "Invite" (Einladungsmail mit PIN, Mitarbeiter setzt eigenes Passwort) oder "Direct" (Admin vergibt InitialPassword direkt, MustChangePassword erzwingt Wechsel).</summary>
public record CreateUserRequest(
Guid EmployeeId,
string Username,
Guid RoleId,
string Mode,
string? InitialPassword,
int? PinValidityDays);
@@ -0,0 +1,22 @@
namespace OmsorgCore.Api.Contracts;
public record EmployeeResponse(
Guid Id,
string FirstName,
string LastName,
string Status,
DateOnly? DateOfBirth,
string? Street,
string? PostalCode,
string? City,
string? Country,
string? PhoneNumber,
string? Email,
DateOnly? EntryDate,
DateOnly? ExitDate,
string? EmergencyContactName,
string? EmergencyContactPhone,
string? EmergencyContactRelation,
string? EmploymentType,
string? Qualification,
string? AvatarFileName);
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record ForgotPasswordRequestRequest(string Username);
@@ -0,0 +1,4 @@
namespace OmsorgCore.Api.Contracts;
/// <summary>"sent" | "cannot_reset" - siehe AuthController.ForgotPasswordRequest für die Anti-Enumeration-Abwägung.</summary>
public record ForgotPasswordRequestResponse(string Status);
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record ForgotPasswordResetRequest(string ResetToken, string NewPassword);
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record ForgotPasswordVerifyRequest(string Username, string Pin);
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record ForgotPasswordVerifyResponse(string ResetToken);
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record LoginRequest(string Username, string Password);
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt, bool MustChangePassword);
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record LogoutRequest(string RefreshToken);
@@ -0,0 +1,13 @@
namespace OmsorgCore.Api.Contracts;
public record MeResponse(
Guid UserId,
string Username,
string Role,
IReadOnlyList<PermissionDto> Permissions,
Guid? EmployeeId,
string? FirstName,
string? LastName,
string? Email,
string? PhoneNumber,
string? AvatarFileName);
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record PagedResponse<T>(IReadOnlyList<T> Items, int TotalCount, int Page, int PageSize);
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record PasswordResetTemplateResponse(string Subject, string BodyTemplate);
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record PermissionDto(string Module, string Action);
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record RefreshRequest(string RefreshToken);
@@ -0,0 +1,4 @@
namespace OmsorgCore.Api.Contracts;
/// <summary>Mode: "Invite" (neuer Einladungs-PIN) oder "Direct" (Admin vergibt InitialPassword direkt, MustChangePassword erzwingt Wechsel).</summary>
public record ResetUserPasswordRequest(string Mode, string? InitialPassword, int? PinValidityDays);
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record RoleResponse(Guid Id, string Name);
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record SendTestEmailRequest(string ToAddress, string? Subject, string? Body);
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record SessionResponse(Guid Id, string Username, DateTime CreatedAt, DateTime ExpiresAt);
@@ -0,0 +1,21 @@
namespace OmsorgCore.Api.Contracts;
public record UpdateEmployeeRequest(
string FirstName,
string LastName,
string Status,
DateOnly? DateOfBirth,
string? Street,
string? PostalCode,
string? City,
string? Country,
string? PhoneNumber,
string? Email,
DateOnly? EntryDate,
DateOnly? ExitDate,
string? EmergencyContactName,
string? EmergencyContactPhone,
string? EmergencyContactRelation,
string? EmploymentType,
string? Qualification,
string? AvatarFileName);
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record UpdateUserRequest(Guid RoleId, bool IsActive);
@@ -0,0 +1,3 @@
namespace OmsorgCore.Api.Contracts;
public record UserResponse(Guid Id, string Username, Guid? EmployeeId, string RoleName, bool IsActive, bool MustChangePassword);
@@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using OmsorgCore.Api.Contracts;
using OmsorgCore.Api.Security;
using OmsorgCore.Application.Abstractions;
using OmsorgCore.Domain.Enums;
using OmsorgCore.Email;
namespace OmsorgCore.Api.Controllers;
/// <summary>
/// Debug-/Admin-Funktion, um den konfigurierten Mail-Versand zu testen (siehe omsorgapp Debug-Seite).
/// Sendet nur eine Testmail über den registrierten IEmailSender, mit vom Admin editierbarem Betreff/Text
/// (vorbefüllt aus der echten Passwort-Reset-Vorlage) - SMTP-Zugangsdaten selbst verlassen den Server
/// dabei zu keinem Zeitpunkt.
/// </summary>
[ApiController]
[Authorize]
[Route("api/admin/email")]
public class AdminEmailController : ControllerBase
{
private readonly IEmailSender _emailSender;
private readonly EmailOptions _emailOptions;
public AdminEmailController(IEmailSender emailSender, IOptions<EmailOptions> emailOptions)
{
_emailSender = emailSender;
_emailOptions = emailOptions.Value;
}
[HttpGet("password-reset-template")]
[RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
public ActionResult<PasswordResetTemplateResponse> GetPasswordResetTemplate()
{
var template = _emailOptions.PasswordResetTemplate;
return Ok(new PasswordResetTemplateResponse(template.Subject, template.BodyTemplate));
}
[HttpPost("test-send")]
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
public async Task<IActionResult> TestSend(SendTestEmailRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.ToAddress))
{
return BadRequest(new { error = "to_address_required" });
}
var template = _emailOptions.PasswordResetTemplate;
var subject = string.IsNullOrWhiteSpace(request.Subject) ? template.Subject : request.Subject;
var body = string.IsNullOrWhiteSpace(request.Body) ? template.BodyTemplate : request.Body;
// Testmail nutzt echte Platzhalter-Ersetzung (mit Beispiel-Werten), damit sie genau zeigt,
// was ein Nutzer im echten Reset-Flow bekommen würde.
var values = new Dictionary<string, string>
{
["RESET_PIN"] = "123456",
["RESET_PIN_EXPIRY_MINUTES"] = _emailOptions.PinExpiryMinutes.ToString()
};
subject = EmailTemplateRenderer.Render(subject, values);
body = EmailTemplateRenderer.Render(body, values);
await _emailSender.SendAsync(new EmailMessage(request.ToAddress, subject, body), cancellationToken);
return NoContent();
}
}
@@ -0,0 +1,49 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OmsorgCore.Api.Contracts;
using OmsorgCore.Api.Security;
using OmsorgCore.Application.Services;
using OmsorgCore.Domain.Enums;
namespace OmsorgCore.Api.Controllers;
/// <summary>
/// Debug-/Admin-Sicht auf aktive Sessions (siehe omsorgapp Debug-Seite). Nur für Rollen mit
/// UserManagement-Rechten (per Default: Administrator, siehe DbSeeder).
/// </summary>
[ApiController]
[Authorize]
[Route("api/admin/sessions")]
public class AdminSessionsController : ControllerBase
{
private readonly ISessionAdminService _sessionAdminService;
public AdminSessionsController(ISessionAdminService sessionAdminService)
{
_sessionAdminService = sessionAdminService;
}
[HttpGet]
[RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
public async Task<ActionResult<IReadOnlyList<SessionResponse>>> GetAll(CancellationToken cancellationToken)
{
var sessions = await _sessionAdminService.GetActiveSessionsAsync(cancellationToken);
return Ok(sessions.Select(s => new SessionResponse(s.Id, s.Username, s.CreatedAt, s.ExpiresAt)));
}
[HttpPost("{id:guid}/revoke")]
[RequirePermission(ModuleType.UserManagement, PermissionAction.Delete)]
public async Task<IActionResult> Revoke(Guid id, CancellationToken cancellationToken)
{
await _sessionAdminService.RevokeSessionAsync(id, cancellationToken);
return NoContent();
}
[HttpPost("revoke-all")]
[RequirePermission(ModuleType.UserManagement, PermissionAction.Delete)]
public async Task<IActionResult> RevokeAll(CancellationToken cancellationToken)
{
await _sessionAdminService.RevokeAllSessionsAsync(cancellationToken);
return NoContent();
}
}
@@ -0,0 +1,163 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OmsorgCore.Api.Contracts;
using OmsorgCore.Application.Abstractions;
using OmsorgCore.Application.Services;
using OmsorgCore.Engine.Events;
namespace OmsorgCore.Api.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
private readonly IAuthService _authService;
private readonly ICurrentUserService _currentUserService;
private readonly IPasswordResetService _passwordResetService;
private readonly IUserService _userService;
private readonly IDomainEventDispatcher _dispatcher;
public AuthController(
IAuthService authService,
ICurrentUserService currentUserService,
IPasswordResetService passwordResetService,
IUserService userService,
IDomainEventDispatcher dispatcher)
{
_authService = authService;
_currentUserService = currentUserService;
_passwordResetService = passwordResetService;
_userService = userService;
_dispatcher = dispatcher;
}
[HttpPost("login")]
public async Task<ActionResult<LoginResponse>> Login(LoginRequest request, CancellationToken cancellationToken)
{
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var result = await _authService.LoginAsync(request.Username, request.Password, ipAddress, cancellationToken);
if (result.IsLockedOut)
{
return StatusCode(StatusCodes.Status429TooManyRequests, new { error = "too_many_attempts" });
}
if (!result.Success || result.Token is null || result.RefreshToken is null || result.ExpiresAt is null)
{
return Unauthorized();
}
return Ok(new LoginResponse(result.Token, result.RefreshToken, result.ExpiresAt.Value, result.MustChangePassword));
}
[HttpPost("refresh")]
public async Task<ActionResult<LoginResponse>> Refresh(RefreshRequest request, CancellationToken cancellationToken)
{
var result = await _authService.RefreshAsync(request.RefreshToken, cancellationToken);
if (!result.Success || result.Token is null || result.RefreshToken is null || result.ExpiresAt is null)
{
return Unauthorized();
}
return Ok(new LoginResponse(result.Token, result.RefreshToken, result.ExpiresAt.Value, result.MustChangePassword));
}
[HttpPost("logout")]
public async Task<IActionResult> Logout(LogoutRequest request, CancellationToken cancellationToken)
{
await _authService.RevokeAsync(request.RefreshToken, cancellationToken);
return NoContent();
}
[HttpGet("me")]
[Authorize]
public async Task<ActionResult<MeResponse>> Me(CancellationToken cancellationToken)
{
if (_currentUserService.UserId is not { } userId)
{
return Unauthorized();
}
var profile = await _authService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
{
return Unauthorized();
}
var permissions = profile.Permissions
.Select(p => new PermissionDto(p.Module.ToString(), p.Action.ToString()))
.ToList();
return Ok(new MeResponse(
profile.UserId,
profile.Username,
profile.RoleName,
permissions,
profile.EmployeeId,
profile.FirstName,
profile.LastName,
profile.Email,
profile.PhoneNumber,
profile.AvatarFileName));
}
[HttpPost("change-password")]
[Authorize]
public async Task<IActionResult> ChangePassword(ChangePasswordRequest request, CancellationToken cancellationToken)
{
if (_currentUserService.UserId is not { } userId)
{
return Unauthorized();
}
if (string.IsNullOrWhiteSpace(request.NewPassword) || request.NewPassword.Length < 8)
{
return BadRequest("NewPassword muss mindestens 8 Zeichen lang sein.");
}
var success = await _userService.ChangeOwnPasswordAsync(userId, request.CurrentPassword, request.NewPassword, cancellationToken);
return success ? NoContent() : Unauthorized();
}
[HttpPost("forgot-password/request")]
public async Task<ActionResult<ForgotPasswordRequestResponse>> ForgotPasswordRequest(
ForgotPasswordRequestRequest request, CancellationToken cancellationToken)
{
var result = await _passwordResetService.RequestResetAsync(request.Username, cancellationToken);
if (result.Status == PasswordResetRequestStatus.Sent && result.Email is not null && result.RawPin is not null)
{
await _dispatcher.DispatchAsync(
new PasswordResetRequestedEvent(result.Email, result.RawPin), cancellationToken);
}
var status = result.Status == PasswordResetRequestStatus.Sent ? "sent" : "cannot_reset";
return Ok(new ForgotPasswordRequestResponse(status));
}
[HttpPost("forgot-password/verify")]
public async Task<ActionResult<ForgotPasswordVerifyResponse>> ForgotPasswordVerify(
ForgotPasswordVerifyRequest request, CancellationToken cancellationToken)
{
var result = await _passwordResetService.VerifyCodeAsync(request.Username, request.Pin, cancellationToken);
if (!result.Success || result.ResetToken is null)
{
return Unauthorized(new { error = result.FailureReason ?? "invalid_or_expired" });
}
return Ok(new ForgotPasswordVerifyResponse(result.ResetToken));
}
[HttpPost("forgot-password/reset")]
public async Task<IActionResult> ForgotPasswordReset(
ForgotPasswordResetRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.NewPassword))
{
return Unauthorized();
}
var success = await _passwordResetService.ResetPasswordAsync(
request.ResetToken, request.NewPassword, cancellationToken);
return success ? NoContent() : Unauthorized();
}
}
@@ -0,0 +1,289 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OmsorgCore.Api.Contracts;
using OmsorgCore.Api.Security;
using OmsorgCore.Application.Services;
using OmsorgCore.Domain.Entities;
using OmsorgCore.Domain.Enums;
using OmsorgCore.Engine.Events;
namespace OmsorgCore.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/employees")]
public class EmployeesController : ControllerBase
{
private static readonly string[] AllowedEmploymentTypes =
{ "Vollzeit", "Teilzeit", "Minijob", "Aushilfe", "Praktikant", "Freiberuflich" };
private readonly IEmployeeService _employeeService;
private readonly IDomainEventDispatcher _dispatcher;
public EmployeesController(IEmployeeService employeeService, IDomainEventDispatcher dispatcher)
{
_employeeService = employeeService;
_dispatcher = dispatcher;
}
[HttpGet]
[RequirePermission(ModuleType.Employees, PermissionAction.View)]
public async Task<ActionResult<PagedResponse<EmployeeResponse>>> GetAll(
[FromQuery] string? search,
[FromQuery] string? status,
[FromQuery] string? employmentType,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default)
{
if (employmentType is not null && !AllowedEmploymentTypes.Contains(employmentType))
{
return BadRequest($"employmentType muss einer der folgenden Werte sein: {string.Join(", ", AllowedEmploymentTypes)}.");
}
page = Math.Max(page, 1);
pageSize = Math.Clamp(pageSize, 1, 100);
var (items, totalCount) = await _employeeService.GetPagedAsync(search, status, employmentType, page, pageSize, cancellationToken);
return Ok(new PagedResponse<EmployeeResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
}
[HttpGet("{id:guid}")]
[RequirePermission(ModuleType.Employees, PermissionAction.View)]
public async Task<ActionResult<EmployeeResponse>> GetById(Guid id, CancellationToken cancellationToken)
{
var employee = await _employeeService.GetByIdAsync(id, cancellationToken);
return employee is null ? NotFound() : Ok(ToResponse(employee));
}
[HttpPost]
[RequirePermission(ModuleType.Employees, PermissionAction.Create)]
public async Task<ActionResult<EmployeeResponse>> Create(CreateEmployeeRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.FirstName) || request.FirstName.Length > 200)
{
return BadRequest("FirstName ist erforderlich und darf maximal 200 Zeichen lang sein.");
}
if (string.IsNullOrWhiteSpace(request.LastName) || request.LastName.Length > 200)
{
return BadRequest("LastName ist erforderlich und darf maximal 200 Zeichen lang sein.");
}
if (request.PhoneNumber is { Length: > 50 })
{
return BadRequest("PhoneNumber darf maximal 50 Zeichen lang sein.");
}
if (request.Email is { Length: > 200 })
{
return BadRequest("Email darf maximal 200 Zeichen lang sein.");
}
if (request.EntryDate is not null && request.ExitDate is not null && request.ExitDate < request.EntryDate)
{
return BadRequest("ExitDate darf nicht vor EntryDate liegen.");
}
var addressError = ValidateAddressFields(request.Street, request.PostalCode, request.City, request.Country);
if (addressError is not null)
{
return BadRequest(addressError);
}
var stammdatenError = ValidateStammdatenFields(
request.EmergencyContactName,
request.EmergencyContactPhone,
request.EmergencyContactRelation,
request.EmploymentType,
request.Qualification);
if (stammdatenError is not null)
{
return BadRequest(stammdatenError);
}
var employee = new Employee
{
FirstName = request.FirstName,
LastName = request.LastName,
DateOfBirth = request.DateOfBirth,
Street = request.Street,
PostalCode = request.PostalCode,
City = request.City,
Country = request.Country,
PhoneNumber = request.PhoneNumber,
Email = request.Email,
EntryDate = request.EntryDate,
ExitDate = request.ExitDate,
EmergencyContactName = request.EmergencyContactName,
EmergencyContactPhone = request.EmergencyContactPhone,
EmergencyContactRelation = request.EmergencyContactRelation,
EmploymentType = request.EmploymentType,
Qualification = request.Qualification
};
var created = await _employeeService.CreateAsync(employee, cancellationToken);
await _dispatcher.DispatchAsync(new EmployeeCreatedEvent(created.Id), cancellationToken);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, ToResponse(created));
}
[HttpPut("{id:guid}")]
[RequirePermission(ModuleType.Employees, PermissionAction.Edit)]
public async Task<ActionResult<EmployeeResponse>> Update(Guid id, UpdateEmployeeRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.FirstName) || request.FirstName.Length > 200)
{
return BadRequest("FirstName ist erforderlich und darf maximal 200 Zeichen lang sein.");
}
if (string.IsNullOrWhiteSpace(request.LastName) || request.LastName.Length > 200)
{
return BadRequest("LastName ist erforderlich und darf maximal 200 Zeichen lang sein.");
}
if (string.IsNullOrWhiteSpace(request.Status) || request.Status.Length > 50)
{
return BadRequest("Status ist erforderlich und darf maximal 50 Zeichen lang sein.");
}
if (request.PhoneNumber is { Length: > 50 })
{
return BadRequest("PhoneNumber darf maximal 50 Zeichen lang sein.");
}
if (request.Email is { Length: > 200 })
{
return BadRequest("Email darf maximal 200 Zeichen lang sein.");
}
if (request.EntryDate is not null && request.ExitDate is not null && request.ExitDate < request.EntryDate)
{
return BadRequest("ExitDate darf nicht vor EntryDate liegen.");
}
var addressError = ValidateAddressFields(request.Street, request.PostalCode, request.City, request.Country);
if (addressError is not null)
{
return BadRequest(addressError);
}
var stammdatenError = ValidateStammdatenFields(
request.EmergencyContactName,
request.EmergencyContactPhone,
request.EmergencyContactRelation,
request.EmploymentType,
request.Qualification);
if (stammdatenError is not null)
{
return BadRequest(stammdatenError);
}
var updates = new Employee
{
FirstName = request.FirstName,
LastName = request.LastName,
Status = request.Status,
DateOfBirth = request.DateOfBirth,
Street = request.Street,
PostalCode = request.PostalCode,
City = request.City,
Country = request.Country,
PhoneNumber = request.PhoneNumber,
Email = request.Email,
EntryDate = request.EntryDate,
ExitDate = request.ExitDate,
EmergencyContactName = request.EmergencyContactName,
EmergencyContactPhone = request.EmergencyContactPhone,
EmergencyContactRelation = request.EmergencyContactRelation,
EmploymentType = request.EmploymentType,
Qualification = request.Qualification,
AvatarFileName = request.AvatarFileName
};
var updated = await _employeeService.UpdateAsync(id, updates, cancellationToken);
return updated is null ? NotFound() : Ok(ToResponse(updated));
}
private static string? ValidateAddressFields(string? street, string? postalCode, string? city, string? country)
{
if (street is { Length: > 200 })
{
return "Street darf maximal 200 Zeichen lang sein.";
}
if (postalCode is { Length: > 10 })
{
return "PostalCode darf maximal 10 Zeichen lang sein.";
}
if (city is { Length: > 100 })
{
return "City darf maximal 100 Zeichen lang sein.";
}
if (country is { Length: > 100 })
{
return "Country darf maximal 100 Zeichen lang sein.";
}
return null;
}
private static string? ValidateStammdatenFields(
string? emergencyContactName,
string? emergencyContactPhone,
string? emergencyContactRelation,
string? employmentType,
string? qualification)
{
if (emergencyContactName is { Length: > 200 })
{
return "EmergencyContactName darf maximal 200 Zeichen lang sein.";
}
if (emergencyContactPhone is { Length: > 50 })
{
return "EmergencyContactPhone darf maximal 50 Zeichen lang sein.";
}
if (emergencyContactRelation is { Length: > 100 })
{
return "EmergencyContactRelation darf maximal 100 Zeichen lang sein.";
}
if (employmentType is not null && !AllowedEmploymentTypes.Contains(employmentType))
{
return $"EmploymentType muss einer der folgenden Werte sein: {string.Join(", ", AllowedEmploymentTypes)}.";
}
if (qualification is { Length: > 500 })
{
return "Qualification darf maximal 500 Zeichen lang sein.";
}
return null;
}
private static EmployeeResponse ToResponse(Employee employee)
=> new(
employee.Id,
employee.FirstName,
employee.LastName,
employee.Status,
employee.DateOfBirth,
employee.Street,
employee.PostalCode,
employee.City,
employee.Country,
employee.PhoneNumber,
employee.Email,
employee.EntryDate,
employee.ExitDate,
employee.EmergencyContactName,
employee.EmergencyContactPhone,
employee.EmergencyContactRelation,
employee.EmploymentType,
employee.Qualification,
employee.AvatarFileName);
}
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OmsorgCore.Infrastructure.Persistence;
namespace OmsorgCore.Api.Controllers;
[ApiController]
[Route("api/health")]
public class HealthController : ControllerBase
{
private readonly OmsorgCoreDbContext _db;
public HealthController(OmsorgCoreDbContext db)
{
_db = db;
}
[HttpGet]
public async Task<IActionResult> Get(CancellationToken cancellationToken)
{
bool databaseReachable;
try
{
databaseReachable = await _db.Database.CanConnectAsync(cancellationToken);
}
catch
{
databaseReachable = false;
}
return Ok(new { status = "ok", databaseReachable });
}
}
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OmsorgCore.Api.Contracts;
using OmsorgCore.Api.Security;
using OmsorgCore.Application.Services;
using OmsorgCore.Domain.Enums;
namespace OmsorgCore.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/roles")]
public class RolesController : ControllerBase
{
private readonly IRoleService _roleService;
public RolesController(IRoleService roleService)
{
_roleService = roleService;
}
[HttpGet]
[RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
public async Task<ActionResult<IReadOnlyList<RoleResponse>>> GetAll(CancellationToken cancellationToken)
{
var roles = await _roleService.GetAllAsync(cancellationToken);
return Ok(roles.Select(r => new RoleResponse(r.Id, r.Name)).ToList());
}
[HttpPost]
[RequirePermission(ModuleType.UserManagement, PermissionAction.Create)]
public async Task<ActionResult<RoleResponse>> Create(CreateRoleRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100)
{
return BadRequest("Name ist erforderlich und darf maximal 100 Zeichen lang sein.");
}
var result = await _roleService.CreateAsync(request.Name, cancellationToken);
if (!result.Success)
{
return result.FailureReason switch
{
CreateRoleFailureReason.NameTaken => Conflict("Diese Rolle existiert bereits."),
_ => BadRequest("Name ist erforderlich.")
};
}
return CreatedAtAction(nameof(GetAll), new RoleResponse(result.Role!.Id, result.Role.Name));
}
}
@@ -0,0 +1,167 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OmsorgCore.Api.Contracts;
using OmsorgCore.Api.Security;
using OmsorgCore.Application.Abstractions;
using OmsorgCore.Application.Services;
using OmsorgCore.Domain.Enums;
using OmsorgCore.Engine.Events;
namespace OmsorgCore.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/users")]
public class UsersController : ControllerBase
{
private const int MinInitialPasswordLength = 8;
private const int DefaultPinValidityDays = 7;
private const int MinPinValidityDays = 1;
private const int MaxPinValidityDays = 30;
private readonly IUserService _userService;
private readonly IDomainEventDispatcher _dispatcher;
public UsersController(IUserService userService, IDomainEventDispatcher dispatcher)
{
_userService = userService;
_dispatcher = dispatcher;
}
[HttpGet]
[RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
public async Task<ActionResult<IReadOnlyList<UserResponse>>> GetAll(CancellationToken cancellationToken)
{
var users = await _userService.GetAllAsync(cancellationToken);
return Ok(users.Select(ToResponse).ToList());
}
[HttpPost]
[RequirePermission(ModuleType.UserManagement, PermissionAction.Create)]
public async Task<ActionResult<UserResponse>> Create(CreateUserRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Username) || request.Username.Length > 100)
{
return BadRequest("Username ist erforderlich und darf maximal 100 Zeichen lang sein.");
}
var (parseError, mode, pinValidity) = ParseModeAndPinValidity(request.Mode, request.PinValidityDays, request.InitialPassword);
if (parseError is not null)
{
return BadRequest(parseError);
}
var result = await _userService.CreateForEmployeeAsync(
request.EmployeeId, request.Username, request.RoleId, mode,
request.InitialPassword, pinValidity, cancellationToken);
if (!result.Success)
{
return result.FailureReason switch
{
CreateUserFailureReason.EmployeeNotFound => NotFound("Mitarbeiter nicht gefunden."),
CreateUserFailureReason.RoleNotFound => NotFound("Rolle nicht gefunden."),
CreateUserFailureReason.EmployeeAlreadyHasAccount => Conflict("Dieser Mitarbeiter hat bereits ein Konto."),
CreateUserFailureReason.UsernameTaken => Conflict("Dieser Username ist bereits vergeben."),
CreateUserFailureReason.EmployeeEmailMissing => BadRequest("Für den Einladungs-Modus braucht der Mitarbeiter eine hinterlegte E-Mail-Adresse."),
CreateUserFailureReason.InitialPasswordRequired => BadRequest("InitialPassword ist im Direct-Modus erforderlich."),
_ => BadRequest()
};
}
if (mode == UserCreationMode.Invite && result.Email is not null && result.RawInvitePin is not null)
{
await _dispatcher.DispatchAsync(
new UserInvitedEvent(result.Email, result.RawInvitePin, (int)pinValidity!.Value.TotalDays),
cancellationToken);
}
return CreatedAtAction(nameof(GetAll), new UserResponse(
result.UserId!.Value, request.Username, request.EmployeeId,
result.RoleName!, IsActive: true, MustChangePassword: mode == UserCreationMode.Direct));
}
[HttpPost("{id:guid}/reset-password")]
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
public async Task<IActionResult> ResetPassword(Guid id, ResetUserPasswordRequest request, CancellationToken cancellationToken)
{
var (parseError, mode, pinValidity) = ParseModeAndPinValidity(request.Mode, request.PinValidityDays, request.InitialPassword);
if (parseError is not null)
{
return BadRequest(parseError);
}
var result = await _userService.AdminResetPasswordAsync(id, mode, request.InitialPassword, pinValidity, cancellationToken);
if (!result.Success)
{
return result.FailureReason switch
{
AdminResetPasswordFailureReason.UserNotFound => NotFound("User nicht gefunden."),
AdminResetPasswordFailureReason.EmployeeEmailMissing => BadRequest("Für den Einladungs-Modus braucht der Mitarbeiter eine hinterlegte E-Mail-Adresse."),
AdminResetPasswordFailureReason.InitialPasswordRequired => BadRequest("InitialPassword ist im Direct-Modus erforderlich."),
_ => BadRequest()
};
}
if (mode == UserCreationMode.Invite && result.Email is not null && result.RawInvitePin is not null)
{
await _dispatcher.DispatchAsync(
new UserInvitedEvent(result.Email, result.RawInvitePin, (int)pinValidity!.Value.TotalDays),
cancellationToken);
}
return NoContent();
}
[HttpPut("{id:guid}")]
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
public async Task<IActionResult> Update(Guid id, UpdateUserRequest request, CancellationToken cancellationToken)
{
var result = await _userService.UpdateAsync(id, request.RoleId, request.IsActive, cancellationToken);
if (!result.Success)
{
return result.FailureReason switch
{
UpdateUserFailureReason.UserNotFound => NotFound("User nicht gefunden."),
UpdateUserFailureReason.RoleNotFound => NotFound("Rolle nicht gefunden."),
_ => BadRequest()
};
}
return NoContent();
}
private static (string? Error, UserCreationMode Mode, TimeSpan? PinValidity) ParseModeAndPinValidity(
string modeRaw, int? pinValidityDays, string? initialPassword)
{
if (!Enum.TryParse<UserCreationMode>(modeRaw, ignoreCase: true, out var mode))
{
return ("Mode muss 'Invite' oder 'Direct' sein.", default, null);
}
TimeSpan? pinValidity = null;
if (mode == UserCreationMode.Invite)
{
var days = pinValidityDays ?? DefaultPinValidityDays;
if (days < MinPinValidityDays || days > MaxPinValidityDays)
{
return ($"PinValidityDays muss zwischen {MinPinValidityDays} und {MaxPinValidityDays} liegen.", mode, null);
}
pinValidity = TimeSpan.FromDays(days);
}
if (mode == UserCreationMode.Direct
&& (initialPassword is null || initialPassword.Length < MinInitialPasswordLength))
{
return ($"InitialPassword ist im Direct-Modus erforderlich und muss mindestens {MinInitialPasswordLength} Zeichen lang sein.", mode, null);
}
return (null, mode, pinValidity);
}
private static UserResponse ToResponse(UserSummary summary)
=> new(summary.Id, summary.Username, summary.EmployeeId, summary.RoleName, summary.IsActive, summary.MustChangePassword);
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OmsorgCore.Application\OmsorgCore.Application.csproj" />
<ProjectReference Include="..\OmsorgCore.Infrastructure\OmsorgCore.Infrastructure.csproj" />
<ProjectReference Include="..\OmsorgCore.Engine\OmsorgCore.Engine.csproj" />
<ProjectReference Include="..\OmsorgCore.Email\OmsorgCore.Email.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
@OmsorgCore.Api_HostAddress = http://localhost:5245
GET {{OmsorgCore.Api_HostAddress}}/weatherforecast/
Accept: application/json
###
+161
View File
@@ -0,0 +1,161 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using OmsorgCore.Api.Security;
using OmsorgCore.Application;
using OmsorgCore.Application.Abstractions;
using OmsorgCore.Email;
using OmsorgCore.Engine;
using OmsorgCore.Infrastructure;
using OmsorgCore.Infrastructure.Persistence;
using OmsorgCore.Infrastructure.Security;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "Bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
});
// Schichten verdrahten: Domain kennt niemanden, Application kennt nur Domain,
// Infrastructure implementiert Application-Interfaces, Engine ist die Event-Schicht
// auf denselben Daten, Api verdrahtet alles nur hier.
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddEngine();
builder.Services.AddEmail(builder.Configuration);
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() ?? new JwtOptions();
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtOptions.Issuer,
ValidAudience = jwtOptions.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Secret))
};
// Killswitch: ein JWT ist an sich zustandslos gültig bis zum Ablauf. Damit ein erzwungener
// Logout (SessionAdminService) SOFORT wirkt und nicht erst nach bis zu 60 Minuten, wird bei
// jedem Request der "sst"-Claim gegen den aktuellen User.SecurityStamp in der DB geprüft.
options.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
var userIdClaim = context.Principal?.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
var stampClaim = context.Principal?.FindFirstValue("sst");
if (!Guid.TryParse(userIdClaim, out var userId) || string.IsNullOrEmpty(stampClaim))
{
context.Fail("Token ohne gültige Benutzer-/Security-Stamp-Claims.");
return;
}
var userRepository = context.HttpContext.RequestServices.GetRequiredService<IUserRepository>();
var user = await userRepository.GetByIdAsync(userId);
if (user is null || !user.IsActive || user.SecurityStamp.ToString() != stampClaim)
{
context.Fail("Session wurde widerrufen.");
return;
}
// Erzwingt einen Passwortwechsel serverseitig (nicht nur im UI versteckt) - solange
// MustChangePassword gesetzt ist (Admin hat ein initiales Passwort direkt vergeben),
// sind nur noch change-password/logout/refresh/me erlaubt. Frischer DB-Wert statt
// Claim, damit ein erfolgreicher Wechsel sofort wirkt und nicht erst nach Token-Ablauf.
if (user.MustChangePassword)
{
var path = context.HttpContext.Request.Path;
var isAllowlisted = path.StartsWithSegments("/api/auth/change-password")
|| path.StartsWithSegments("/api/auth/logout")
|| path.StartsWithSegments("/api/auth/refresh")
|| path.StartsWithSegments("/api/auth/me");
if (!isAllowlisted)
{
context.Fail("Passwort muss vor weiteren Aktionen geändert werden.");
}
}
}
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
// Wendet beim Start ausstehende Migrationen an - in jeder Umgebung, nicht nur Development,
// damit ein Deploy/Update nie mit einem Schema laufen kann, das hinter dem Code zurückliegt.
using (var migrationScope = app.Services.CreateScope())
{
var db = migrationScope.ServiceProvider.GetRequiredService<OmsorgCoreDbContext>();
await db.Database.MigrateAsync();
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
try
{
using var seedScope = app.Services.CreateScope();
var db = seedScope.ServiceProvider.GetRequiredService<OmsorgCoreDbContext>();
var passwordHasher = seedScope.ServiceProvider.GetRequiredService<IPasswordHasher>();
var seedOptions = seedScope.ServiceProvider.GetRequiredService<IOptions<SeedOptions>>().Value;
await DbSeeder.SeedDefaultAdminAsync(db, passwordHasher, seedOptions);
}
catch (Exception ex)
{
app.Logger.LogWarning(ex, "Seed des Standard-Admin-Users fehlgeschlagen (z.B. Datenbank nicht erreichbar).");
}
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
@@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:35930",
"sslPort": 44310
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5245",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7268;http://localhost:5245",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,27 @@
using System.Security.Claims;
using OmsorgCore.Application.Abstractions;
namespace OmsorgCore.Api.Security;
public class CurrentUserService : ICurrentUserService
{
private readonly IHttpContextAccessor _httpContextAccessor;
public CurrentUserService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public bool IsAuthenticated => _httpContextAccessor.HttpContext?.User.Identity?.IsAuthenticated ?? false;
public Guid? UserId
{
get
{
var value = _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? _httpContextAccessor.HttpContext?.User.FindFirstValue("sub");
return Guid.TryParse(value, out var id) ? id : null;
}
}
}
@@ -0,0 +1,41 @@
using Microsoft.AspNetCore.Mvc.Filters;
using OmsorgCore.Application.Abstractions;
using OmsorgCore.Domain.Enums;
namespace OmsorgCore.Api.Security;
/// <summary>
/// Erzwingt die Rechteprüfung auf Endpunkt-Ebene serverseitig, zusätzlich zur Prüfung auf
/// Datenebene in den Application-Services (REQUIREMENTS.md NFR-9).
/// Verwendung: [RequirePermission(ModuleType.Employees, PermissionAction.Create)]
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class RequirePermissionAttribute : Attribute, IAsyncAuthorizationFilter
{
private readonly ModuleType _module;
private readonly PermissionAction _action;
public RequirePermissionAttribute(ModuleType module, PermissionAction action)
{
_module = module;
_action = action;
}
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
var currentUser = context.HttpContext.RequestServices.GetRequiredService<ICurrentUserService>();
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
{
context.Result = new Microsoft.AspNetCore.Mvc.UnauthorizedResult();
return;
}
var permissionService = context.HttpContext.RequestServices.GetRequiredService<IPermissionService>();
var allowed = await permissionService.HasPermissionAsync(currentUser.UserId.Value, _module, _action);
if (!allowed)
{
context.Result = new Microsoft.AspNetCore.Mvc.ForbidResult();
}
}
}
@@ -0,0 +1,57 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Jwt": {
"Issuer": "OmsorgCore",
"Audience": "OmsorgClients",
"ExpiryMinutes": 60
},
"RefreshToken": {
"ExpiryDays": 60
},
"Auth": {
"MaxLoginFailures": 5,
"LoginLockoutMinutes": 10
},
"Email": {
"PinExpiryMinutes": 5,
"MaxAttempts": 3,
"ResetTokenExpiryMinutes": 10,
"RequestCooldownSeconds": 60,
"Provider": "Console",
"PasswordResetAccount": "PasswordReset",
"UserInviteAccount": "UserInvite",
"Smtp": {
"Host": "",
"Port": 587,
"EnableSsl": true
},
"Accounts": {
"PasswordReset": {
"Username": "",
"Password": "",
"SenderAddress": "",
"SenderDisplayName": "OMSORG"
},
"UserInvite": {
"Username": "",
"Password": "",
"SenderAddress": "",
"SenderDisplayName": "OMSORG"
}
},
"PasswordResetTemplate": {
"Subject": "Ihr OMSORG Passwort-Reset-Code",
"BodyTemplate": "Ihr Code lautet: $RESET_PIN$\nDer Code ist $RESET_PIN_EXPIRY_MINUTES$ Minuten gültig."
},
"UserInviteTemplate": {
"Subject": "Ihr OMSORG-Zugang",
"BodyTemplate": "Für Sie wurde ein OMSORG-Konto angelegt.\nIhr Einladungscode lautet: $INVITE_PIN$\nDer Code ist $INVITE_PIN_EXPIRY_DAYS$ Tage gültig. Damit legen Sie beim ersten Login Ihr eigenes Passwort fest."
}
}
}
@@ -0,0 +1,8 @@
namespace OmsorgCore.Application.Abstractions;
/// <summary>
/// <paramref name="FromAccountKey"/> wählt einen der konfigurierten Mail-Accounts (Username/Passwort/
/// Absenderadresse) aus, z. B. den dedizierten Passwort-Reset-Account - der SMTP-Server selbst ist
/// gemeinsam konfiguriert, nur die Accounts unterscheiden sich. <c>null</c> = Default-Account.
/// </summary>
public record EmailMessage(string ToAddress, string Subject, string PlainTextBody, string? FromAccountKey = null);
@@ -0,0 +1,12 @@
namespace OmsorgCore.Application.Abstractions;
/// <summary>
/// Liest die Identität des aktuell angemeldeten Benutzers aus dem Request-Kontext.
/// Implementierung lebt in der Api-Schicht (dort ist HttpContext bekannt), Application/Engine
/// kennen nur dieses Interface.
/// </summary>
public interface ICurrentUserService
{
Guid? UserId { get; }
bool IsAuthenticated { get; }
}
@@ -0,0 +1,6 @@
namespace OmsorgCore.Application.Abstractions;
public interface IEmailSender
{
Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,19 @@
using OmsorgCore.Domain.Entities;
namespace OmsorgCore.Application.Abstractions;
public interface IEmployeeRepository
{
Task<Employee?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<Employee>> GetAllAsync(CancellationToken cancellationToken = default);
Task<(IReadOnlyList<Employee> Items, int TotalCount)> GetPagedAsync(
string? search,
string? status,
string? employmentType,
int page,
int pageSize,
CancellationToken cancellationToken = default);
Task AddAsync(Employee employee, CancellationToken cancellationToken = default);
Task UpdateAsync(Employee employee, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,8 @@
using OmsorgCore.Domain.Entities;
namespace OmsorgCore.Application.Abstractions;
public interface IJwtTokenGenerator
{
(string Token, DateTime ExpiresAt) GenerateToken(User user);
}
@@ -0,0 +1,16 @@
using OmsorgCore.Domain.Entities;
namespace OmsorgCore.Application.Abstractions;
public interface ILoginAttemptRepository
{
/// <summary>Anzahl fehlgeschlagener Versuche dieser IP seit <paramref name="since"/>.</summary>
Task<int> CountRecentFailuresAsync(string ipAddress, DateTime since, CancellationToken cancellationToken = default);
Task AddAsync(LoginAttempt attempt, CancellationToken cancellationToken = default);
/// <summary>Löscht alle bisherigen Fehlversuche dieser IP - nach einem erfolgreichen Login.</summary>
Task ClearFailuresAsync(string ipAddress, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,8 @@
namespace OmsorgCore.Application.Abstractions;
/// <summary>Konfigurierte Schwellwerte für die IP-basierte Login-Sperre (siehe AuthService.LoginAsync).</summary>
public interface ILoginLockoutPolicy
{
int MaxFailures { get; }
TimeSpan LockoutWindow { get; }
}
@@ -0,0 +1,7 @@
namespace OmsorgCore.Application.Abstractions;
public interface IPasswordHasher
{
string Hash(string plainPassword);
bool Verify(string plainPassword, string hash);
}
@@ -0,0 +1,23 @@
namespace OmsorgCore.Application.Abstractions;
public interface IPasswordResetCodeGenerator
{
/// <summary>Mindestabstand zwischen zwei Code-Anfragen desselben Users (minimaler Spam-Schutz).</summary>
TimeSpan RequestCooldown { get; }
/// <summary>Maximale Anzahl Fehlversuche bei der PIN-Eingabe, bevor der Code invalidiert wird.</summary>
int MaxAttempts { get; }
/// <summary>Erzeugt einen neuen 6-stelligen PIN, dessen Hash zur Speicherung und sein Ablaufdatum.</summary>
(string RawPin, string PinHash, DateTime ExpiresAt) GeneratePin();
/// <summary>Wie <see cref="GeneratePin()"/>, aber mit explizit vorgegebener Gültigkeitsdauer statt der konfigurierten (z. B. für Account-Einladungen mit mehrtägiger Gültigkeit).</summary>
(string RawPin, string PinHash, DateTime ExpiresAt) GeneratePin(TimeSpan validity);
/// <summary>Erzeugt einen neuen Reset-Token, dessen Hash zur Speicherung und sein Ablaufdatum.</summary>
(string RawResetToken, string ResetTokenHash, DateTime ExpiresAt) GenerateResetToken();
string HashPin(string rawPin);
string HashResetToken(string rawResetToken);
}
@@ -0,0 +1,21 @@
using OmsorgCore.Domain.Entities;
namespace OmsorgCore.Application.Abstractions;
public interface IPasswordResetCodeRepository
{
Task AddAsync(PasswordResetCode code, CancellationToken cancellationToken = default);
/// <summary>Aktivster (nicht widerrufener, nicht abgelaufener, noch nicht konsumierter, Versuche unter maxAttempts) Code eines Users, falls vorhanden.</summary>
Task<PasswordResetCode?> GetActiveByUserIdAsync(Guid userId, int maxAttempts, CancellationToken cancellationToken = default);
/// <summary>Der jüngste Code eines Users unabhängig vom Status - für den Anfrage-Cooldown.</summary>
Task<PasswordResetCode?> GetLatestByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
Task<PasswordResetCode?> GetByResetTokenHashAsync(string resetTokenHash, CancellationToken cancellationToken = default);
/// <summary>Entwertet alle noch aktiven Codes eines Users (z. B. bei einer neuen Anfrage - immer nur ein gültiger Code).</summary>
Task InvalidateActiveForUserAsync(Guid userId, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,11 @@
namespace OmsorgCore.Application.Abstractions;
/// <summary>
/// Baut die fertige Passwort-Reset-Mail (Betreff+Text) aus dem PIN. Implementierung liegt in
/// OmsorgCore.Email (kennt die dortige Template-Konfiguration) - Engine kennt nur dieses Interface,
/// damit die Abhängigkeitsrichtung (Engine -> Application, nicht -> Email) gewahrt bleibt.
/// </summary>
public interface IPasswordResetEmailComposer
{
EmailMessage Compose(string toEmail, string rawPin);
}
@@ -0,0 +1,17 @@
using OmsorgCore.Application.Models;
using OmsorgCore.Domain.Enums;
namespace OmsorgCore.Application.Abstractions;
/// <summary>
/// Zentrale Rechteprüfung: Rollen-Default kombiniert mit individuellen Overrides des Benutzers.
/// Wird sowohl vom API-Authorization-Attribut als auch von Business-Services genutzt, damit
/// Rechte auf Daten- UND Funktionsebene geprüft werden (REQUIREMENTS.md NFR-9).
/// </summary>
public interface IPermissionService
{
Task<bool> HasPermissionAsync(Guid userId, ModuleType module, PermissionAction action, CancellationToken cancellationToken = default);
/// <summary>Alle tatsächlich gewährten Modul/Aktion-Kombinationen dieses Users (Rollen-Default + Overrides aufgelöst).</summary>
Task<IReadOnlyList<PermissionGrant>> GetGrantedPermissionsAsync(Guid userId, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,10 @@
namespace OmsorgCore.Application.Abstractions;
public interface IRefreshTokenGenerator
{
/// <summary>Erzeugt einen neuen zufälligen Rohtoken, dessen Hash zur Speicherung und sein Ablaufdatum.</summary>
(string RawToken, string TokenHash, DateTime ExpiresAt) GenerateToken();
/// <summary>Hasht einen vom Client vorgelegten Rohtoken zum Nachschlagen in der DB.</summary>
string HashToken(string rawToken);
}
@@ -0,0 +1,20 @@
using OmsorgCore.Domain.Entities;
namespace OmsorgCore.Application.Abstractions;
public interface IRefreshTokenRepository
{
Task AddAsync(RefreshToken token, CancellationToken cancellationToken = default);
Task<RefreshToken?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default);
/// <summary>Für die Admin-Sessions-Ansicht - dort ist nur die Session-Id bekannt, nicht der Rohtoken.</summary>
Task<RefreshToken?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>Alle nicht widerrufenen, nicht abgelaufenen Refresh-Tokens (inkl. User) für die Sessions-Liste.</summary>
Task<IReadOnlyList<RefreshToken>> GetAllActiveAsync(CancellationToken cancellationToken = default);
/// <summary>Alle aktiven Refresh-Tokens eines bestimmten Users (für die Ein-Session-Regel beim Login).</summary>
Task<IReadOnlyList<RefreshToken>> GetActiveByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,12 @@
using OmsorgCore.Domain.Entities;
namespace OmsorgCore.Application.Abstractions;
public interface IRoleRepository
{
Task<IReadOnlyList<Role>> GetAllAsync(CancellationToken cancellationToken = default);
Task<Role?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<bool> ExistsByNameAsync(string name, CancellationToken cancellationToken = default);
Task AddAsync(Role role, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,10 @@
namespace OmsorgCore.Application.Abstractions;
/// <summary>
/// Baut die fertige Einladungsmail (Betreff+Text) aus dem Invite-PIN. Implementierung liegt in
/// OmsorgCore.Email (analog IPasswordResetEmailComposer) - Engine kennt nur dieses Interface.
/// </summary>
public interface IUserInviteEmailComposer
{
EmailMessage Compose(string toEmail, string rawPin, int validityDays);
}
@@ -0,0 +1,27 @@
using OmsorgCore.Domain.Entities;
namespace OmsorgCore.Application.Abstractions;
public interface IUserRepository
{
Task<User?> GetByUsernameAsync(string username, CancellationToken cancellationToken = default);
/// <summary>Inklusive Role+RolePermissions+PermissionOverrides (für die Rechteauflösung) und Employee (für /api/auth/me).</summary>
Task<User?> GetByIdWithPermissionsAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>Schlanker Lookup ohne Includes - für den SecurityStamp-Check bei jedem Request.</summary>
Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>Inklusive Employee (für die E-Mail-Adresse) und Role (für den Rollennamen in Ergebnissen) - für Admin-Aktionen auf einem bestehenden User.</summary>
Task<User?> GetByIdWithEmployeeAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<User>> GetAllAsync(CancellationToken cancellationToken = default);
Task<User?> GetByEmployeeIdAsync(Guid employeeId, CancellationToken cancellationToken = default);
Task<bool> ExistsByUsernameAsync(string username, CancellationToken cancellationToken = default);
Task AddAsync(User user, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,21 @@
using Microsoft.Extensions.DependencyInjection;
using OmsorgCore.Application.Abstractions;
using OmsorgCore.Application.Services;
namespace OmsorgCore.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddScoped<IPermissionService, PermissionService>();
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IEmployeeService, EmployeeService>();
services.AddScoped<ISessionAdminService, SessionAdminService>();
services.AddScoped<IPasswordResetService, PasswordResetService>();
services.AddScoped<IUserService, UserService>();
services.AddScoped<IRoleService, RoleService>();
return services;
}
}
@@ -0,0 +1,6 @@
using OmsorgCore.Domain.Enums;
namespace OmsorgCore.Application.Models;
/// <summary>Eine für einen User tatsächlich gewährte Modul/Aktion-Kombination (Ergebnis der Rechte-Auflösung).</summary>
public record PermissionGrant(ModuleType Module, PermissionAction Action);
@@ -0,0 +1,19 @@
namespace OmsorgCore.Application.Models;
/// <summary>
/// Profil des eingeloggten Users samt aufgelöster Rechte, für den `/api/auth/me`-Endpunkt. Trägt
/// zusätzlich die verknüpften Employee-Basisdaten (falls vorhanden) - JWT und Login-Response tragen
/// nur Username/Rolle, Clients brauchen für "angemeldet als ..."-Anzeigen aber Name/E-Mail/Avatar,
/// ohne dafür einen separaten Employee-Request zu brauchen (siehe omsorgWeb/mitarbeiter-app).
/// </summary>
public record UserProfile(
Guid UserId,
string Username,
string RoleName,
IReadOnlyList<PermissionGrant> Permissions,
Guid? EmployeeId,
string? FirstName,
string? LastName,
string? Email,
string? PhoneNumber,
string? AvatarFileName);
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\OmsorgCore.Domain\OmsorgCore.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,31 @@
namespace OmsorgCore.Application.Services;
public enum AdminResetPasswordFailureReason
{
UserNotFound,
EmployeeEmailMissing,
InitialPasswordRequired
}
public class AdminResetPasswordResult
{
public bool Success { get; init; }
public AdminResetPasswordFailureReason? FailureReason { get; init; }
/// <summary>Nur im Invite-Modus gesetzt - für den Versand der Einladungsmail.</summary>
public string? Email { get; init; }
public string? RawInvitePin { get; init; }
public static AdminResetPasswordResult Fail(AdminResetPasswordFailureReason reason) => new()
{
Success = false,
FailureReason = reason
};
public static AdminResetPasswordResult Ok(string? email = null, string? rawInvitePin = null) => new()
{
Success = true,
Email = email,
RawInvitePin = rawInvitePin
};
}
@@ -0,0 +1,24 @@
namespace OmsorgCore.Application.Services;
public class AuthResult
{
public bool Success { get; init; }
public string? Token { get; init; }
public string? RefreshToken { get; init; }
public DateTime? ExpiresAt { get; init; }
public bool MustChangePassword { get; init; }
public bool IsLockedOut { get; init; }
public static AuthResult Fail() => new() { Success = false };
public static AuthResult LockedOut() => new() { Success = false, IsLockedOut = true };
public static AuthResult Ok(string token, string refreshToken, DateTime expiresAt, bool mustChangePassword) => new()
{
Success = true,
Token = token,
RefreshToken = refreshToken,
ExpiresAt = expiresAt,
MustChangePassword = mustChangePassword
};
}
@@ -0,0 +1,155 @@
using OmsorgCore.Application.Abstractions;
using OmsorgCore.Application.Models;
using OmsorgCore.Domain.Entities;
namespace OmsorgCore.Application.Services;
public class AuthService : IAuthService
{
private readonly IUserRepository _userRepository;
private readonly IRefreshTokenRepository _refreshTokenRepository;
private readonly IPasswordHasher _passwordHasher;
private readonly IJwtTokenGenerator _tokenGenerator;
private readonly IRefreshTokenGenerator _refreshTokenGenerator;
private readonly IPermissionService _permissionService;
private readonly ILoginAttemptRepository _loginAttemptRepository;
private readonly ILoginLockoutPolicy _loginLockoutPolicy;
public AuthService(
IUserRepository userRepository,
IRefreshTokenRepository refreshTokenRepository,
IPasswordHasher passwordHasher,
IJwtTokenGenerator tokenGenerator,
IRefreshTokenGenerator refreshTokenGenerator,
IPermissionService permissionService,
ILoginAttemptRepository loginAttemptRepository,
ILoginLockoutPolicy loginLockoutPolicy)
{
_userRepository = userRepository;
_refreshTokenRepository = refreshTokenRepository;
_passwordHasher = passwordHasher;
_tokenGenerator = tokenGenerator;
_refreshTokenGenerator = refreshTokenGenerator;
_permissionService = permissionService;
_loginAttemptRepository = loginAttemptRepository;
_loginLockoutPolicy = loginLockoutPolicy;
}
public async Task<AuthResult> LoginAsync(string username, string password, string ipAddress, CancellationToken cancellationToken = default)
{
var lockoutSince = DateTime.UtcNow - _loginLockoutPolicy.LockoutWindow;
var recentFailures = await _loginAttemptRepository.CountRecentFailuresAsync(ipAddress, lockoutSince, cancellationToken);
if (recentFailures >= _loginLockoutPolicy.MaxFailures)
{
return AuthResult.LockedOut();
}
var user = await _userRepository.GetByUsernameAsync(username, cancellationToken);
if (user is null || !user.IsActive || !_passwordHasher.Verify(password, user.PasswordHash))
{
await _loginAttemptRepository.AddAsync(new LoginAttempt
{
IpAddress = ipAddress,
Username = username,
Succeeded = false
}, cancellationToken);
await _loginAttemptRepository.SaveChangesAsync(cancellationToken);
return AuthResult.Fail();
}
await _loginAttemptRepository.ClearFailuresAsync(ipAddress, cancellationToken);
await _loginAttemptRepository.SaveChangesAsync(cancellationToken);
await EndOtherSessionsAsync(user, cancellationToken);
return await IssueTokenPairAsync(user, cancellationToken);
}
// Erzwingt "ein User = eine aktive Session": widerruft alle bisherigen Refresh-Tokens dieses
// Users und würfelt den SecurityStamp neu, damit auch bereits ausgestellte Access-Tokens
// anderer Sessions sofort ungültig werden (derselbe Killswitch-Mechanismus wie SessionAdminService).
private async Task EndOtherSessionsAsync(User user, CancellationToken cancellationToken)
{
var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(user.Id, cancellationToken);
foreach (var session in activeSessions)
{
session.RevokedAt = DateTime.UtcNow;
}
user.SecurityStamp = Guid.NewGuid();
}
public async Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default)
{
var tokenHash = _refreshTokenGenerator.HashToken(refreshToken);
var existing = await _refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
if (existing is null || !existing.IsActive)
{
return AuthResult.Fail();
}
existing.RevokedAt = DateTime.UtcNow;
return await IssueTokenPairAsync(existing.User, cancellationToken, replaces: existing);
}
public async Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default)
{
var tokenHash = _refreshTokenGenerator.HashToken(refreshToken);
var existing = await _refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
if (existing is null || existing.RevokedAt is not null)
{
return;
}
existing.RevokedAt = DateTime.UtcNow;
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
}
public async Task<UserProfile?> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default)
{
var user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
if (user is null || !user.IsActive)
{
return null;
}
var permissions = await _permissionService.GetGrantedPermissionsAsync(userId, cancellationToken);
return new UserProfile(
user.Id,
user.Username,
user.Role.Name,
permissions,
user.EmployeeId,
user.Employee?.FirstName,
user.Employee?.LastName,
user.Employee?.Email,
user.Employee?.PhoneNumber,
user.Employee?.AvatarFileName);
}
private async Task<AuthResult> IssueTokenPairAsync(User user, CancellationToken cancellationToken, RefreshToken? replaces = null)
{
var (accessToken, accessTokenExpiresAt) = _tokenGenerator.GenerateToken(user);
var (rawRefreshToken, refreshTokenHash, refreshTokenExpiresAt) = _refreshTokenGenerator.GenerateToken();
var newToken = new RefreshToken
{
UserId = user.Id,
User = user,
TokenHash = refreshTokenHash,
ExpiresAt = refreshTokenExpiresAt
};
await _refreshTokenRepository.AddAsync(newToken, cancellationToken);
if (replaces is not null)
{
replaces.ReplacedByTokenId = newToken.Id;
}
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
return AuthResult.Ok(accessToken, rawRefreshToken, accessTokenExpiresAt, user.MustChangePassword);
}
}
@@ -0,0 +1,28 @@
using OmsorgCore.Domain.Entities;
namespace OmsorgCore.Application.Services;
public enum CreateRoleFailureReason
{
NameRequired,
NameTaken
}
public class CreateRoleResult
{
public bool Success { get; init; }
public CreateRoleFailureReason? FailureReason { get; init; }
public Role? Role { get; init; }
public static CreateRoleResult Fail(CreateRoleFailureReason reason) => new()
{
Success = false,
FailureReason = reason
};
public static CreateRoleResult Ok(Role role) => new()
{
Success = true,
Role = role
};
}
@@ -0,0 +1,38 @@
namespace OmsorgCore.Application.Services;
public enum CreateUserFailureReason
{
EmployeeNotFound,
EmployeeAlreadyHasAccount,
EmployeeEmailMissing,
UsernameTaken,
RoleNotFound,
InitialPasswordRequired
}
public class CreateUserResult
{
public bool Success { get; init; }
public CreateUserFailureReason? FailureReason { get; init; }
public Guid? UserId { get; init; }
public string? RoleName { get; init; }
/// <summary>Nur im Invite-Modus gesetzt - für den Versand der Einladungsmail.</summary>
public string? Email { get; init; }
public string? RawInvitePin { get; init; }
public static CreateUserResult Fail(CreateUserFailureReason reason) => new()
{
Success = false,
FailureReason = reason
};
public static CreateUserResult Ok(Guid userId, string roleName, string? email = null, string? rawInvitePin = null) => new()
{
Success = true,
UserId = userId,
RoleName = roleName,
Email = email,
RawInvitePin = rawInvitePin
};
}
@@ -0,0 +1,69 @@
using OmsorgCore.Application.Abstractions;
using OmsorgCore.Domain.Entities;
namespace OmsorgCore.Application.Services;
public class EmployeeService : IEmployeeService
{
private readonly IEmployeeRepository _employeeRepository;
public EmployeeService(IEmployeeRepository employeeRepository)
{
_employeeRepository = employeeRepository;
}
public Task<IReadOnlyList<Employee>> GetAllAsync(CancellationToken cancellationToken = default)
=> _employeeRepository.GetAllAsync(cancellationToken);
public Task<(IReadOnlyList<Employee> Items, int TotalCount)> GetPagedAsync(
string? search,
string? status,
string? employmentType,
int page,
int pageSize,
CancellationToken cancellationToken = default)
=> _employeeRepository.GetPagedAsync(search, status, employmentType, page, pageSize, cancellationToken);
public Task<Employee?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
=> _employeeRepository.GetByIdAsync(id, cancellationToken);
public async Task<Employee> CreateAsync(Employee employee, CancellationToken cancellationToken = default)
{
await _employeeRepository.AddAsync(employee, cancellationToken);
await _employeeRepository.SaveChangesAsync(cancellationToken);
return employee;
}
public async Task<Employee?> UpdateAsync(Guid id, Employee updates, CancellationToken cancellationToken = default)
{
var employee = await _employeeRepository.GetByIdAsync(id, cancellationToken);
if (employee is null)
{
return null;
}
employee.FirstName = updates.FirstName;
employee.LastName = updates.LastName;
employee.DateOfBirth = updates.DateOfBirth;
employee.Street = updates.Street;
employee.PostalCode = updates.PostalCode;
employee.City = updates.City;
employee.Country = updates.Country;
employee.PhoneNumber = updates.PhoneNumber;
employee.Email = updates.Email;
employee.EntryDate = updates.EntryDate;
employee.ExitDate = updates.ExitDate;
employee.Status = updates.Status;
employee.EmergencyContactName = updates.EmergencyContactName;
employee.EmergencyContactPhone = updates.EmergencyContactPhone;
employee.EmergencyContactRelation = updates.EmergencyContactRelation;
employee.EmploymentType = updates.EmploymentType;
employee.Qualification = updates.Qualification;
employee.AvatarFileName = updates.AvatarFileName;
employee.UpdatedAt = DateTime.UtcNow;
await _employeeRepository.UpdateAsync(employee, cancellationToken);
await _employeeRepository.SaveChangesAsync(cancellationToken);
return employee;
}
}
@@ -0,0 +1,17 @@
using OmsorgCore.Application.Models;
namespace OmsorgCore.Application.Services;
public interface IAuthService
{
Task<AuthResult> LoginAsync(string username, string password, string ipAddress, CancellationToken cancellationToken = default);
/// <summary>Löst den vorgelegten Refresh-Token ein: widerruft ihn und stellt ein neues Token-Paar aus (Rotation).</summary>
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default);
/// <summary>Widerruft einen Refresh-Token (Logout). Idempotent, kein Fehler bei unbekanntem/bereits widerrufenem Token.</summary>
Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default);
/// <summary>Profil (Username, Rolle, aufgelöste Rechte) des eingeloggten Users, für `/api/auth/me`.</summary>
Task<UserProfile?> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,18 @@
using OmsorgCore.Domain.Entities;
namespace OmsorgCore.Application.Services;
public interface IEmployeeService
{
Task<IReadOnlyList<Employee>> GetAllAsync(CancellationToken cancellationToken = default);
Task<(IReadOnlyList<Employee> Items, int TotalCount)> GetPagedAsync(
string? search,
string? status,
string? employmentType,
int page,
int pageSize,
CancellationToken cancellationToken = default);
Task<Employee?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<Employee> CreateAsync(Employee employee, CancellationToken cancellationToken = default);
Task<Employee?> UpdateAsync(Guid id, Employee updates, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,17 @@
namespace OmsorgCore.Application.Services;
public interface IPasswordResetService
{
Task<PasswordResetRequestResult> RequestResetAsync(string username, CancellationToken cancellationToken = default);
Task<PasswordResetVerifyResult> VerifyCodeAsync(string username, string pin, CancellationToken cancellationToken = default);
Task<bool> ResetPasswordAsync(string resetToken, string newPassword, CancellationToken cancellationToken = default);
/// <summary>
/// Stellt einen PIN für eine Account-Einladung aus (admin-ausgelöst, kein Self-Service-Request des Users
/// selbst) - läuft durch dieselbe PasswordResetCode-Tabelle wie RequestResetAsync, aber ohne Cooldown-Prüfung
/// und mit einer explizit vorgegebenen, i. d. R. mehrtägigen Gültigkeit statt der kurzen Reset-PIN-Gültigkeit.
/// </summary>
Task<string> IssueInviteCodeAsync(Guid userId, TimeSpan validity, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,9 @@
using OmsorgCore.Domain.Entities;
namespace OmsorgCore.Application.Services;
public interface IRoleService
{
Task<IReadOnlyList<Role>> GetAllAsync(CancellationToken cancellationToken = default);
Task<CreateRoleResult> CreateAsync(string name, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,12 @@
namespace OmsorgCore.Application.Services;
public interface ISessionAdminService
{
Task<IReadOnlyList<SessionInfo>> GetActiveSessionsAsync(CancellationToken cancellationToken = default);
/// <summary>Widerruft genau diese Session und erzwingt für ihren Nutzer sofort einen neuen Login.</summary>
Task RevokeSessionAsync(Guid sessionId, CancellationToken cancellationToken = default);
/// <summary>Widerruft alle aktiven Sessions und erzwingt für ALLE Nutzer sofort einen neuen Login.</summary>
Task RevokeAllSessionsAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,36 @@
namespace OmsorgCore.Application.Services;
public interface IUserService
{
Task<IReadOnlyList<UserSummary>> GetAllAsync(CancellationToken cancellationToken = default);
Task<CreateUserResult> CreateForEmployeeAsync(
Guid employeeId,
string username,
Guid roleId,
UserCreationMode mode,
string? initialPassword,
TimeSpan? invitePinValidity,
CancellationToken cancellationToken = default);
Task<bool> ChangeOwnPasswordAsync(
Guid userId,
string currentPassword,
string newPassword,
CancellationToken cancellationToken = default);
/// <summary>Admin setzt das Passwort eines bestehenden Users zurück (Invite-PIN oder direktes Passwort) - spiegelt CreateForEmployeeAsync, aber ohne Neuanlage.</summary>
Task<AdminResetPasswordResult> AdminResetPasswordAsync(
Guid userId,
UserCreationMode mode,
string? initialPassword,
TimeSpan? invitePinValidity,
CancellationToken cancellationToken = default);
/// <summary>Rolle und/oder Aktiv-Status ändern. Deaktivieren beendet sofort alle Sessions (kein Hard-Delete).</summary>
Task<UpdateUserResult> UpdateAsync(
Guid userId,
Guid roleId,
bool isActive,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,27 @@
namespace OmsorgCore.Application.Services;
public enum PasswordResetRequestStatus
{
Sent,
CannotReset
}
public class PasswordResetRequestResult
{
public PasswordResetRequestStatus Status { get; init; }
public Guid? UserId { get; init; }
public string? Email { get; init; }
/// <summary>Nur gesetzt, wenn tatsächlich ein neuer Code erzeugt wurde und dispatcht werden soll.</summary>
public string? RawPin { get; init; }
public static PasswordResetRequestResult CannotReset() => new() { Status = PasswordResetRequestStatus.CannotReset };
public static PasswordResetRequestResult Sent(Guid userId, string email, string? rawPin) => new()
{
Status = PasswordResetRequestStatus.Sent,
UserId = userId,
Email = email,
RawPin = rawPin
};
}
@@ -0,0 +1,139 @@
using OmsorgCore.Application.Abstractions;
using OmsorgCore.Domain.Entities;
namespace OmsorgCore.Application.Services;
public class PasswordResetService : IPasswordResetService
{
private readonly IUserRepository _userRepository;
private readonly IPasswordResetCodeRepository _codeRepository;
private readonly IPasswordResetCodeGenerator _codeGenerator;
private readonly IPasswordHasher _passwordHasher;
private readonly IRefreshTokenRepository _refreshTokenRepository;
public PasswordResetService(
IUserRepository userRepository,
IPasswordResetCodeRepository codeRepository,
IPasswordResetCodeGenerator codeGenerator,
IPasswordHasher passwordHasher,
IRefreshTokenRepository refreshTokenRepository)
{
_userRepository = userRepository;
_codeRepository = codeRepository;
_codeGenerator = codeGenerator;
_passwordHasher = passwordHasher;
_refreshTokenRepository = refreshTokenRepository;
}
public async Task<PasswordResetRequestResult> RequestResetAsync(string username, CancellationToken cancellationToken = default)
{
var user = await _userRepository.GetByUsernameAsync(username, cancellationToken);
var email = user?.Employee?.Email;
if (user is null || !user.IsActive || string.IsNullOrWhiteSpace(email))
{
return PasswordResetRequestResult.CannotReset();
}
var latest = await _codeRepository.GetLatestByUserIdAsync(user.Id, cancellationToken);
if (latest is not null && DateTime.UtcNow - latest.CreatedAt < _codeGenerator.RequestCooldown)
{
// Cooldown aktiv: kein neuer Code, kein erneuter Versand, aber nach außen unauffällig -
// der Client bekommt weiterhin "Sent" ohne dass tatsächlich etwas verschickt wird.
return PasswordResetRequestResult.Sent(user.Id, email, rawPin: null);
}
await _codeRepository.InvalidateActiveForUserAsync(user.Id, cancellationToken);
var (rawPin, pinHash, expiresAt) = _codeGenerator.GeneratePin();
await _codeRepository.AddAsync(new PasswordResetCode
{
UserId = user.Id,
User = user,
CodeHash = pinHash,
ExpiresAt = expiresAt
}, cancellationToken);
await _codeRepository.SaveChangesAsync(cancellationToken);
return PasswordResetRequestResult.Sent(user.Id, email, rawPin);
}
public async Task<PasswordResetVerifyResult> VerifyCodeAsync(string username, string pin, CancellationToken cancellationToken = default)
{
var user = await _userRepository.GetByUsernameAsync(username, cancellationToken);
if (user is null)
{
return PasswordResetVerifyResult.Fail("invalid_or_expired");
}
var code = await _codeRepository.GetActiveByUserIdAsync(user.Id, _codeGenerator.MaxAttempts, cancellationToken);
if (code is null)
{
return PasswordResetVerifyResult.Fail("invalid_or_expired");
}
var pinHash = _codeGenerator.HashPin(pin);
if (code.CodeHash != pinHash)
{
code.AttemptCount++;
if (code.AttemptCount >= _codeGenerator.MaxAttempts)
{
code.InvalidatedAt = DateTime.UtcNow;
}
await _codeRepository.SaveChangesAsync(cancellationToken);
return code.AttemptCount >= _codeGenerator.MaxAttempts
? PasswordResetVerifyResult.Fail("too_many_attempts")
: PasswordResetVerifyResult.Fail("invalid_or_expired");
}
code.ConsumedAt = DateTime.UtcNow;
var (rawResetToken, resetTokenHash, resetExpiresAt) = _codeGenerator.GenerateResetToken();
code.ResetTokenHash = resetTokenHash;
code.ResetTokenExpiresAt = resetExpiresAt;
await _codeRepository.SaveChangesAsync(cancellationToken);
return PasswordResetVerifyResult.Ok(rawResetToken);
}
public async Task<string> IssueInviteCodeAsync(Guid userId, TimeSpan validity, CancellationToken cancellationToken = default)
{
await _codeRepository.InvalidateActiveForUserAsync(userId, cancellationToken);
var (rawPin, pinHash, expiresAt) = _codeGenerator.GeneratePin(validity);
await _codeRepository.AddAsync(new PasswordResetCode
{
UserId = userId,
CodeHash = pinHash,
ExpiresAt = expiresAt
}, cancellationToken);
await _codeRepository.SaveChangesAsync(cancellationToken);
return rawPin;
}
public async Task<bool> ResetPasswordAsync(string resetToken, string newPassword, CancellationToken cancellationToken = default)
{
var resetTokenHash = _codeGenerator.HashResetToken(resetToken);
var code = await _codeRepository.GetByResetTokenHashAsync(resetTokenHash, cancellationToken);
if (code is null || !code.IsResetTokenActive)
{
return false;
}
code.ResetTokenUsedAt = DateTime.UtcNow;
code.User.PasswordHash = _passwordHasher.Hash(newPassword);
// Wie nach einem normalen Login: alle bestehenden Sessions beenden, SecurityStamp erneuern -
// ein Passwort-Reset darf alte, evtl. kompromittierte Sessions nicht überleben lassen.
var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(code.UserId, cancellationToken);
foreach (var session in activeSessions)
{
session.RevokedAt = DateTime.UtcNow;
}
code.User.SecurityStamp = Guid.NewGuid();
await _codeRepository.SaveChangesAsync(cancellationToken);
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
return true;
}
}
@@ -0,0 +1,14 @@
namespace OmsorgCore.Application.Services;
public class PasswordResetVerifyResult
{
public bool Success { get; init; }
public string? ResetToken { get; init; }
/// <summary>"invalid_or_expired" | "too_many_attempts"</summary>
public string? FailureReason { get; init; }
public static PasswordResetVerifyResult Fail(string reason) => new() { Success = false, FailureReason = reason };
public static PasswordResetVerifyResult Ok(string resetToken) => new() { Success = true, ResetToken = resetToken };
}
@@ -0,0 +1,67 @@
using OmsorgCore.Application.Abstractions;
using OmsorgCore.Application.Models;
using OmsorgCore.Domain.Entities;
using OmsorgCore.Domain.Enums;
namespace OmsorgCore.Application.Services;
/// <summary>
/// Implementiert die Regel aus REQUIREMENTS.md Abschnitt 3/7 und Blueprint 6.5:
/// Rollen-Default gilt, ein individueller Override (Grant oder Revoke) gewinnt immer.
/// </summary>
public class PermissionService : IPermissionService
{
private readonly IUserRepository _userRepository;
public PermissionService(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<bool> HasPermissionAsync(Guid userId, ModuleType module, PermissionAction action, CancellationToken cancellationToken = default)
{
User? user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
if (user is null || !user.IsActive)
{
return false;
}
return IsGranted(user, module, action);
}
public async Task<IReadOnlyList<PermissionGrant>> GetGrantedPermissionsAsync(Guid userId, CancellationToken cancellationToken = default)
{
User? user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
if (user is null || !user.IsActive)
{
return Array.Empty<PermissionGrant>();
}
var grants = new List<PermissionGrant>();
foreach (ModuleType module in Enum.GetValues<ModuleType>())
{
foreach (PermissionAction action in Enum.GetValues<PermissionAction>())
{
if (IsGranted(user, module, action))
{
grants.Add(new PermissionGrant(module, action));
}
}
}
return grants;
}
private static bool IsGranted(User user, ModuleType module, PermissionAction action)
{
UserPermissionOverride? override_ = user.PermissionOverrides
.FirstOrDefault(o => o.Module == module && o.Action == action);
if (override_ is not null)
{
return override_.Effect == PermissionEffect.Grant;
}
return user.Role.RolePermissions.Any(rp => rp.Module == module && rp.Action == action);
}
}
@@ -0,0 +1,36 @@
using OmsorgCore.Application.Abstractions;
using OmsorgCore.Domain.Entities;
namespace OmsorgCore.Application.Services;
public class RoleService : IRoleService
{
private readonly IRoleRepository _roleRepository;
public RoleService(IRoleRepository roleRepository)
{
_roleRepository = roleRepository;
}
public Task<IReadOnlyList<Role>> GetAllAsync(CancellationToken cancellationToken = default)
=> _roleRepository.GetAllAsync(cancellationToken);
public async Task<CreateRoleResult> CreateAsync(string name, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(name))
{
return CreateRoleResult.Fail(CreateRoleFailureReason.NameRequired);
}
if (await _roleRepository.ExistsByNameAsync(name, cancellationToken))
{
return CreateRoleResult.Fail(CreateRoleFailureReason.NameTaken);
}
var role = new Role { Name = name };
await _roleRepository.AddAsync(role, cancellationToken);
await _roleRepository.SaveChangesAsync(cancellationToken);
return CreateRoleResult.Ok(role);
}
}
@@ -0,0 +1,54 @@
using OmsorgCore.Application.Abstractions;
namespace OmsorgCore.Application.Services;
public class SessionAdminService : ISessionAdminService
{
private readonly IRefreshTokenRepository _refreshTokenRepository;
private readonly IUserRepository _userRepository;
public SessionAdminService(IRefreshTokenRepository refreshTokenRepository, IUserRepository userRepository)
{
_refreshTokenRepository = refreshTokenRepository;
_userRepository = userRepository;
}
public async Task<IReadOnlyList<SessionInfo>> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
{
var tokens = await _refreshTokenRepository.GetAllActiveAsync(cancellationToken);
return tokens
.Select(t => new SessionInfo(t.Id, t.User.Username, t.CreatedAt, t.ExpiresAt))
.ToList();
}
public async Task RevokeSessionAsync(Guid sessionId, CancellationToken cancellationToken = default)
{
var token = await _refreshTokenRepository.GetByIdAsync(sessionId, cancellationToken);
if (token is null || token.RevokedAt is not null)
{
return;
}
token.RevokedAt = DateTime.UtcNow;
token.User.SecurityStamp = Guid.NewGuid();
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
}
public async Task RevokeAllSessionsAsync(CancellationToken cancellationToken = default)
{
var activeTokens = await _refreshTokenRepository.GetAllActiveAsync(cancellationToken);
foreach (var token in activeTokens)
{
token.RevokedAt = DateTime.UtcNow;
}
var users = await _userRepository.GetAllAsync(cancellationToken);
foreach (var user in users)
{
user.SecurityStamp = Guid.NewGuid();
}
await _userRepository.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,3 @@
namespace OmsorgCore.Application.Services;
public record SessionInfo(Guid Id, string Username, DateTime CreatedAt, DateTime ExpiresAt);
@@ -0,0 +1,21 @@
namespace OmsorgCore.Application.Services;
public enum UpdateUserFailureReason
{
UserNotFound,
RoleNotFound
}
public class UpdateUserResult
{
public bool Success { get; init; }
public UpdateUserFailureReason? FailureReason { get; init; }
public static UpdateUserResult Fail(UpdateUserFailureReason reason) => new()
{
Success = false,
FailureReason = reason
};
public static UpdateUserResult Ok() => new() { Success = true };
}
@@ -0,0 +1,10 @@
namespace OmsorgCore.Application.Services;
public enum UserCreationMode
{
/// <summary>Mitarbeiter bekommt eine Einladungsmail mit PIN und setzt sein Passwort selbst - der Admin kennt es nie.</summary>
Invite,
/// <summary>Admin vergibt ein initiales Passwort direkt (z. B. ohne hinterlegte E-Mail) - MustChangePassword erzwingt einen Wechsel beim ersten Login.</summary>
Direct
}
@@ -0,0 +1,222 @@
using OmsorgCore.Application.Abstractions;
using OmsorgCore.Domain.Entities;
namespace OmsorgCore.Application.Services;
public class UserService : IUserService
{
private readonly IUserRepository _userRepository;
private readonly IEmployeeRepository _employeeRepository;
private readonly IRoleRepository _roleRepository;
private readonly IPasswordHasher _passwordHasher;
private readonly IPasswordResetService _passwordResetService;
private readonly IRefreshTokenRepository _refreshTokenRepository;
public UserService(
IUserRepository userRepository,
IEmployeeRepository employeeRepository,
IRoleRepository roleRepository,
IPasswordHasher passwordHasher,
IPasswordResetService passwordResetService,
IRefreshTokenRepository refreshTokenRepository)
{
_userRepository = userRepository;
_employeeRepository = employeeRepository;
_roleRepository = roleRepository;
_passwordHasher = passwordHasher;
_passwordResetService = passwordResetService;
_refreshTokenRepository = refreshTokenRepository;
}
public async Task<IReadOnlyList<UserSummary>> GetAllAsync(CancellationToken cancellationToken = default)
{
var users = await _userRepository.GetAllAsync(cancellationToken);
return users
.Select(u => new UserSummary(u.Id, u.Username, u.EmployeeId, u.Role.Name, u.IsActive, u.MustChangePassword))
.ToList();
}
public async Task<CreateUserResult> CreateForEmployeeAsync(
Guid employeeId,
string username,
Guid roleId,
UserCreationMode mode,
string? initialPassword,
TimeSpan? invitePinValidity,
CancellationToken cancellationToken = default)
{
var employee = await _employeeRepository.GetByIdAsync(employeeId, cancellationToken);
if (employee is null)
{
return CreateUserResult.Fail(CreateUserFailureReason.EmployeeNotFound);
}
var existingForEmployee = await _userRepository.GetByEmployeeIdAsync(employeeId, cancellationToken);
if (existingForEmployee is not null)
{
return CreateUserResult.Fail(CreateUserFailureReason.EmployeeAlreadyHasAccount);
}
if (await _userRepository.ExistsByUsernameAsync(username, cancellationToken))
{
return CreateUserResult.Fail(CreateUserFailureReason.UsernameTaken);
}
var role = await _roleRepository.GetByIdAsync(roleId, cancellationToken);
if (role is null)
{
return CreateUserResult.Fail(CreateUserFailureReason.RoleNotFound);
}
if (mode == UserCreationMode.Direct && string.IsNullOrWhiteSpace(initialPassword))
{
return CreateUserResult.Fail(CreateUserFailureReason.InitialPasswordRequired);
}
if (mode == UserCreationMode.Invite && string.IsNullOrWhiteSpace(employee.Email))
{
return CreateUserResult.Fail(CreateUserFailureReason.EmployeeEmailMissing);
}
var user = new User
{
Username = username,
RoleId = roleId,
EmployeeId = employeeId,
// Invite-Modus: kein für den Login nutzbares Passwort - Hash eines Zufallswerts, den niemand
// je eingibt. Der Mitarbeiter setzt sein eigenes Passwort über den unten ausgestellten PIN-Flow.
PasswordHash = mode == UserCreationMode.Direct
? _passwordHasher.Hash(initialPassword!)
: _passwordHasher.Hash(Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N")),
MustChangePassword = mode == UserCreationMode.Direct
};
await _userRepository.AddAsync(user, cancellationToken);
await _userRepository.SaveChangesAsync(cancellationToken);
if (mode == UserCreationMode.Invite)
{
var validity = invitePinValidity ?? TimeSpan.FromDays(7);
var rawPin = await _passwordResetService.IssueInviteCodeAsync(user.Id, validity, cancellationToken);
return CreateUserResult.Ok(user.Id, role.Name, employee.Email, rawPin);
}
return CreateUserResult.Ok(user.Id, role.Name);
}
public async Task<bool> ChangeOwnPasswordAsync(
Guid userId,
string currentPassword,
string newPassword,
CancellationToken cancellationToken = default)
{
var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
if (user is null || !_passwordHasher.Verify(currentPassword, user.PasswordHash))
{
return false;
}
user.PasswordHash = _passwordHasher.Hash(newPassword);
user.MustChangePassword = false;
// Wie nach einem Passwort-Reset: bestehende Sessions beenden, SecurityStamp erneuern - der
// gerade benutzte Access-Token wird dadurch ab dem nächsten Request ungültig, ein neuer Login
// mit dem neuen Passwort ist danach nötig (siehe PasswordResetService.ResetPasswordAsync).
var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(userId, cancellationToken);
foreach (var session in activeSessions)
{
session.RevokedAt = DateTime.UtcNow;
}
user.SecurityStamp = Guid.NewGuid();
await _userRepository.SaveChangesAsync(cancellationToken);
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
return true;
}
public async Task<AdminResetPasswordResult> AdminResetPasswordAsync(
Guid userId,
UserCreationMode mode,
string? initialPassword,
TimeSpan? invitePinValidity,
CancellationToken cancellationToken = default)
{
var user = await _userRepository.GetByIdWithEmployeeAsync(userId, cancellationToken);
if (user is null)
{
return AdminResetPasswordResult.Fail(AdminResetPasswordFailureReason.UserNotFound);
}
if (mode == UserCreationMode.Direct && string.IsNullOrWhiteSpace(initialPassword))
{
return AdminResetPasswordResult.Fail(AdminResetPasswordFailureReason.InitialPasswordRequired);
}
if (mode == UserCreationMode.Invite && string.IsNullOrWhiteSpace(user.Employee?.Email))
{
return AdminResetPasswordResult.Fail(AdminResetPasswordFailureReason.EmployeeEmailMissing);
}
if (mode == UserCreationMode.Direct)
{
user.PasswordHash = _passwordHasher.Hash(initialPassword!);
user.MustChangePassword = true;
// Admin-gesetztes Passwort ersetzt sofort alle laufenden Sessions - wie ein regulärer
// Passwort-Reset (siehe PasswordResetService.ResetPasswordAsync).
var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(userId, cancellationToken);
foreach (var session in activeSessions)
{
session.RevokedAt = DateTime.UtcNow;
}
user.SecurityStamp = Guid.NewGuid();
await _userRepository.SaveChangesAsync(cancellationToken);
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
return AdminResetPasswordResult.Ok();
}
var validity = invitePinValidity ?? TimeSpan.FromDays(7);
var rawPin = await _passwordResetService.IssueInviteCodeAsync(user.Id, validity, cancellationToken);
return AdminResetPasswordResult.Ok(user.Employee!.Email, rawPin);
}
public async Task<UpdateUserResult> UpdateAsync(
Guid userId,
Guid roleId,
bool isActive,
CancellationToken cancellationToken = default)
{
var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
if (user is null)
{
return UpdateUserResult.Fail(UpdateUserFailureReason.UserNotFound);
}
var role = await _roleRepository.GetByIdAsync(roleId, cancellationToken);
if (role is null)
{
return UpdateUserResult.Fail(UpdateUserFailureReason.RoleNotFound);
}
user.RoleId = roleId;
var wasActive = user.IsActive;
user.IsActive = isActive;
if (wasActive && !isActive)
{
// Sofortiger Killswitch bei Deaktivierung - kein Warten auf Token-Ablauf (siehe SessionAdminService).
var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(userId, cancellationToken);
foreach (var session in activeSessions)
{
session.RevokedAt = DateTime.UtcNow;
}
user.SecurityStamp = Guid.NewGuid();
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
}
await _userRepository.SaveChangesAsync(cancellationToken);
return UpdateUserResult.Ok();
}
}
@@ -0,0 +1,4 @@
namespace OmsorgCore.Application.Services;
/// <summary>Für die Account-Übersicht (Mitarbeiter mit/ohne Login-Konto) - siehe UsersController.</summary>
public record UserSummary(Guid Id, string Username, Guid? EmployeeId, string RoleName, bool IsActive, bool MustChangePassword);
@@ -0,0 +1,11 @@
namespace OmsorgCore.Domain.Common;
public abstract class AuditableEntity : Entity
{
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
// Soft-Delete statt Hard-Delete für geschäftsrelevante Daten (siehe CLAUDE.md).
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
}
@@ -0,0 +1,6 @@
namespace OmsorgCore.Domain.Common;
public abstract class Entity
{
public Guid Id { get; protected set; } = Guid.NewGuid();
}
@@ -0,0 +1,22 @@
using OmsorgCore.Domain.Common;
namespace OmsorgCore.Domain.Entities;
/// <summary>
/// Core-Objekt "Vertrag" — verbindliche Vereinbarung mit Mitarbeiter oder Einrichtung
/// (REQUIREMENTS.md Abschnitt 6, Blueprint 19.3).
/// </summary>
public class Contract : AuditableEntity
{
public string ContractType { get; set; } = string.Empty;
public Guid? EmployeeId { get; set; }
public Employee? Employee { get; set; }
public Guid? FacilityId { get; set; }
public Facility? Facility { get; set; }
public DateOnly StartDate { get; set; }
public DateOnly? EndDate { get; set; }
public string Status { get; set; } = "Entwurf";
}
@@ -0,0 +1,32 @@
using OmsorgCore.Domain.Common;
namespace OmsorgCore.Domain.Entities;
/// <summary>
/// Core-Objekt "Mitarbeiter" — digitale Personalakte (REQUIREMENTS.md Abschnitt 6, Blueprint 19.1).
/// Kernfelder für das Fundament; Ausbau (Qualifikationen, Fortbildungen, Dokumente, ...) folgt in
/// späteren Schritten.
/// </summary>
public class Employee : AuditableEntity
{
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public DateOnly? DateOfBirth { get; set; }
public string? Street { get; set; }
public string? PostalCode { get; set; }
public string? City { get; set; }
public string? Country { get; set; }
public string? PhoneNumber { get; set; }
public string? Email { get; set; }
public DateOnly? EntryDate { get; set; }
public DateOnly? ExitDate { get; set; }
public string Status { get; set; } = "Aktiv";
public string? EmergencyContactName { get; set; }
public string? EmergencyContactPhone { get; set; }
public string? EmergencyContactRelation { get; set; }
public string? EmploymentType { get; set; }
public string? Qualification { get; set; }
/// <summary>Dateiname des Profilbilds - die Datei selbst liegt weiterhin lokal beim jeweiligen Client (z. B. omsorgWeb/assets/avatars/), hier nur die Zuordnung.</summary>
public string? AvatarFileName { get; set; }
}
@@ -0,0 +1,17 @@
using OmsorgCore.Domain.Common;
namespace OmsorgCore.Domain.Entities;
/// <summary>
/// Core-Objekt "Einrichtung" — CRM-/Kundendatensatz (REQUIREMENTS.md Abschnitt 6, Blueprint 19.2).
/// </summary>
public class Facility : AuditableEntity
{
public string Name { get; set; } = string.Empty;
public string? FacilityType { get; set; }
public string? Address { get; set; }
public string? BillingAddress { get; set; }
public string? PhoneNumber { get; set; }
public string? Email { get; set; }
public string CrmStatus { get; set; } = "Lead";
}
@@ -0,0 +1,24 @@
using OmsorgCore.Domain.Common;
namespace OmsorgCore.Domain.Entities;
/// <summary>
/// Core-Objekt "Rechnung" — entsteht ausschließlich aus freigegebener Zeiterfassung + Konditionen
/// der Einrichtung (REQUIREMENTS.md Abschnitt 6, Blueprint 19.6, Regel FR-RE-1/FR-ZE-3).
/// </summary>
public class Invoice : AuditableEntity
{
public string InvoiceNumber { get; set; } = string.Empty;
public Guid FacilityId { get; set; }
public Facility Facility { get; set; } = null!;
public DateOnly BillingPeriodStart { get; set; }
public DateOnly BillingPeriodEnd { get; set; }
public decimal NetAmount { get; set; }
public decimal GrossAmount { get; set; }
public string Status { get; set; } = "Entwurf";
public DateOnly? DueDate { get; set; }
}
@@ -0,0 +1,15 @@
using OmsorgCore.Domain.Common;
namespace OmsorgCore.Domain.Entities;
/// <summary>
/// Protokolliert jeden Login-Versuch (Erfolg und Fehlschlag) IP-basiert - Grundlage für die
/// zentrale Brute-Force-Sperre in <see cref="OmsorgCore.Application.Services.AuthService"/>.
/// </summary>
public class LoginAttempt : Entity
{
public string IpAddress { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public bool Succeeded { get; set; }
public DateTime AttemptedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,17 @@
using OmsorgCore.Domain.Common;
namespace OmsorgCore.Domain.Entities;
/// <summary>
/// Core-Objekt "Auftrag" — Personalbedarf einer Einrichtung (REQUIREMENTS.md Abschnitt 6, Blueprint 19.4).
/// </summary>
public class Order : AuditableEntity
{
public Guid FacilityId { get; set; }
public Facility Facility { get; set; } = null!;
public DateOnly StartDate { get; set; }
public DateOnly? EndDate { get; set; }
public string? RequiredQualification { get; set; }
public string Status { get; set; } = "Anfrage";
}
@@ -0,0 +1,35 @@
using OmsorgCore.Domain.Common;
namespace OmsorgCore.Domain.Entities;
/// <summary>
/// Einmal-PIN für den "Passwort vergessen"-Flow. Es wird nie der rohe 6-stellige PIN gespeichert,
/// nur sein SHA-256-Hash (analog RefreshToken.TokenHash). Nach erfolgreicher Verifikation wird ein
/// separater, ebenfalls nur gehashter Reset-Token ausgestellt (ResetTokenHash), der für den
/// abschließenden Passwort-Set-Aufruf gebraucht wird - der PIN selbst wird nie zweimal als
/// Credential verwendet (siehe RefreshToken-Rotationsmuster).
/// </summary>
public class PasswordResetCode : Entity
{
public Guid UserId { get; set; }
public User User { get; set; } = null!;
public string CodeHash { get; set; } = string.Empty;
public int AttemptCount { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime ExpiresAt { get; set; }
public DateTime? ConsumedAt { get; set; }
public DateTime? InvalidatedAt { get; set; }
/// <summary>Nur gesetzt, nachdem der PIN erfolgreich verifiziert wurde (ConsumedAt != null).</summary>
public string? ResetTokenHash { get; set; }
public DateTime? ResetTokenExpiresAt { get; set; }
public DateTime? ResetTokenUsedAt { get; set; }
public bool IsActive => InvalidatedAt is null && ConsumedAt is null
&& DateTime.UtcNow < ExpiresAt;
public bool IsResetTokenActive => ResetTokenHash is not null && ResetTokenUsedAt is null
&& ResetTokenExpiresAt is not null && DateTime.UtcNow < ResetTokenExpiresAt;
}
@@ -0,0 +1,22 @@
using OmsorgCore.Domain.Common;
namespace OmsorgCore.Domain.Entities;
/// <summary>
/// Langlebiges Gegenstück zum kurzlebigen JWT-Access-Token. Rotiert bei jeder Nutzung
/// (<see cref="ReplacedByTokenId"/>), damit ein Nutzer nicht täglich neu einloggen muss.
/// Es wird nie der Rohtoken gespeichert, nur sein Hash.
/// </summary>
public class RefreshToken : Entity
{
public Guid UserId { get; set; }
public User User { get; set; } = null!;
public string TokenHash { get; set; } = string.Empty;
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? RevokedAt { get; set; }
public Guid? ReplacedByTokenId { get; set; }
public bool IsActive => RevokedAt is null && DateTime.UtcNow < ExpiresAt;
}
@@ -0,0 +1,15 @@
using OmsorgCore.Domain.Common;
namespace OmsorgCore.Domain.Entities;
/// <summary>
/// Rolle als Rechte-Vorlage (z. B. Geschäftsführung, Disposition, Recruiting, Außendienst).
/// Siehe REQUIREMENTS.md Abschnitt 3 und 7.
/// </summary>
public class Role : Entity
{
public string Name { get; set; } = string.Empty;
public ICollection<RolePermission> RolePermissions { get; set; } = new List<RolePermission>();
public ICollection<User> Users { get; set; } = new List<User>();
}
@@ -0,0 +1,16 @@
using OmsorgCore.Domain.Common;
using OmsorgCore.Domain.Enums;
namespace OmsorgCore.Domain.Entities;
/// <summary>
/// Standard-Recht einer Rolle für ein Modul/eine Aktion.
/// </summary>
public class RolePermission : Entity
{
public Guid RoleId { get; set; }
public Role Role { get; set; } = null!;
public ModuleType Module { get; set; }
public PermissionAction Action { get; set; }
}
@@ -0,0 +1,24 @@
using OmsorgCore.Domain.Common;
namespace OmsorgCore.Domain.Entities;
/// <summary>
/// Core-Objekt "Zeiterfassung" — geleistete Arbeitszeit eines Mitarbeiters innerhalb eines Auftrags
/// (REQUIREMENTS.md Abschnitt 6, Blueprint 19.5). Nur freigegebene Einträge (Status "Freigegeben")
/// dürfen in die Rechnungserstellung einfließen (FR-ZE-3).
/// </summary>
public class TimeEntry : AuditableEntity
{
public Guid EmployeeId { get; set; }
public Employee Employee { get; set; } = null!;
public Guid OrderId { get; set; }
public Order Order { get; set; } = null!;
public DateOnly Date { get; set; }
public TimeOnly Start { get; set; }
public TimeOnly End { get; set; }
public TimeSpan BreakDuration { get; set; }
public string Status { get; set; } = "Entwurf";
}
@@ -0,0 +1,36 @@
using OmsorgCore.Domain.Common;
namespace OmsorgCore.Domain.Entities;
/// <summary>
/// Systembenutzer (Login-Konto). Getrennt von <see cref="Employee"/>, da nicht jeder Mitarbeiter
/// einen App-Zugang hat und nicht jeder Benutzer zwingend ein Mitarbeiter ist (siehe Blueprint 19.1).
/// </summary>
public class User : AuditableEntity
{
public string Username { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
/// <summary>
/// Wechselt, wenn eine Session dieses Users erzwungen beendet wird (siehe SessionAdminService).
/// Wird als Claim in jeden Access-Token eingebettet und bei jedem Request dagegen geprüft -
/// ein Wechsel invalidiert sofort alle bereits ausgestellten Access-Tokens dieses Users.
/// </summary>
public Guid SecurityStamp { get; set; } = Guid.NewGuid();
/// <summary>
/// Erzwingt beim nächsten Request (außer Passwort-ändern/Logout/Refresh/Me) einen Passwortwechsel -
/// gesetzt, wenn ein Admin ein initiales Passwort direkt vergibt statt eine Einladungsmail zu senden.
/// </summary>
public bool MustChangePassword { get; set; }
public Guid RoleId { get; set; }
public Role Role { get; set; } = null!;
/// <summary>Optionale Verknüpfung zum Personalakte-Datensatz (nicht jeder Benutzer ist Mitarbeiter).</summary>
public Guid? EmployeeId { get; set; }
public Employee? Employee { get; set; }
public ICollection<UserPermissionOverride> PermissionOverrides { get; set; } = new List<UserPermissionOverride>();
}
@@ -0,0 +1,19 @@
using OmsorgCore.Domain.Common;
using OmsorgCore.Domain.Enums;
namespace OmsorgCore.Domain.Entities;
/// <summary>
/// Individuell für einen Benutzer gewährtes oder entzogenes Recht — überschreibt den Rollen-Default.
/// Siehe Blueprint Kap. 6.5 ("Rollen dienen als Vorlage. Individuelle Rechte können zusätzlich
/// vergeben oder entzogen werden.").
/// </summary>
public class UserPermissionOverride : Entity
{
public Guid UserId { get; set; }
public User User { get; set; } = null!;
public ModuleType Module { get; set; }
public PermissionAction Action { get; set; }
public PermissionEffect Effect { get; set; }
}
@@ -0,0 +1,17 @@
namespace OmsorgCore.Domain.Enums;
/// <summary>
/// Fachliche Module, gegen die Rechte geprüft werden (siehe REQUIREMENTS.md Abschnitt 7, Rechtematrix).
/// </summary>
public enum ModuleType
{
Employees,
Facilities,
Contracts,
Orders,
TimeEntries,
Invoices,
Recruiting,
Controlling,
UserManagement
}
@@ -0,0 +1,14 @@
namespace OmsorgCore.Domain.Enums;
/// <summary>
/// Mögliche Aktionen innerhalb eines Moduls (siehe Blueprint Kap. 6.5).
/// </summary>
public enum PermissionAction
{
View,
Create,
Edit,
Delete,
Export,
Approve
}

Some files were not shown because too many files have changed in this diff Show More