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\Resources\UserResource;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class UserController extends Controller
{
@ -14,9 +15,12 @@ class UserController extends Controller
*
* @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
? new UserResource($user)

View File

@ -16,8 +16,9 @@ class UserResource extends JsonResource
public function toArray($request)
{
return [
'id' => $this->when($request->user(), $this->id),
'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([
'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
// 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
namespace App\Extensions;
@ -8,7 +8,7 @@
use App\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Support\Str;
use Illuminate\Support\Arr;
use Exception;
class RemoteUserProvider implements UserProvider
@ -16,19 +16,20 @@ class RemoteUserProvider implements UserProvider
/**
* @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 = User::create(
// [
// 'name' => $identifier,
// 'email' => $identifier,
// 'password' => bcrypt(Str::random(64)),
// ]
// );
// }
$user = new User;
$user->name = $identifier['user'];
$user->email = Arr::has($identifier, 'email') ? $identifier['email'] : $identifier['user'];
return $user;
}

View File

@ -5,6 +5,7 @@
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
use App\Exceptions\UnsupportedWithReverseProxyException;
class ForgotPasswordController extends Controller
{
@ -21,6 +22,20 @@ class ForgotPasswordController extends Controller
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.
*

View File

@ -11,6 +11,7 @@
use Illuminate\Validation\ValidationException;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Carbon\Carbon;
use App\Exceptions\UnsupportedWithReverseProxyException;
class LoginController extends Controller
@ -28,6 +29,20 @@ class LoginController extends Controller
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.
*

View File

@ -6,10 +6,25 @@
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use App\Exceptions\UnsupportedWithReverseProxyException;
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.
*

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\Auth\Events\Registered;
use Illuminate\Foundation\Auth\RegistersUsers;
use App\Exceptions\UnsupportedWithReverseProxyException;
class RegisterController extends Controller
{
@ -26,6 +27,19 @@ class RegisterController extends Controller
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.
*

View File

@ -5,6 +5,7 @@
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ResetsPasswords;
use App\Exceptions\UnsupportedWithReverseProxyException;
class ResetPasswordController extends Controller
{
@ -21,4 +22,17 @@ class ResetPasswordController extends Controller
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\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use App\Exceptions\UnsupportedWithReverseProxyException;
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.
*

View File

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

View File

@ -6,6 +6,7 @@
use DarkGhostHunter\Larapass\Http\SendsWebAuthnRecoveryEmail;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use App\Exceptions\UnsupportedWithReverseProxyException;
class WebAuthnDeviceLostController extends Controller
{
@ -22,9 +23,16 @@ class WebAuthnDeviceLostController extends Controller
|
*/
/**
* Create a new controller instance.
*/
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 App\Http\Controllers\Controller;
use DarkGhostHunter\Larapass\Http\AuthenticatesWebAuthn;
use App\Exceptions\UnsupportedWithReverseProxyException;
class WebAuthnLoginController extends Controller
{
@ -26,9 +27,16 @@ class WebAuthnLoginController extends Controller
|
*/
/**
* Create a new controller instance.
*/
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 App\Http\Requests\WebauthnRenameRequest;
use DarkGhostHunter\Larapass\Eloquent\WebAuthnCredential;
use App\Exceptions\UnsupportedWithReverseProxyException;
class WebAuthnManageController extends Controller
{
@ -34,6 +35,14 @@ public function __construct()
*/
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();
$allUserCredentials = $user->webAuthnCredentials()
->enabled()

View File

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

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller;
use DarkGhostHunter\Larapass\Http\RegistersWebAuthn;
use App\Exceptions\UnsupportedWithReverseProxyException;
class WebAuthnRegisterController extends Controller
{
@ -22,11 +23,13 @@ class WebAuthnRegisterController extends Controller
/**
* Create a new controller instance.
*
* @return void
*/
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\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::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,
],

View File

@ -3,8 +3,43 @@
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
use Illuminate\Support\Arr;
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
* @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
if (Auth::guest() || $request->bearerToken()) {
// We do not track activity of:
// - 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);
}

View File

@ -21,8 +21,11 @@ public function handle($request, Closure $next, ...$quards)
$guards = empty($guards) ? [null] : $guards;
foreach ($guards as $guard) {
// Activity coming from a client authenticated with a personal access token is not logged
if( Auth::guard($guard)->check() && !$request->bearerToken()) {
// We do not track activity of:
// - 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()->save();
break;

View File

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

View File

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

View File

@ -73,7 +73,7 @@ private function getApiNamespace($version)
protected function configureRateLimiting()
{
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
// 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;
use Exception;
@ -66,16 +69,26 @@ public function user()
$user = null;
// Get the user identifier from $_SERVER or apache filtered headers
$header = config('auth.guard_header', 'REMOTE_USER');
$userID = request()->server($header) ?? apache_request_headers()[$header] ?? null;
$remoteUserHeader = config('auth.auth_proxy_headers.user', 'REMOTE_USER');
$identifier['user'] = request()->server($remoteUserHeader) ?? apache_request_headers()[$remoteUserHeader] ?? null;
if (null === $userID) {
Log::error(sprintf('No user in header "%s".', $header));
if ($identifier['user'] === null) {
Log::error(sprintf('No user in header "%s".', $remoteUserHeader));
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;
}

View File

@ -14,11 +14,24 @@
*/
'defaults' => [
'guard' => env('AUTHENTICATION_GUARD', 'web'),
'guard' => env('AUTHENTICATION_GUARD', 'web-guard'),
'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' => [
'web' => [
'web-guard' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'api-guard' => [
'driver' => 'passport',
'provider' => 'users',
'hash' => false,
],
'reverse-proxy' => [
'reverse-proxy-guard' => [
'driver' => 'reverse-proxy',
'provider' => 'remote-user',
],

View File

@ -1,6 +1,6 @@
<template>
<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>
<p class="help" v-html="help" v-if="help"></p>
</div>
@ -9,6 +9,7 @@
<script>
export default {
name: 'FormCheckbox',
inheritAttrs: false,
data() {
return {

View File

@ -129,6 +129,8 @@
const { data } = await vm.axios.get('api/v1/user/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 ) {
return next({ name: 'accounts' });
}

View File

@ -1,7 +1,7 @@
<template>
<div>
<!-- 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">
<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)">

View File

@ -4,18 +4,23 @@
<div class="options-tabs">
<form-wrapper>
<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>
<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="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')" />
<fieldset :disabled="isRemoteUser">
<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="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')" />
</fieldset>
</form>
<form @submit.prevent="submitPassword" @keydown="formPassword.onKeydown($event)">
<h4 class="title is-4 pt-6 has-text-grey-light">{{ $t('settings.change_password') }}</h4>
<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="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')" />
<fieldset :disabled="isRemoteUser">
<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="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')" />
</fieldset>
</form>
</form-wrapper>
</div>
@ -46,13 +51,16 @@
currentPassword : '',
password : '',
password_confirmation : '',
})
}),
isRemoteUser: false,
}
},
async mounted() {
const { data } = await this.formProfile.get('/api/v1/user')
if( data.id === null ) this.isRemoteUser = true
this.formProfile.fill(data)
},

View File

@ -2,56 +2,55 @@
<div>
<setting-tabs :activeTab="'settings.oauth'"></setting-tabs>
<div class="options-tabs">
<div class="columns is-centered">
<div class="form-column column is-two-thirds-tablet is-half-desktop is-one-third-widescreen is-one-third-fullhd">
<h4 class="title is-4 has-text-grey-light">{{ $t('settings.personal_access_tokens') }}</h4>
<div class="is-size-7-mobile">
{{ $t('settings.token_legend')}}
</div>
<div class="mt-3">
<router-link class="is-link" :to="{ name: 'settings.oauth.generatePAT' }">
<font-awesome-icon :icon="['fas', 'plus-circle']" /> {{ $t('settings.generate_new_token')}}
</router-link>
</div>
<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">
<font-awesome-icon v-if="token.value" class="has-text-success" :icon="['fas', 'check']" /> {{ token.name }}
<!-- revoke link -->
<div class="tags is-pulled-right">
<a v-if="token.value" class="tag" v-clipboard="() => token.value" v-clipboard:success="clipboardSuccessHandler">{{ $t('commons.copy') }}</a>
<a class="tag is-dark " @click="revokeToken(token.id)" :title="$t('settings.revoke')">{{ $t('settings.revoke') }}</a>
</div>
<!-- edit link -->
<!-- <router-link :to="{ name: 'settings.oauth.editPAT' }" class="has-text-grey pl-1" :title="$t('commons.edit')">
<font-awesome-icon :icon="['fas', 'pen-square']" />
</router-link> -->
<!-- warning msg -->
<span v-if="token.value" class="is-size-7-mobile is-size-6 my-3">
{{ $t('settings.make_sure_copy_token') }}
</span>
<!-- token value -->
<span v-if="token.value" class="pat is-family-monospace is-size-6 is-size-7-mobile has-text-success">
{{ token.value }}
</span>
<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('settings.personal_access_tokens') }}</h4>
<div class="is-size-7-mobile">
{{ $t('settings.token_legend')}}
</div>
<div class="mt-3">
<a class="is-link" @click="createToken()">
<font-awesome-icon :icon="['fas', 'plus-circle']" /> {{ $t('settings.generate_new_token')}}
</a>
</div>
<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">
<font-awesome-icon v-if="token.value" class="has-text-success" :icon="['fas', 'check']" /> {{ token.name }}
<!-- revoke link -->
<div class="tags is-pulled-right">
<a v-if="token.value" class="tag" v-clipboard="() => token.value" v-clipboard:success="clipboardSuccessHandler">{{ $t('commons.copy') }}</a>
<a class="tag is-dark " @click="revokeToken(token.id)" :title="$t('settings.revoke')">{{ $t('settings.revoke') }}</a>
</div>
<div class="mt-2 is-size-7 is-pulled-right">
{{ $t('settings.revoking_a_token_is_permanent')}}
</div>
</div>
<div v-if="isFetching && tokens.length === 0" class="has-text-centered mt-6">
<span class="is-size-4">
<font-awesome-icon :icon="['fas', 'spinner']" spin />
<!-- edit link -->
<!-- <router-link :to="{ name: 'settings.oauth.editPAT' }" class="has-text-grey pl-1" :title="$t('commons.edit')">
<font-awesome-icon :icon="['fas', 'pen-square']" />
</router-link> -->
<!-- warning msg -->
<span v-if="token.value" class="is-size-7-mobile is-size-6 my-3">
{{ $t('settings.make_sure_copy_token') }}
</span>
<!-- token value -->
<span v-if="token.value" class="pat is-family-monospace is-size-6 is-size-7-mobile has-text-success">
{{ token.value }}
</span>
</div>
<!-- footer -->
<vue-footer :showButtons="true">
<!-- close button -->
<p class="control">
<router-link :to="{ name: 'accounts', params: { toRefresh: false } }" class="button is-dark is-rounded">{{ $t('commons.close') }}</router-link>
</p>
</vue-footer>
<div class="mt-2 is-size-7 is-pulled-right">
{{ $t('settings.revoking_a_token_is_permanent')}}
</div>
</div>
</div>
<div v-if="isFetching && tokens.length === 0" class="has-text-centered mt-6">
<span class="is-size-4">
<font-awesome-icon :icon="['fas', 'spinner']" spin />
</span>
</div>
<!-- footer -->
<vue-footer :showButtons="true">
<!-- close button -->
<p class="control">
<router-link :to="{ name: 'accounts', params: { toRefresh: false } }" class="button is-dark is-rounded">{{ $t('commons.close') }}</router-link>
</p>
</vue-footer>
</form-wrapper>
</div>
</div>
</template>
@ -67,7 +66,8 @@
isFetching: false,
form: new Form({
token : '',
})
}),
isRemoteUser: false,
}
},
@ -84,7 +84,12 @@
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 = []
response.data.forEach((data) => {
@ -124,7 +129,17 @@
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>

View File

@ -3,6 +3,7 @@
<setting-tabs :activeTab="'settings.webauthn'"></setting-tabs>
<div class="options-tabs">
<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>
<div class="is-size-7-mobile">
{{ $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>
<form>
<!-- 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 -->
<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>
<!-- footer -->
<vue-footer :showButtons="true">
@ -66,6 +67,7 @@
}),
credentials: [],
isFetching: false,
isRemoteUser: false,
}
},
@ -102,7 +104,10 @@
this.isFetching = true
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
@ -113,6 +118,12 @@
* Register a new security device
*/
async register() {
if (this.isRemoteUser) {
this.$notify({ type: 'is-warning', text: this.$t('errors.unsupported_with_reverseproxy') })
return false
}
// Check https context
if (!window.isSecureContext) {
this.$notify({ type: 'is-danger', text: this.$t('errors.https_required') })

View File

@ -28,6 +28,8 @@
'already_authenticated' => 'Already authenticated',
'authentication' => 'Authentication',
'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' => [
'logout' => 'Are you sure you want to log out?',
'revoke_device' => 'Are you sure you want to revoke this device?',
@ -36,7 +38,7 @@
'security_device' => 'a security device',
'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)',
'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.',
'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?',
@ -57,7 +59,7 @@
'unknown_device' => 'Unknown device',
'use_webauthn_only' => [
'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.'
],
'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',
'aborted_by_user' => 'Aborted by user',
'security_device_unsupported' => 'Security device unsupported',
'unsupported_with_reverseproxy' => 'Not applicable when using an auth proxy',
];

View File

@ -1,27 +1,24 @@
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| 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::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');
Route::delete('oauth/personal-access-tokens/{token_id}', '\Laravel\Passport\Http\Controllers\PersonalAccessTokenController@destroy')->name('passport.personal.tokens.destroy');
Route::get('user', 'UserController@show')->name('user.show');
/**
* Routes protected by the api authentication guard
*/
Route::group(['middleware' => 'auth:api-guard'], function () {
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', 'SettingController@index')->name('settings.index');
@ -47,5 +44,4 @@
Route::post('icons', 'IconController@upload')->name('icons.upload');
Route::delete('icons/{icon}', 'IconController@delete')->name('icons.delete');
});

View File

@ -11,24 +11,42 @@
|--------------------------------------------------------------------------
| 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');;
Route::post('user/password/reset', 'Auth\ResetPasswordController@reset')->name('user.password.reset');
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');
/**
* Routes that only work for unauthenticated user (return an error otherwise)
*/
Route::group(['middleware' => 'guest'], function () {
Route::post('user', 'Auth\RegisterController@register')->name('user.register');
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::patch('user/password', 'Auth\PasswordController@update')->name('user.password.update');
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', [WebAuthnRegisterController::class, 'register'])->name('webauthn.register');
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::group(['middleware' => ['guest:web', 'throttle:10,1']], function () {
Route::post('user/login', 'Auth\LoginController@login')->name('user.login');
Route::post('webauthn/login/options', [WebAuthnLoginController::class, 'options'])->name('webauthn.login.options');
Route::post('webauthn/login', [WebAuthnLoginController::class, 'login'])->name('webauthn.login');
});
/**
* Route for the main landing view
*/
Route::get('/{any}', 'SinglePageController@index')->where('any', '.*')->name('landing');