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:
co-authored by
Claude Sonnet 5
parent
8beb0fcf52
commit
b6c1389c55
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "8.0.10",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
###
|
||||
@@ -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
Reference in New Issue
Block a user