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
+12
View File
@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "8.0.10",
"commands": [
"dotnet-ef"
]
}
}
}
+12
View File
@@ -0,0 +1,12 @@
bin/
obj/
*.user
.vs/
.idea/
.vscode/
*.swp
*.swo
.DS_Store
# Enthält echte Secrets (DB-Passwort, JWT-Secret) - nie einchecken
src/OmsorgCore.Api/appsettings.Development.json
+179
View File
@@ -0,0 +1,179 @@
# CLAUDE.md — omsorgCore (OMSORG Backend: Core + Engine)
Gilt zusätzlich zur Root-`CLAUDE.md`. Das Grundgerüst ist angelegt und baut (`dotnet build` läuft grün); der fachliche Umfang ist bewusst noch minimal (Fundament, nicht Vollständigkeit).
## Auftrag dieses Projekts
`omsorgCore` ist die gemeinsame Datenbasis + Automatisierungsschicht für die gesamte Plattform (siehe `REQUIREMENTS.md` Abschnitt 2 und 6, Blueprint Kap. 19/20). Es soll schrittweise ersetzen:
- die MySQL-Datenhaltung in `omsorgWeb/mitarbeiter-app`
- die lokale JSON-Datenhaltung in `omsorgapp`
Beide bestehenden Projekte sollen künftig gegen dieses Backend sprechen statt eigene, getrennte Datenspeicher zu pflegen. Diese Anbindung ist noch **nicht** erfolgt — aktueller Schritt ist nur das Backend-Fundament selbst.
## Tech-Stack (umgesetzt)
- C# / **.NET 8** (LTS)
- ASP.NET Core Web API mit **Controllern** (kein Minimal-API-Stil)
- **PostgreSQL** über `Npgsql.EntityFrameworkCore.PostgreSQL` 8.0.10 (bewusst auf net8-kompatible Version gepinnt — neuere Paketversionen zielen auf .NET 10)
- **JWT Bearer Tokens** für Auth (`Microsoft.AspNetCore.Authentication.JwtBearer`)
- Passwort-Hashing über `Microsoft.AspNetCore.Identity.PasswordHasher<T>` (PBKDF2, kein eigenes Krypto-Rad)
- **Core + Engine = eine Backend-Komponente**, kein separates Deployable für die Engine — die Event-Schicht läuft im selben Prozess wie die Datenschicht (siehe Root-`CLAUDE.md` und `REQUIREMENTS.md` Abschnitt 1.2)
## Reale Projektstruktur
```
omsorgCore/
OmsorgCore.sln
.config/dotnet-tools.json # lokales dotnet-ef Tool (dotnet tool restore)
src/
OmsorgCore.Domain/ # Entitäten, Enums. Keine Abhängigkeit auf andere Projekte.
Common/ # Entity, AuditableEntity (Basisklassen)
Enums/ # ModuleType, PermissionAction, PermissionEffect
Entities/ # Employee, Facility, Contract, Order, TimeEntry, Invoice,
# User, Role, RolePermission, UserPermissionOverride
OmsorgCore.Application/ # Business-Logik. Abhängig von Domain.
Abstractions/ # Interfaces: IEmployeeRepository, IUserRepository,
# IPasswordHasher, IJwtTokenGenerator, ICurrentUserService,
# IPermissionService
Services/ # PermissionService, AuthService, EmployeeService, SessionAdminService
DependencyInjection.cs # AddApplication()
OmsorgCore.Infrastructure/ # Technische Umsetzung. Abhängig von Domain + Application.
Persistence/
OmsorgCoreDbContext.cs
Configurations/ # ein IEntityTypeConfiguration<T> pro Entität
Migrations/ # EF-Core-Migrationen (InitialCreate bereits erzeugt)
Repositories/ # EmployeeRepository, UserRepository, RefreshTokenRepository (implementieren Application-Interfaces)
Security/ # PasswordHasher, JwtOptions, JwtTokenGenerator, RefreshTokenOptions, RefreshTokenGenerator
DependencyInjection.cs # AddInfrastructure(configuration)
OmsorgCore.Engine/ # Event-Schicht. Abhängig von Domain + Application.
Events/ # IDomainEvent, IDomainEventHandler<T>, IDomainEventDispatcher,
# DomainEventDispatcher (In-Process, kein Message-Bus), Beispiel-Event
Handlers/ # Beispiel-Handler (EmployeeCreatedHandler)
DependencyInjection.cs # AddEngine()
OmsorgCore.Api/ # ASP.NET Core Web API. Abhängig von Application+Infrastructure+Engine.
Controllers/ # AuthController, EmployeesController, HealthController, AdminSessionsController
Contracts/ # Request-/Response-DTOs (LoginRequest, EmployeeResponse, ...)
Security/ # CurrentUserService, RequirePermissionAttribute
Program.cs # einziger Ort, an dem alle Schichten verdrahtet werden
tests/
OmsorgCore.Tests/ # xUnit, referenziert Domain + Application
```
**Konvention: eine Klasse/ein Interface/ein Enum pro Datei.** Ausnahme: keine — auch kleine DTOs (Requests/Responses als `record`) bekommen eine eigene Datei.
**Abhängigkeitsrichtung ist strikt:** Domain kennt niemanden. Application kennt nur Domain und definiert Interfaces, die Infrastructure implementiert (Ports-and-Adapters). Engine kennt Domain + Application, nicht Infrastructure oder Api. Api verdrahtet alles ausschließlich in `Program.cs` — Controller rufen nur Application-Services und den `IDomainEventDispatcher` (Engine) auf, **niemals** direkt `OmsorgCoreDbContext`/EF Core.
## Rechtesystem
Rolle liefert Standard-Rechte (`RolePermission`: Modul × Aktion), ein individueller `UserPermissionOverride` (Grant/Revoke) gewinnt immer gegen den Rollen-Default — siehe `PermissionService.HasPermissionAsync` (`src/OmsorgCore.Application/Services/PermissionService.cs`). Deckt Blueprint 6.5 ("Rolle als Vorlage + individuelle Rechte") ab.
Die aufgelösten Rechte eines Users (nicht nur eine einzelne Prüfung) liefert `PermissionService.GetGrantedPermissionsAsync` als Liste von `PermissionGrant(Module, Action)`. Exponiert über `GET /api/auth/me` (`AuthController.Me`, `[Authorize]`) als `MeResponse { username, role, permissions: [{ module, action }, ...] }` — der einzige Weg, wie granulare Rechte den Client erreichen (das JWT trägt nur den Rollennamen). `omsorgapp` ruft diesen Endpunkt nach Login/Refresh auf (siehe `omsorgapp/CLAUDE.md`, "Rechtesystem im Client") und trifft UI-Entscheidungen darüber statt über einen Rollennamen-Vergleich.
Rechteprüfung auf Controller-Actions:
```csharp
[RequirePermission(ModuleType.Employees, PermissionAction.Create)]
```
Das Attribut (`src/OmsorgCore.Api/Security/RequirePermissionAttribute.cs`) prüft serverseitig über `IPermissionService` — nicht nur im Client (REQUIREMENTS.md NFR-9). Jeder Controller außer `AuthController` trägt zusätzlich `[Authorize]`.
## Auth-Flow
1. `POST /api/auth/login` (`AuthController`) → `AuthService.LoginAsync` prüft Username/Passwort-Hash, widerruft **alle bisherigen aktiven Refresh-Tokens dieses Users und würfelt seinen `SecurityStamp` neu** (`EndOtherSessionsAsync` — ein User hat immer nur eine aktive Session; ältere Sessions werden per Killswitch sofort ungültig, siehe "Session-Killswitch" unten), dann erzeugt `JwtTokenGenerator` ein Access-Token (Claims `sub`/`name`/`role`) + `RefreshTokenGenerator` einen langlebigen Refresh-Token. Response (`LoginResponse`): `accessToken`, `refreshToken`, `expiresAt` (camelCase, Default-JSON-Serialisierung von ASP.NET Core).
2. Client sendet Access-Token als `Authorization: Bearer <token>`.
3. `Program.cs` validiert das Token gegen `Jwt:Issuer`/`Jwt:Audience`/`Jwt:Secret` aus der Konfiguration.
4. `POST /api/auth/refresh` (kein `[Authorize]` — der Refresh-Token selbst ist das Credential): `AuthService.RefreshAsync` prüft den Refresh-Token per Hash-Lookup, **rotiert** ihn (alten Token widerrufen, neuen ausstellen, per `ReplacedByTokenId` verkettet) und liefert ein neues Token-Paar. Erlaubt langlebige Sessions ohne täglichen Passwort-Login (siehe `omsorgapp/CLAUDE.md`).
5. `POST /api/auth/logout` widerruft den vorgelegten Refresh-Token (`AuthService.RevokeAsync`, idempotent).
**Refresh-Token:** kein Rohtoken wird gespeichert, nur sein SHA-256-Hash (`RefreshToken`-Entity, `IRefreshTokenGenerator`). Gültigkeit über `RefreshToken:ExpiryDays` in `appsettings.json` (Default 60 Tage, sliding — jede Nutzung verlängert effektiv die Session), überschreibbar per `RefreshToken__ExpiryDays`.
**Secret-Handling:** `appsettings.json` enthält nur Issuer/Audience/ExpiryMinutes/RefreshToken:ExpiryDays. `appsettings.Development.json` enthält einen **lokalen Platzhalter** für `Jwt:Secret` und den Connection-String (`CHANGE_ME_...`) — für echte Umgebungen über Umgebungsvariable (`Jwt__Secret`) oder `dotnet user-secrets` überschreiben, nie ein echtes Secret einchecken.
**Standard-Admin-Seed:** `DbSeeder.SeedDefaultAdminAsync` (`src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs`) legt beim Start **nur im Development-Modus** (`Program.cs`, `app.Environment.IsDevelopment()`) einen Benutzer `admin`/`abersicher` mit einer neuen Rolle `Administrator` (alle `ModuleType`×`PermissionAction`-Kombinationen als `RolePermission`) an — idempotent, läuft nur wenn noch **kein** `User` existiert. DB-Fehler dabei (z. B. keine Verbindung — Migrationen sind zu diesem Zeitpunkt bereits automatisch angewendet, siehe "Datenbank" unten) sind nicht fatal, werden nur geloggt (`try/catch` um den Seed-Aufruf). Bewusst **nicht** in Produktion aktiv, um kein bekanntes Standard-Passwort auszuliefern — für einen echten Produktivbetrieb muss ein richtiger User-Anlage-Flow her.
## Session-Killswitch (SecurityStamp)
Ein Access-Token ist als JWT zustandslos gültig bis zum Ablauf (60 Min) — ein reiner Refresh-Token-Widerruf verhindert nur das *stille Verlängern*, der bereits ausgestellte Access-Token bliebe sonst bis zu 60 Minuten gültig. Für einen echten Not-Aus-Schalter (Debug-Sicht in `omsorgapp`, "alle Nutzer sofort zum Neu-Login zwingen") trägt `User.SecurityStamp` (Guid) einen Wert, der:
1. bei jedem Access-Token-Ausstellen als Claim `"sst"` mit eingebettet wird (`JwtTokenGenerator`),
2. bei **jedem** authentifizierten Request per `JwtBearerEvents.OnTokenValidated` (`Program.cs`) gegen den aktuellen `User.SecurityStamp` in der DB geprüft wird (`IUserRepository.GetByIdAsync`, schlanker Lookup ohne Includes) — bei Mismatch oder fehlendem Claim `context.Fail(...)`, sofort 401, unabhängig von der Token-Restlaufzeit.
`ISessionAdminService`/`SessionAdminService` (`src/OmsorgCore.Application/Services/`) kapselt die Admin-Operationen:
- `GetActiveSessionsAsync()` — alle nicht widerrufenen/nicht abgelaufenen Refresh-Tokens (`IRefreshTokenRepository.GetAllActiveAsync`) als `SessionInfo` (Id, Username, CreatedAt, ExpiresAt).
- `RevokeSessionAsync(sessionId)` — widerruft genau diesen Refresh-Token und würfelt den `SecurityStamp` nur des zugehörigen Users neu (sofortiger Kick für genau diesen Nutzer).
- `RevokeAllSessionsAsync()` — widerruft alle aktiven Refresh-Tokens und würfelt `SecurityStamp` **aller** Nutzer neu (globaler Killswitch).
Exponiert über `AdminSessionsController` (`GET /api/admin/sessions`, `POST /api/admin/sessions/{id}/revoke`, `POST /api/admin/sessions/revoke-all`), geschützt über `[RequirePermission(ModuleType.UserManagement, ...)]` — per Default nur die `Administrator`-Rolle aus dem `DbSeeder`. Im Frontend: `omsorgapp/src/modules/debug/DebugSessionsPage.jsx`, im Sidebar-Menü nur sichtbar, wenn `hasPermission("UserManagement", "View")` (siehe `GET /api/auth/me` unten) — dieselbe Rechteprüfung wie serverseitig, kein reiner Rollennamen-Vergleich mehr im Client.
**Kosten:** ein zusätzlicher DB-Read pro authentifiziertem Request (`GetByIdAsync`). Für die aktuelle Nutzerzahl vernachlässigbar — bei relevantem Traffic-Wachstum wäre ein Cache (z. B. In-Memory mit kurzer TTL) der nächste Schritt, aber kein Caching ohne echten Bedarf, um die Sofortigkeit des Killswitches nicht zu unterlaufen.
## Passwort-Reset / E-Mail-Versand
Vollständig implementiert: `PasswordResetCode`-Entity + `PasswordResetService` (PIN anfordern/verifizieren, Passwort setzen, siehe Auth-Flow-Analogie: Session-Revoke + `SecurityStamp`-Rotation nach erfolgreichem Reset), exponiert über `AuthController` (`POST /api/auth/forgot-password/{request,verify,reset}`).
**E-Mail-Versand:** `IEmailSender` (`OmsorgCore.Application.Abstractions`) hat zwei Implementierungen in `OmsorgCore.Email`, Auswahl über `Email:Provider` zur Startzeit (`DependencyInjection.AddEmail`):
- `"Console"` (Default) — schreibt nur ins Log, kein echter Versand. Sicherer Default für Umgebungen ohne SMTP-Konfiguration.
- `"Smtp"` — echter Versand über MailKit (`SmtpEmailSender`). **Ein gemeinsamer SMTP-Server (`Email:Smtp:*`), darüber mehrere benannte Accounts (`Email:Accounts:<Name>:*`)** — z. B. ein Account, der exklusiv für Passwort-Reset-Mails verwendet wird, getrennt von einem künftigen allgemeinen Absender. Welcher Account für den Reset-Flow gilt, steht in `Email:PasswordResetAccount` (Default `"PasswordReset"`); `EmailMessage.FromAccountKey` transportiert die Auswahl vom Aufrufer bis zum `SmtpEmailSender`. Details der Keys: `CONFIGURATION.md`. **Zugangsdaten (Host/Username/Passwort) kommen ausschließlich aus appsettings/Env-Var/user-secrets, nie über eine API — es gibt bewusst keinen Settings-Controller oder UI-Formular dafür.**
**Templating:** Betreff/Text der Passwort-Reset-Mail sind über `Email:PasswordResetTemplate:{Subject,BodyTemplate}` konfigurierbar, Platzhalter im Format `$NAME$` (`$RESET_PIN$`, `$RESET_PIN_EXPIRY_MINUTES$`), ersetzt von `EmailTemplateRenderer.Render` (pure Funktion, unit-getestet in `EmailTemplateRendererTests`). Die Zusammensetzung passiert in `PasswordResetEmailComposer` (`OmsorgCore.Email`, implementiert `IPasswordResetEmailComposer` aus `Application.Abstractions`) — bewusst nicht direkt im `Engine`-Handler, weil `Engine` nur `Domain`+`Application` kennen darf, nicht `Email` (Abhängigkeitsrichtung, siehe oben). `PasswordResetRequestedHandler` (`Engine/Handlers/`) ruft nur `IPasswordResetEmailComposer.Compose(...)` + `IEmailSender.SendAsync(...)` auf.
**Testversand ohne Zugangsdaten-Leak:** `AdminEmailController` (`POST /api/admin/email/test-send`, `[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]`) verschickt eine feste Testmail über den aktuell konfigurierten `IEmailSender` — Host/Username/Passwort verlassen dabei nie den Server, nur Erfolg/Fehlschlag geht an den Client. Im Frontend: `omsorgapp/src/modules/debug/DebugSessionsPage.jsx`, gleicher Rechteschutz wie die Sessions-Verwaltung dort.
## Datenbank
- Connection-String-Key: `ConnectionStrings:OmsorgCore` (Format `Host=...;Port=5432;Database=omsorg_core;Username=...;Password=...`).
- `dotnet-ef` ist als lokales Tool eingerichtet (`.config/dotnet-tools.json`) — vor erster Nutzung `dotnet tool restore`. Wird nur noch zum **Erzeugen** neuer Migrationen gebraucht (`dotnet ef migrations add ...`), nicht mehr zum Anwenden.
- **`Program.cs` ruft bei jedem Start `db.Database.MigrateAsync()` auf, in jeder Umgebung** (nicht nur Development) — ausstehende Migrationen werden automatisch angewendet, bevor der Server Requests annimmt. Ein manuelles `dotnet ef database update` ist dadurch nur noch zum gezielten Vorab-Prüfen/Debuggen einer Migration nötig, nicht mehr für den normalen Start/Deploy. Schlägt die Migration fehl, crasht der Start bewusst fatal (fail-fast) statt mit einem veralteten Schema weiterzulaufen.
- Migrationen `InitialCreate`, `AddRefreshTokens`, `AddUserSecurityStamp`, `AddEmployeeContactFieldConstraints` und `AddAuditableSoftDelete` existieren (`src/OmsorgCore.Infrastructure/Persistence/Migrations/`) und wurden erfolgreich gegen eine echte PostgreSQL-Instanz angewendet.
## Build- und Run-Befehle
```bash
cd omsorgCore
dotnet build # ganze Solution
dotnet tool restore # einmalig, für dotnet-ef
# Neue Migration erzeugen, nachdem sich das Entity-Modell geändert hat (wird NICHT automatisch angewendet):
dotnet tool run dotnet-ef migrations add <Name> \
--project src/OmsorgCore.Infrastructure/OmsorgCore.Infrastructure.csproj \
--startup-project src/OmsorgCore.Api/OmsorgCore.Api.csproj
# Angewendet wird sie automatisch beim nächsten Start der API (siehe oben) — kein manueller
# `database update`-Schritt im Normalfall mehr nötig.
# API starten (lädt appsettings.Development.json):
ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/OmsorgCore.Api
# → GET /api/health, POST /api/auth/login, GET/POST /api/employees (Bearer-Token nötig)
# Swagger UI unter /swagger im Development-Modus
```
## Verifiziert
- `dotnet build` für alle 6 Projekte: grün. `dotnet test`: grün (u. a. `AuthServiceTests` mit Fake-Repositories für Login/Refresh-Rotation/Revoke).
- `dotnet tool run dotnet-ef database update` erfolgreich gegen echte PostgreSQL-Instanz ausgeführt (`InitialCreate` + `AddRefreshTokens`).
- API-Start mit `ASPNETCORE_ENVIRONMENT=Development`: `GET /api/health``{"status":"ok","databaseReachable":true}`. `POST /api/auth/login`/`refresh` mit falschen/unbekannten Credentials → 401, `POST /api/auth/logout` → 204. `GET /api/employees` ohne Token → 401 (Auth-Pipeline greift korrekt).
- `omsorgapp`s `authClient.cjs` erfolgreich gegen den laufenden Server getestet (Login/Refresh/Logout-Fehlerfälle).
- **Kompletter Login-Flow end-to-end mit echtem Postgres verifiziert:** Login mit `admin`/`abersicher` (Seed) → gültiges Token-Paar; `refresh` rotiert korrekt (neues Paar, alter Refresh-Token danach 401 bei Wiederverwendung); `GET /api/employees` mit frischem Access-Token → 200 (Administrator-Rolle hat volle Rechte über den Seed).
- **Session-Killswitch end-to-end verifiziert:** `GET /api/admin/sessions` liefert aktive Sessions; `POST /api/admin/sessions/revoke-all` → 204, danach liefert **derselbe, zuvor gültige Access-Token sofort 401** (nicht erst nach Ablauf) und der zugehörige Refresh-Token liefert bei `POST /api/auth/refresh` ebenfalls 401. Erneuter Login mit `admin`/`abersicher` funktioniert danach wieder normal.
## Offene Punkte
- Facility/Contract/Order/TimeEntry/Invoice haben noch keine Controller/Repositories/Services — nur Domain-Entitäten + DB-Konfiguration. Nächste Schritte folgen demselben Muster wie `Employee` (Repository-Interface in Application, Implementierung in Infrastructure, Service in Application, Controller in Api).
- Keine E-Mail-Verifizierung bei User-Anlage (Passwort-Reset per E-Mail ist fertig, siehe "Passwort-Reset / E-Mail-Versand" oben).
- `omsorgapp` spricht seit Kurzem gegen dieses Backend (Login-Screen + Refresh-Token-Session, siehe `omsorgapp/CLAUDE.md`) — `omsorgWeb` ist noch nicht angebunden.
- Kein Docker-/CI-Setup.
## Die sechs Core-Objekte (Domain-Entitäten)
Aus `REQUIREMENTS.md` Abschnitt 6 / Blueprint Kap. 19: **Mitarbeiter, Einrichtung, Vertrag, Auftrag, Zeiterfassung, Rechnung** — als `Employee`, `Facility`, `Contract`, `Order`, `TimeEntry`, `Invoice` in `src/OmsorgCore.Domain/Entities/` angelegt, mit Kernfeldern (nicht vollständig ausmodelliert).
Verbindliche Regeln für das Datenmodell:
1. Jede Entität hat eine eindeutige `Guid Id` (siehe `Entity`-Basisklasse).
2. Beziehungen ausschließlich über Foreign Keys/IDs, keine redundante Texteingabe verwandter Daten.
3. Stammdaten nur an einer Stelle — kein Feld, das auch in `omsorgWeb` oder `omsorgapp` unabhängig gepflegt wird, sobald die Migration dorthin begonnen hat.
4. Änderungen an geschäftsrelevanten Daten müssen nachvollziehbar sein — `AuditableEntity` liefert `CreatedAt`/`UpdatedAt`; ein vollständiger Audit-Trail (wer hat was geändert) ist noch nicht gebaut.
5. Kein Hard-Delete für sensible/geschäftsrelevante Daten — Soft-Delete/Archivierung (noch nicht implementiert, bei Bedarf einbauen statt Datensätze zu löschen).
6. Berechtigungsprüfung auf Daten- und Funktionsebene (siehe Rechtesystem oben und Rechtematrix in `REQUIREMENTS.md` Abschnitt 7).
7. Rechnungen entstehen ausschließlich aus freigegebener Zeiterfassung (FR-ZE-3/FR-RE-1) — muss bei Ausbau von `TimeEntry`/`Invoice` auf Domain-/Application-Ebene erzwungen werden, nicht nur als UI-Regel im Client.
## Event-Schicht (Engine) — Funktionsweise
`IDomainEventDispatcher` (Singleton, In-Process) löst über den DI-Container alle registrierten `IDomainEventHandler<TEvent>` für ein Event auf und ruft sie auf. Beispiel: `EmployeesController.Create` ruft nach dem Speichern `_dispatcher.DispatchAsync(new EmployeeCreatedEvent(created.Id))` auf, `EmployeeCreatedHandler` reagiert darauf (aktuell nur Logging). Neue Trigger aus `REQUIREMENTS.md` Abschnitt 4.10 (Krankmeldung, fehlender Tätigkeitsnachweis, Vertragsende, ...) folgen demselben Muster: Event-Klasse in `Engine/Events/`, Handler in `Engine/Handlers/`, Registrierung in `Engine/DependencyInjection.cs`, Dispatch-Aufruf an der Stelle im Api-Layer, wo das auslösende Ereignis passiert.
**Wichtig:** `Application`-Services dispatchen bewusst *nicht* selbst (sie kennen `Engine` nicht, das würde die Abhängigkeitsrichtung verletzen) — das Dispatchen passiert im Api-Layer, der als einziger alle Schichten kennt.
+87
View File
@@ -0,0 +1,87 @@
# CONFIGURATION.md — omsorgCore
Übersicht aller Konfigurationswerte des Backends: was ist einstellbar, was ist der Default, wie überschreibt man ihn. Ziel: **kein Wert, den ein Betreiber sinnvoll ändern könnte, steckt fest im Code.**
## Wie Konfiguration geladen wird
ASP.NET Core liest Konfiguration in dieser Reihenfolge (später gewinnt):
1. `appsettings.json` — Defaults, die für jede Umgebung gelten.
2. `appsettings.{ASPNETCORE_ENVIRONMENT}.json` (z. B. `appsettings.Development.json`) — Overrides pro Umgebung. **Enthält keine echten Secrets**, nur `CHANGE_ME_...`-Platzhalter.
3. Umgebungsvariablen — verschachtelte Keys wie `Jwt:Secret` werden zu `Jwt__Secret` (doppelter Unterstrich statt Doppelpunkt).
4. In Development zusätzlich: `dotnet user-secrets` (lokal, nicht eingecheckt) — `dotnet user-secrets init` im `OmsorgCore.Api`-Projekt einmalig ausführen, dann z. B. `dotnet user-secrets set "ConnectionStrings:OmsorgCore" "..."`.
**Secrets nie einchecken.** `Jwt:Secret`, `ConnectionStrings:OmsorgCore` und (sobald produktiv genutzt) `Seed:AdminPassword` haben in den eingecheckten `appsettings*.json`-Dateien nur Platzhalter — echte Werte kommen aus Umgebungsvariablen oder User-Secrets.
## Auth / JWT
| Key | Default | Env-Var | Beschreibung |
|---|---|---|---|
| `Jwt:Secret` | *(kein Default, Platzhalter in Dev)* | `Jwt__Secret` | Signaturschlüssel für Access-Tokens. Muss mind. 32 Zeichen, produktiv nur per Env-Var/Secret-Store. |
| `Jwt:Issuer` | `"OmsorgCore"` | `Jwt__Issuer` | JWT-`iss`-Claim, wird beim Validieren geprüft. |
| `Jwt:Audience` | `"OmsorgClients"` | `Jwt__Audience` | JWT-`aud`-Claim, wird beim Validieren geprüft. |
| `Jwt:ExpiryMinutes` | `60` | `Jwt__ExpiryMinutes` | Gültigkeitsdauer eines Access-Tokens. |
| `RefreshToken:ExpiryDays` | `60` | `RefreshToken__ExpiryDays` | Gültigkeitsdauer eines Refresh-Tokens (sliding — jede Nutzung/Rotation verlängert effektiv die Session). |
| `Auth:MaxLoginFailures` | `5` | `Auth__MaxLoginFailures` | Fehlversuche pro IP innerhalb von `Auth:LoginLockoutMinutes`, ab denen `POST /api/auth/login` mit `429` sperrt (zentral für alle Clients, siehe `AuthService.LoginAsync`/`LoginAttempt`). |
| `Auth:LoginLockoutMinutes` | `10` | `Auth__LoginLockoutMinutes` | Zeitfenster, in dem Fehlversuche gezählt werden, bevor die Sperre wieder abläuft. |
## Passwort-Reset / E-Mail
| Key | Default | Env-Var | Beschreibung |
|---|---|---|---|
| `Email:Provider` | `"Console"` | `Email__Provider` | Mail-Versandweg. `Console` schreibt Mails nur ins Log (sicherer Default). `Smtp` versendet echt über die Werte unten (implementiert über MailKit). |
| `Email:PinExpiryMinutes` | `5` | `Email__PinExpiryMinutes` | Gültigkeitsdauer des 6-stelligen PINs beim "Passwort vergessen"-Flow. |
| `Email:MaxAttempts` | `3` | `Email__MaxAttempts` | Maximale Anzahl Fehlversuche bei der PIN-Eingabe, bevor der Code invalidiert wird. |
| `Email:ResetTokenExpiryMinutes` | `10` | `Email__ResetTokenExpiryMinutes` | Gültigkeitsdauer des Reset-Tokens nach erfolgreicher PIN-Verifikation. |
| `Email:RequestCooldownSeconds` | `60` | `Email__RequestCooldownSeconds` | Mindestabstand zwischen zwei Reset-Anfragen desselben Users (Spam-Schutz). |
**Ein SMTP-Server, mehrere Accounts:** die Verbindungsdaten (`Email:Smtp:*`) sind einmal gemeinsam konfiguriert, Login + Absenderidentität liegen pro benanntem Account unter `Email:Accounts:<Name>:*` — so lässt sich z. B. ein Account exklusiv für Passwort-Reset-Mails führen, getrennt von einem künftigen allgemeinen Absender, ohne einen zweiten Server zu brauchen.
| Key | Default | Env-Var | Beschreibung |
|---|---|---|---|
| `Email:Smtp:Host` | *(leer)* | `Email__Smtp__Host` | SMTP-Server-Adresse. Nur wirksam, wenn `Email:Provider` = `"Smtp"`. |
| `Email:Smtp:Port` | `587` | `Email__Smtp__Port` | SMTP-Port. |
| `Email:Smtp:EnableSsl` | `true` | `Email__Smtp__EnableSsl` | Ob StartTLS/SSL beim Verbindungsaufbau verwendet wird. |
| `Email:Accounts:<Name>:Username` | *(leer, Platzhalter in Dev)* | `Email__Accounts__<Name>__Username` | SMTP-Login-Username dieses Accounts. Secret — nie echten Wert einchecken. |
| `Email:Accounts:<Name>:Password` | *(leer, Platzhalter in Dev)* | `Email__Accounts__<Name>__Password` | SMTP-Login-Passwort dieses Accounts. Secret — produktiv nur per Env-Var/user-secrets, **nie** in `appsettings*.json` einchecken. |
| `Email:Accounts:<Name>:SenderAddress` | *(leer)* | `Email__Accounts__<Name>__SenderAddress` | Absenderadresse dieses Accounts. |
| `Email:Accounts:<Name>:SenderDisplayName` | `"OMSORG"` | `Email__Accounts__<Name>__SenderDisplayName` | Absendername dieses Accounts. |
| `Email:PasswordResetAccount` | `"PasswordReset"` | `Email__PasswordResetAccount` | Welcher Account-Name aus `Email:Accounts` für Passwort-Reset-Mails verwendet wird. Standardmäßig ist `"PasswordReset"` bereits als Account in `appsettings.json` angelegt (leer, muss per Env-Var/user-secrets befüllt werden). |
| `Email:PasswordResetTemplate:Subject` | `"Ihr OMSORG Passwort-Reset-Code"` | `Email__PasswordResetTemplate__Subject` | Betreff der Passwort-Reset-Mail. |
| `Email:PasswordResetTemplate:BodyTemplate` | siehe `appsettings.json` | `Email__PasswordResetTemplate__BodyTemplate` | Text der Passwort-Reset-Mail. Platzhalter im Format `$NAME$`: `$RESET_PIN$` (der 6-stellige Code) und `$RESET_PIN_EXPIRY_MINUTES$` (aus `Email:PinExpiryMinutes`). Ersetzung über `OmsorgCore.Email.EmailTemplateRenderer`. |
| `Email:UserInviteAccount` | `"UserInvite"` | `Email__UserInviteAccount` | Welcher Account-Name aus `Email:Accounts` für Account-Einladungsmails (neuer User-Account für einen Mitarbeiter) verwendet wird. |
| `Email:UserInviteTemplate:Subject` | `"Ihr OMSORG-Zugang"` | `Email__UserInviteTemplate__Subject` | Betreff der Einladungsmail. |
| `Email:UserInviteTemplate:BodyTemplate` | siehe `appsettings.json` | `Email__UserInviteTemplate__BodyTemplate` | Text der Einladungsmail. Platzhalter: `$INVITE_PIN$`, `$INVITE_PIN_EXPIRY_DAYS$`. Die Gültigkeitsdauer wird beim Anlegen des Accounts pro Einladung gewählt (`POST /api/users`, `PinValidityDays`, Default 7), nicht global konfiguriert — der Einladungs-PIN läuft über dieselbe `PasswordResetCode`-Tabelle wie der Passwort-vergessen-Flow, nur mit individuell längerer Gültigkeit statt der 5 Minuten aus `Email:PinExpiryMinutes`. |
**Kein API-Endpoint zum Setzen dieser Werte:** SMTP-Zugangsdaten und Mail-Texte werden bewusst nie über eine vom Client erreichbare Route entgegengenommen oder ausgeliefert — nur über die üblichen serverseitigen Konfigurationswege oben. Ein `POST /api/admin/email/test-send` (siehe `AdminEmailController`) löst lediglich den Versand einer festen Testmail über die bereits konfigurierte Verbindung aus, liefert aber nie Host/Username/Passwort an den Client zurück.
## Seed (nur Development)
| Key | Default | Env-Var | Beschreibung |
|---|---|---|---|
| `Seed:AdminUsername` | `"admin"` | `Seed__AdminUsername` | Username des automatisch angelegten Standard-Admin-Users. |
| `Seed:AdminPassword` | `"abersicher"` | `Seed__AdminPassword` | Passwort des Standard-Admin-Users. |
Der Seed läuft nur, wenn `ASPNETCORE_ENVIRONMENT=Development` **und** noch kein `User` in der DB existiert (idempotent, siehe `src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs`). In Produktion läuft er nicht — dort braucht es einen echten User-Anlage-Flow (noch nicht gebaut, siehe `omsorgCore/CLAUDE.md`, "Offene Punkte").
## Datenbank
| Key | Default | Env-Var | Beschreibung |
|---|---|---|---|
| `ConnectionStrings:OmsorgCore` | *(kein Default, Platzhalter in Dev)* | `ConnectionStrings__OmsorgCore` | Postgres-Connection-String (`Host=...;Port=5432;Database=omsorg_core;Username=...;Password=...`). |
## Logging / Hosting
| Key | Default | Env-Var | Beschreibung |
|---|---|---|---|
| `Logging:LogLevel:Default` | `"Information"` | `Logging__LogLevel__Default` | Standard-Log-Level. |
| `Logging:LogLevel:Microsoft.AspNetCore` | `"Warning"` | `Logging__LogLevel__Microsoft.AspNetCore` | Log-Level für das ASP.NET-Core-Framework selbst. |
| `AllowedHosts` | `"*"` | `AllowedHosts` | Host-Header-Filter von ASP.NET Core. |
## Bewusst nicht konfigurierbar
Diese Werte sind absichtlich fest im Code und **keine** Konfigurationslücke:
- **`ModuleType`, `PermissionAction`, `PermissionEffect`** (`src/OmsorgCore.Domain/Enums/`) — die Rechtematrix-Bausteine. Compile-Time-Domänenkonzepte: ein neues Modul oder eine neue Aktion braucht ohnehin neuen Controller-Code, ein Config-Wert würde hier nur Scheinflexibilität vortäuschen.
- **JWT-Validierungs-Invarianten** in `Program.cs` (`ValidateIssuer/Audience/Lifetime/IssuerSigningKey = true`) — Sicherheits-Grundannahmen, kein Betriebsparameter.
- **Passwort-Hashing-Algorithmus** (`PasswordHasher<T>`, ASP.NET-Identity-Standard-PBKDF2-Iterationszahl) — bewusst der Framework-Default, kein eigenes Krypto-Rad.
+71
View File
@@ -0,0 +1,71 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{56212531-1A9C-478F-AE76-FF2EBF4739A2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Domain", "src\OmsorgCore.Domain\OmsorgCore.Domain.csproj", "{6609F28E-F062-4A28-B161-D9A76A498987}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Application", "src\OmsorgCore.Application\OmsorgCore.Application.csproj", "{979A8C32-B0C6-461A-8BB4-D7EB247CAAB9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Infrastructure", "src\OmsorgCore.Infrastructure\OmsorgCore.Infrastructure.csproj", "{3972F31A-A0F7-4A12-882B-FB60BE774936}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Engine", "src\OmsorgCore.Engine\OmsorgCore.Engine.csproj", "{4D108EE6-C808-4543-AC6B-1B20D0900ED3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Api", "src\OmsorgCore.Api\OmsorgCore.Api.csproj", "{8DB4399D-945C-4064-AF9D-881B6BD229B4}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{CBBB0229-FAD9-4443-BA91-DFC04FDA8913}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Tests", "tests\OmsorgCore.Tests\OmsorgCore.Tests.csproj", "{5BB394B3-7350-4A6E-808F-F300742F2EEC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Email", "src\OmsorgCore.Email\OmsorgCore.Email.csproj", "{6B58D108-8D89-4D8F-A262-309D0BCF674F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{6609F28E-F062-4A28-B161-D9A76A498987}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6609F28E-F062-4A28-B161-D9A76A498987}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6609F28E-F062-4A28-B161-D9A76A498987}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6609F28E-F062-4A28-B161-D9A76A498987}.Release|Any CPU.Build.0 = Release|Any CPU
{979A8C32-B0C6-461A-8BB4-D7EB247CAAB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{979A8C32-B0C6-461A-8BB4-D7EB247CAAB9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{979A8C32-B0C6-461A-8BB4-D7EB247CAAB9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{979A8C32-B0C6-461A-8BB4-D7EB247CAAB9}.Release|Any CPU.Build.0 = Release|Any CPU
{3972F31A-A0F7-4A12-882B-FB60BE774936}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3972F31A-A0F7-4A12-882B-FB60BE774936}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3972F31A-A0F7-4A12-882B-FB60BE774936}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3972F31A-A0F7-4A12-882B-FB60BE774936}.Release|Any CPU.Build.0 = Release|Any CPU
{4D108EE6-C808-4543-AC6B-1B20D0900ED3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4D108EE6-C808-4543-AC6B-1B20D0900ED3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4D108EE6-C808-4543-AC6B-1B20D0900ED3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4D108EE6-C808-4543-AC6B-1B20D0900ED3}.Release|Any CPU.Build.0 = Release|Any CPU
{8DB4399D-945C-4064-AF9D-881B6BD229B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8DB4399D-945C-4064-AF9D-881B6BD229B4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8DB4399D-945C-4064-AF9D-881B6BD229B4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8DB4399D-945C-4064-AF9D-881B6BD229B4}.Release|Any CPU.Build.0 = Release|Any CPU
{5BB394B3-7350-4A6E-808F-F300742F2EEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5BB394B3-7350-4A6E-808F-F300742F2EEC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5BB394B3-7350-4A6E-808F-F300742F2EEC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5BB394B3-7350-4A6E-808F-F300742F2EEC}.Release|Any CPU.Build.0 = Release|Any CPU
{6B58D108-8D89-4D8F-A262-309D0BCF674F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6B58D108-8D89-4D8F-A262-309D0BCF674F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6B58D108-8D89-4D8F-A262-309D0BCF674F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6B58D108-8D89-4D8F-A262-309D0BCF674F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{6609F28E-F062-4A28-B161-D9A76A498987} = {56212531-1A9C-478F-AE76-FF2EBF4739A2}
{979A8C32-B0C6-461A-8BB4-D7EB247CAAB9} = {56212531-1A9C-478F-AE76-FF2EBF4739A2}
{3972F31A-A0F7-4A12-882B-FB60BE774936} = {56212531-1A9C-478F-AE76-FF2EBF4739A2}
{4D108EE6-C808-4543-AC6B-1B20D0900ED3} = {56212531-1A9C-478F-AE76-FF2EBF4739A2}
{8DB4399D-945C-4064-AF9D-881B6BD229B4} = {56212531-1A9C-478F-AE76-FF2EBF4739A2}
{5BB394B3-7350-4A6E-808F-F300742F2EEC} = {CBBB0229-FAD9-4443-BA91-DFC04FDA8913}
{6B58D108-8D89-4D8F-A262-309D0BCF674F} = {56212531-1A9C-478F-AE76-FF2EBF4739A2}
EndGlobalSection
EndGlobal
@@ -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; }
}

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