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
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SsoSetting;
use Illuminate\Http\Request;
class SsoSettingController extends Controller
{
public function show()
{
$sso = SsoSetting::get();
return view('admin.sso.show', compact('sso'));
}
public function update(Request $request)
{
$request->validate([
'base_url' => 'nullable|url|max:255',
'client_id' => 'nullable|string|max:255',
'client_secret' => 'nullable|string|max:255',
'slug' => 'nullable|string|max:255',
]);
$sso = SsoSetting::get();
$sso->base_url = rtrim($request->base_url ?? '', '/') ?: null;
$sso->client_id = $request->client_id ?: null;
$sso->client_secret = $request->client_secret ?: null;
$sso->slug = $request->slug ?: null;
$sso->enabled = $request->boolean('enabled');
$sso->save();
return redirect()->route('admin.sso.show')->with('success', 'SSO-Einstellungen gespeichert.');
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\SsoSetting;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class SsoController extends Controller
{
public function redirect(Request $request)
{
$sso = SsoSetting::get();
if (!$sso->isConfigured()) {
return redirect()->route('login')->with('error', 'SSO ist nicht konfiguriert.');
}
$state = Str::random(40);
session(['sso_state' => $state]);
$query = http_build_query([
'client_id' => $sso->client_id,
'redirect_uri' => route('sso.callback'),
'response_type' => 'code',
'scope' => 'openid email profile',
'state' => $state,
]);
return redirect($sso->authorizeUrl() . '?' . $query);
}
public function callback(Request $request)
{
$sso = SsoSetting::get();
if (!$sso->isConfigured()) {
return redirect()->route('login')->with('error', 'SSO ist nicht konfiguriert.');
}
// State prüfen
if ($request->get('state') !== session('sso_state')) {
return redirect()->route('login')->with('error', 'Ungültige SSO-Anfrage (state mismatch).');
}
session()->forget('sso_state');
if ($request->has('error')) {
return redirect()->route('login')->with('error', 'SSO-Anmeldung abgebrochen: ' . $request->get('error_description', $request->get('error')));
}
// Code gegen Token tauschen
$tokenResponse = Http::asForm()->post($sso->tokenUrl(), [
'grant_type' => 'authorization_code',
'client_id' => $sso->client_id,
'client_secret' => $sso->client_secret,
'redirect_uri' => route('sso.callback'),
'code' => $request->get('code'),
]);
if ($tokenResponse->failed()) {
return redirect()->route('login')->with('error', 'SSO-Token konnte nicht abgerufen werden.');
}
$accessToken = $tokenResponse->json('access_token');
// Userinfo abrufen
$userInfo = Http::withToken($accessToken)->get($sso->userInfoUrl());
if ($userInfo->failed()) {
return redirect()->route('login')->with('error', 'SSO-Benutzerinformationen konnten nicht abgerufen werden.');
}
$info = $userInfo->json();
$providerId = $info['sub'] ?? null;
$email = $info['email'] ?? null;
$name = $info['name'] ?? $info['preferred_username'] ?? $email;
if (!$providerId || !$email) {
return redirect()->route('login')->with('error', 'Unvollständige Benutzerinformationen vom SSO-Provider.');
}
// User suchen oder anlegen
$user = User::where('sso_provider_id', $providerId)->first()
?? User::where('email', $email)->first();
if (!$user) {
$user = User::create([
'name' => $name,
'email' => $email,
'password' => bcrypt(Str::random(32)),
'sso_provider_id' => $providerId,
'is_sso_user' => true,
]);
} elseif (!$user->sso_provider_id) {
// Bestehenden Account mit SSO verknüpfen
$user->sso_provider_id = $providerId;
$user->is_sso_user = true;
$user->save();
}
Auth::login($user, true);
return redirect()->intended(route('user-nail-polishes.index'));
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SsoSetting extends Model
{
protected $table = 'sso_settings';
protected $fillable = [
'base_url',
'client_id',
'client_secret',
'slug',
'enabled',
];
protected function casts(): array
{
return [
'enabled' => 'boolean',
];
}
public static function get(): self
{
return static::firstOrCreate([]);
}
public function isConfigured(): bool
{
return $this->enabled
&& !empty($this->base_url)
&& !empty($this->client_id)
&& !empty($this->client_secret);
}
public function authorizeUrl(): string
{
return rtrim($this->base_url, '/') . '/application/o/authorize/';
}
public function tokenUrl(): string
{
return rtrim($this->base_url, '/') . '/application/o/token/';
}
public function userInfoUrl(): string
{
return rtrim($this->base_url, '/') . '/application/o/userinfo/';
}
}
+4 -1
View File
@@ -22,6 +22,8 @@ class User extends Authenticatable
'email',
'password',
'is_admin',
'sso_provider_id',
'is_sso_user',
];
/**
@@ -43,7 +45,8 @@ class User extends Authenticatable
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'password' => 'hashed',
'is_sso_user' => 'boolean',
];
}
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('sso_settings', function (Blueprint $table) {
$table->id();
$table->string('base_url')->nullable();
$table->string('client_id')->nullable();
$table->string('client_secret')->nullable();
$table->string('slug')->nullable()->comment('Authentik Application Slug');
$table->boolean('enabled')->default(false);
$table->timestamps();
});
// Leere Zeile anlegen, damit immer genau eine Konfiguration existiert
DB::table('sso_settings')->insert([
'enabled' => false,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function down(): void
{
Schema::dropIfExists('sso_settings');
}
};
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('sso_provider_id')->nullable()->unique()->after('email');
$table->boolean('is_sso_user')->default(false)->after('sso_provider_id');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['sso_provider_id', 'is_sso_user']);
});
}
};
+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
+8
View File
@@ -5,6 +5,8 @@ use App\Http\Controllers\NailPolishController;
use App\Http\Controllers\UserNailPolishController;
use App\Http\Controllers\AdminController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\SsoController;
use App\Http\Controllers\Admin\SsoSettingController;
use App\Http\Controllers\ManufacturerController;
// Startseite
@@ -16,8 +18,12 @@ Route::get('/', function () {
Route::middleware('guest')->group(function () {
Route::get('/login', [LoginController::class, 'showLoginForm'])->name('login');
Route::post('/login', [LoginController::class, 'login']);
Route::get('/sso/redirect', [SsoController::class, 'redirect'])->name('sso.redirect');
});
// SSO Callback (kein guest-Middleware, da Authentik zurückleitet)
Route::get('/sso/callback', [SsoController::class, 'callback'])->name('sso.callback');
// Logout (nur für eingeloggte Benutzer)
Route::post('/logout', [LoginController::class, 'logout'])->name('logout')->middleware('auth');
@@ -42,6 +48,8 @@ Route::middleware(['auth'])->group(function () {
Route::delete('/users/{user}', [AdminController::class, 'destroyUser'])->name('users.destroy');
Route::get('/statistics', [AdminController::class, 'statistics'])->name('statistics');
Route::get('/users/{user}/collection', [UserNailPolishController::class, 'showUserCollection'])->name('users.collection');
Route::get('/sso', [SsoSettingController::class, 'show'])->name('sso.show');
Route::put('/sso', [SsoSettingController::class, 'update'])->name('sso.update');
});
// Nagellack-Verwaltung (nur für Admin)