openapi: 3.1.0
x-document-control:
  stand: "09.08.2026 15:33"
  branch: main
  status: historisch
  replaced_by: ./openapi-uc00.yaml
  change_history:
    - version: "1.0.1-doc"
      date_time: "09.08.2026 15:33"
      change: "Als historischen API-Block gekennzeichnet; verbindlicher UC00-Vertrag in openapi-uc00.yaml"
      author: Codex

info:
  title: "DiveLogix360 API – Block 1: Auth & Invitation"
  version: 1.0.0
  contact:
    name: DiveLogix360 Support
    url: https://divelogix360.ch
  description: |
    > **Historisch:** Dieser Block bleibt als Arbeitsstand erhalten. Fuer UC00 ist
    > `openapi-uc00.yaml` verbindlich.

    ## DiveLogix360 REST API

    Dieses Dokument beschreibt Block 1 der DiveLogix360 API:
    Authentifizierung (Login, Logout, Token-Refresh, 2FA) sowie
    den Invitation-Token-Flow für neue Benutzer (Onboarding-Modell C1).

    ---

    ## API-Basis-URL – Entscheidung & Begründung (ADR-007)

    ### Gewählte Variante: `https://api.divelogix360.ch/v1`

    Die API ist unter einer dedizierten Subdomain erreichbar:

    ```
    https://api.divelogix360.ch/v1/{resource}
    ```

    **Begründung:**
    - Klare Trennung zwischen Frontend (`divelogix360.ch`) und Backend-API (`api.divelogix360.ch`)
    - Eigene DNS-Einträge ermöglichen unabhängiges Routing, Caching (CDN) und Rate-Limiting
    - TLS-Zertifikat kann separat ausgestellt und erneuert werden
    - Sicherheitsrelevant: Unterschiedliche CORS-Policies möglich (API akzeptiert nur bekannte Origins)
    - Skalierbar: API kann auf eigenem Server laufen (Hetzner), Frontend auf CDN

    ### Alternative: `https://divelogix360.ch/api/v1`

    Die API wäre unter dem gleichen Host wie das Frontend erreichbar:

    ```
    https://divelogix360.ch/api/v1/{resource}
    ```

    **Warum nicht gewählt:**
    - Kein klarer Schnitt zwischen Frontend und API
    - CORS-Konfiguration wird komplexer (gleicher Origin, aber unterschiedliche Pfade)
    - Reverse-Proxy-Regeln (Nginx/Traefik) sind fehleranfälliger
    - Schwerer zu skalieren: API und Frontend teilen denselben Einstiegspunkt
    - Sicherheitsrisiko: Path-basiertes Routing öffnet mehr Angriffsfläche bei Fehlkonfiguration

    ### Security-Hinweise (beide Varianten):
    - HTTPS (TLS 1.3) ist für beide Varianten Pflicht – kein HTTP
    - HSTS-Header muss gesetzt sein (`Strict-Transport-Security: max-age=31536000; includeSubDomains`)
    - CORS: Nur explizit erlaubte Origins (`Access-Control-Allow-Origin`)
    - API-Keys, Tokens und Credentials niemals in der URL (Query-Parameter) – immer im Header
    - Rate-Limiting auf allen Endpoints (siehe `x-rateLimit-*` Header in Responses)

    ### Versionierung:
    - URL-basiert (`/v1/`), nicht Header-basiert
    - Einfach, sichtbar, gut cachebar
    - Breaking Changes → neue Version (`/v2/`), alte Version mind. 12 Monate parallel

    > **Entwicklungsphase:** `api.divelogix360.ch` ist aktuell ein Platzhalter.
    > DNS-Eintrag wird bei Go-Live auf Hetzner Cloud gesetzt.

    ---

    ## Authentifizierungskonzept

    - **JWT Access Token:** 15 Minuten Laufzeit, signiert mit RS256
    - **Refresh Token:** 7 Tage, HttpOnly Cookie, rotiert bei jedem Refresh
    - **2FA:** TOTP (RFC 6238), Pflicht für SuperAdmin und Tenant-Admin
    - **Tokens:** Niemals im LocalStorage – ausschliesslich HttpOnly Cookies
    - **Invitation Token:** 72 Stunden gültig, einmalig einlösbar (Modell C1)

servers:
  - url: https://api.divelogix360.ch/v1
    description: Produktion (Hetzner Cloud, Go-Live)
  - url: https://api-dev.divelogix360.ch/v1
    description: Entwicklung / Staging

tags:
  - name: Auth
    description: Login, Logout, Token-Refresh, 2FA-Verifizierung
  - name: Invitation
    description: Einladungs-Token einlösen (Onboarding-Modell C1)

# ============================================================
# PATHS
# ============================================================

paths:

  # ----------------------------------------------------------
  # POST /auth/login
  # ----------------------------------------------------------
  /auth/login:
    post:
      tags: [Auth]
      summary: Benutzer einloggen
      description: |
        Authentifiziert einen Benutzer mit E-Mail und Passwort.

        **Ablauf:**
        1. Credentials validieren
        2. Wenn 2FA aktiviert: `requires_2fa: true` zurückgeben, kein Token
        3. Wenn 2FA nicht aktiviert (nur Mitarbeiter/Kunde möglich): Tokens sofort ausliefern

        **Sicherheitshinweise:**
        - Bei falschem Passwort: generischer Fehler (kein Hinweis ob E-Mail existiert)
        - Nach 5 Fehlversuchen: Account temporär gesperrt (15 Minuten)
        - Login-Ereignis wird immer in `auth_log` geschrieben (IP, User-Agent, Erfolg/Misserfolg)
      operationId: authLogin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
            example:
              email: "admin@tauchschule-muster.ch"
              password: "MeinSicheresPasswort!2024"
      responses:
        '200':
          description: Login erfolgreich
          headers:
            Set-Cookie:
              description: HttpOnly Refresh-Token Cookie
              schema:
                type: string
                example: "refresh_token=eyJ...; HttpOnly; Secure; SameSite=Strict; Path=/auth/token/refresh"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginResponse'
              examples:
                login_success:
                  summary: Direkter Login (kein 2FA)
                  value:
                    access_token: "eyJhbGciOiJSUzI1NiJ9..."
                    token_type: "Bearer"
                    expires_in: 900
                    requires_2fa: false
                    user:
                      id: "a3f9b2c1-..."
                      email: "admin@tauchschule-muster.ch"
                      role: "tenant_admin"
                      tenant_id: "b7e2d4f1-..."
                login_2fa_required:
                  summary: 2FA erforderlich
                  value:
                    access_token: null
                    token_type: null
                    expires_in: null
                    requires_2fa: true
                    two_fa_token: "tmp_7f3a9b2c..."
                    user: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '423':
          $ref: '#/components/responses/AccountLocked'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ----------------------------------------------------------
  # POST /auth/logout
  # ----------------------------------------------------------
  /auth/logout:
    post:
      tags: [Auth]
      summary: Benutzer ausloggen
      description: |
        Invalidiert den Refresh-Token des Benutzers und löscht den Cookie.

        - Access Token läuft natürlich ab (15 Min.) – kann nicht aktiv invalidiert werden
        - Refresh Token wird in der Datenbank als `revoked` markiert
        - Auth-Event wird in `auth_log` geschrieben
      operationId: authLogout
      security:
        - BearerAuth: []
      responses:
        '204':
          description: Logout erfolgreich (kein Body)
          headers:
            Set-Cookie:
              description: Refresh-Token Cookie wird gelöscht
              schema:
                type: string
                example: "refresh_token=; HttpOnly; Secure; SameSite=Strict; Max-Age=0; Path=/auth/token/refresh"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ----------------------------------------------------------
  # POST /auth/token/refresh
  # ----------------------------------------------------------
  /auth/token/refresh:
    post:
      tags: [Auth]
      summary: Access Token erneuern
      description: |
        Erneuert den Access Token anhand des HttpOnly Refresh-Token Cookies.

        **Rotation:** Bei jedem erfolgreichen Refresh wird ein neuer Refresh-Token ausgestellt
        und der alte invalidiert (Token-Rotation verhindert Wiederverwendung gestohlener Tokens).

        Der Refresh-Token wird automatisch aus dem Cookie gelesen –
        kein Request Body erforderlich.
      operationId: authTokenRefresh
      responses:
        '200':
          description: Neuer Access Token ausgestellt
          headers:
            Set-Cookie:
              description: Neuer HttpOnly Refresh-Token Cookie
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenRefreshResponse'
              example:
                access_token: "eyJhbGciOiJSUzI1NiJ9..."
                token_type: "Bearer"
                expires_in: 900
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Refresh Token wurde bereits verwendet (Rotation-Verletzung) oder ist abgelaufen
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "token_reuse_detected"
                message: "Refresh Token wurde bereits verwendet. Bitte erneut einloggen."
                status: 403
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ----------------------------------------------------------
  # POST /auth/2fa/verify
  # ----------------------------------------------------------
  /auth/2fa/verify:
    post:
      tags: [Auth]
      summary: 2FA-Code verifizieren
      description: |
        Zweiter Schritt des Login-Flows wenn 2FA aktiviert ist.

        **Ablauf:**
        1. Client sendet `two_fa_token` (temporäres Token aus `/auth/login`) + TOTP-Code
        2. Server verifiziert TOTP gegen das gespeicherte Secret
        3. Bei Erfolg: Echte Tokens ausliefern (wie normaler Login)
        4. Bei Misserfolg: Fehlversuch zählen (max. 5, danach Account-Sperre)

        **Recovery Codes:**
        Statt einem TOTP-Code kann ein Einmal-Recovery-Code verwendet werden.
        Recovery Codes werden nach einmaligem Gebrauch invalidiert.
      operationId: auth2faVerify
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TwoFAVerifyRequest'
            examples:
              totp_code:
                summary: Mit TOTP-Code
                value:
                  two_fa_token: "tmp_7f3a9b2c..."
                  code: "847291"
                  code_type: "totp"
              recovery_code:
                summary: Mit Recovery-Code
                value:
                  two_fa_token: "tmp_7f3a9b2c..."
                  code: "ABCD-EFGH"
                  code_type: "recovery"
      responses:
        '200':
          description: 2FA erfolgreich – echte Tokens ausgeliefert
          headers:
            Set-Cookie:
              description: HttpOnly Refresh-Token Cookie
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginResponse'
              example:
                access_token: "eyJhbGciOiJSUzI1NiJ9..."
                token_type: "Bearer"
                expires_in: 900
                requires_2fa: false
                user:
                  id: "a3f9b2c1-..."
                  email: "admin@tauchschule-muster.ch"
                  role: "tenant_admin"
                  tenant_id: "b7e2d4f1-..."
        '400':
          description: Ungültiger oder abgelaufener two_fa_token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "invalid_2fa_session"
                message: "2FA-Session ungültig oder abgelaufen. Bitte erneut einloggen."
                status: 400
        '401':
          description: Falscher TOTP-Code oder Recovery-Code
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "invalid_2fa_code"
                message: "Ungültiger 2FA-Code. Noch 3 Versuche."
                status: 401
        '423':
          $ref: '#/components/responses/AccountLocked'
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ----------------------------------------------------------
  # POST /auth/invitation/redeem
  # ----------------------------------------------------------
  /auth/invitation/redeem:
    post:
      tags: [Invitation]
      summary: Einladungs-Token einlösen
      description: |
        Einladungs-Token einlösen und Benutzer-Account aktivieren (Onboarding-Modell C1).

        **Ablauf:**
        1. Tenant-Admin erstellt Einladung → System generiert 72h-Token
        2. Eingeladene Person erhält E-Mail mit Link (enthält Token)
        3. Person öffnet Link, setzt Passwort und löst Token ein
        4. Account wird aktiviert, Person wird automatisch eingeloggt

        **Token-Eigenschaften:**
        - 72 Stunden gültig ab Erstellung
        - Einmalig verwendbar (wird nach Einlösung als `used` markiert)
        - Zufälliger UUID-basierter Token (nicht erratbar)
        - Bei Ablauf: Tenant-Admin muss neuen Token erstellen

        **Passwort-Policy:**
        - Plattform-Minimum: mind. 12 Zeichen, Gross-/Kleinbuchstaben, Zahl, Sonderzeichen
        - Tenant-Policy: In V2 konfigurierbar (kann strenger sein, nicht lockerer)
      operationId: authInvitationRedeem
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InvitationRedeemRequest'
            example:
              token: "inv_a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5"
              password: "MeinNeuesPasswort!2024"
              password_confirmation: "MeinNeuesPasswort!2024"
      responses:
        '200':
          description: Token erfolgreich eingelöst – Benutzer aktiviert und eingeloggt
          headers:
            Set-Cookie:
              description: HttpOnly Refresh-Token Cookie (automatischer Login)
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InvitationRedeemResponse'
              example:
                access_token: "eyJhbGciOiJSUzI1NiJ9..."
                token_type: "Bearer"
                expires_in: 900
                user:
                  id: "c1d2e3f4-..."
                  email: "neuer.mitarbeiter@tauchschule-muster.ch"
                  role: "mitarbeiter"
                  tenant_id: "b7e2d4f1-..."
                  requires_2fa_setup: false
        '400':
          description: Validierungsfehler (Passwörter stimmen nicht überein, Policy verletzt)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationErrorResponse'
              example:
                error: "validation_error"
                message: "Passwort erfüllt die Mindestanforderungen nicht."
                status: 400
                details:
                  - field: "password"
                    code: "password_too_weak"
                    message: "Mindestens 12 Zeichen, Gross-/Kleinbuchstaben, Zahl und Sonderzeichen erforderlich."
        '404':
          description: Token nicht gefunden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error: "invitation_not_found"
                message: "Einladungs-Token nicht gefunden."
                status: 404
        '410':
          description: Token abgelaufen oder bereits verwendet
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                token_expired:
                  summary: Token abgelaufen
                  value:
                    error: "invitation_expired"
                    message: "Dieser Einladungs-Link ist abgelaufen (72h). Bitte kontaktieren Sie Ihren Administrator."
                    status: 410
                token_used:
                  summary: Token bereits eingelöst
                  value:
                    error: "invitation_already_used"
                    message: "Dieser Einladungs-Link wurde bereits verwendet."
                    status: 410
        '429':
          $ref: '#/components/responses/RateLimitExceeded'
        '500':
          $ref: '#/components/responses/InternalServerError'

# ============================================================
# COMPONENTS
# ============================================================

components:

  # ----------------------------------------------------------
  # Security Schemes
  # ----------------------------------------------------------
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        JWT Access Token (RS256, 15 Minuten Laufzeit).
        Wird im `Authorization: Bearer <token>` Header gesendet.

  # ----------------------------------------------------------
  # Schemas
  # ----------------------------------------------------------
  schemas:

    LoginRequest:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          format: email
          description: E-Mail-Adresse des Benutzers
          example: "admin@tauchschule-muster.ch"
        password:
          type: string
          format: password
          minLength: 12
          description: Passwort des Benutzers
          example: "MeinSicheresPasswort!2024"

    LoginResponse:
      type: object
      properties:
        access_token:
          type: string
          nullable: true
          description: JWT Access Token (null wenn 2FA erforderlich)
        token_type:
          type: string
          nullable: true
          example: "Bearer"
        expires_in:
          type: integer
          nullable: true
          description: Gültigkeitsdauer in Sekunden
          example: 900
        requires_2fa:
          type: boolean
          description: Gibt an ob ein zweiter Schritt (2FA) erforderlich ist
        two_fa_token:
          type: string
          nullable: true
          description: Temporäres Token für den 2FA-Schritt (nur wenn requires_2fa=true)
        user:
          nullable: true
          $ref: '#/components/schemas/AuthenticatedUser'

    TokenRefreshResponse:
      type: object
      properties:
        access_token:
          type: string
          description: Neuer JWT Access Token
        token_type:
          type: string
          example: "Bearer"
        expires_in:
          type: integer
          example: 900

    TwoFAVerifyRequest:
      type: object
      required: [two_fa_token, code, code_type]
      properties:
        two_fa_token:
          type: string
          description: Temporäres Token aus dem ersten Login-Schritt
        code:
          type: string
          description: TOTP-Code (6 Stellen) oder Recovery-Code (Format XXXX-XXXX)
          examples:
            totp: "847291"
            recovery: "ABCD-EFGH"
        code_type:
          type: string
          enum: [totp, recovery]
          description: Art des Codes

    InvitationRedeemRequest:
      type: object
      required: [token, password, password_confirmation]
      properties:
        token:
          type: string
          description: Einladungs-Token aus der E-Mail
        password:
          type: string
          format: password
          minLength: 12
          description: Neues Passwort (mind. 12 Zeichen, Gross-/Kleinbuchstaben, Zahl, Sonderzeichen)
        password_confirmation:
          type: string
          format: password
          description: Passwort-Wiederholung (muss identisch sein)

    InvitationRedeemResponse:
      type: object
      properties:
        access_token:
          type: string
          description: JWT Access Token (automatischer Login nach Aktivierung)
        token_type:
          type: string
          example: "Bearer"
        expires_in:
          type: integer
          example: 900
        user:
          $ref: '#/components/schemas/AuthenticatedUser'

    AuthenticatedUser:
      type: object
      description: Eingeloggte Benutzer-Informationen (kein vollständiges User-Objekt)
      properties:
        id:
          type: string
          format: uuid
          description: UUID des Benutzers
        email:
          type: string
          format: email
        role:
          type: string
          enum: [superadmin, tenant_admin, mitarbeiter, kunde]
          description: Rolle des Benutzers
        tenant_id:
          type: string
          format: uuid
          nullable: true
          description: UUID des Tenants (null nur für SuperAdmin)
        requires_2fa_setup:
          type: boolean
          description: Gibt an ob der Benutzer noch keinen 2FA-Authenticator eingerichtet hat

    ErrorResponse:
      type: object
      required: [error, message, status]
      properties:
        error:
          type: string
          description: Maschinenlesbarer Fehler-Code
          example: "unauthorized"
        message:
          type: string
          description: Menschenlesbarer Fehlertext (für UI oder Logs)
          example: "Ungültige Anmeldedaten."
        status:
          type: integer
          description: HTTP Status Code
          example: 401

    ValidationErrorResponse:
      allOf:
        - $ref: '#/components/schemas/ErrorResponse'
        - type: object
          properties:
            details:
              type: array
              description: Feldspezifische Validierungsfehler
              items:
                type: object
                properties:
                  field:
                    type: string
                    description: Feldname
                    example: "password"
                  code:
                    type: string
                    description: Maschinenlesbarer Fehler-Code
                    example: "password_too_weak"
                  message:
                    type: string
                    description: Menschenlesbarer Fehlertext
                    example: "Mindestens 12 Zeichen erforderlich."

  # ----------------------------------------------------------
  # Reusable Responses
  # ----------------------------------------------------------
  responses:

    Unauthorized:
      description: Nicht authentifiziert oder ungültige Credentials
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: "unauthorized"
            message: "Ungültige Anmeldedaten."
            status: 401

    AccountLocked:
      description: Account temporär gesperrt (zu viele Fehlversuche)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: "account_locked"
            message: "Account temporär gesperrt. Bitte in 15 Minuten erneut versuchen."
            status: 423

    RateLimitExceeded:
      description: Zu viele Anfragen (Rate Limit überschritten)
      headers:
        Retry-After:
          schema:
            type: integer
          description: Sekunden bis zum nächsten erlaubten Request
        X-RateLimit-Limit:
          schema:
            type: integer
          description: Maximale Anfragen pro Zeitfenster
        X-RateLimit-Remaining:
          schema:
            type: integer
          description: Verbleibende Anfragen im aktuellen Zeitfenster
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: "rate_limit_exceeded"
            message: "Zu viele Anfragen. Bitte warten Sie 60 Sekunden."
            status: 429

    InternalServerError:
      description: Interner Serverfehler
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: "internal_server_error"
            message: "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut."
            status: 500
