From b60165648da705eb104ed8ee5262362ea2491ccc Mon Sep 17 00:00:00 2001 From: Alex Macocian Date: Sat, 1 Nov 2025 15:47:28 +0100 Subject: [PATCH] Setup authentik sso support --- .env.dev | 6 + .env.example | 6 + .../Controllers/Api/Auth/OAuthController.php | 2 +- app/Http/Controllers/Auth/OIDCController.php | 143 ++++++++++++++++++ app/Models/User/User.php | 2 + app/Providers/AppServiceProvider.php | 5 + app/Providers/EventServiceProvider.php | 3 + app/Providers/SocialiteServiceProvider.php | 29 ++++ composer.json | 2 + composer.lock | 134 +++++++++++++++- config/app.php | 1 + config/services.php | 7 + ..._000000_add_oidc_fields_to_users_table.php | 23 +++ docker-compose.dev.yml | 2 +- resources/lang/en/auth.php | 6 +- resources/views/auth/login.blade.php | 14 ++ routes/web.php | 4 + scripts/docker/Dockerfile | 46 ++++-- 18 files changed, 416 insertions(+), 19 deletions(-) create mode 100644 app/Http/Controllers/Auth/OIDCController.php create mode 100644 app/Providers/SocialiteServiceProvider.php create mode 100644 database/migrations/2025_11_01_000000_add_oidc_fields_to_users_table.php diff --git a/.env.dev b/.env.dev index a82610054..8de14b4d6 100644 --- a/.env.dev +++ b/.env.dev @@ -74,3 +74,9 @@ MFA_ENABLED=true # Enable DAV support (beta feature) DAV_ENABLED=true + +# OIDC Configuration for Authentik +AUTHENTIK_BASE_URL= +AUTHENTIK_CLIENT_ID= +AUTHENTIK_CLIENT_SECRET= +AUTHENTIK_REDIRECT_URI=http://localhost:8080/auth/authentik/callback diff --git a/.env.example b/.env.example index 8304243e8..0b39aa4d8 100644 --- a/.env.example +++ b/.env.example @@ -142,6 +142,12 @@ DAV_ENABLED=true PASSPORT_PASSWORD_GRANT_CLIENT_ID= PASSPORT_PASSWORD_GRANT_CLIENT_SECRET= +# OIDC Authentication via Authentik +AUTHENTIK_BASE_URL= +AUTHENTIK_CLIENT_ID= +AUTHENTIK_CLIENT_SECRET= +AUTHENTIK_REDIRECT_URI="${APP_URL}/auth/authentik/callback" + # Allow to access general statistics about your instance through a public API # call ALLOW_STATISTICS_THROUGH_PUBLIC_API_ACCESS=false diff --git a/app/Http/Controllers/Api/Auth/OAuthController.php b/app/Http/Controllers/Api/Auth/OAuthController.php index 970b9ce0c..6e565d2ba 100644 --- a/app/Http/Controllers/Api/Auth/OAuthController.php +++ b/app/Http/Controllers/Api/Auth/OAuthController.php @@ -40,7 +40,7 @@ class OAuthController extends Controller { $this->encrypter = $encrypter; - if (config('app.debug')) { + if (config('app.debug') && class_exists('Barryvdh\Debugbar\Facades\Debugbar')) { Debugbar::disable(); } } diff --git a/app/Http/Controllers/Auth/OIDCController.php b/app/Http/Controllers/Auth/OIDCController.php new file mode 100644 index 000000000..aee1bbb75 --- /dev/null +++ b/app/Http/Controllers/Auth/OIDCController.php @@ -0,0 +1,143 @@ + $driver->getScopes()]); + + return $driver + ->redirect(); + } + + /** + * Obtain the user information from Authentik. + */ + public function handleProviderCallback(Request $request) + { + \Log::info('OIDC Callback started', ['url' => $request->fullUrl(), 'params' => $request->all()]); + + // Add early error handling + if (!$request->has('code')) { + \Log::error('OIDC Callback missing authorization code', ['params' => $request->all()]); + return redirect('/login')->withErrors(['msg' => 'Authorization code missing. Please try again.']); + } + + try { + $authentikUser = Socialite::driver('authentik')->user(); + \Log::info('OIDC User retrieved', [ + 'email' => $authentikUser->email, + 'id' => $authentikUser->id, + 'name' => $authentikUser->name, + 'raw_user' => $authentikUser->user, + 'all_data' => $authentikUser->getRaw() + ]); + } catch (\Exception $e) { + \Log::error('OIDC Authentication failed', ['error' => $e->getMessage()]); + return redirect('/login')->withErrors(['msg' => 'Authentication failed. Please try again.']); + } + + // Check if user already exists by OIDC subject ID + $user = User::where('oidc_sub', $authentikUser->id)->first(); + + if ($user) { + \Log::info('OIDC User found by subject ID', ['user_id' => $user->id]); + // Update user info from OIDC provider + $this->updateUserOIDCInfo($user, $authentikUser); + Auth::login($user); + \Log::info('OIDC User logged in successfully', ['user_id' => $user->id]); + return redirect()->intended('/dashboard'); + } + + // Check if user exists by email (if email is available) + if (!empty($authentikUser->email)) { + $existingUser = User::where('email', $authentikUser->email)->first(); + + if ($existingUser) { + \Log::info('OIDC User found by email', ['user_id' => $existingUser->id]); + // Link existing user to OIDC provider + $this->updateUserOIDCInfo($existingUser, $authentikUser); + Auth::login($existingUser); + \Log::info('OIDC Existing user logged in successfully', ['user_id' => $existingUser->id]); + return redirect()->intended('/dashboard'); + } + } + + // Create new user + \Log::info('OIDC Creating new user', ['email' => $authentikUser->email]); + try { + $user = $this->createUser($authentikUser); + Auth::login($user); + \Log::info('OIDC New user created and logged in successfully', ['user_id' => $user->id]); + return redirect()->intended('/dashboard'); + } catch (\Exception $e) { + \Log::error('OIDC User creation failed', ['error' => $e->getMessage()]); + return redirect('/login')->withErrors(['msg' => 'User creation failed. Please try again.']); + } + } /** + * Create a new user from OIDC provider data. + */ + private function createUser($authentikUser) + { + // Generate a unique username if name is not provided + $firstName = $authentikUser->user['given_name'] ?? explode(' ', $authentikUser->name)[0] ?? 'User'; + $lastName = $authentikUser->user['family_name'] ?? (explode(' ', $authentikUser->name)[1] ?? ''); + + // Require email from OIDC provider + if (empty($authentikUser->email)) { + \Log::error('OIDC User missing email', ['user_id' => $authentikUser->id, 'raw_data' => $authentikUser->getRaw()]); + throw new \Exception('Email not provided by OIDC provider'); + } + $email = $authentikUser->email; + + // Create account and user using Monica's default method + // Generate a secure random password for OIDC users (they won't use it) + $randomPassword = Str::random(32); + + $account = \App\Models\Account\Account::createDefault( + $firstName, + $lastName, + $email, + $randomPassword, + \App\Helpers\RequestHelper::ip(), + 'en' // Default locale + ); + + // Get the created user + $user = $account->users()->first(); + + // Mark email as verified for OIDC users (since they're authenticated via OIDC provider) + $user->markEmailAsVerified(); + + // Update user with OIDC info + $this->updateUserOIDCInfo($user, $authentikUser); + + return $user; + } + + /** + * Update user with OIDC provider information. + */ + private function updateUserOIDCInfo(User $user, $authentikUser) + { + $user->update([ + 'oidc_sub' => $authentikUser->id, + 'oidc_email' => $authentikUser->email, + ]); + } +} \ No newline at end of file diff --git a/app/Models/User/User.php b/app/Models/User/User.php index d614f276e..e180f8e98 100644 --- a/app/Models/User/User.php +++ b/app/Models/User/User.php @@ -49,6 +49,8 @@ class User extends Authenticatable implements MustVerifyEmail, HasLocalePreferen 'temperature_scale', 'name_order', 'google2fa_secret', + 'oidc_sub', + 'oidc_provider', ]; /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 76604afcd..793a90d4e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -29,6 +29,11 @@ class AppServiceProvider extends ServiceProvider */ public function boot() { + // Register Authentik Socialite Provider + \Illuminate\Support\Facades\Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) { + $event->extendSocialite('authentik', \SocialiteProviders\Authentik\Provider::class); + }); + if (App::runningInConsole()) { Command::macro('exec', function (string $message, string $commandline) { // @codeCoverageIgnoreStart diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 9bcc63b88..671f617d2 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -18,6 +18,9 @@ class EventServiceProvider extends ServiceProvider \Illuminate\Auth\Events\PasswordReset::class => [ \App\Listeners\LogoutUserDevices::class, ], + \SocialiteProviders\Manager\SocialiteWasCalled::class => [ + \SocialiteProviders\Authentik\AuthentikExtendSocialite::class.'@handle', + ], ]; /** diff --git a/app/Providers/SocialiteServiceProvider.php b/app/Providers/SocialiteServiceProvider.php new file mode 100644 index 000000000..66df2e44e --- /dev/null +++ b/app/Providers/SocialiteServiceProvider.php @@ -0,0 +1,29 @@ + [ + 'base_url' => env('AUTHENTIK_BASE_URL'), + 'client_id' => env('AUTHENTIK_CLIENT_ID'), + 'client_secret' => env('AUTHENTIK_CLIENT_SECRET'), + 'redirect' => env('AUTHENTIK_REDIRECT_URI') + ], + ]; diff --git a/database/migrations/2025_11_01_000000_add_oidc_fields_to_users_table.php b/database/migrations/2025_11_01_000000_add_oidc_fields_to_users_table.php new file mode 100644 index 000000000..642773204 --- /dev/null +++ b/database/migrations/2025_11_01_000000_add_oidc_fields_to_users_table.php @@ -0,0 +1,23 @@ +string('oidc_sub')->nullable()->index()->comment('OIDC Subject ID from provider'); + $table->string('oidc_provider')->nullable()->comment('OIDC Provider name (e.g., authentik)'); + }); + } + + public function down() + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['oidc_sub', 'oidc_provider']); + }); + } +} \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index dc486c7d4..e38b43bb6 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -24,10 +24,10 @@ services: - ./database:/var/www/html/database - ./resources:/var/www/html/resources - ./routes:/var/www/html/routes + - ./config:/var/www/html/config mysql: image: mysql:8 - command: --default-authentication-plugin=mysql_native_password environment: - MYSQL_ROOT_PASSWORD=sekret_root_password - MYSQL_DATABASE=monica diff --git a/resources/lang/en/auth.php b/resources/lang/en/auth.php index 73e0db03f..363231248 100644 --- a/resources/lang/en/auth.php +++ b/resources/lang/en/auth.php @@ -45,9 +45,13 @@ return [ 'password_forget' => 'Forget your password?', 'password_reset' => 'Reset your password', 'use_recovery' => 'Or you can use a recovery code', - 'signup_no_account' => 'Don’t have an account?', + 'signup_no_account' => 'Don\'t have an account?', 'signup' => 'Sign up', 'create_account' => 'Create the first account by signing up', + + 'or' => 'or', + 'login_with_oidc' => 'Login with Single Sign-On', + 'change_language_title' => 'Change language:', 'change_language' => 'Change language to :lang', diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 63c631238..2616b9c2d 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -57,6 +57,20 @@ + + + @if(config('services.authentik.base_url')) +
+
+ {{ trans('auth.or') }} +
+ +  {{ trans('auth.login_with_oidc') }} + +
+ @endif + +