Add Authentik SSO via OAuth2/OIDC

Implements Single Sign-On with Authentik alongside the existing
password login. Admins configure Client ID, Client Secret and Base URL
directly in the app; the SSO button on the login page only appears
when the configuration is complete and enabled.

- SsoSetting model + migration (one-row config table)
- sso_provider_id / is_sso_user fields on users (migration)
- SsoController: redirect to Authentik + callback (token exchange,
  userinfo fetch, auto-create unknown users)
- Admin\SsoSettingController + admin/sso/show view with setup guide
  and one-click Redirect URI copy
- Admin dropdown: SSO-Konfiguration entry
- Login page: Authentik button rendered conditionally

No additional Composer packages required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Housemann
2026-06-13 15:13:30 +02:00
co-authored by Claude Sonnet 4.6
parent c5fe3ad808
commit 60259a3655
10 changed files with 507 additions and 1 deletions
+222
View File
@@ -0,0 +1,222 @@
@extends('layouts.app')
@section('title', 'SSO-Konfiguration NeoNail DB')
@section('content')
<div class="page-hero d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4">
<div>
<h1><i class="fas fa-shield-alt me-2"></i>Single Sign-On</h1>
<p>Authentik OAuth2/OIDC Konfiguration</p>
</div>
<span class="count-chip">
@if($sso->isConfigured())
<i class="fas fa-circle text-success" style="font-size:.6rem"></i> Aktiv
@else
<i class="fas fa-circle text-danger" style="font-size:.6rem"></i> Nicht konfiguriert
@endif
</span>
</div>
{{-- Status-Banner --}}
@if($sso->isConfigured())
<div class="alert alert-success mb-4">
<i class="fas fa-check-circle me-2"></i>
SSO ist aktiv. Auf der Login-Seite wird der <strong>„Mit Authentik anmelden"</strong>-Button angezeigt.
</div>
@elseif($sso->enabled && !$sso->isConfigured())
<div class="alert alert-warning mb-4">
<i class="fas fa-exclamation-triangle me-2"></i>
SSO ist aktiviert, aber die Konfiguration ist unvollständig. Bitte alle Felder ausfüllen.
</div>
@endif
<div class="row g-4">
{{-- Konfigurationsformular --}}
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<i class="fas fa-cog me-2"></i>Einstellungen
</div>
<div class="card-body p-4">
<form method="POST" action="{{ route('admin.sso.update') }}">
@csrf
@method('PUT')
{{-- Aktivieren --}}
<div class="mb-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
name="enabled" id="enabled" value="1"
{{ $sso->enabled ? 'checked' : '' }}>
<label class="form-check-label fw-semibold" for="enabled">
SSO aktivieren
</label>
</div>
<div class="form-text mt-1">
Nur wenn aktiviert <em>und</em> alle Felder ausgefüllt sind, erscheint der SSO-Button auf der Login-Seite.
</div>
</div>
<hr class="my-4">
{{-- Base URL --}}
<div class="mb-3">
<label class="form-label" for="base_url">
Authentik Base URL <span class="text-danger">*</span>
</label>
<input type="url" class="form-control @error('base_url') is-invalid @enderror"
id="base_url" name="base_url"
value="{{ old('base_url', $sso->base_url) }}"
placeholder="https://authentik.example.com">
@error('base_url')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<div class="form-text">Die URL deiner Authentik-Instanz, ohne abschließenden Slash.</div>
</div>
{{-- Client ID --}}
<div class="mb-3">
<label class="form-label" for="client_id">
Client ID <span class="text-danger">*</span>
</label>
<input type="text" class="form-control @error('client_id') is-invalid @enderror"
id="client_id" name="client_id"
value="{{ old('client_id', $sso->client_id) }}"
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
autocomplete="off">
@error('client_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
{{-- Client Secret --}}
<div class="mb-3">
<label class="form-label" for="client_secret">
Client Secret <span class="text-danger">*</span>
</label>
<div class="input-group">
<input type="password" class="form-control @error('client_secret') is-invalid @enderror"
id="client_secret" name="client_secret"
value="{{ old('client_secret', $sso->client_secret) }}"
placeholder="{{ $sso->client_secret ? '••••••••••••••••' : 'Client Secret eingeben' }}"
autocomplete="off">
<button class="btn btn-outline-secondary" type="button"
onclick="toggleSecret()" id="toggleSecretBtn">
<i class="fas fa-eye" id="toggleSecretIcon"></i>
</button>
</div>
@error('client_secret')
<div class="invalid-feedback d-block">{{ $message }}</div>
@enderror
</div>
{{-- Slug --}}
<div class="mb-4">
<label class="form-label" for="slug">
Application Slug
<span class="badge bg-secondary ms-1" style="font-size:.65rem">optional</span>
</label>
<input type="text" class="form-control @error('slug') is-invalid @enderror"
id="slug" name="slug"
value="{{ old('slug', $sso->slug) }}"
placeholder="neonail-db">
@error('slug')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<div class="form-text">Nur zur Dokumentation wird nicht im OAuth2-Flow verwendet.</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>Speichern
</button>
@if($sso->isConfigured())
<a href="{{ route('sso.redirect') }}" target="_blank" class="btn btn-outline-secondary">
<i class="fas fa-external-link-alt me-2"></i>SSO testen
</a>
@endif
</div>
</form>
</div>
</div>
</div>
{{-- Info-Sidebar --}}
<div class="col-lg-4">
{{-- Callback URL --}}
<div class="card mb-3">
<div class="card-header">
<i class="fas fa-link me-2"></i>Redirect URI
</div>
<div class="card-body p-3">
<p style="font-size:.82rem; color:#6b7280; margin-bottom:8px;">
Diese URL musst du in Authentik als <strong>Redirect URI</strong> eintragen:
</p>
<div class="d-flex align-items-center gap-2">
<code style="font-size:.78rem; background:#f3f4f6; padding:8px 10px; border-radius:8px; flex:1; word-break:break-all;">
{{ route('sso.callback') }}
</code>
<button class="btn btn-sm btn-outline-secondary flex-shrink-0"
onclick="copyRedirectUri()" title="Kopieren">
<i class="fas fa-copy" id="copyIcon"></i>
</button>
</div>
</div>
</div>
{{-- Setup-Anleitung --}}
<div class="card">
<div class="card-header">
<i class="fas fa-book me-2"></i>Setup in Authentik
</div>
<div class="card-body p-3">
<ol style="font-size:.83rem; color:#374151; padding-left:1.2rem; margin:0; line-height:1.8;">
<li>In Authentik: <strong>Applications Create</strong></li>
<li>Provider-Typ: <strong>OAuth2/OpenID Provider</strong></li>
<li>Authorization flow wählen</li>
<li>Redirect URI eintragen (siehe links)</li>
<li><strong>Client ID</strong> und <strong>Client Secret</strong> kopieren</li>
<li>Hier eintragen und speichern</li>
</ol>
<hr style="margin: 12px 0;">
<p style="font-size:.78rem; color:#9ca3af; margin:0;">
<i class="fas fa-info-circle me-1"></i>
Scopes: <code>openid email profile</code>
</p>
</div>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
function toggleSecret() {
const input = document.getElementById('client_secret');
const icon = document.getElementById('toggleSecretIcon');
if (input.type === 'password') {
input.type = 'text';
icon.classList.replace('fa-eye', 'fa-eye-slash');
} else {
input.type = 'password';
icon.classList.replace('fa-eye-slash', 'fa-eye');
}
}
function copyRedirectUri() {
const uri = @json(route('sso.callback'));
const icon = document.getElementById('copyIcon');
navigator.clipboard.writeText(uri).then(() => {
icon.classList.replace('fa-copy', 'fa-check');
setTimeout(() => icon.classList.replace('fa-check', 'fa-copy'), 2000);
});
}
</script>
@endsection
+15
View File
@@ -74,6 +74,21 @@
</button>
</form>
@php $sso = \App\Models\SsoSetting::get(); @endphp
@if($sso->isConfigured())
<div class="d-flex align-items-center my-4" style="gap:12px;">
<hr style="flex:1; border-color:rgba(0,0,0,.12); margin:0;">
<span style="font-size:.78rem; color:#9ca3af; white-space:nowrap;">oder</span>
<hr style="flex:1; border-color:rgba(0,0,0,.12); margin:0;">
</div>
<a href="{{ route('sso.redirect') }}" class="btn btn-outline-secondary w-100 btn-lg d-flex align-items-center justify-content-center gap-2">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20A14.5 14.5 0 0 0 12 2"/><path d="M2 12h20"/>
</svg>
Mit Authentik anmelden
</a>
@endif
</div>
</div>
+3
View File
@@ -471,6 +471,9 @@
<i class="fas fa-palette me-2"></i>Nagellacke</a></li>
<li><a class="dropdown-item" href="{{ route('admin.statistics') }}">
<i class="fas fa-chart-bar me-2"></i>Statistiken</a></li>
<li><hr class="dropdown-divider" style="border-color:rgba(255,255,255,0.1);"></li>
<li><a class="dropdown-item" href="{{ route('admin.sso.show') }}">
<i class="fas fa-shield-alt me-2"></i>SSO-Konfiguration</a></li>
</ul>
</li>
@endif