Fix and complete reverse-proxy support & Adjust front-end views

This commit is contained in:
Bubka 2022-03-24 14:58:30 +01:00
parent 911e18c9c4
commit 725c012042
37 changed files with 455 additions and 151 deletions

View File

@ -6,6 +6,7 @@
use App\Api\v1\Requests\UserUpdateRequest; use App\Api\v1\Requests\UserUpdateRequest;
use App\Api\v1\Resources\UserResource; use App\Api\v1\Resources\UserResource;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class UserController extends Controller class UserController extends Controller
{ {
@ -14,9 +15,12 @@ class UserController extends Controller
* *
* @return \App\Api\v1\Resources\UserResource * @return \App\Api\v1\Resources\UserResource
*/ */
public function show() public function show(Request $request)
{ {
$user = User::first(); // 2 cases:
// - The method is called from a protected route > we return the request's authenticated user
// - The method is called from a guest route > we fetch a possible registered user
$user = $request->user() ?: User::first();
return $user return $user
? new UserResource($user) ? new UserResource($user)

View File

@ -16,8 +16,9 @@ class UserResource extends JsonResource
public function toArray($request) public function toArray($request)
{ {
return [ return [
'id' => $this->when($request->user(), $this->id),
'name' => $this->name, 'name' => $this->name,
'email' => $this->when(Auth::guard()->check(), $this->email), 'email' => $this->when($request->user(), $this->email),
]; ];
} }
} }

View File

@ -65,5 +65,10 @@ public function register()
return response()->json([ return response()->json([
'message' => $exception->getMessage()], 400); 'message' => $exception->getMessage()], 400);
}); });
$this->renderable(function (UnsupportedWithReverseProxyException $exception, $request) {
return response()->json([
'message' => __('errors.unsupported_with_reverseproxy')], 400);
});
} }
} }

View File

@ -0,0 +1,14 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* Class UnsupportedWithReverseProxyException.
*
* @codeCoverageIgnore
*/
class UnsupportedWithReverseProxyException extends Exception
{
}

View File

@ -1,6 +1,6 @@
<?php <?php
// Part of Firefly III (https://github.com/firefly-iii) // Largely inspired by Firefly III remote user implementation (https://github.com/firefly-iii)
// see https://github.com/firefly-iii/firefly-iii/blob/main/app/Support/Authentication/RemoteUserProvider.php // see https://github.com/firefly-iii/firefly-iii/blob/main/app/Support/Authentication/RemoteUserProvider.php
namespace App\Extensions; namespace App\Extensions;
@ -8,7 +8,7 @@
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider; use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Support\Str; use Illuminate\Support\Arr;
use Exception; use Exception;
class RemoteUserProvider implements UserProvider class RemoteUserProvider implements UserProvider
@ -16,19 +16,20 @@ class RemoteUserProvider implements UserProvider
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function retrieveById($identifier): User public function retrieveById($identifier)
{ {
$user = User::where('email', $identifier)->first(); // 2FAuth is single user by design and domain data are not coupled to the user model.
// So we provide a non-persisted user, dynamically instanciated using data
// from the auth proxy.
// This way no matter the user account used at proxy level, 2FAuth will always
// authenticate a request from the proxy and will return domain data without restriction.
//
// The downside of this approach is that we have to be sure that no change that needs
// to be persisted will be made to the user instance afterward (i.e through middlewares).
// if (null === $user) { $user = new User;
// $user = User::create( $user->name = $identifier['user'];
// [ $user->email = Arr::has($identifier, 'email') ? $identifier['email'] : $identifier['user'];
// 'name' => $identifier,
// 'email' => $identifier,
// 'password' => bcrypt(Str::random(64)),
// ]
// );
// }
return $user; return $user;
} }

View File

@ -5,6 +5,7 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails; use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
use App\Exceptions\UnsupportedWithReverseProxyException;
class ForgotPasswordController extends Controller class ForgotPasswordController extends Controller
{ {
@ -21,6 +22,20 @@ class ForgotPasswordController extends Controller
use SendsPasswordResetEmails; use SendsPasswordResetEmails;
/**
* Create a new controller instance.
*/
public function __construct()
{
$authGuard = config('auth.defaults.guard');
if ($authGuard === 'reverse-proxy-guard') {
throw new UnsupportedWithReverseProxyException();
}
}
/** /**
* Validate the email for the given request. * Validate the email for the given request.
* *

View File

@ -11,6 +11,7 @@
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Illuminate\Foundation\Auth\AuthenticatesUsers; use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Carbon\Carbon; use Carbon\Carbon;
use App\Exceptions\UnsupportedWithReverseProxyException;
class LoginController extends Controller class LoginController extends Controller
@ -28,6 +29,20 @@ class LoginController extends Controller
use AuthenticatesUsers; use AuthenticatesUsers;
/**
* Create a new controller instance.
*/
public function __construct()
{
$authGuard = config('auth.defaults.guard');
if ($authGuard === 'reverse-proxy-guard') {
throw new UnsupportedWithReverseProxyException();
}
}
/** /**
* Handle a login request to the application. * Handle a login request to the application.
* *

View File

@ -6,10 +6,25 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use App\Exceptions\UnsupportedWithReverseProxyException;
class PasswordController extends Controller class PasswordController extends Controller
{ {
/**
* Create a new controller instance.
*/
public function __construct()
{
$authGuard = config('auth.defaults.guard');
if ($authGuard === 'reverse-proxy-guard') {
throw new UnsupportedWithReverseProxyException();
}
}
/** /**
* Update the user's password. * Update the user's password.
* *

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use Illuminate\Http\Request;
use Laravel\Passport\Http\Controllers\PersonalAccessTokenController as PassportPersonalAccessTokenController;
class PersonalAccessTokenController extends PassportPersonalAccessTokenController
{
/**
* Get all of the personal access tokens for the authenticated user.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Database\Eloquent\Collection
*/
public function forUser(Request $request)
{
// WebAuthn is useless when authentication is handle by
// a reverse proxy so we return a 202 response to tell the
// client nothing more will happen
if (config('auth.defaults.guard') === 'reverse-proxy-guard') {
return response()->json([
'message' => 'no personal access token with reverse proxy'], 202);
}
parent::forUser($request);
}
}

View File

@ -9,6 +9,7 @@
// use Illuminate\Support\Facades\Auth; // use Illuminate\Support\Facades\Auth;
use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Registered;
use Illuminate\Foundation\Auth\RegistersUsers; use Illuminate\Foundation\Auth\RegistersUsers;
use App\Exceptions\UnsupportedWithReverseProxyException;
class RegisterController extends Controller class RegisterController extends Controller
{ {
@ -26,6 +27,19 @@ class RegisterController extends Controller
use RegistersUsers; use RegistersUsers;
/**
* Create a new controller instance.
*/
public function __construct()
{
$authGuard = config('auth.defaults.guard');
if ($authGuard === 'reverse-proxy-guard') {
throw new UnsupportedWithReverseProxyException();
}
}
/** /**
* Handle a registration request for the application. * Handle a registration request for the application.
* *

View File

@ -5,6 +5,7 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ResetsPasswords; use Illuminate\Foundation\Auth\ResetsPasswords;
use App\Exceptions\UnsupportedWithReverseProxyException;
class ResetPasswordController extends Controller class ResetPasswordController extends Controller
{ {
@ -21,4 +22,17 @@ class ResetPasswordController extends Controller
use ResetsPasswords; use ResetsPasswords;
/**
* Create a new controller instance.
*/
public function __construct()
{
$authGuard = config('auth.defaults.guard');
if ($authGuard === 'reverse-proxy-guard') {
throw new UnsupportedWithReverseProxyException();
}
}
} }

View File

@ -8,9 +8,23 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use App\Exceptions\UnsupportedWithReverseProxyException;
class UserController extends Controller class UserController extends Controller
{ {
/**
* Create a new controller instance.
*/
public function __construct()
{
$authGuard = config('auth.defaults.guard');
if ($authGuard === 'reverse-proxy-guard') {
throw new UnsupportedWithReverseProxyException();
}
}
/** /**
* Update the user's profile information. * Update the user's profile information.
* *

View File

@ -5,6 +5,7 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Providers\RouteServiceProvider; use App\Providers\RouteServiceProvider;
use DarkGhostHunter\Larapass\Http\ConfirmsWebAuthn; use DarkGhostHunter\Larapass\Http\ConfirmsWebAuthn;
use App\Exceptions\UnsupportedWithReverseProxyException;
class WebAuthnConfirmController extends Controller class WebAuthnConfirmController extends Controller
{ {
@ -35,7 +36,10 @@ class WebAuthnConfirmController extends Controller
*/ */
public function __construct() public function __construct()
{ {
$this->middleware('auth'); $authGuard = config('auth.defaults.guard');
$this->middleware('throttle:10,1')->only('options', 'confirm');
if ($authGuard === 'reverse-proxy-guard') {
throw new UnsupportedWithReverseProxyException();
}
} }
} }

View File

@ -6,6 +6,7 @@
use DarkGhostHunter\Larapass\Http\SendsWebAuthnRecoveryEmail; use DarkGhostHunter\Larapass\Http\SendsWebAuthnRecoveryEmail;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use App\Exceptions\UnsupportedWithReverseProxyException;
class WebAuthnDeviceLostController extends Controller class WebAuthnDeviceLostController extends Controller
{ {
@ -22,9 +23,16 @@ class WebAuthnDeviceLostController extends Controller
| |
*/ */
/**
* Create a new controller instance.
*/
public function __construct() public function __construct()
{ {
// $this->middleware('guest'); $authGuard = config('auth.defaults.guard');
if ($authGuard === 'reverse-proxy-guard') {
throw new UnsupportedWithReverseProxyException();
}
} }

View File

@ -6,6 +6,7 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use DarkGhostHunter\Larapass\Http\AuthenticatesWebAuthn; use DarkGhostHunter\Larapass\Http\AuthenticatesWebAuthn;
use App\Exceptions\UnsupportedWithReverseProxyException;
class WebAuthnLoginController extends Controller class WebAuthnLoginController extends Controller
{ {
@ -26,9 +27,16 @@ class WebAuthnLoginController extends Controller
| |
*/ */
/**
* Create a new controller instance.
*/
public function __construct() public function __construct()
{ {
// $this->middleware(['guest', 'throttle:10,1']); $authGuard = config('auth.defaults.guard');
if ($authGuard === 'reverse-proxy-guard') {
throw new UnsupportedWithReverseProxyException();
}
} }

View File

@ -6,6 +6,7 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Requests\WebauthnRenameRequest; use App\Http\Requests\WebauthnRenameRequest;
use DarkGhostHunter\Larapass\Eloquent\WebAuthnCredential; use DarkGhostHunter\Larapass\Eloquent\WebAuthnCredential;
use App\Exceptions\UnsupportedWithReverseProxyException;
class WebAuthnManageController extends Controller class WebAuthnManageController extends Controller
{ {
@ -34,6 +35,14 @@ public function __construct()
*/ */
public function index(Request $request) public function index(Request $request)
{ {
// WebAuthn is useless when authentication is handle by
// a reverse proxy so we return a 202 response to tell the
// client nothing more will happen
if (config('auth.defaults.guard') === 'reverse-proxy-guard') {
return response()->json([
'message' => 'no webauthn with reverse proxy'], 202);
}
$user = $request->user(); $user = $request->user();
$allUserCredentials = $user->webAuthnCredentials() $allUserCredentials = $user->webAuthnCredentials()
->enabled() ->enabled()

View File

@ -9,6 +9,7 @@
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use App\Exceptions\UnsupportedWithReverseProxyException;
class WebAuthnRecoveryController extends Controller class WebAuthnRecoveryController extends Controller
{ {
@ -32,15 +33,17 @@ class WebAuthnRecoveryController extends Controller
*/ */
protected $redirectTo = RouteServiceProvider::HOME; protected $redirectTo = RouteServiceProvider::HOME;
/** /**
* Create a new controller instance. * Create a new controller instance.
*
* @return void
*/ */
public function __construct() public function __construct()
{ {
// $this->middleware('guest'); $authGuard = config('auth.defaults.guard');
// $this->middleware('throttle:10,1')->only('options', 'recover');
if ($authGuard === 'reverse-proxy-guard') {
throw new UnsupportedWithReverseProxyException();
}
} }
/** /**

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use DarkGhostHunter\Larapass\Http\RegistersWebAuthn; use DarkGhostHunter\Larapass\Http\RegistersWebAuthn;
use App\Exceptions\UnsupportedWithReverseProxyException;
class WebAuthnRegisterController extends Controller class WebAuthnRegisterController extends Controller
{ {
@ -22,11 +23,13 @@ class WebAuthnRegisterController extends Controller
/** /**
* Create a new controller instance. * Create a new controller instance.
*
* @return void
*/ */
public function __construct() public function __construct()
{ {
// $this->middleware('auth'); $authGuard = config('auth.defaults.guard');
if ($authGuard === 'reverse-proxy-guard') {
throw new UnsupportedWithReverseProxyException();
}
} }
} }

View File

@ -36,10 +36,19 @@ class Kernel extends HttpKernel
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class, \Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class, // \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class, \App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\LogUserLastSeen::class, \App\Http\Middleware\CustomCreateFreshApiToken::class,
],
'behind-auth' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\Authenticate::class,
\App\Http\Middleware\KickOutInactiveUser::class,
\App\Http\Middleware\CustomCreateFreshApiToken::class, \App\Http\Middleware\CustomCreateFreshApiToken::class,
], ],

View File

@ -3,8 +3,43 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware; use Illuminate\Auth\Middleware\Authenticate as Middleware;
use Illuminate\Support\Arr;
class Authenticate extends Middleware class Authenticate extends Middleware
{ {
/**
* Determine if the user is logged in to any of the given guards.
*
* @param \Illuminate\Http\Request $request
* @param array $guards
* @return void
*
* @throws \Illuminate\Auth\AuthenticationException
*/
protected function authenticate($request, array $guards)
{
if (empty($guards)) {
// Will retreive the default guard
$guards = [null];
}
else {
// We inject the reserve-proxy guard to ensure it will be available for every routes
// besides their declared guards. This way we ensure priority to declared guards and
// a fallback to the reverse-proxy guard
$proxyGuard = 'reverse-proxy-guard';
if (config('auth.defaults.guard') === $proxyGuard && !Arr::has($guards, $proxyGuard)) {
$guards[] = $proxyGuard;
}
}
foreach ($guards as $guard) {
if ($this->auth->guard($guard)->check()) {
return $this->auth->shouldUse($guard);
}
}
$this->unauthenticated($request, $guards);
}
} }

View File

@ -18,10 +18,13 @@ class KickOutInactiveUser
* @param \Closure $next * @param \Closure $next
* @return mixed * @return mixed
*/ */
public function handle($request, Closure $next, $guard = null) public function handle($request, Closure $next, ...$quards)
{ {
// We do not track activity of guest or user authenticated against a bearer token // We do not track activity of:
if (Auth::guest() || $request->bearerToken()) { // - Guest
// - User authenticated against a bearer token
// - User authenticated via a reverse-proxy
if (Auth::guest() || $request->bearerToken() || config('auth.defaults.guard') === 'reverse-proxy-guard') {
return $next($request); return $next($request);
} }

View File

@ -21,8 +21,11 @@ public function handle($request, Closure $next, ...$quards)
$guards = empty($guards) ? [null] : $guards; $guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) { foreach ($guards as $guard) {
// Activity coming from a client authenticated with a personal access token is not logged // We do not track activity of:
if( Auth::guard($guard)->check() && !$request->bearerToken()) { // - Guest
// - User authenticated against a bearer token
// - User authenticated via a reverse-proxy
if (Auth::guard($guard)->check() && !$request->bearerToken() && config('auth.defaults.guard') !== 'reverse-proxy-guard') {
Auth::guard($guard)->user()->last_seen_at = Carbon::now()->format('Y-m-d H:i:s'); Auth::guard($guard)->user()->last_seen_at = Carbon::now()->format('Y-m-d H:i:s');
Auth::guard($guard)->user()->save(); Auth::guard($guard)->user()->save();
break; break;

View File

@ -2,12 +2,11 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
class RedirectIfAuthenticated class RejectIfAuthenticated
{ {
/** /**
* Handle an incoming request. * Handle an incoming request.

View File

@ -18,9 +18,9 @@ class AuthServiceProvider extends ServiceProvider
* *
* @var array * @var array
*/ */
protected $policies = [ // protected $policies = [
// 'App\Models\Model' => 'App\Policies\ModelPolicy', // 'App\Models\Model' => 'App\Policies\ModelPolicy',
]; // ];
/** /**
* Register any authentication / authorization services. * Register any authentication / authorization services.
@ -46,15 +46,17 @@ static function ($app, $config) {
$app[Hasher::class], $app[Hasher::class],
$config['model'] $config['model']
); );
}); }
);
// // Register a custom provider for reverse-proxy authentication
Auth::provider('remote-user', function ($app, array $config) { Auth::provider('remote-user', function ($app, array $config) {
// Return an instance of Illuminate\Contracts\Auth\UserProvider... // Return an instance of Illuminate\Contracts\Auth\UserProvider...
return new RemoteUserProvider; return new RemoteUserProvider;
}); });
// Register a custom driver for reverse-proxy authentication
Auth::extend('reverse-proxy', function ($app, string $name, array $config) { Auth::extend('reverse-proxy', function ($app, string $name, array $config) {
// Return an instance of Illuminate\Contracts\Auth\Guard... // Return an instance of Illuminate\Contracts\Auth\Guard...

View File

@ -73,7 +73,7 @@ private function getApiNamespace($version)
protected function configureRateLimiting() protected function configureRateLimiting()
{ {
RateLimiter::for('api', function (Request $request) { RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip()); return Limit::perMinute(60)->by($request->ip());
}); });
} }
} }

View File

@ -1,5 +1,8 @@
<?php <?php
// Largely inspired by Firefly III remote user implementation (https://github.com/firefly-iii)
// See https://github.com/firefly-iii/firefly-iii/blob/main/app/Support/Authentication/RemoteUserGuard.php
namespace App\Services\Auth; namespace App\Services\Auth;
use Exception; use Exception;
@ -66,16 +69,26 @@ public function user()
$user = null; $user = null;
// Get the user identifier from $_SERVER or apache filtered headers // Get the user identifier from $_SERVER or apache filtered headers
$header = config('auth.guard_header', 'REMOTE_USER'); $remoteUserHeader = config('auth.auth_proxy_headers.user', 'REMOTE_USER');
$userID = request()->server($header) ?? apache_request_headers()[$header] ?? null; $identifier['user'] = request()->server($remoteUserHeader) ?? apache_request_headers()[$remoteUserHeader] ?? null;
if (null === $userID) { if ($identifier['user'] === null) {
Log::error(sprintf('No user in header "%s".', $header)); Log::error(sprintf('No user in header "%s".', $remoteUserHeader));
return $this->user = null; return $this->user = null;
// throw new Exception('The guard header was unexpectedly empty. See the logs.');
} }
$user = $this->provider->retrieveById($userID); // Get the email identifier from $_SERVER
$remoteEmailHeader = config('auth.auth_proxy_headers.email');
if ($remoteEmailHeader) {
$remoteEmail = (string)(request()->server($remoteEmailHeader) ?? apache_request_headers()[$remoteEmailHeader] ?? null);
if ($remoteEmail) {
$identifier['email'] = $remoteEmail;
}
}
$user = $this->provider->retrieveById($identifier);
return $this->user = $user; return $this->user = $user;
} }

View File

@ -14,11 +14,24 @@
*/ */
'defaults' => [ 'defaults' => [
'guard' => env('AUTHENTICATION_GUARD', 'web'), 'guard' => env('AUTHENTICATION_GUARD', 'web-guard'),
'passwords' => 'users', 'passwords' => 'users',
], ],
'guard_header' => env('AUTHENTICATION_GUARD_HEADER', 'REMOTE_USER'),
// 'guard_email' => env('AUTHENTICATION_GUARD_EMAIL_HEADER', null), /*
|--------------------------------------------------------------------------
| Authentication Proxy Headers
|--------------------------------------------------------------------------
|
| When using a reverse proxy for authentication this option controls the
| default name of the headers sent by the proxy.
|
*/
'auth_proxy_headers' => [
'user' => env('AUTH_PROXY_HEADER_FOR_USER', 'REMOTE_USER'),
'email' => env('AUTH_PROXY_HEADER_FOR_EMAIL', null),
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -38,18 +51,18 @@
*/ */
'guards' => [ 'guards' => [
'web' => [ 'web-guard' => [
'driver' => 'session', 'driver' => 'session',
'provider' => 'users', 'provider' => 'users',
], ],
'api' => [ 'api-guard' => [
'driver' => 'passport', 'driver' => 'passport',
'provider' => 'users', 'provider' => 'users',
'hash' => false, 'hash' => false,
], ],
'reverse-proxy' => [ 'reverse-proxy-guard' => [
'driver' => 'reverse-proxy', 'driver' => 'reverse-proxy',
'provider' => 'remote-user', 'provider' => 'remote-user',
], ],

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="field"> <div class="field">
<input :id="fieldName" type="checkbox" :name="fieldName" class="is-checkradio is-info" v-model="form[fieldName]" v-on:change="$emit(fieldName, form[fieldName])" > <input :id="fieldName" type="checkbox" :name="fieldName" class="is-checkradio is-info" v-model="form[fieldName]" v-on:change="$emit(fieldName, form[fieldName])" v-bind="$attrs">
<label :for="fieldName" class="label" v-html="label"></label> <label :for="fieldName" class="label" v-html="label"></label>
<p class="help" v-html="help" v-if="help"></p> <p class="help" v-html="help" v-if="help"></p>
</div> </div>
@ -9,6 +9,7 @@
<script> <script>
export default { export default {
name: 'FormCheckbox', name: 'FormCheckbox',
inheritAttrs: false,
data() { data() {
return { return {

View File

@ -129,6 +129,8 @@
const { data } = await vm.axios.get('api/v1/user/name') const { data } = await vm.axios.get('api/v1/user/name')
if( data.name ) { if( data.name ) {
// The email property is only sent when the user is logged in.
// In this case we push the user to the index view.
if( data.email ) { if( data.email ) {
return next({ name: 'accounts' }); return next({ name: 'accounts' });
} }

View File

@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<!-- webauthn registration --> <!-- webauthn registration -->
<form-wrapper v-if="showWebauthnRegistration" :title="$t('auth.authentication')" :punchline="$t('auth.webauthn.enforce_security_using_webauthn')"> <form-wrapper v-if="showWebauthnRegistration" :title="$t('auth.authentication')" :punchline="$t('auth.webauthn.enhance_security_using_webauthn')">
<div v-if="deviceRegistered" class="field"> <div v-if="deviceRegistered" class="field">
<label class="label mb-5">{{ $t('auth.webauthn.device_successfully_registered') }}&nbsp;<font-awesome-icon :icon="['fas', 'check']" /></label> <label class="label mb-5">{{ $t('auth.webauthn.device_successfully_registered') }}&nbsp;<font-awesome-icon :icon="['fas', 'check']" /></label>
<form @submit.prevent="handleDeviceSubmit" @keydown="deviceForm.onKeydown($event)"> <form @submit.prevent="handleDeviceSubmit" @keydown="deviceForm.onKeydown($event)">

View File

@ -4,18 +4,23 @@
<div class="options-tabs"> <div class="options-tabs">
<form-wrapper> <form-wrapper>
<form @submit.prevent="submitProfile" @keydown="formProfile.onKeydown($event)"> <form @submit.prevent="submitProfile" @keydown="formProfile.onKeydown($event)">
<div v-if="isRemoteUser" class="notification is-warning has-text-centered" v-html="$t('auth.user_account_controlled_by_proxy')" />
<h4 class="title is-4 has-text-grey-light">{{ $t('settings.profile') }}</h4> <h4 class="title is-4 has-text-grey-light">{{ $t('settings.profile') }}</h4>
<fieldset :disabled="isRemoteUser">
<form-field :form="formProfile" fieldName="name" :label="$t('auth.forms.name')" autofocus /> <form-field :form="formProfile" fieldName="name" :label="$t('auth.forms.name')" autofocus />
<form-field :form="formProfile" fieldName="email" inputType="email" :label="$t('auth.forms.email')" /> <form-field :form="formProfile" fieldName="email" inputType="email" :label="$t('auth.forms.email')" />
<form-field :form="formProfile" fieldName="password" inputType="password" :label="$t('auth.forms.current_password.label')" :help="$t('auth.forms.current_password.help')" /> <form-field :form="formProfile" fieldName="password" inputType="password" :label="$t('auth.forms.current_password.label')" :help="$t('auth.forms.current_password.help')" />
<form-buttons :isBusy="formProfile.isBusy" :caption="$t('commons.update')" /> <form-buttons :isBusy="formProfile.isBusy" :caption="$t('commons.update')" />
</fieldset>
</form> </form>
<form @submit.prevent="submitPassword" @keydown="formPassword.onKeydown($event)"> <form @submit.prevent="submitPassword" @keydown="formPassword.onKeydown($event)">
<h4 class="title is-4 pt-6 has-text-grey-light">{{ $t('settings.change_password') }}</h4> <h4 class="title is-4 pt-6 has-text-grey-light">{{ $t('settings.change_password') }}</h4>
<fieldset :disabled="isRemoteUser">
<form-field :form="formPassword" fieldName="password" inputType="password" :label="$t('auth.forms.new_password')" /> <form-field :form="formPassword" fieldName="password" inputType="password" :label="$t('auth.forms.new_password')" />
<form-field :form="formPassword" fieldName="password_confirmation" inputType="password" :label="$t('auth.forms.confirm_new_password')" /> <form-field :form="formPassword" fieldName="password_confirmation" inputType="password" :label="$t('auth.forms.confirm_new_password')" />
<form-field :form="formPassword" fieldName="currentPassword" inputType="password" :label="$t('auth.forms.current_password.label')" :help="$t('auth.forms.current_password.help')" /> <form-field :form="formPassword" fieldName="currentPassword" inputType="password" :label="$t('auth.forms.current_password.label')" :help="$t('auth.forms.current_password.help')" />
<form-buttons :isBusy="formPassword.isBusy" :caption="$t('auth.forms.change_password')" /> <form-buttons :isBusy="formPassword.isBusy" :caption="$t('auth.forms.change_password')" />
</fieldset>
</form> </form>
</form-wrapper> </form-wrapper>
</div> </div>
@ -46,13 +51,16 @@
currentPassword : '', currentPassword : '',
password : '', password : '',
password_confirmation : '', password_confirmation : '',
}) }),
isRemoteUser: false,
} }
}, },
async mounted() { async mounted() {
const { data } = await this.formProfile.get('/api/v1/user') const { data } = await this.formProfile.get('/api/v1/user')
if( data.id === null ) this.isRemoteUser = true
this.formProfile.fill(data) this.formProfile.fill(data)
}, },

View File

@ -2,16 +2,16 @@
<div> <div>
<setting-tabs :activeTab="'settings.oauth'"></setting-tabs> <setting-tabs :activeTab="'settings.oauth'"></setting-tabs>
<div class="options-tabs"> <div class="options-tabs">
<div class="columns is-centered"> <form-wrapper>
<div class="form-column column is-two-thirds-tablet is-half-desktop is-one-third-widescreen is-one-third-fullhd"> <div v-if="isRemoteUser" class="notification is-warning has-text-centered" v-html="$t('auth.auth_handled_by_proxy')" />
<h4 class="title is-4 has-text-grey-light">{{ $t('settings.personal_access_tokens') }}</h4> <h4 class="title is-4 has-text-grey-light">{{ $t('settings.personal_access_tokens') }}</h4>
<div class="is-size-7-mobile"> <div class="is-size-7-mobile">
{{ $t('settings.token_legend')}} {{ $t('settings.token_legend')}}
</div> </div>
<div class="mt-3"> <div class="mt-3">
<router-link class="is-link" :to="{ name: 'settings.oauth.generatePAT' }"> <a class="is-link" @click="createToken()">
<font-awesome-icon :icon="['fas', 'plus-circle']" /> {{ $t('settings.generate_new_token')}} <font-awesome-icon :icon="['fas', 'plus-circle']" /> {{ $t('settings.generate_new_token')}}
</router-link> </a>
</div> </div>
<div v-if="tokens.length > 0"> <div v-if="tokens.length > 0">
<div v-for="token in tokens" :key="token.id" class="group-item has-text-light is-size-5 is-size-6-mobile"> <div v-for="token in tokens" :key="token.id" class="group-item has-text-light is-size-5 is-size-6-mobile">
@ -50,8 +50,7 @@
<router-link :to="{ name: 'accounts', params: { toRefresh: false } }" class="button is-dark is-rounded">{{ $t('commons.close') }}</router-link> <router-link :to="{ name: 'accounts', params: { toRefresh: false } }" class="button is-dark is-rounded">{{ $t('commons.close') }}</router-link>
</p> </p>
</vue-footer> </vue-footer>
</div> </form-wrapper>
</div>
</div> </div>
</div> </div>
</template> </template>
@ -67,7 +66,8 @@
isFetching: false, isFetching: false,
form: new Form({ form: new Form({
token : '', token : '',
}) }),
isRemoteUser: false,
} }
}, },
@ -84,7 +84,12 @@
this.isFetching = true this.isFetching = true
await this.axios.get('/api/v1/oauth/personal-access-tokens').then(response => { await this.axios.get('/oauth/personal-access-tokens').then(response => {
if (response.status === 202) {
this.isRemoteUser = true
return
}
const tokens = [] const tokens = []
response.data.forEach((data) => { response.data.forEach((data) => {
@ -124,7 +129,17 @@
this.$notify({ type: 'is-success', text: this.$t('settings.token_revoked') }) this.$notify({ type: 'is-success', text: this.$t('settings.token_revoked') })
}); });
} }
},
/**
* Open the PAT creation view
*/
createToken() {
if (this.isRemoteUser) {
this.$notify({ type: 'is-warning', text: this.$t('errors.unsupported_with_reverseproxy') })
} }
else this.$router.push({ name: 'settings.oauth.generatePAT' })
},
}, },
} }
</script> </script>

View File

@ -3,6 +3,7 @@
<setting-tabs :activeTab="'settings.webauthn'"></setting-tabs> <setting-tabs :activeTab="'settings.webauthn'"></setting-tabs>
<div class="options-tabs"> <div class="options-tabs">
<form-wrapper> <form-wrapper>
<div v-if="isRemoteUser" class="notification is-warning has-text-centered" v-html="$t('auth.auth_handled_by_proxy')" />
<h4 class="title is-4 has-text-grey-light">{{ $t('auth.webauthn.security_devices') }}</h4> <h4 class="title is-4 has-text-grey-light">{{ $t('auth.webauthn.security_devices') }}</h4>
<div class="is-size-7-mobile"> <div class="is-size-7-mobile">
{{ $t('auth.webauthn.security_devices_legend')}} {{ $t('auth.webauthn.security_devices_legend')}}
@ -37,9 +38,9 @@
<h4 class="title is-4 pt-6 has-text-grey-light">{{ $t('settings.options') }}</h4> <h4 class="title is-4 pt-6 has-text-grey-light">{{ $t('settings.options') }}</h4>
<form> <form>
<!-- use webauthn only --> <!-- use webauthn only -->
<form-checkbox v-on:useWebauthnOnly="saveSetting('useWebauthnOnly', $event)" :form="form" fieldName="useWebauthnOnly" :label="$t('auth.webauthn.use_webauthn_only.label')" :help="$t('auth.webauthn.use_webauthn_only.help')" /> <form-checkbox v-on:useWebauthnOnly="saveSetting('useWebauthnOnly', $event)" :form="form" fieldName="useWebauthnOnly" :label="$t('auth.webauthn.use_webauthn_only.label')" :help="$t('auth.webauthn.use_webauthn_only.help')" :disabled="isRemoteUser" />
<!-- default sign in method --> <!-- default sign in method -->
<form-checkbox v-on:useWebauthnAsDefault="saveSetting('useWebauthnAsDefault', $event)" :form="form" fieldName="useWebauthnAsDefault" :label="$t('auth.webauthn.use_webauthn_as_default.label')" :help="$t('auth.webauthn.use_webauthn_as_default.help')" /> <form-checkbox v-on:useWebauthnAsDefault="saveSetting('useWebauthnAsDefault', $event)" :form="form" fieldName="useWebauthnAsDefault" :label="$t('auth.webauthn.use_webauthn_as_default.label')" :help="$t('auth.webauthn.use_webauthn_as_default.help')" :disabled="isRemoteUser" />
</form> </form>
<!-- footer --> <!-- footer -->
<vue-footer :showButtons="true"> <vue-footer :showButtons="true">
@ -66,6 +67,7 @@
}), }),
credentials: [], credentials: [],
isFetching: false, isFetching: false,
isRemoteUser: false,
} }
}, },
@ -102,7 +104,10 @@
this.isFetching = true this.isFetching = true
await this.axios.get('/webauthn/credentials').then(response => { await this.axios.get('/webauthn/credentials').then(response => {
this.credentials = response.data if (response.status === 202) {
this.isRemoteUser = true
}
else this.credentials = response.data
}) })
this.isFetching = false this.isFetching = false
@ -113,6 +118,12 @@
* Register a new security device * Register a new security device
*/ */
async register() { async register() {
if (this.isRemoteUser) {
this.$notify({ type: 'is-warning', text: this.$t('errors.unsupported_with_reverseproxy') })
return false
}
// Check https context // Check https context
if (!window.isSecureContext) { if (!window.isSecureContext) {
this.$notify({ type: 'is-danger', text: this.$t('errors.https_required') }) this.$notify({ type: 'is-danger', text: this.$t('errors.https_required') })

View File

@ -28,6 +28,8 @@
'already_authenticated' => 'Already authenticated', 'already_authenticated' => 'Already authenticated',
'authentication' => 'Authentication', 'authentication' => 'Authentication',
'maybe_later' => 'Maybe later', 'maybe_later' => 'Maybe later',
'user_account_controlled_by_proxy' => 'User account made available by an authentication proxy.<br />Manage the account at proxy level.',
'auth_handled_by_proxy' => 'Authentication handled by a reverse proxy, below settings are disabled.<br />Manage authentication at proxy level.',
'confirm' => [ 'confirm' => [
'logout' => 'Are you sure you want to log out?', 'logout' => 'Are you sure you want to log out?',
'revoke_device' => 'Are you sure you want to revoke this device?', 'revoke_device' => 'Are you sure you want to revoke this device?',
@ -36,7 +38,7 @@
'security_device' => 'a security device', 'security_device' => 'a security device',
'security_devices' => 'Security devices', 'security_devices' => 'Security devices',
'security_devices_legend' => 'Authentication devices you can use to sign in 2FAuth, like security keys (i.e Yubikey) or smartphones with biometric capabilities (i.e. Apple FaceId/TouchId)', 'security_devices_legend' => 'Authentication devices you can use to sign in 2FAuth, like security keys (i.e Yubikey) or smartphones with biometric capabilities (i.e. Apple FaceId/TouchId)',
'enforce_security_using_webauthn' => 'You can enforce the security of your 2FAuth account by enabling WebAuthn authentication.<br /><br /> 'enhance_security_using_webauthn' => 'You can enhance the security of your 2FAuth account by enabling WebAuthn authentication.<br /><br />
WebAuthn allows you to use trusted devices (like Yubikeys or smartphones with biometric capabilities) to sign in quickly and more securely.', WebAuthn allows you to use trusted devices (like Yubikeys or smartphones with biometric capabilities) to sign in quickly and more securely.',
'use_security_device_to_sign_in' => 'Get ready to authenticate yourself using (one of) your security devices. Plug your key in, remove face mask or gloves, etc.', 'use_security_device_to_sign_in' => 'Get ready to authenticate yourself using (one of) your security devices. Plug your key in, remove face mask or gloves, etc.',
'lost_your_device' => 'Lost your device?', 'lost_your_device' => 'Lost your device?',
@ -57,7 +59,7 @@
'unknown_device' => 'Unknown device', 'unknown_device' => 'Unknown device',
'use_webauthn_only' => [ 'use_webauthn_only' => [
'label' => 'Use WebAuthn only (recommended)', 'label' => 'Use WebAuthn only (recommended)',
'help' => 'Make WebAuthn the only available method to sign in 2FAuth. This is the recommended setup to take advantage of the WebAuthn enforced security.<br /> 'help' => 'Make WebAuthn the only available method to sign in 2FAuth. This is the recommended setup to take advantage of the WebAuthn enhanced security.<br />
In case of device lost you will always be able to register a new security device to recover your account.' In case of device lost you will always be able to register a new security device to recover your account.'
], ],
'use_webauthn_as_default' => [ 'use_webauthn_as_default' => [

View File

@ -35,4 +35,5 @@
'browser_does_not_support_webauthn' => 'Your device does not support webauthn. Try again later using a more modern browser', 'browser_does_not_support_webauthn' => 'Your device does not support webauthn. Try again later using a more modern browser',
'aborted_by_user' => 'Aborted by user', 'aborted_by_user' => 'Aborted by user',
'security_device_unsupported' => 'Security device unsupported', 'security_device_unsupported' => 'Security device unsupported',
'unsupported_with_reverseproxy' => 'Not applicable when using an auth proxy',
]; ];

View File

@ -1,27 +1,24 @@
<?php <?php
use Illuminate\Http\Request; use Illuminate\Support\Facades\Route;
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| API Routes | API Routes
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/ */
/**
* Unprotected routes
*/
Route::get('user/name', 'UserController@show')->name('user.show.name'); Route::get('user/name', 'UserController@show')->name('user.show.name');
Route::group(['middleware' => 'auth:reverse-proxy,api'], function() {
Route::get('oauth/personal-access-tokens', '\Laravel\Passport\Http\Controllers\PersonalAccessTokenController@forUser')->name('passport.personal.tokens.index'); /**
Route::post('oauth/personal-access-tokens', '\Laravel\Passport\Http\Controllers\PersonalAccessTokenController@store')->name('passport.personal.tokens.store'); * Routes protected by the api authentication guard
Route::delete('oauth/personal-access-tokens/{token_id}', '\Laravel\Passport\Http\Controllers\PersonalAccessTokenController@destroy')->name('passport.personal.tokens.destroy'); */
Route::group(['middleware' => 'auth:api-guard'], function () {
Route::get('user', 'UserController@show')->name('user.show'); Route::get('user', 'UserController@show')->name('user.show'); // Returns email address in addition to the username
Route::get('settings/{settingName}', 'SettingController@show')->name('settings.show'); Route::get('settings/{settingName}', 'SettingController@show')->name('settings.show');
Route::get('settings', 'SettingController@index')->name('settings.index'); Route::get('settings', 'SettingController@index')->name('settings.index');
@ -47,5 +44,4 @@
Route::post('icons', 'IconController@upload')->name('icons.upload'); Route::post('icons', 'IconController@upload')->name('icons.upload');
Route::delete('icons/{icon}', 'IconController@delete')->name('icons.delete'); Route::delete('icons/{icon}', 'IconController@delete')->name('icons.delete');
}); });

View File

@ -11,24 +11,42 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Web Routes | Web Routes
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/ */
Route::post('user', 'Auth\RegisterController@register')->name('user.register'); /**
Route::post('user/password/lost', 'Auth\ForgotPasswordController@sendResetLinkEmail')->middleware('AvoidResetPassword')->name('user.password.lost');; * Routes that only work for unauthenticated user (return an error otherwise)
Route::post('user/password/reset', 'Auth\ResetPasswordController@reset')->name('user.password.reset'); */
Route::post('webauthn/lost', [WebAuthnDeviceLostController::class, 'sendRecoveryEmail'])->name('webauthn.lost'); Route::group(['middleware' => 'guest'], function () {
Route::post('webauthn/recover/options', [WebAuthnRecoveryController::class, 'options'])->name('webauthn.recover.options'); Route::post('user', 'Auth\RegisterController@register')->name('user.register');
Route::post('webauthn/recover', [WebAuthnRecoveryController::class, 'recover'])->name('webauthn.recover'); Route::post('user/password/lost', 'Auth\ForgotPasswordController@sendResetLinkEmail')->middleware('AvoidResetPassword')->name('user.password.lost');;
Route::post('user/password/reset', 'Auth\ResetPasswordController@reset')->name('user.password.reset');
Route::post('webauthn/login/options', [WebAuthnLoginController::class, 'options'])->name('webauthn.login.options');
Route::post('webauthn/lost', [WebAuthnDeviceLostController::class, 'sendRecoveryEmail'])->name('webauthn.lost');
Route::post('webauthn/recover/options', [WebAuthnRecoveryController::class, 'options'])->name('webauthn.recover.options');
Route::post('webauthn/recover', [WebAuthnRecoveryController::class, 'recover'])->name('webauthn.recover');
});
Route::group(['middleware' => 'auth:reverse-proxy,web'], function () { /**
* Routes that only work for unauthenticated user (return an error otherwise)
* that can be requested max 10 times per minute by the same IP
*/
Route::group(['middleware' => ['guest', 'throttle:10,1']], function () {
Route::post('user/login', 'Auth\LoginController@login')->name('user.login');
Route::post('webauthn/login', [WebAuthnLoginController::class, 'login'])->name('webauthn.login');
});
/**
* Routes protected by an authentication guard
*/
Route::group(['middleware' => 'behind-auth'], function () {
Route::put('user', 'Auth\UserController@update')->name('user.update'); Route::put('user', 'Auth\UserController@update')->name('user.update');
Route::patch('user/password', 'Auth\PasswordController@update')->name('user.password.update'); Route::patch('user/password', 'Auth\PasswordController@update')->name('user.password.update');
Route::get('user/logout', 'Auth\LoginController@logout')->name('user.logout'); Route::get('user/logout', 'Auth\LoginController@logout')->name('user.logout');
Route::get('oauth/personal-access-tokens', 'Auth\PersonalAccessTokenController@forUser')->name('passport.personal.tokens.index');
Route::post('oauth/personal-access-tokens', 'Auth\PersonalAccessTokenController@store')->name('passport.personal.tokens.store');
Route::delete('oauth/personal-access-tokens/{token_id}', 'Auth\PersonalAccessTokenController@destroy')->name('passport.personal.tokens.destroy');
Route::post('webauthn/register/options', [WebAuthnRegisterController::class, 'options'])->name('webauthn.register.options'); Route::post('webauthn/register/options', [WebAuthnRegisterController::class, 'options'])->name('webauthn.register.options');
Route::post('webauthn/register', [WebAuthnRegisterController::class, 'register'])->name('webauthn.register'); Route::post('webauthn/register', [WebAuthnRegisterController::class, 'register'])->name('webauthn.register');
Route::get('webauthn/credentials', [WebAuthnManageController::class, 'index'])->name('webauthn.credentials.index'); Route::get('webauthn/credentials', [WebAuthnManageController::class, 'index'])->name('webauthn.credentials.index');
@ -36,10 +54,7 @@
Route::delete('webauthn/credentials/{credential}', [WebAuthnManageController::class, 'delete'])->name('webauthn.credentials.delete'); Route::delete('webauthn/credentials/{credential}', [WebAuthnManageController::class, 'delete'])->name('webauthn.credentials.delete');
}); });
Route::group(['middleware' => ['guest:web', 'throttle:10,1']], function () { /**
Route::post('user/login', 'Auth\LoginController@login')->name('user.login'); * Route for the main landing view
Route::post('webauthn/login/options', [WebAuthnLoginController::class, 'options'])->name('webauthn.login.options'); */
Route::post('webauthn/login', [WebAuthnLoginController::class, 'login'])->name('webauthn.login');
});
Route::get('/{any}', 'SinglePageController@index')->where('any', '.*')->name('landing'); Route::get('/{any}', 'SinglePageController@index')->where('any', '.*')->name('landing');