Compare commits

...

10 Commits

Author SHA1 Message Date
Bubka c6f8317d81 Set array mailer for Travis CI tests 2024-04-25 17:01:35 +02:00
Bubka bc89760e74 Fix twofaccounts count not being returned 2024-04-25 17:00:32 +02:00
Bubka 6b96fced3d Complete phpdoc comment 2024-04-25 17:00:04 +02:00
Bubka 7a033409a5 Make list of tested preferences dynamic 2024-04-25 16:58:55 +02:00
Bubka 8734e9c807 Add Timezone setting/pref & Update tests accordingly 2024-04-25 16:56:35 +02:00
Bubka 99bf9d7d80 Add cascade delete on AuthLog table 2024-04-25 13:21:24 +02:00
Bubka 4987e060c4 Refactor & Complete tests for the authentication log feature 2024-04-24 21:46:50 +02:00
Bubka 76c3b6fe0c Add logging of auth proxy user 2024-04-24 14:06:15 +02:00
Bubka e498350f62 Complete merge with refactoring for better integration 2024-04-24 09:33:45 +02:00
Bubka e75589526b Merge authentication-log into codebase 2024-04-22 14:59:20 +02:00
57 changed files with 2566 additions and 730 deletions

View File

@ -9,6 +9,14 @@ APP_NAME=2FAuth
APP_ENV=local
# The timezone for your application, which is used to record dates and times to database. This global setting can be
# overridden by users via in-app settings for a personalised dates and times display.
# If this setting is changed while the application is already running, existing records in the database won't be updated.
APP_TIMEZONE=UTC
# Set to true if you want to see debug information in error screens.
APP_DEBUG=false
@ -179,6 +187,12 @@ LOGIN_THROTTLE=5
AUTHENTICATION_GUARD=web-guard
# Authentication log retention time, in days.
# Log entries older than that are automatically deleted.
AUTHENTICATION_LOG_RETENTION=365
# Name of the HTTP headers sent by the reverse proxy that identifies the authenticated user at proxy level.
# Check your proxy documentation to find out how these headers are named (i.e 'REMOTE_USER', 'REMOTE_EMAIL', etc...)
# (only relevant when AUTHENTICATION_GUARD is set to 'reverse-proxy-guard')

View File

@ -12,4 +12,5 @@ BCRYPT_ROUNDS=4
BROADCAST_DRIVER=log
CACHE_DRIVER=array
SESSION_DRIVER=array
QUEUE_DRIVER=sync
QUEUE_DRIVER=sync
MAIL_MAILER=array

7
Dockerfile vendored
View File

@ -114,6 +114,10 @@ ENV \
# You can leave this on "local". If you change it to production most console commands will ask for extra confirmation.
# Never set it to "testing".
APP_ENV=local \
# The timezone for your application, which is used to record dates and times to database. This global setting can be
# overridden by users via in-app settings for a personalised dates and times display.
# If this setting is changed while the application is already running, existing records in the database won't be updated.
APP_TIMEZONE=UTC \
# Set to true if you want to see debug information in error screens.
APP_DEBUG=false \
# This should be your email address
@ -186,6 +190,9 @@ ENV \
# authentication checks. That means your proxy is fully responsible of the authentication process, 2FAuth will
# trust him as long as headers are presents.
AUTHENTICATION_GUARD=web-guard \
# Authentication log retention time, in days.
# Log entries older than that are automatically deleted.
AUTHENTICATION_LOG_RETENTION=365 \
# Name of the HTTP headers sent by the reverse proxy that identifies the authenticated user at proxy level.
# Check your proxy documentation to find out how these headers are named (i.e 'REMOTE_USER', 'REMOTE_EMAIL', etc...)
# (only relevant when AUTHENTICATION_GUARD is set to 'reverse-proxy-guard')

View File

@ -5,7 +5,7 @@
/**
* A helper file for Laravel, to provide autocomplete information to your IDE
* Generated for Laravel 10.48.4.
* Generated for Laravel 10.48.8.
*
* This file should not be included in your code, only analyzed by your IDE!
*
@ -1640,36 +1640,6 @@ namespace Illuminate\Support\Facades {
/**
*
*
* @method static bool attempt(array $credentials = [], bool $remember = false)
* @method static bool once(array $credentials = [])
* @method static void login(\Illuminate\Contracts\Auth\Authenticatable $user, bool $remember = false)
* @method static \Illuminate\Contracts\Auth\Authenticatable|bool loginUsingId(mixed $id, bool $remember = false)
* @method static \Illuminate\Contracts\Auth\Authenticatable|bool onceUsingId(mixed $id)
* @method static bool viaRemember()
* @method static void logout()
* @method static \Symfony\Component\HttpFoundation\Response|null basic(string $field = 'email', array $extraConditions = [])
* @method static \Symfony\Component\HttpFoundation\Response|null onceBasic(string $field = 'email', array $extraConditions = [])
* @method static bool attemptWhen(array $credentials = [], array|callable|null $callbacks = null, bool $remember = false)
* @method static void logoutCurrentDevice()
* @method static \Illuminate\Contracts\Auth\Authenticatable|null logoutOtherDevices(string $password, string $attribute = 'password')
* @method static void attempting(mixed $callback)
* @method static \Illuminate\Contracts\Auth\Authenticatable getLastAttempted()
* @method static string getName()
* @method static string getRecallerName()
* @method static \Illuminate\Auth\SessionGuard setRememberDuration(int $minutes)
* @method static \Illuminate\Contracts\Cookie\QueueingFactory getCookieJar()
* @method static void setCookieJar(\Illuminate\Contracts\Cookie\QueueingFactory $cookie)
* @method static \Illuminate\Contracts\Events\Dispatcher getDispatcher()
* @method static void setDispatcher(\Illuminate\Contracts\Events\Dispatcher $events)
* @method static \Illuminate\Contracts\Session\Session getSession()
* @method static \Illuminate\Contracts\Auth\Authenticatable|null getUser()
* @method static \Symfony\Component\HttpFoundation\Request getRequest()
* @method static \Illuminate\Auth\SessionGuard setRequest(\Symfony\Component\HttpFoundation\Request $request)
* @method static \Illuminate\Support\Timebox getTimebox()
* @method static void macro(string $name, object|callable $macro)
* @method static void mixin(object $mixin, bool $replace = true)
* @method static bool hasMacro(string $name)
* @method static void flushMacros()
* @see \Illuminate\Auth\AuthManager
* @see \Illuminate\Auth\SessionGuard
*/ class Auth {
@ -1851,24 +1821,336 @@ namespace Illuminate\Support\Facades {
return $instance->getDefaultUserProvider();
}
/**
* {@inheritDoc}
* Get the currently authenticated user.
*
* @return \App\Models\User|null
* @static
*/ public static function user()
{
/** @var \App\Services\Auth\ReverseProxyGuard $instance */
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->user();
}
/**
* Get the ID for the currently authenticated user.
*
* @return int|string|null
* @static
*/ public static function id()
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->id();
}
/**
* Log a user into the application without sessions or cookies.
*
* @param array $credentials
* @return bool
* @static
*/ public static function once($credentials = [])
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->once($credentials);
}
/**
* Log the given user ID into the application without sessions or cookies.
*
* @param mixed $id
* @return \App\Models\User|false
* @static
*/ public static function onceUsingId($id)
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->onceUsingId($id);
}
/**
* Validate a user's credentials.
*
* @param array $credentials
* @return bool
* @codeCoverageIgnore
* @static
*/ public static function validate($credentials = [])
{
/** @var \App\Services\Auth\ReverseProxyGuard $instance */
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->validate($credentials);
}
/**
* Attempt to authenticate using HTTP Basic Auth.
*
* @param string $field
* @param array $extraConditions
* @return \Symfony\Component\HttpFoundation\Response|null
* @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException
* @static
*/ public static function basic($field = 'email', $extraConditions = [])
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->basic($field, $extraConditions);
}
/**
* Perform a stateless HTTP Basic login attempt.
*
* @param string $field
* @param array $extraConditions
* @return \Symfony\Component\HttpFoundation\Response|null
* @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException
* @static
*/ public static function onceBasic($field = 'email', $extraConditions = [])
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->onceBasic($field, $extraConditions);
}
/**
* Attempt to authenticate a user using the given credentials.
*
* @param array $credentials
* @param bool $remember
* @return bool
* @static
*/ public static function attempt($credentials = [], $remember = false)
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->attempt($credentials, $remember);
}
/**
* Attempt to authenticate a user with credentials and additional callbacks.
*
* @param array $credentials
* @param array|callable|null $callbacks
* @param bool $remember
* @return bool
* @static
*/ public static function attemptWhen($credentials = [], $callbacks = null, $remember = false)
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->attemptWhen($credentials, $callbacks, $remember);
}
/**
* Log the given user ID into the application.
*
* @param mixed $id
* @param bool $remember
* @return \App\Models\User|false
* @static
*/ public static function loginUsingId($id, $remember = false)
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->loginUsingId($id, $remember);
}
/**
* Log a user into the application.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @param bool $remember
* @return void
* @static
*/ public static function login($user, $remember = false)
{
/** @var \Illuminate\Auth\SessionGuard $instance */
$instance->login($user, $remember);
}
/**
* Log the user out of the application.
*
* @return void
* @static
*/ public static function logout()
{
/** @var \Illuminate\Auth\SessionGuard $instance */
$instance->logout();
}
/**
* Log the user out of the application on their current device only.
*
* This method does not cycle the "remember" token.
*
* @return void
* @static
*/ public static function logoutCurrentDevice()
{
/** @var \Illuminate\Auth\SessionGuard $instance */
$instance->logoutCurrentDevice();
}
/**
* Invalidate other sessions for the current user.
*
* The application must be using the AuthenticateSession middleware.
*
* @param string $password
* @param string $attribute
* @return \App\Models\User|null
* @throws \Illuminate\Auth\AuthenticationException
* @static
*/ public static function logoutOtherDevices($password, $attribute = 'password')
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->logoutOtherDevices($password, $attribute);
}
/**
* Register an authentication attempt event listener.
*
* @param mixed $callback
* @return void
* @static
*/ public static function attempting($callback)
{
/** @var \Illuminate\Auth\SessionGuard $instance */
$instance->attempting($callback);
}
/**
* Get the last user we attempted to authenticate.
*
* @return \App\Models\User
* @static
*/ public static function getLastAttempted()
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->getLastAttempted();
}
/**
* Get a unique identifier for the auth session value.
*
* @return string
* @static
*/ public static function getName()
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->getName();
}
/**
* Get the name of the cookie used to store the "recaller".
*
* @return string
* @static
*/ public static function getRecallerName()
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->getRecallerName();
}
/**
* Determine if the user was authenticated via "remember me" cookie.
*
* @return bool
* @static
*/ public static function viaRemember()
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->viaRemember();
}
/**
* Set the number of minutes the remember me cookie should be valid for.
*
* @param int $minutes
* @return \Illuminate\Auth\SessionGuard
* @static
*/ public static function setRememberDuration($minutes)
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->setRememberDuration($minutes);
}
/**
* Get the cookie creator instance used by the guard.
*
* @return \Illuminate\Contracts\Cookie\QueueingFactory
* @throws \RuntimeException
* @static
*/ public static function getCookieJar()
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->getCookieJar();
}
/**
* Set the cookie creator instance used by the guard.
*
* @param \Illuminate\Contracts\Cookie\QueueingFactory $cookie
* @return void
* @static
*/ public static function setCookieJar($cookie)
{
/** @var \Illuminate\Auth\SessionGuard $instance */
$instance->setCookieJar($cookie);
}
/**
* Get the event dispatcher instance.
*
* @return \Illuminate\Contracts\Events\Dispatcher
* @static
*/ public static function getDispatcher()
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->getDispatcher();
}
/**
* Set the event dispatcher instance.
*
* @param \Illuminate\Contracts\Events\Dispatcher $events
* @return void
* @static
*/ public static function setDispatcher($events)
{
/** @var \Illuminate\Auth\SessionGuard $instance */
$instance->setDispatcher($events);
}
/**
* Get the session store used by the guard.
*
* @return \Illuminate\Contracts\Session\Session
* @static
*/ public static function getSession()
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->getSession();
}
/**
* Return the currently cached user.
*
* @return \App\Models\User|null
* @static
*/ public static function getUser()
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->getUser();
}
/**
* Set the current user.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @return \Illuminate\Auth\SessionGuard
* @static
*/ public static function setUser($user)
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->setUser($user);
}
/**
* Get the current request instance.
*
* @return \Symfony\Component\HttpFoundation\Request
* @static
*/ public static function getRequest()
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->getRequest();
}
/**
* Set the current request instance.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* @return \Illuminate\Auth\SessionGuard
* @static
*/ public static function setRequest($request)
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->setRequest($request);
}
/**
* Get the timebox instance used by the guard.
*
* @return \Illuminate\Support\Timebox
* @static
*/ public static function getTimebox()
{
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->getTimebox();
}
/**
* Determine if the current user is authenticated. If not, throw an exception.
@ -1878,7 +2160,7 @@ namespace Illuminate\Support\Facades {
* @static
*/ public static function authenticate()
{
/** @var \App\Services\Auth\ReverseProxyGuard $instance */
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->authenticate();
}
/**
@ -1888,7 +2170,7 @@ namespace Illuminate\Support\Facades {
* @static
*/ public static function hasUser()
{
/** @var \App\Services\Auth\ReverseProxyGuard $instance */
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->hasUser();
}
/**
@ -1898,7 +2180,7 @@ namespace Illuminate\Support\Facades {
* @static
*/ public static function check()
{
/** @var \App\Services\Auth\ReverseProxyGuard $instance */
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->check();
}
/**
@ -1908,38 +2190,17 @@ namespace Illuminate\Support\Facades {
* @static
*/ public static function guest()
{
/** @var \App\Services\Auth\ReverseProxyGuard $instance */
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->guest();
}
/**
* Get the ID for the currently authenticated user.
*
* @return int|string|null
* @static
*/ public static function id()
{
/** @var \App\Services\Auth\ReverseProxyGuard $instance */
return $instance->id();
}
/**
* Set the current user.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @return \App\Services\Auth\ReverseProxyGuard
* @static
*/ public static function setUser($user)
{
/** @var \App\Services\Auth\ReverseProxyGuard $instance */
return $instance->setUser($user);
}
/**
* Forget the current user.
*
* @return \App\Services\Auth\ReverseProxyGuard
* @return \Illuminate\Auth\SessionGuard
* @static
*/ public static function forgetUser()
{
/** @var \App\Services\Auth\ReverseProxyGuard $instance */
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->forgetUser();
}
/**
@ -1949,7 +2210,7 @@ namespace Illuminate\Support\Facades {
* @static
*/ public static function getProvider()
{
/** @var \App\Services\Auth\ReverseProxyGuard $instance */
/** @var \Illuminate\Auth\SessionGuard $instance */
return $instance->getProvider();
}
/**
@ -1960,8 +2221,50 @@ namespace Illuminate\Support\Facades {
* @static
*/ public static function setProvider($provider)
{
/** @var \App\Services\Auth\ReverseProxyGuard $instance */
/** @var \Illuminate\Auth\SessionGuard $instance */
$instance->setProvider($provider);
}
/**
* Register a custom macro.
*
* @param string $name
* @param object|callable $macro
* @return void
* @static
*/ public static function macro($name, $macro)
{
\Illuminate\Auth\SessionGuard::macro($name, $macro);
}
/**
* Mix another object into the class.
*
* @param object $mixin
* @param bool $replace
* @return void
* @throws \ReflectionException
* @static
*/ public static function mixin($mixin, $replace = true)
{
\Illuminate\Auth\SessionGuard::mixin($mixin, $replace);
}
/**
* Checks if macro is registered.
*
* @param string $name
* @return bool
* @static
*/ public static function hasMacro($name)
{
return \Illuminate\Auth\SessionGuard::hasMacro($name);
}
/**
* Flush the existing macros.
*
* @return void
* @static
*/ public static function flushMacros()
{
\Illuminate\Auth\SessionGuard::flushMacros();
}
}
/**
@ -8569,6 +8872,17 @@ namespace Illuminate\Support\Facades {
{
/** @var \Illuminate\Support\Testing\Fakes\NotificationFake $instance */
return $instance->hasSent($notifiable, $notification);
}
/**
* Specify if notification should be serialized and restored when being "pushed" to the queue.
*
* @param bool $serializeAndRestore
* @return \Illuminate\Support\Testing\Fakes\NotificationFake
* @static
*/ public static function serializeAndRestore($serializeAndRestore = true)
{
/** @var \Illuminate\Support\Testing\Fakes\NotificationFake $instance */
return $instance->serializeAndRestore($serializeAndRestore);
}
/**
* Get the notifications that have been sent.

View File

@ -23,7 +23,7 @@ class UserManagerController extends Controller
*/
public function index(Request $request)
{
return UserManagerResource::collection(User::all());
return UserManagerResource::collection(User::withCount('twofaccounts')->get());
}
/**

View File

@ -18,6 +18,7 @@ use Jenssegers\Agent\Agent;
* @property Carbon|null $logout_at
* @property bool $login_successful
* @property string|null $duration
* @property string|null $login_method
*/
class UserAuthentication extends JsonResource
{
@ -50,6 +51,8 @@ class UserAuthentication extends JsonResource
*/
public function toArray($request)
{
$tz = $request->user()->preferences['timezone'];
return [
'id' => $this->id,
'ip_address' => $this->ip_address,
@ -58,15 +61,16 @@ class UserAuthentication extends JsonResource
'platform' => $this->agent->platform(),
'device' => $this->agent->deviceType(),
'login_at' => $this->login_at
? Carbon::parse($this->login_at)->toDayDateTimeString()
? Carbon::parse($this->login_at,)->tz($tz)->toDayDateTimeString()
: null,
'logout_at' => $this->logout_at
? Carbon::parse($this->logout_at)->toDayDateTimeString()
? Carbon::parse($this->logout_at)->tz($tz)->toDayDateTimeString()
: null,
'login_successful' => $this->login_successful,
'duration' => $this->logout_at
? Carbon::parse($this->logout_at)->diffForHumans(Carbon::parse($this->login_at), ['syntax' => CarbonInterface::DIFF_ABSOLUTE])
: null,
'login_method' => $this->login_method,
];
}
}

View File

@ -85,12 +85,14 @@ class UserManagerResource extends UserResource
*/
public function toArray($request)
{
$tz = $request->user()?->preferences['timezone'];
return array_merge(
parent::toArray($request),
[
'twofaccounts_count' => is_null($this->twofaccounts_count) ? 0 : $this->twofaccounts_count,
'last_seen_at' => Carbon::parse($this->last_seen_at)->locale(App::getLocale())->diffForHumans(),
'created_at' => Carbon::parse($this->created_at)->locale(App::getLocale())->diffForHumans(),
'last_seen_at' => Carbon::parse($this->last_seen_at)->tz($tz)->locale(App::getLocale())->diffForHumans(),
'created_at' => Carbon::parse($this->created_at)->tz($tz)->locale(App::getLocale())->diffForHumans(),
]
);
}

View File

@ -0,0 +1,57 @@
<?php
/**
* The MIT License (MIT)
* Copyright (c) 2024 Bubka
* Copyright (c) 2024 Anthony Rappa
* Copyright (c) 2017 Yaakov Dahan
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial
* portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
namespace App\Console\Commands;
use App\Models\AuthLog;
use Illuminate\Console\Command;
class PurgeAuthLog extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
public $signature = '2fauth:purge-log';
/**
* The console command description.
*
* @var string
*/
public $description = 'Purge all authentication logs older than the configurable amount of days.';
/**
* Execute the console command.
*/
public function handle() : void
{
$this->comment('Clearing authentication log...');
$deleted = AuthLog::where('login_at', '<', now()->subDays(config('2fauth.authLogRetentionTime'))->format('Y-m-d H:i:s'))->delete();
$this->info($deleted . ' authentication logs cleared.');
}
}

View File

@ -70,7 +70,7 @@ trait ResetTrait
DB::table('groups')->delete();
DB::table('users')->delete();
DB::table('options')->delete();
DB::table(config('authentication-log.table_name'))->delete();
DB::table('auth_logs')->delete();
$this->line('Database cleaned');
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Events;
use App\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;
class VisitedByProxyUser
{
/**
* The authenticated user.
*
* @var User|Authenticatable
*/
public $user;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct(User|Authenticatable $user)
{
$this->user = $user;
}
}

View File

@ -16,6 +16,8 @@ use Illuminate\Validation\ValidationException;
class RemoteUserProvider implements UserProvider
{
const FAKE_REMOTE_DOMAIN = '@remote';
/**
* The currently authenticated user.
*
@ -85,7 +87,7 @@ class RemoteUserProvider implements UserProvider
*/
protected function fakeRemoteEmail(mixed $id)
{
return substr($id, 0, 184) . '@remote';
return substr($id, 0, 184) . self::FAKE_REMOTE_DOMAIN;
}
/**

View File

@ -5,6 +5,9 @@ namespace App\Facades;
use App\Services\QrCodeService;
use Illuminate\Support\Facades\Facade;
/**
* @see \App\Services\QrCodeService
*/
class QrCode extends Facade
{
protected static function getFacadeAccessor()

View File

@ -5,6 +5,9 @@ namespace App\Facades;
use App\Services\SettingService;
use Illuminate\Support\Facades\Facade;
/**
* @see \App\Services\SettingService
*/
class Settings extends Facade
{
protected static function getFacadeAccessor()

View File

@ -23,8 +23,7 @@ class LogUserLastSeen
// 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') {
if (Auth::guard($guard)->check() && ! $request->bearerToken()) {
Auth::guard($guard)->user()->last_seen_at = Carbon::now()->format('Y-m-d H:i:s');
Auth::guard($guard)->user()->save();
break;

View File

@ -0,0 +1,44 @@
<?php
namespace App\Listeners\Authentication;
use Illuminate\Http\Request;
abstract class AbstractAccessListener
{
/**
* The current request
*/
public Request $request;
/**
* Create the event listener.
*
* @return void
*/
public function __construct(Request $request)
{
$this->request = $request;
}
/**
* Handle the event.
*
* @return void
*/
abstract public function handle(mixed $event);
/**
* Get the login method based on the request input parameters
*/
public function loginMethod() : ?string
{
if ($this->request->has('response.authenticatorData')) {
return 'webauthn';
} elseif ($this->request->has('password')) {
return 'password';
} else {
return null;
}
}
}

View File

@ -0,0 +1,65 @@
<?php
/**
* The MIT License (MIT)
* Copyright (c) 2024 Bubka
* Copyright (c) 2024 Anthony Rappa
* Copyright (c) 2017 Yaakov Dahan
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial
* portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
namespace App\Listeners\Authentication;
use App\Notifications\FailedLogin;
use Illuminate\Auth\Events\Failed;
class FailedLoginListener extends AbstractAccessListener
{
/**
* Handle the event.
*
* @return void
*/
public function handle(mixed $event) : void
{
if (! $event instanceof Failed) {
return;
}
if ($event->user) {
/**
* @var \App\Models\User
*/
$user = $event->user;
$guard = $event->guard;
$ip = config('2fauth.proxy_headers.forIp') ?? $this->request->ip();
$log = $user->authentications()->create([
'ip_address' => $ip,
'user_agent' => $this->request->userAgent(),
'login_at' => now(),
'login_successful' => false,
'guard' => $guard,
'login_method' => $this->loginMethod(),
]);
if ($user->preferences['notifyOnFailedLogin'] == true) {
$user->notify(new FailedLogin($log));
}
}
}
}

View File

@ -0,0 +1,67 @@
<?php
/**
* The MIT License (MIT)
* Copyright (c) 2024 Bubka
* Copyright (c) 2024 Anthony Rappa
* Copyright (c) 2017 Yaakov Dahan
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial
* portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
namespace App\Listeners\Authentication;
use App\Notifications\SignedInWithNewDevice;
use Illuminate\Auth\Events\Login;
use Illuminate\Support\Carbon;
class LoginListener extends AbstractAccessListener
{
/**
* Handle the event.
*
* @return void
*/
public function handle(mixed $event) : void
{
if (! $event instanceof Login) {
return;
}
/**
* @var \App\Models\User
*/
$user = $event->user;
$ip = config('2fauth.proxy_headers.forIp') ?? $this->request->ip();
$userAgent = $this->request->userAgent();
$known = $user->authentications()->whereIpAddress($ip)->whereUserAgent($userAgent)->whereLoginSuccessful(true)->first();
$newUser = Carbon::parse($user->{$user->getCreatedAtColumn()})->diffInMinutes(Carbon::now()) < 1;
$guard = $event->guard;
$log = $user->authentications()->create([
'ip_address' => $ip,
'user_agent' => $userAgent,
'login_at' => now(),
'login_successful' => true,
'guard' => $guard,
'login_method' => $this->loginMethod(),
]);
if (! $known && ! $newUser && $user->preferences['notifyOnNewAuthDevice'] == true) {
$user->notify(new SignedInWithNewDevice($log));
}
}
}

View File

@ -0,0 +1,63 @@
<?php
/**
* The MIT License (MIT)
* Copyright (c) 2024 Bubka
* Copyright (c) 2024 Anthony Rappa
* Copyright (c) 2017 Yaakov Dahan
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial
* portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
namespace App\Listeners\Authentication;
use App\Models\AuthLog;
use Illuminate\Auth\Events\Logout;
class LogoutListener extends AbstractAccessListener
{
/**
* Handle the event.
*
* @return void
*/
public function handle(mixed $event) : void
{
if (! $event instanceof Logout) {
return;
}
/**
* @var \App\Models\User
*/
$user = $event->user;
$ip = config('2fauth.proxy_headers.forIp') ?? $this->request->ip();
$userAgent = $this->request->userAgent();
$log = $user->authentications()->whereIpAddress($ip)->whereUserAgent($userAgent)->whereGuard($event->guard)->orderByDesc('login_at')->first();
$guard = $event->guard;
if (! $log) {
$log = new AuthLog([
'ip_address' => $ip,
'user_agent' => $userAgent,
'guard' => $guard,
]);
}
$log->logout_at = now();
$user->authentications()->save($log);
}
}

View File

@ -0,0 +1,69 @@
<?php
/**
* The MIT License (MIT)
* Copyright (c) 2024 Bubka
* Copyright (c) 2024 Anthony Rappa
* Copyright (c) 2017 Yaakov Dahan
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial
* portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
namespace App\Listeners\Authentication;
use App\Models\AuthLog;
use Illuminate\Auth\Events\OtherDeviceLogout;
class OtherDeviceLogoutListener extends AbstractAccessListener
{
/**
* Handle the event.
*
* @return void
*/
public function handle(mixed $event) : void
{
if (! $event instanceof OtherDeviceLogout) {
return;
}
/**
* @var \App\Models\User
*/
$user = $event->user;
$ip = config('2fauth.proxy_headers.forIp') ?? $this->request->ip();
$userAgent = $this->request->userAgent();
$authLog = $user->authentications()->whereIpAddress($ip)->whereUserAgent($userAgent)->first();
$guard = $event->guard;
if (! $authLog) {
$authLog = new AuthLog([
'ip_address' => $ip,
'user_agent' => $userAgent,
'guard' => $guard,
]);
}
foreach ($user->authentications()->whereLoginSuccessful(true)->whereNull('logout_at')->get() as $log) {
if ($log->id !== $authLog->id) {
$log->update([
'cleared_by_user' => true,
'logout_at' => now(),
]);
}
}
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Listeners\Authentication;
use App\Events\VisitedByProxyUser;
use App\Extensions\RemoteUserProvider;
use App\Listeners\Authentication\AbstractAccessListener;
use App\Notifications\SignedInWithNewDevice;
use Illuminate\Support\Carbon;
class VisitedByProxyUserListener extends AbstractAccessListener
{
/**
* Handle the event.
*
* @return void
*/
public function handle(mixed $event): void
{
if (! $event instanceof VisitedByProxyUser) {
return;
}
/**
* @var \App\Models\User
*/
$user = $event->user;
$ip = config('2fauth.proxy_headers.forIp') ?? $this->request->ip();
$userAgent = $this->request->userAgent();
$known = $user->authentications()->whereIpAddress($ip)->whereUserAgent($userAgent)->whereLoginSuccessful(true)->first();
$newUser = Carbon::parse($user->{$user->getCreatedAtColumn()})->diffInMinutes(Carbon::now()) < 1;
$guard = config('auth.defaults.guard');
$log = $user->authentications()->create([
'ip_address' => $ip,
'user_agent' => $userAgent,
'login_at' => now(),
'login_successful' => true,
'guard' => $guard,
]);
if (! $known && ! $newUser && ! str_ends_with($user->email, RemoteUserProvider::FAKE_REMOTE_DOMAIN) && $user->preferences['notifyOnNewAuthDevice']) {
$user->notify(new SignedInWithNewDevice($log));
}
}
}

86
app/Models/AuthLog.php Normal file
View File

@ -0,0 +1,86 @@
<?php
/**
* The MIT License (MIT)
* Copyright (c) 2024 Bubka
* Copyright (c) 2024 Anthony Rappa
* Copyright (c) 2017 Yaakov Dahan
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial
* portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @property string $authenticatable_type
* @property int $authenticatable_id
* @property string|null $ip_address
* @property string|null $user_agent
* @property \Illuminate\Support\Carbon|null $login_at
* @property bool $login_successful
* @property \Illuminate\Support\Carbon|null $logout_at
* @property bool $cleared_by_user
* @property string|null $guard
* @property string|null $method
*/
class AuthLog extends Model
{
use HasFactory;
/**
* Indicates if the model should be timestamped.
*/
public $timestamps = false;
/**
* The attributes that are mass assignable.
*/
protected $fillable = [
'ip_address',
'user_agent',
'login_at',
'login_successful',
'logout_at',
'cleared_by_user',
'guard',
'login_method',
];
/**
* The attributes that should be cast.
*/
protected $casts = [
'cleared_by_user' => 'boolean',
'login_successful' => 'boolean',
'login_at' => 'datetime',
'logout_at' => 'datetime',
];
/**
* MorphTo relation to get the associated authenticatable user
*
* @return MorphTo<\Illuminate\Database\Eloquent\Model, AuthLog>
*/
public function authenticatable()
{
return $this->morphTo();
}
}

View File

@ -0,0 +1,121 @@
<?php
/**
* The MIT License (MIT)
* Copyright (c) 2024 Bubka
* Copyright (c) 2024 Anthony Rappa
* Copyright (c) 2017 Yaakov Dahan
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial
* portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
namespace App\Models\Traits;
use App\Models\AuthLog;
use Illuminate\Support\Carbon;
trait HasAuthenticationLog
{
/**
* Get all user's authentications from the auth log
*
* @return \Illuminate\Database\Eloquent\Relations\MorphMany<AuthLog>
*/
public function authentications()
{
return $this->morphMany(AuthLog::class, 'authenticatable')->latest('id');
}
/**
* Get authentications for the provided timespan (in month)
*
* @return \Illuminate\Database\Eloquent\Collection<int, AuthLog>
*/
public function authenticationsByPeriod(int $period = 1)
{
$from = Carbon::now()->subMonths($period);
return $this->authentications->filter(function (AuthLog $authentication) use ($from) {
return $authentication->login_at >= $from || $authentication->logout_at >= $from;
});
}
/**
* Get the user's latest authentication
*
* @return \Illuminate\Database\Eloquent\Relations\MorphOne<AuthLog>
*/
public function latestAuthentication()
{
return $this->morphOne(AuthLog::class, 'authenticatable')->latestOfMany('login_at');
}
/**
* Get the user's latest authentication datetime
*/
public function lastLoginAt() : ?Carbon
{
return $this->authentications()->first()?->login_at;
}
/**
* Get the user's latest successful login datetime
*/
public function lastSuccessfulLoginAt() : ?Carbon
{
return $this->authentications()->whereLoginSuccessful(true)->first()?->login_at;
}
/**
* Get the ip address of user's latest login
*/
public function lastLoginIp() : ?string
{
return $this->authentications()->first()?->ip_address;
}
/**
* Get the ip address of user's latest successful login
*/
public function lastSuccessfulLoginIp() : ?string
{
return $this->authentications()->whereLoginSuccessful(true)->first()?->ip_address;
}
/**
* Get the user's previous login datetime
*/
public function previousLoginAt() : ?Carbon
{
return $this->authentications()->skip(1)->first()?->login_at;
}
/**
* Get the ip address of user's previous login
*/
public function previousLoginIp() : ?string
{
return $this->authentications()->skip(1)->first()?->ip_address;
}
/**
* The notification channels to be used for notifications
*/
public function notifyAuthLogVia() : array
{
return ['mail'];
}
}

View File

@ -3,7 +3,7 @@
namespace App\Models;
use App\Models\Traits\WebAuthnManageCredentials;
use Bubka\LaravelAuthenticationLog\Traits\AuthenticationLoggable;
use App\Models\Traits\HasAuthenticationLog;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Contracts\Translation\HasLocalePreference;
@ -43,9 +43,9 @@ use Laravel\Passport\HasApiTokens;
* @property-read int|null $web_authn_credentials_count
* @property string|null $oauth_id
* @property string|null $oauth_provider
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Bubka\LaravelAuthenticationLog\Models\AuthenticationLog> $authentications
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\AuthLog> $authentications
* @property-read int|null $authentications_count
* @property-read \Bubka\LaravelAuthenticationLog\Models\AuthenticationLog|null $latestAuthentication
* @property-read \App\Models\AuthLog|null $latestAuthentication
*
* @method static \Illuminate\Database\Eloquent\Builder|User admins()
*
@ -53,7 +53,7 @@ use Laravel\Passport\HasApiTokens;
*/
class User extends Authenticatable implements HasLocalePreference, WebAuthnAuthenticatable
{
use AuthenticationLoggable;
use HasAuthenticationLog;
use HasApiTokens, HasFactory, Notifiable;
use WebAuthnAuthentication, WebAuthnManageCredentials;

View File

@ -0,0 +1,61 @@
<?php
namespace App\Notifications;
use App\Models\AuthLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Jenssegers\Agent\Agent;
class FailedLogin extends Notification implements ShouldQueue
{
use Queueable;
/**
* A user agent parser instance.
*
* @var mixed
*/
protected $agent;
/**
* The AuthLog model instance
*/
public AuthLog $authLog;
/**
* Create a new FailedLogin instance
*/
public function __construct(AuthLog $authLog)
{
$this->authLog = $authLog;
$this->agent = new Agent();
$this->agent->setUserAgent($authLog->user_agent);
}
/**
* Get the notification's channels.
*/
public function via(mixed $notifiable) : array|string
{
return $notifiable->notifyAuthLogVia();
}
/**
* Build the mail representation of the notification.
*/
public function toMail(mixed $notifiable) : MailMessage
{
return (new MailMessage())
->subject(__('notifications.failed_login.subject'))
->markdown('emails.failedLogin', [
'account' => $notifiable,
'time' => $this->authLog->login_at,
'ipAddress' => $this->authLog->ip_address,
'browser' => $this->authLog->user_agent,
'platform' => $this->agent->platform(),
]);
}
}

View File

@ -2,7 +2,7 @@
namespace App\Notifications;
use Bubka\LaravelAuthenticationLog\Models\AuthenticationLog;
use App\Models\AuthLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
@ -12,8 +12,11 @@ use Jenssegers\Agent\Agent;
class SignedInWithNewDevice extends Notification implements ShouldQueue
{
use Queueable;
public AuthenticationLog $authenticationLog;
/**
* The AuthLog model instance
*/
public AuthLog $authLog;
/**
* A user agent parser instance.
@ -25,16 +28,16 @@ class SignedInWithNewDevice extends Notification implements ShouldQueue
/**
* Create a new SignedInWithNewDevice instance
*/
public function __construct(AuthenticationLog $authenticationLog)
public function __construct(AuthLog $authLog)
{
$this->authenticationLog = $authenticationLog;
$this->agent = new Agent();
$this->agent->setUserAgent($authenticationLog->user_agent);
$this->authLog = $authLog;
$this->agent = new Agent();
$this->agent->setUserAgent($authLog->user_agent);
}
public function via(mixed $notifiable) : array|string
{
return $notifiable->notifyAuthenticationLogVia();
return $notifiable->notifyAuthLogVia();
}
/**
@ -44,10 +47,10 @@ class SignedInWithNewDevice extends Notification implements ShouldQueue
{
return (new MailMessage())
->subject(__('notifications.new_device.subject'))
->markdown('emails.newDevice', [
->markdown('emails.SignedInWithNewDevice', [
'account' => $notifiable,
'time' => $this->authenticationLog->login_at,
'ipAddress' => $this->authenticationLog->ip_address,
'time' => $this->authLog->login_at,
'ipAddress' => $this->authLog->ip_address,
'browser' => $this->agent->browser(),
'platform' => $this->agent->platform(),
]);

View File

@ -2,10 +2,15 @@
namespace App\Providers;
use App\Listeners\Authentication\VisitedByProxyUserListener;
use App\Events\GroupDeleted;
use App\Events\GroupDeleting;
use App\Events\VisitedByProxyUser;
use App\Events\ScanForNewReleaseCalled;
use App\Events\TwoFAccountDeleted;
use App\Listeners\Authentication\FailedLoginListener;
use App\Listeners\Authentication\LoginListener;
use App\Listeners\Authentication\LogoutListener;
use App\Listeners\CleanIconStorage;
use App\Listeners\DissociateTwofaccountFromGroup;
use App\Listeners\LogNotification;
@ -14,6 +19,9 @@ use App\Listeners\ReleaseRadar;
use App\Listeners\ResetUsersPreference;
use App\Models\User;
use App\Observers\UserObserver;
use Illuminate\Auth\Events\Failed;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Logout;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
@ -49,6 +57,18 @@ class EventServiceProvider extends ServiceProvider
NotificationSent::class => [
LogNotification::class,
],
Login::class => [
LoginListener::class,
],
Failed::class => [
FailedLoginListener::class,
],
Logout::class => [
LogoutListener::class,
],
VisitedByProxyUser::class => [
VisitedByProxyUserListener::class,
],
];
/**

View File

@ -5,6 +5,7 @@
namespace App\Services\Auth;
use App\Events\VisitedByProxyUser;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Contracts\Auth\UserProvider;
@ -17,14 +18,12 @@ class ReverseProxyGuard implements Guard
/**
* The currently authenticated user.
*
* @var \Illuminate\Contracts\Auth\Authenticatable|null
* @var \Illuminate\Contracts\Auth\Authenticatable|\App\Models\User|null
*/
protected $user;
/**
* Create a new authentication guard.
*
* @return void
*/
public function __construct(UserProvider $provider)
{
@ -76,7 +75,13 @@ class ReverseProxyGuard implements Guard
}
}
return $this->user = $this->provider->retrieveById($identifier);
if ($this->user = $this->provider->retrieveById($identifier)) {
if ($this->user->lastLoginAt() < now()->subMinutes(15)) {
event(new VisitedByProxyUser($this->user));
}
}
return $this->user;
}
/**

View File

@ -37,18 +37,8 @@
"paragonie/constant_time_encoding": "^2.6",
"socialiteproviders/manager": "^4.4",
"spatie/eloquent-sortable": "^4.0.1",
"spomky-labs/otphp": "^11.0",
"bubka/laravel-authentication-log": "@dev"
"spomky-labs/otphp": "^11.0"
},
"repositories": [
{
"type": "path",
"url": "../packages/bubka/laravel-authentication-log",
"options": {
"symlink": true
}
}
],
"require-dev": {
"barryvdh/laravel-ide-helper": "^2.13",
"brianium/paratest": "^7.3",

307
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "3d355aab37adc72f3901f200edcf2445",
"content-hash": "93e717902e3c1435d0768115a200b000",
"packages": [
{
"name": "brick/math",
@ -61,59 +61,6 @@
],
"time": "2023-01-15T23:15:59+00:00"
},
{
"name": "bubka/laravel-authentication-log",
"version": "dev-main",
"dist": {
"type": "path",
"url": "../packages/bubka/laravel-authentication-log",
"reference": "a916caaa979b1d18d679d8b063325fe547f691c6"
},
"require": {
"illuminate/contracts": "^10.0|^11.0",
"laravel/pint": "^1.15",
"php": "^8.1",
"phpstan/phpstan": "^1.10",
"spatie/laravel-package-tools": "^1.4.3"
},
"require-dev": {
"larastan/larastan": "^2.9",
"nunomaduro/collision": "^6.0",
"orchestra/testbench": "^8.22"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Bubka\\LaravelAuthenticationLog\\LaravelAuthenticationLogServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Bubka\\LaravelAuthenticationLog\\": "src",
"Bubka\\LaravelAuthenticationLog\\Database\\Factories\\": "database/factories"
}
},
"license": [
"MIT"
],
"authors": [
{
"name": "Bubka"
}
],
"description": "Log user authentication details and send new device notifications.",
"homepage": "https://github.com/bubka/laravel-authentication-log",
"keywords": [
"laravel",
"laravel-authentication-log"
],
"transport-options": {
"symlink": true,
"relative": true
}
},
{
"name": "carbonphp/carbon-doctrine-types",
"version": "2.1.0",
@ -2543,72 +2490,6 @@
},
"time": "2024-03-01T11:11:18+00:00"
},
{
"name": "laravel/pint",
"version": "v1.15.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/pint.git",
"reference": "5f288b5e79938cc72f5c298d384e639de87507c6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/pint/zipball/5f288b5e79938cc72f5c298d384e639de87507c6",
"reference": "5f288b5e79938cc72f5c298d384e639de87507c6",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"ext-tokenizer": "*",
"ext-xml": "*",
"php": "^8.1.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.52.1",
"illuminate/view": "^10.48.4",
"larastan/larastan": "^2.9.2",
"laravel-zero/framework": "^10.3.0",
"mockery/mockery": "^1.6.11",
"nunomaduro/termwind": "^1.15.1",
"pestphp/pest": "^2.34.5"
},
"bin": [
"builds/pint"
],
"type": "project",
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Seeders\\": "database/seeders/",
"Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nuno Maduro",
"email": "enunomaduro@gmail.com"
}
],
"description": "An opinionated code formatter for PHP.",
"homepage": "https://laravel.com",
"keywords": [
"format",
"formatter",
"lint",
"linter",
"php"
],
"support": {
"issues": "https://github.com/laravel/pint/issues",
"source": "https://github.com/laravel/pint"
},
"time": "2024-04-02T14:28:47+00:00"
},
{
"name": "laravel/prompts",
"version": "v0.1.19",
@ -4791,64 +4672,6 @@
],
"time": "2024-03-03T02:14:58+00:00"
},
{
"name": "phpstan/phpstan",
"version": "1.10.67",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan.git",
"reference": "16ddbe776f10da6a95ebd25de7c1dbed397dc493"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/16ddbe776f10da6a95ebd25de7c1dbed397dc493",
"reference": "16ddbe776f10da6a95ebd25de7c1dbed397dc493",
"shasum": ""
},
"require": {
"php": "^7.2|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
},
"bin": [
"phpstan",
"phpstan.phar"
],
"type": "library",
"autoload": {
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
"static analysis"
],
"support": {
"docs": "https://phpstan.org/user-guide/getting-started",
"forum": "https://github.com/phpstan/phpstan/discussions",
"issues": "https://github.com/phpstan/phpstan/issues",
"security": "https://github.com/phpstan/phpstan/security/policy",
"source": "https://github.com/phpstan/phpstan-src"
},
"funding": [
{
"url": "https://github.com/ondrejmirtes",
"type": "github"
},
{
"url": "https://github.com/phpstan",
"type": "github"
}
],
"time": "2024-04-16T07:22:02+00:00"
},
{
"name": "psr/cache",
"version": "3.0.0",
@ -9285,6 +9108,72 @@
],
"time": "2024-04-16T19:13:34+00:00"
},
{
"name": "laravel/pint",
"version": "v1.15.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/pint.git",
"reference": "5f288b5e79938cc72f5c298d384e639de87507c6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/pint/zipball/5f288b5e79938cc72f5c298d384e639de87507c6",
"reference": "5f288b5e79938cc72f5c298d384e639de87507c6",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"ext-tokenizer": "*",
"ext-xml": "*",
"php": "^8.1.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.52.1",
"illuminate/view": "^10.48.4",
"larastan/larastan": "^2.9.2",
"laravel-zero/framework": "^10.3.0",
"mockery/mockery": "^1.6.11",
"nunomaduro/termwind": "^1.15.1",
"pestphp/pest": "^2.34.5"
},
"bin": [
"builds/pint"
],
"type": "project",
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Seeders\\": "database/seeders/",
"Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nuno Maduro",
"email": "enunomaduro@gmail.com"
}
],
"description": "An opinionated code formatter for PHP.",
"homepage": "https://laravel.com",
"keywords": [
"format",
"formatter",
"lint",
"linter",
"php"
],
"support": {
"issues": "https://github.com/laravel/pint/issues",
"source": "https://github.com/laravel/pint"
},
"time": "2024-04-02T14:28:47+00:00"
},
{
"name": "mockery/mockery",
"version": "1.6.11",
@ -9887,6 +9776,64 @@
},
"time": "2024-04-03T18:51:33+00:00"
},
{
"name": "phpstan/phpstan",
"version": "1.10.67",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan.git",
"reference": "16ddbe776f10da6a95ebd25de7c1dbed397dc493"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/16ddbe776f10da6a95ebd25de7c1dbed397dc493",
"reference": "16ddbe776f10da6a95ebd25de7c1dbed397dc493",
"shasum": ""
},
"require": {
"php": "^7.2|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
},
"bin": [
"phpstan",
"phpstan.phar"
],
"type": "library",
"autoload": {
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
"static analysis"
],
"support": {
"docs": "https://phpstan.org/user-guide/getting-started",
"forum": "https://github.com/phpstan/phpstan/discussions",
"issues": "https://github.com/phpstan/phpstan/issues",
"security": "https://github.com/phpstan/phpstan/security/policy",
"source": "https://github.com/phpstan/phpstan-src"
},
"funding": [
{
"url": "https://github.com/ondrejmirtes",
"type": "github"
},
{
"url": "https://github.com/phpstan",
"type": "github"
}
],
"time": "2024-04-16T07:22:02+00:00"
},
{
"name": "phpunit/php-code-coverage",
"version": "10.1.14",
@ -11584,9 +11531,7 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {
"bubka/laravel-authentication-log": 20
},
"stability-flags": [],
"prefer-stable": true,
"prefer-lowest": false,
"platform": {

View File

@ -28,6 +28,18 @@ return [
'outgoingProxy' => env('PROXY_FOR_OUTGOING_REQUESTS', ''),
'proxyLogoutUrl' => env('PROXY_LOGOUT_URL', null),
'appSubdirectory' => env('APP_SUBDIRECTORY', ''),
'authLogRetentionTime' => env('AUTHENTICATION_LOG_RETENTION', 365),
],
/*
|--------------------------------------------------------------------------
| Proxy headers
|--------------------------------------------------------------------------
|
*/
'proxy_headers' => [
'forIp' => env('PROXY_HEADER_FOR_IP', null),
],
/*
@ -110,6 +122,9 @@ return [
'formatPasswordBy' => 0.5,
'lang' => 'browser',
'getOtpOnRequest' => true,
'notifyOnNewAuthDevice' => true,
'notifyOnFailedLogin' => true,
'timezone' => env('APP_TIMEZONE', 'UTC'),
],
];

View File

@ -72,7 +72,7 @@ return [
|
*/
'timezone' => 'UTC',
'timezone' => env('APP_TIMEZONE', 'UTC'),
/*
|--------------------------------------------------------------------------

View File

@ -1,63 +0,0 @@
<?php
return [
// The database table name
// You can change this if the database keys get too long for your driver
'table_name' => 'authentication_log',
// The database connection where the authentication_log table resides. Leave empty to use the default
'db_connection' => null,
// The events the package listens for to log
'events' => [
'login' => \Illuminate\Auth\Events\Login::class,
'failed' => \Illuminate\Auth\Events\Failed::class,
'logout' => \Illuminate\Auth\Events\Logout::class,
// 'logout-other-devices' => \Illuminate\Auth\Events\OtherDeviceLogout::class,
// 'proxyUserAccess' => \App\Events\VisitedByProxyUser::class,
],
'listeners' => [
'login' => \Bubka\LaravelAuthenticationLog\Listeners\LoginListener::class,
'failed' => \Bubka\LaravelAuthenticationLog\Listeners\FailedLoginListener::class,
'logout' => \Bubka\LaravelAuthenticationLog\Listeners\LogoutListener::class,
// 'logout-other-devices' => \Bubka\LaravelAuthenticationLog\Listeners\OtherDeviceLogoutListener::class,
// 'proxyUserAccess' => \App\Listeners\VisitedByProxyUserListener::class,
],
'notifications' => [
'new-device' => [
// Send the NewDevice notification
'enabled' => env('NEW_DEVICE_NOTIFICATION', true),
// Use torann/geoip to attempt to get a location
'location' => false,
// The Notification class to send
'template' => \App\Notifications\SignedInWithNewDevice::class,
],
'failed-login' => [
// Send the FailedLogin notification
'enabled' => env('FAILED_LOGIN_NOTIFICATION', false),
// Use torann/geoip to attempt to get a location
'location' => false,
// The Notification class to send
'template' => \Bubka\LaravelAuthenticationLog\Notifications\FailedLogin::class,
],
],
// When the clean-up command is run, delete old logs greater than `purge` days
// Don't schedule the clean-up command if you want to keep logs forever.
'purge' => 365,
// If you are behind an CDN proxy, set 'behind_cdn.http_header_field' to the corresponding http header field of your cdn
// For cloudflare you can have look at: https://developers.cloudflare.com/fundamentals/get-started/reference/http-request-headers/
// 'behind_cdn' => [
// 'http_header_field' => 'HTTP_CF_CONNECTING_IP' // used by Cloudflare
// ],
// If you are not a cdn user, use false
'behind_cdn' => false,
];

View File

@ -0,0 +1,164 @@
<?php
namespace Database\Factories;
use ParagonIE\ConstantTime\Base32;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\AuthLog>
*/
class AuthLogFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'ip_address' => '127.0.0.1',
'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
'login_at' => now(),
'login_successful' => true,
'logout_at' => null,
'guard' => 'web-guard',
'login_method' => 'password',
];
}
/**
* Indicate that the model is a failed login.
*
* @return \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\AuthLog>
*/
public function failedLogin()
{
return $this->state(function (array $attributes) {
return [
'login_successful' => false,
];
});
}
/**
* Indicate that the model has a logout date too.
*
* @return \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\AuthLog>
*/
public function withLogout()
{
return $this->state(function (array $attributes) {
return [
'logout_at' => now(),
];
});
}
/**
* Indicate that the model has a logout date only, without login date.
*
* @return \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\AuthLog>
*/
public function logoutOnly()
{
return $this->state(function (array $attributes) {
return [
'login_at' => null,
'login_successful' => false,
'logout_at' => now(),
];
});
}
/**
* Indicate that the model has login during last month.
*
* @return \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\AuthLog>
*/
public function duringLastMonth()
{
return $this->state(function (array $attributes) {
$loginDate = now()->subDays(15);
$logoutDate = $loginDate->addHours(1);
return [
'login_at' => $loginDate,
'logout_at' => $logoutDate,
];
});
}
/**
* Indicate that the model has login during last 3 months.
*
* @return \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\AuthLog>
*/
public function duringLastThreeMonth()
{
return $this->state(function (array $attributes) {
$loginDate = now()->subMonths(2);
$logoutDate = $loginDate->addHours(1);
return [
'login_at' => $loginDate,
'logout_at' => $logoutDate,
];
});
}
/**
* Indicate that the model has login during last 6 month.
*
* @return \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\AuthLog>
*/
public function duringLastSixMonth()
{
return $this->state(function (array $attributes) {
$loginDate = now()->subMonths(4);
$logoutDate = $loginDate->addHours(1);
return [
'login_at' => $loginDate,
'logout_at' => $logoutDate,
];
});
}
/**
* Indicate that the model has login during last year.
*
* @return \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\AuthLog>
*/
public function duringLastYear()
{
return $this->state(function (array $attributes) {
$loginDate = now()->subMonths(10);
$logoutDate = $loginDate->addHours(1);
return [
'login_at' => $loginDate,
'logout_at' => $logoutDate,
];
});
}
/**
* Indicate that the model has login before last year.
*
* @return \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\AuthLog>
*/
public function beforeLastYear()
{
return $this->state(function (array $attributes) {
$loginDate = now()->subYears(2);
$logoutDate = $loginDate->addHours(1);
return [
'login_at' => $loginDate,
'logout_at' => $logoutDate,
];
});
}
}

View File

@ -0,0 +1,60 @@
<?php
/**
* The MIT License (MIT)
* Copyright (c) 2024 Bubka
* Copyright (c) 2024 Anthony Rappa
* Copyright (c) 2017 Yaakov Dahan
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial
* portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('auth_logs', function (Blueprint $table) {
$table->id();
$table->morphs('authenticatable');
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->timestamp('login_at')->nullable();
$table->boolean('login_successful')->default(false);
$table->timestamp('logout_at')->nullable();
$table->boolean('cleared_by_user')->default(false);
$table->string('guard', 40)->nullable();
$table->string('login_method', 40)->nullable();
$table->foreign('authenticatable_id')->references('id')->on('users')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::whenTableHasColumn('auth_logs', 'authenticatable_id', function (Blueprint $table) {
// cannot drop foreign keys in SQLite:
if (DB::getDriverName() !== 'sqlite') {
$table->dropForeign(['authenticatable_id']);
}
});
Schema::dropIfExists('auth_logs');
}
};

View File

@ -1,30 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create(config('authentication-log.table_name'), function (Blueprint $table) {
$table->id();
$table->morphs('authenticatable');
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->timestamp('login_at')->nullable();
$table->boolean('login_successful')->default(false);
$table->timestamp('logout_at')->nullable();
$table->boolean('cleared_by_user')->default(false);
$table->json('location')->nullable();
$table->string('guard', 40)->nullable();
$table->string('login_method', 40)->nullable();
});
}
public function down(): void
{
Schema::dropIfExists(config('authentication-log.table_name'));
}
};

View File

@ -13,6 +13,10 @@ services:
# You can leave this on "local". If you change it to production most console commands will ask for extra confirmation.
# Never set it to "testing".
- APP_ENV=local
# The timezone for your application, which is used to record dates and times to database. This global setting can be
# overridden by users via in-app settings for a personalised dates and times display.
# If this setting is changed while the application is already running, existing records in the database won't be updated
- APP_TIMEZONE=UTC
# Set to true if you want to see debug information in error screens.
- APP_DEBUG=false
# This should be your email address
@ -83,6 +87,9 @@ services:
# authentication checks. That means your proxy is fully responsible of the authentication process, 2FAuth will
# trust him as long as headers are presents.
- AUTHENTICATION_GUARD=web-guard
# Authentication log retention time, in days.
# Log entries older than that are automatically deleted.
- AUTHENTICATION_LOG_RETENTION=365
# Name of the HTTP headers sent by the reverse proxy that identifies the authenticated user at proxy level.
# Check your proxy documentation to find out how these headers are named (i.e 'REMOTE_USER', 'REMOTE_EMAIL', etc...)
# (only relevant when AUTHENTICATION_GUARD is set to 'reverse-proxy-guard')

View File

@ -11,6 +11,4 @@ parameters:
analyse:
- app/Protobuf/*
ignoreErrors:
-
message: '#.*geoip.*#'
checkMissingIterableValueType: false

View File

@ -7,6 +7,7 @@
import { UseColorMode } from '@vueuse/components'
const notify = useNotifyStore()
const $2fauth = inject('2fauth')
const props = defineProps({
userId: [Number, String],
@ -171,6 +172,7 @@
<div>
<span v-if="isFailedEntry(authentication)" v-html="$t('admin.failed_login_on', { login_at: authentication.login_at })" />
<span v-else-if="isSuccessfulLogout(authentication)" v-html="$t('admin.successful_logout_on', { login_at: authentication.logout_at })" />
<span v-else-if="$2fauth.config.proxyAuth" v-html="$t('admin.viewed_on', { login_at: authentication.login_at })" />
<span v-else v-html="$t('admin.successful_login_on', { login_at: authentication.login_at })" />
</div>
<div>

View File

@ -11,6 +11,7 @@
const router = useRouter()
const user = useUserStore()
const bus = useBusStore()
const $2fauth = inject('2fauth')
const isFetching = ref(false)
const managedUser = ref(null)
@ -212,12 +213,14 @@
<div class="block is-size-6 is-size-7-mobile has-text-grey">
{{ $t('admin.registered_on_date', { date: managedUser.info.created_at }) }} - {{ $t('admin.last_seen_on_date', { date: managedUser.info.last_seen_at }) }}
</div>
<!-- isAdmin option -->
<div class="block">
<!-- otp as dot -->
<FormCheckbox v-model="managedUser.info.is_admin" @update:model-value="val => saveAdminRole(val === true)" fieldName="is_admin" label="admin.forms.is_admin.label" help="admin.forms.is_admin.help" />
</div>
<h2 class="title is-4 has-text-grey-light">{{ $t('admin.access') }}</h2>
<div class="block">
<h2 v-if="!$2fauth.config.proxyAuth" class="title is-4 has-text-grey-light">{{ $t('admin.access') }}</h2>
<!-- access -->
<div v-if="!$2fauth.config.proxyAuth" class="block">
<!-- reset password -->
<div class="list-item is-size-6 is-size-6-mobile has-text-grey">
<div class="mb-3 is-flex is-justify-content-space-between">
<div>
@ -245,6 +248,7 @@
<span v-html="$t('admin.reset_password_help')" class="is-block block"></span>
</div>
</div>
<!-- personal access tokens -->
<div class="list-item is-size-6 is-size-6-mobile has-text-grey is-flex is-justify-content-space-between">
<div>
<span class="has-text-weight-bold">{{ $t('settings.personal_access_tokens') }}</span>
@ -263,6 +267,7 @@
</div>
</div>
</div>
<!-- webauthn devices -->
<div class="list-item is-size-6 is-size-6-mobile has-text-grey is-flex is-justify-content-space-between">
<div>
<span class="has-text-weight-bold">{{ $t('auth.webauthn.security_devices') }}</span>
@ -282,6 +287,7 @@
</div>
</div>
</div>
<!-- last access -->
<div class="block">
<h3 class="title is-5 has-text-grey-light mb-2">{{ $t('admin.last_accesses') }}</h3>
<AccessLogViewer :userId="props.userId" :lastOnly="true" @has-more-entries="showFullLogLink = true"/>

View File

@ -4,6 +4,7 @@
import { useUserStore } from '@/stores/user'
import { useGroups } from '@/stores/groups'
import { useNotifyStore } from '@/stores/notify'
import { timezones } from './timezones'
const $2fauth = inject('2fauth')
const user = useUserStore()
@ -123,6 +124,8 @@
<FontAwesomeIcon :icon="['fas', 'external-link-alt']" />
</a>
</div>
<!-- timezone -->
<FormSelect v-model="user.preferences.timezone" @update:model-value="val => savePreference('timezone', val)" :options="timezones" fieldName="timezone" label="settings.forms.timezone.label" help="settings.forms.timezone.help" />
<!-- display mode -->
<FormToggle v-model="user.preferences.displayMode" @update:model-value="val => savePreference('displayMode', val)" :choices="layouts" fieldName="displayMode" label="settings.forms.display_mode.label" help="settings.forms.display_mode.help"/>
<!-- theme -->

537
resources/js/views/settings/timezones.js vendored Normal file
View File

@ -0,0 +1,537 @@
export let timezones = [
{ text: 'Africa/Abidjan', value: 'Africa/Abidjan' },
{ text: 'Africa/Accra', value: 'Africa/Accra' },
{ text: 'Africa/Addis_Ababa', value: 'Africa/Addis_Ababa' },
{ text: 'Africa/Algiers', value: 'Africa/Algiers' },
{ text: 'Africa/Asmara', value: 'Africa/Asmara' },
{ text: 'Africa/Asmera', value: 'Africa/Asmera' },
{ text: 'Africa/Bamako', value: 'Africa/Bamako' },
{ text: 'Africa/Bangui', value: 'Africa/Bangui' },
{ text: 'Africa/Banjul', value: 'Africa/Banjul' },
{ text: 'Africa/Bissau', value: 'Africa/Bissau' },
{ text: 'Africa/Blantyre', value: 'Africa/Blantyre' },
{ text: 'Africa/Brazzaville', value: 'Africa/Brazzaville' },
{ text: 'Africa/Bujumbura', value: 'Africa/Bujumbura' },
{ text: 'Africa/Cairo', value: 'Africa/Cairo' },
{ text: 'Africa/Casablanca', value: 'Africa/Casablanca' },
{ text: 'Africa/Ceuta', value: 'Africa/Ceuta' },
{ text: 'Africa/Conakry', value: 'Africa/Conakry' },
{ text: 'Africa/Dakar', value: 'Africa/Dakar' },
{ text: 'Africa/Dar_es_Salaam', value: 'Africa/Dar_es_Salaam' },
{ text: 'Africa/Djibouti', value: 'Africa/Djibouti' },
{ text: 'Africa/Douala', value: 'Africa/Douala' },
{ text: 'Africa/El_Aaiun', value: 'Africa/El_Aaiun' },
{ text: 'Africa/Freetown', value: 'Africa/Freetown' },
{ text: 'Africa/Gaborone', value: 'Africa/Gaborone' },
{ text: 'Africa/Harare', value: 'Africa/Harare' },
{ text: 'Africa/Johannesburg', value: 'Africa/Johannesburg' },
{ text: 'Africa/Juba', value: 'Africa/Juba' },
{ text: 'Africa/Kampala', value: 'Africa/Kampala' },
{ text: 'Africa/Khartoum', value: 'Africa/Khartoum' },
{ text: 'Africa/Kigali', value: 'Africa/Kigali' },
{ text: 'Africa/Kinshasa', value: 'Africa/Kinshasa' },
{ text: 'Africa/Lagos', value: 'Africa/Lagos' },
{ text: 'Africa/Libreville', value: 'Africa/Libreville' },
{ text: 'Africa/Lome', value: 'Africa/Lome' },
{ text: 'Africa/Luanda', value: 'Africa/Luanda' },
{ text: 'Africa/Lubumbashi', value: 'Africa/Lubumbashi' },
{ text: 'Africa/Lusaka', value: 'Africa/Lusaka' },
{ text: 'Africa/Malabo', value: 'Africa/Malabo' },
{ text: 'Africa/Maputo', value: 'Africa/Maputo' },
{ text: 'Africa/Maseru', value: 'Africa/Maseru' },
{ text: 'Africa/Mbabane', value: 'Africa/Mbabane' },
{ text: 'Africa/Mogadishu', value: 'Africa/Mogadishu' },
{ text: 'Africa/Monrovia', value: 'Africa/Monrovia' },
{ text: 'Africa/Nairobi', value: 'Africa/Nairobi' },
{ text: 'Africa/Ndjamena', value: 'Africa/Ndjamena' },
{ text: 'Africa/Niamey', value: 'Africa/Niamey' },
{ text: 'Africa/Nouakchott', value: 'Africa/Nouakchott' },
{ text: 'Africa/Ouagadougou', value: 'Africa/Ouagadougou' },
{ text: 'Africa/Porto-Novo', value: 'Africa/Porto-Novo' },
{ text: 'Africa/Sao_Tome', value: 'Africa/Sao_Tome' },
{ text: 'Africa/Timbuktu', value: 'Africa/Timbuktu' },
{ text: 'Africa/Tripoli', value: 'Africa/Tripoli' },
{ text: 'Africa/Tunis', value: 'Africa/Tunis' },
{ text: 'Africa/Windhoek', value: 'Africa/Windhoek' },
{ text: 'America/Adak', value: 'America/Adak' },
{ text: 'America/Anchorage', value: 'America/Anchorage' },
{ text: 'America/Anguilla', value: 'America/Anguilla' },
{ text: 'America/Antigua', value: 'America/Antigua' },
{ text: 'America/Araguaina', value: 'America/Araguaina' },
{ text: 'America/Argentina/Buenos_Aires', value: 'America/Argentina/Buenos_Aires' },
{ text: 'America/Argentina/Catamarca', value: 'America/Argentina/Catamarca' },
{ text: 'America/Argentina/ComodRivadavia', value: 'America/Argentina/ComodRivadavia' },
{ text: 'America/Argentina/Cordoba', value: 'America/Argentina/Cordoba' },
{ text: 'America/Argentina/Jujuy', value: 'America/Argentina/Jujuy' },
{ text: 'America/Argentina/La_Rioja', value: 'America/Argentina/La_Rioja' },
{ text: 'America/Argentina/Mendoza', value: 'America/Argentina/Mendoza' },
{ text: 'America/Argentina/Rio_Gallegos', value: 'America/Argentina/Rio_Gallegos' },
{ text: 'America/Argentina/Salta', value: 'America/Argentina/Salta' },
{ text: 'America/Argentina/San_Juan', value: 'America/Argentina/San_Juan' },
{ text: 'America/Argentina/San_Luis', value: 'America/Argentina/San_Luis' },
{ text: 'America/Argentina/Tucuman', value: 'America/Argentina/Tucuman' },
{ text: 'America/Argentina/Ushuaia', value: 'America/Argentina/Ushuaia' },
{ text: 'America/Aruba', value: 'America/Aruba' },
{ text: 'America/Asuncion', value: 'America/Asuncion' },
{ text: 'America/Atikokan', value: 'America/Atikokan' },
{ text: 'America/Atka', value: 'America/Atka' },
{ text: 'America/Bahia', value: 'America/Bahia' },
{ text: 'America/Bahia_Banderas', value: 'America/Bahia_Banderas' },
{ text: 'America/Barbados', value: 'America/Barbados' },
{ text: 'America/Belem', value: 'America/Belem' },
{ text: 'America/Belize', value: 'America/Belize' },
{ text: 'America/Blanc-Sablon', value: 'America/Blanc-Sablon' },
{ text: 'America/Boa_Vista', value: 'America/Boa_Vista' },
{ text: 'America/Bogota', value: 'America/Bogota' },
{ text: 'America/Boise', value: 'America/Boise' },
{ text: 'America/Buenos_Aires', value: 'America/Buenos_Aires' },
{ text: 'America/Cambridge_Bay', value: 'America/Cambridge_Bay' },
{ text: 'America/Campo_Grande', value: 'America/Campo_Grande' },
{ text: 'America/Cancun', value: 'America/Cancun' },
{ text: 'America/Caracas', value: 'America/Caracas' },
{ text: 'America/Catamarca', value: 'America/Catamarca' },
{ text: 'America/Cayenne', value: 'America/Cayenne' },
{ text: 'America/Cayman', value: 'America/Cayman' },
{ text: 'America/Chicago', value: 'America/Chicago' },
{ text: 'America/Chihuahua', value: 'America/Chihuahua' },
{ text: 'America/Ciudad_Juarez', value: 'America/Ciudad_Juarez' },
{ text: 'America/Coral_Harbour', value: 'America/Coral_Harbour' },
{ text: 'America/Cordoba', value: 'America/Cordoba' },
{ text: 'America/Costa_Rica', value: 'America/Costa_Rica' },
{ text: 'America/Creston', value: 'America/Creston' },
{ text: 'America/Cuiaba', value: 'America/Cuiaba' },
{ text: 'America/Curacao', value: 'America/Curacao' },
{ text: 'America/Danmarkshavn', value: 'America/Danmarkshavn' },
{ text: 'America/Dawson', value: 'America/Dawson' },
{ text: 'America/Dawson_Creek', value: 'America/Dawson_Creek' },
{ text: 'America/Denver', value: 'America/Denver' },
{ text: 'America/Detroit', value: 'America/Detroit' },
{ text: 'America/Dominica', value: 'America/Dominica' },
{ text: 'America/Edmonton', value: 'America/Edmonton' },
{ text: 'America/Eirunepe', value: 'America/Eirunepe' },
{ text: 'America/El_Salvador', value: 'America/El_Salvador' },
{ text: 'America/Ensenada', value: 'America/Ensenada' },
{ text: 'America/Fort_Nelson', value: 'America/Fort_Nelson' },
{ text: 'America/Fort_Wayne', value: 'America/Fort_Wayne' },
{ text: 'America/Fortaleza', value: 'America/Fortaleza' },
{ text: 'America/Glace_Bay', value: 'America/Glace_Bay' },
{ text: 'America/Godthab', value: 'America/Godthab' },
{ text: 'America/Goose_Bay', value: 'America/Goose_Bay' },
{ text: 'America/Grand_Turk', value: 'America/Grand_Turk' },
{ text: 'America/Grenada', value: 'America/Grenada' },
{ text: 'America/Guadeloupe', value: 'America/Guadeloupe' },
{ text: 'America/Guatemala', value: 'America/Guatemala' },
{ text: 'America/Guayaquil', value: 'America/Guayaquil' },
{ text: 'America/Guyana', value: 'America/Guyana' },
{ text: 'America/Halifax', value: 'America/Halifax' },
{ text: 'America/Havana', value: 'America/Havana' },
{ text: 'America/Hermosillo', value: 'America/Hermosillo' },
{ text: 'America/Indiana/Indianapolis', value: 'America/Indiana/Indianapolis' },
{ text: 'America/Indiana/Knox', value: 'America/Indiana/Knox' },
{ text: 'America/Indiana/Marengo', value: 'America/Indiana/Marengo' },
{ text: 'America/Indiana/Petersburg', value: 'America/Indiana/Petersburg' },
{ text: 'America/Indiana/Tell_City', value: 'America/Indiana/Tell_City' },
{ text: 'America/Indiana/Vevay', value: 'America/Indiana/Vevay' },
{ text: 'America/Indiana/Vincennes', value: 'America/Indiana/Vincennes' },
{ text: 'America/Indiana/Winamac', value: 'America/Indiana/Winamac' },
{ text: 'America/Indianapolis', value: 'America/Indianapolis' },
{ text: 'America/Inuvik', value: 'America/Inuvik' },
{ text: 'America/Iqaluit', value: 'America/Iqaluit' },
{ text: 'America/Jamaica', value: 'America/Jamaica' },
{ text: 'America/Jujuy', value: 'America/Jujuy' },
{ text: 'America/Juneau', value: 'America/Juneau' },
{ text: 'America/Kentucky/Louisville', value: 'America/Kentucky/Louisville' },
{ text: 'America/Kentucky/Monticello', value: 'America/Kentucky/Monticello' },
{ text: 'America/Knox_IN', value: 'America/Knox_IN' },
{ text: 'America/Kralendijk', value: 'America/Kralendijk' },
{ text: 'America/La_Paz', value: 'America/La_Paz' },
{ text: 'America/Lima', value: 'America/Lima' },
{ text: 'America/Los_Angeles', value: 'America/Los_Angeles' },
{ text: 'America/Louisville', value: 'America/Louisville' },
{ text: 'America/Lower_Princes', value: 'America/Lower_Princes' },
{ text: 'America/Maceio', value: 'America/Maceio' },
{ text: 'America/Managua', value: 'America/Managua' },
{ text: 'America/Manaus', value: 'America/Manaus' },
{ text: 'America/Marigot', value: 'America/Marigot' },
{ text: 'America/Martinique', value: 'America/Martinique' },
{ text: 'America/Matamoros', value: 'America/Matamoros' },
{ text: 'America/Mazatlan', value: 'America/Mazatlan' },
{ text: 'America/Mendoza', value: 'America/Mendoza' },
{ text: 'America/Menominee', value: 'America/Menominee' },
{ text: 'America/Merida', value: 'America/Merida' },
{ text: 'America/Metlakatla', value: 'America/Metlakatla' },
{ text: 'America/Mexico_City', value: 'America/Mexico_City' },
{ text: 'America/Miquelon', value: 'America/Miquelon' },
{ text: 'America/Moncton', value: 'America/Moncton' },
{ text: 'America/Monterrey', value: 'America/Monterrey' },
{ text: 'America/Montevideo', value: 'America/Montevideo' },
{ text: 'America/Montreal', value: 'America/Montreal' },
{ text: 'America/Montserrat', value: 'America/Montserrat' },
{ text: 'America/Nassau', value: 'America/Nassau' },
{ text: 'America/New_York', value: 'America/New_York' },
{ text: 'America/Nipigon', value: 'America/Nipigon' },
{ text: 'America/Nome', value: 'America/Nome' },
{ text: 'America/Noronha', value: 'America/Noronha' },
{ text: 'America/North_Dakota/Beulah', value: 'America/North_Dakota/Beulah' },
{ text: 'America/North_Dakota/Center', value: 'America/North_Dakota/Center' },
{ text: 'America/North_Dakota/New_Salem', value: 'America/North_Dakota/New_Salem' },
{ text: 'America/Nuuk', value: 'America/Nuuk' },
{ text: 'America/Ojinaga', value: 'America/Ojinaga' },
{ text: 'America/Panama', value: 'America/Panama' },
{ text: 'America/Pangnirtung', value: 'America/Pangnirtung' },
{ text: 'America/Paramaribo', value: 'America/Paramaribo' },
{ text: 'America/Phoenix', value: 'America/Phoenix' },
{ text: 'America/Port-au-Prince', value: 'America/Port-au-Prince' },
{ text: 'America/Port_of_Spain', value: 'America/Port_of_Spain' },
{ text: 'America/Porto_Acre', value: 'America/Porto_Acre' },
{ text: 'America/Porto_Velho', value: 'America/Porto_Velho' },
{ text: 'America/Puerto_Rico', value: 'America/Puerto_Rico' },
{ text: 'America/Punta_Arenas', value: 'America/Punta_Arenas' },
{ text: 'America/Rainy_River', value: 'America/Rainy_River' },
{ text: 'America/Rankin_Inlet', value: 'America/Rankin_Inlet' },
{ text: 'America/Recife', value: 'America/Recife' },
{ text: 'America/Regina', value: 'America/Regina' },
{ text: 'America/Resolute', value: 'America/Resolute' },
{ text: 'America/Rio_Branco', value: 'America/Rio_Branco' },
{ text: 'America/Rosario', value: 'America/Rosario' },
{ text: 'America/Santa_Isabel', value: 'America/Santa_Isabel' },
{ text: 'America/Santarem', value: 'America/Santarem' },
{ text: 'America/Santiago', value: 'America/Santiago' },
{ text: 'America/Santo_Domingo', value: 'America/Santo_Domingo' },
{ text: 'America/Sao_Paulo', value: 'America/Sao_Paulo' },
{ text: 'America/Scoresbysund', value: 'America/Scoresbysund' },
{ text: 'America/Shiprock', value: 'America/Shiprock' },
{ text: 'America/Sitka', value: 'America/Sitka' },
{ text: 'America/St_Barthelemy', value: 'America/St_Barthelemy' },
{ text: 'America/St_Johns', value: 'America/St_Johns' },
{ text: 'America/St_Kitts', value: 'America/St_Kitts' },
{ text: 'America/St_Lucia', value: 'America/St_Lucia' },
{ text: 'America/St_Thomas', value: 'America/St_Thomas' },
{ text: 'America/St_Vincent', value: 'America/St_Vincent' },
{ text: 'America/Swift_Current', value: 'America/Swift_Current' },
{ text: 'America/Tegucigalpa', value: 'America/Tegucigalpa' },
{ text: 'America/Thule', value: 'America/Thule' },
{ text: 'America/Thunder_Bay', value: 'America/Thunder_Bay' },
{ text: 'America/Tijuana', value: 'America/Tijuana' },
{ text: 'America/Toronto', value: 'America/Toronto' },
{ text: 'America/Tortola', value: 'America/Tortola' },
{ text: 'America/Vancouver', value: 'America/Vancouver' },
{ text: 'America/Virgin', value: 'America/Virgin' },
{ text: 'America/Whitehorse', value: 'America/Whitehorse' },
{ text: 'America/Winnipeg', value: 'America/Winnipeg' },
{ text: 'America/Yakutat', value: 'America/Yakutat' },
{ text: 'America/Yellowknife', value: 'America/Yellowknife' },
{ text: 'Antarctica/Casey', value: 'Antarctica/Casey' },
{ text: 'Antarctica/Davis', value: 'Antarctica/Davis' },
{ text: 'Antarctica/DumontDUrville', value: 'Antarctica/DumontDUrville' },
{ text: 'Antarctica/Macquarie', value: 'Antarctica/Macquarie' },
{ text: 'Antarctica/Mawson', value: 'Antarctica/Mawson' },
{ text: 'Antarctica/McMurdo', value: 'Antarctica/McMurdo' },
{ text: 'Antarctica/Palmer', value: 'Antarctica/Palmer' },
{ text: 'Antarctica/Rothera', value: 'Antarctica/Rothera' },
{ text: 'Antarctica/South_Pole', value: 'Antarctica/South_Pole' },
{ text: 'Antarctica/Syowa', value: 'Antarctica/Syowa' },
{ text: 'Antarctica/Troll', value: 'Antarctica/Troll' },
{ text: 'Antarctica/Vostok', value: 'Antarctica/Vostok' },
{ text: 'Arctic/Longyearbyen', value: 'Arctic/Longyearbyen' },
{ text: 'Asia/Aden', value: 'Asia/Aden' },
{ text: 'Asia/Almaty', value: 'Asia/Almaty' },
{ text: 'Asia/Amman', value: 'Asia/Amman' },
{ text: 'Asia/Anadyr', value: 'Asia/Anadyr' },
{ text: 'Asia/Aqtau', value: 'Asia/Aqtau' },
{ text: 'Asia/Aqtobe', value: 'Asia/Aqtobe' },
{ text: 'Asia/Ashgabat', value: 'Asia/Ashgabat' },
{ text: 'Asia/Ashkhabad', value: 'Asia/Ashkhabad' },
{ text: 'Asia/Atyrau', value: 'Asia/Atyrau' },
{ text: 'Asia/Baghdad', value: 'Asia/Baghdad' },
{ text: 'Asia/Bahrain', value: 'Asia/Bahrain' },
{ text: 'Asia/Baku', value: 'Asia/Baku' },
{ text: 'Asia/Bangkok', value: 'Asia/Bangkok' },
{ text: 'Asia/Barnaul', value: 'Asia/Barnaul' },
{ text: 'Asia/Beirut', value: 'Asia/Beirut' },
{ text: 'Asia/Bishkek', value: 'Asia/Bishkek' },
{ text: 'Asia/Brunei', value: 'Asia/Brunei' },
{ text: 'Asia/Calcutta', value: 'Asia/Calcutta' },
{ text: 'Asia/Chita', value: 'Asia/Chita' },
{ text: 'Asia/Choibalsan', value: 'Asia/Choibalsan' },
{ text: 'Asia/Chongqing', value: 'Asia/Chongqing' },
{ text: 'Asia/Chungking', value: 'Asia/Chungking' },
{ text: 'Asia/Colombo', value: 'Asia/Colombo' },
{ text: 'Asia/Dacca', value: 'Asia/Dacca' },
{ text: 'Asia/Damascus', value: 'Asia/Damascus' },
{ text: 'Asia/Dhaka', value: 'Asia/Dhaka' },
{ text: 'Asia/Dili', value: 'Asia/Dili' },
{ text: 'Asia/Dubai', value: 'Asia/Dubai' },
{ text: 'Asia/Dushanbe', value: 'Asia/Dushanbe' },
{ text: 'Asia/Famagusta', value: 'Asia/Famagusta' },
{ text: 'Asia/Gaza', value: 'Asia/Gaza' },
{ text: 'Asia/Harbin', value: 'Asia/Harbin' },
{ text: 'Asia/Hebron', value: 'Asia/Hebron' },
{ text: 'Asia/Ho_Chi_Minh', value: 'Asia/Ho_Chi_Minh' },
{ text: 'Asia/Hong_Kong', value: 'Asia/Hong_Kong' },
{ text: 'Asia/Hovd', value: 'Asia/Hovd' },
{ text: 'Asia/Irkutsk', value: 'Asia/Irkutsk' },
{ text: 'Asia/Istanbul', value: 'Asia/Istanbul' },
{ text: 'Asia/Jakarta', value: 'Asia/Jakarta' },
{ text: 'Asia/Jayapura', value: 'Asia/Jayapura' },
{ text: 'Asia/Jerusalem', value: 'Asia/Jerusalem' },
{ text: 'Asia/Kabul', value: 'Asia/Kabul' },
{ text: 'Asia/Kamchatka', value: 'Asia/Kamchatka' },
{ text: 'Asia/Karachi', value: 'Asia/Karachi' },
{ text: 'Asia/Kashgar', value: 'Asia/Kashgar' },
{ text: 'Asia/Kathmandu', value: 'Asia/Kathmandu' },
{ text: 'Asia/Katmandu', value: 'Asia/Katmandu' },
{ text: 'Asia/Khandyga', value: 'Asia/Khandyga' },
{ text: 'Asia/Kolkata', value: 'Asia/Kolkata' },
{ text: 'Asia/Krasnoyarsk', value: 'Asia/Krasnoyarsk' },
{ text: 'Asia/Kuala_Lumpur', value: 'Asia/Kuala_Lumpur' },
{ text: 'Asia/Kuching', value: 'Asia/Kuching' },
{ text: 'Asia/Kuwait', value: 'Asia/Kuwait' },
{ text: 'Asia/Macao', value: 'Asia/Macao' },
{ text: 'Asia/Macau', value: 'Asia/Macau' },
{ text: 'Asia/Magadan', value: 'Asia/Magadan' },
{ text: 'Asia/Makassar', value: 'Asia/Makassar' },
{ text: 'Asia/Manila', value: 'Asia/Manila' },
{ text: 'Asia/Muscat', value: 'Asia/Muscat' },
{ text: 'Asia/Nicosia', value: 'Asia/Nicosia' },
{ text: 'Asia/Novokuznetsk', value: 'Asia/Novokuznetsk' },
{ text: 'Asia/Novosibirsk', value: 'Asia/Novosibirsk' },
{ text: 'Asia/Omsk', value: 'Asia/Omsk' },
{ text: 'Asia/Oral', value: 'Asia/Oral' },
{ text: 'Asia/Phnom_Penh', value: 'Asia/Phnom_Penh' },
{ text: 'Asia/Pontianak', value: 'Asia/Pontianak' },
{ text: 'Asia/Pyongyang', value: 'Asia/Pyongyang' },
{ text: 'Asia/Qatar', value: 'Asia/Qatar' },
{ text: 'Asia/Qostanay', value: 'Asia/Qostanay' },
{ text: 'Asia/Qyzylorda', value: 'Asia/Qyzylorda' },
{ text: 'Asia/Rangoon', value: 'Asia/Rangoon' },
{ text: 'Asia/Riyadh', value: 'Asia/Riyadh' },
{ text: 'Asia/Saigon', value: 'Asia/Saigon' },
{ text: 'Asia/Sakhalin', value: 'Asia/Sakhalin' },
{ text: 'Asia/Samarkand', value: 'Asia/Samarkand' },
{ text: 'Asia/Seoul', value: 'Asia/Seoul' },
{ text: 'Asia/Shanghai', value: 'Asia/Shanghai' },
{ text: 'Asia/Singapore', value: 'Asia/Singapore' },
{ text: 'Asia/Srednekolymsk', value: 'Asia/Srednekolymsk' },
{ text: 'Asia/Taipei', value: 'Asia/Taipei' },
{ text: 'Asia/Tashkent', value: 'Asia/Tashkent' },
{ text: 'Asia/Tbilisi', value: 'Asia/Tbilisi' },
{ text: 'Asia/Tehran', value: 'Asia/Tehran' },
{ text: 'Asia/Tel_Aviv', value: 'Asia/Tel_Aviv' },
{ text: 'Asia/Thimbu', value: 'Asia/Thimbu' },
{ text: 'Asia/Thimphu', value: 'Asia/Thimphu' },
{ text: 'Asia/Tokyo', value: 'Asia/Tokyo' },
{ text: 'Asia/Tomsk', value: 'Asia/Tomsk' },
{ text: 'Asia/Ujung_Pandang', value: 'Asia/Ujung_Pandang' },
{ text: 'Asia/Ulaanbaatar', value: 'Asia/Ulaanbaatar' },
{ text: 'Asia/Ulan_Bator', value: 'Asia/Ulan_Bator' },
{ text: 'Asia/Urumqi', value: 'Asia/Urumqi' },
{ text: 'Asia/Ust-Nera', value: 'Asia/Ust-Nera' },
{ text: 'Asia/Vientiane', value: 'Asia/Vientiane' },
{ text: 'Asia/Vladivostok', value: 'Asia/Vladivostok' },
{ text: 'Asia/Yakutsk', value: 'Asia/Yakutsk' },
{ text: 'Asia/Yangon', value: 'Asia/Yangon' },
{ text: 'Asia/Yekaterinburg', value: 'Asia/Yekaterinburg' },
{ text: 'Asia/Yerevan', value: 'Asia/Yerevan' },
{ text: 'Atlantic/Azores', value: 'Atlantic/Azores' },
{ text: 'Atlantic/Bermuda', value: 'Atlantic/Bermuda' },
{ text: 'Atlantic/Canary', value: 'Atlantic/Canary' },
{ text: 'Atlantic/Cape_Verde', value: 'Atlantic/Cape_Verde' },
{ text: 'Atlantic/Faeroe', value: 'Atlantic/Faeroe' },
{ text: 'Atlantic/Faroe', value: 'Atlantic/Faroe' },
{ text: 'Atlantic/Jan_Mayen', value: 'Atlantic/Jan_Mayen' },
{ text: 'Atlantic/Madeira', value: 'Atlantic/Madeira' },
{ text: 'Atlantic/Reykjavik', value: 'Atlantic/Reykjavik' },
{ text: 'Atlantic/South_Georgia', value: 'Atlantic/South_Georgia' },
{ text: 'Atlantic/St_Helena', value: 'Atlantic/St_Helena' },
{ text: 'Atlantic/Stanley', value: 'Atlantic/Stanley' },
{ text: 'Australia/ACT', value: 'Australia/ACT' },
{ text: 'Australia/Adelaide', value: 'Australia/Adelaide' },
{ text: 'Australia/Brisbane', value: 'Australia/Brisbane' },
{ text: 'Australia/Broken_Hill', value: 'Australia/Broken_Hill' },
{ text: 'Australia/Canberra', value: 'Australia/Canberra' },
{ text: 'Australia/Currie', value: 'Australia/Currie' },
{ text: 'Australia/Darwin', value: 'Australia/Darwin' },
{ text: 'Australia/Eucla', value: 'Australia/Eucla' },
{ text: 'Australia/Hobart', value: 'Australia/Hobart' },
{ text: 'Australia/LHI', value: 'Australia/LHI' },
{ text: 'Australia/Lindeman', value: 'Australia/Lindeman' },
{ text: 'Australia/Lord_Howe', value: 'Australia/Lord_Howe' },
{ text: 'Australia/Melbourne', value: 'Australia/Melbourne' },
{ text: 'Australia/NSW', value: 'Australia/NSW' },
{ text: 'Australia/North', value: 'Australia/North' },
{ text: 'Australia/Perth', value: 'Australia/Perth' },
{ text: 'Australia/Queensland', value: 'Australia/Queensland' },
{ text: 'Australia/South', value: 'Australia/South' },
{ text: 'Australia/Sydney', value: 'Australia/Sydney' },
{ text: 'Australia/Tasmania', value: 'Australia/Tasmania' },
{ text: 'Australia/Victoria', value: 'Australia/Victoria' },
{ text: 'Australia/West', value: 'Australia/West' },
{ text: 'Australia/Yancowinna', value: 'Australia/Yancowinna' },
{ text: 'Brazil/Acre', value: 'Brazil/Acre' },
{ text: 'Brazil/DeNoronha', value: 'Brazil/DeNoronha' },
{ text: 'Brazil/East', value: 'Brazil/East' },
{ text: 'Brazil/West', value: 'Brazil/West' },
{ text: 'Canada/Atlantic', value: 'Canada/Atlantic' },
{ text: 'Canada/Central', value: 'Canada/Central' },
{ text: 'Canada/Eastern', value: 'Canada/Eastern' },
{ text: 'Canada/Mountain', value: 'Canada/Mountain' },
{ text: 'Canada/Newfoundland', value: 'Canada/Newfoundland' },
{ text: 'Canada/Pacific', value: 'Canada/Pacific' },
{ text: 'Canada/Saskatchewan', value: 'Canada/Saskatchewan' },
{ text: 'Canada/Yukon', value: 'Canada/Yukon' },
{ text: 'Chile/Continental', value: 'Chile/Continental' },
{ text: 'Chile/EasterIsland', value: 'Chile/EasterIsland' },
{ text: 'Cuba', value: 'Cuba' },
{ text: 'Egypt', value: 'Egypt' },
{ text: 'Eire', value: 'Eire' },
{ text: 'Europe/Amsterdam', value: 'Europe/Amsterdam' },
{ text: 'Europe/Andorra', value: 'Europe/Andorra' },
{ text: 'Europe/Astrakhan', value: 'Europe/Astrakhan' },
{ text: 'Europe/Athens', value: 'Europe/Athens' },
{ text: 'Europe/Belfast', value: 'Europe/Belfast' },
{ text: 'Europe/Belgrade', value: 'Europe/Belgrade' },
{ text: 'Europe/Berlin', value: 'Europe/Berlin' },
{ text: 'Europe/Bratislava', value: 'Europe/Bratislava' },
{ text: 'Europe/Brussels', value: 'Europe/Brussels' },
{ text: 'Europe/Bucharest', value: 'Europe/Bucharest' },
{ text: 'Europe/Budapest', value: 'Europe/Budapest' },
{ text: 'Europe/Busingen', value: 'Europe/Busingen' },
{ text: 'Europe/Chisinau', value: 'Europe/Chisinau' },
{ text: 'Europe/Copenhagen', value: 'Europe/Copenhagen' },
{ text: 'Europe/Dublin', value: 'Europe/Dublin' },
{ text: 'Europe/Gibraltar', value: 'Europe/Gibraltar' },
{ text: 'Europe/Guernsey', value: 'Europe/Guernsey' },
{ text: 'Europe/Helsinki', value: 'Europe/Helsinki' },
{ text: 'Europe/Isle_of_Man', value: 'Europe/Isle_of_Man' },
{ text: 'Europe/Istanbul', value: 'Europe/Istanbul' },
{ text: 'Europe/Jersey', value: 'Europe/Jersey' },
{ text: 'Europe/Kaliningrad', value: 'Europe/Kaliningrad' },
{ text: 'Europe/Kiev', value: 'Europe/Kiev' },
{ text: 'Europe/Kirov', value: 'Europe/Kirov' },
{ text: 'Europe/Kyiv', value: 'Europe/Kyiv' },
{ text: 'Europe/Lisbon', value: 'Europe/Lisbon' },
{ text: 'Europe/Ljubljana', value: 'Europe/Ljubljana' },
{ text: 'Europe/London', value: 'Europe/London' },
{ text: 'Europe/Luxembourg', value: 'Europe/Luxembourg' },
{ text: 'Europe/Madrid', value: 'Europe/Madrid' },
{ text: 'Europe/Malta', value: 'Europe/Malta' },
{ text: 'Europe/Mariehamn', value: 'Europe/Mariehamn' },
{ text: 'Europe/Minsk', value: 'Europe/Minsk' },
{ text: 'Europe/Monaco', value: 'Europe/Monaco' },
{ text: 'Europe/Moscow', value: 'Europe/Moscow' },
{ text: 'Europe/Nicosia', value: 'Europe/Nicosia' },
{ text: 'Europe/Oslo', value: 'Europe/Oslo' },
{ text: 'Europe/Paris', value: 'Europe/Paris' },
{ text: 'Europe/Podgorica', value: 'Europe/Podgorica' },
{ text: 'Europe/Prague', value: 'Europe/Prague' },
{ text: 'Europe/Riga', value: 'Europe/Riga' },
{ text: 'Europe/Rome', value: 'Europe/Rome' },
{ text: 'Europe/Samara', value: 'Europe/Samara' },
{ text: 'Europe/San_Marino', value: 'Europe/San_Marino' },
{ text: 'Europe/Sarajevo', value: 'Europe/Sarajevo' },
{ text: 'Europe/Saratov', value: 'Europe/Saratov' },
{ text: 'Europe/Simferopol', value: 'Europe/Simferopol' },
{ text: 'Europe/Skopje', value: 'Europe/Skopje' },
{ text: 'Europe/Sofia', value: 'Europe/Sofia' },
{ text: 'Europe/Stockholm', value: 'Europe/Stockholm' },
{ text: 'Europe/Tallinn', value: 'Europe/Tallinn' },
{ text: 'Europe/Tirane', value: 'Europe/Tirane' },
{ text: 'Europe/Tiraspol', value: 'Europe/Tiraspol' },
{ text: 'Europe/Ulyanovsk', value: 'Europe/Ulyanovsk' },
{ text: 'Europe/Uzhgorod', value: 'Europe/Uzhgorod' },
{ text: 'Europe/Vaduz', value: 'Europe/Vaduz' },
{ text: 'Europe/Vatican', value: 'Europe/Vatican' },
{ text: 'Europe/Vienna', value: 'Europe/Vienna' },
{ text: 'Europe/Vilnius', value: 'Europe/Vilnius' },
{ text: 'Europe/Volgograd', value: 'Europe/Volgograd' },
{ text: 'Europe/Warsaw', value: 'Europe/Warsaw' },
{ text: 'Europe/Zagreb', value: 'Europe/Zagreb' },
{ text: 'Europe/Zaporozhye', value: 'Europe/Zaporozhye' },
{ text: 'Europe/Zurich', value: 'Europe/Zurich' },
{ text: 'Hongkong', value: 'Hongkong' },
{ text: 'Iceland', value: 'Iceland' },
{ text: 'Indian/Antananarivo', value: 'Indian/Antananarivo' },
{ text: 'Indian/Chagos', value: 'Indian/Chagos' },
{ text: 'Indian/Christmas', value: 'Indian/Christmas' },
{ text: 'Indian/Cocos', value: 'Indian/Cocos' },
{ text: 'Indian/Comoro', value: 'Indian/Comoro' },
{ text: 'Indian/Kerguelen', value: 'Indian/Kerguelen' },
{ text: 'Indian/Mahe', value: 'Indian/Mahe' },
{ text: 'Indian/Maldives', value: 'Indian/Maldives' },
{ text: 'Indian/Mauritius', value: 'Indian/Mauritius' },
{ text: 'Indian/Mayotte', value: 'Indian/Mayotte' },
{ text: 'Indian/Reunion', value: 'Indian/Reunion' },
{ text: 'Iran', value: 'Iran' },
{ text: 'Israel', value: 'Israel' },
{ text: 'Jamaica', value: 'Jamaica' },
{ text: 'Japan', value: 'Japan' },
{ text: 'Kwajalein', value: 'Kwajalein' },
{ text: 'Libya', value: 'Libya' },
{ text: 'Mexico/BajaNorte', value: 'Mexico/BajaNorte' },
{ text: 'Mexico/BajaSur', value: 'Mexico/BajaSur' },
{ text: 'Mexico/General', value: 'Mexico/General' },
{ text: 'Navajo', value: 'Navajo' },
{ text: 'Pacific/Apia', value: 'Pacific/Apia' },
{ text: 'Pacific/Auckland', value: 'Pacific/Auckland' },
{ text: 'Pacific/Bougainville', value: 'Pacific/Bougainville' },
{ text: 'Pacific/Chatham', value: 'Pacific/Chatham' },
{ text: 'Pacific/Chuuk', value: 'Pacific/Chuuk' },
{ text: 'Pacific/Easter', value: 'Pacific/Easter' },
{ text: 'Pacific/Efate', value: 'Pacific/Efate' },
{ text: 'Pacific/Enderbury', value: 'Pacific/Enderbury' },
{ text: 'Pacific/Fakaofo', value: 'Pacific/Fakaofo' },
{ text: 'Pacific/Fiji', value: 'Pacific/Fiji' },
{ text: 'Pacific/Funafuti', value: 'Pacific/Funafuti' },
{ text: 'Pacific/Galapagos', value: 'Pacific/Galapagos' },
{ text: 'Pacific/Gambier', value: 'Pacific/Gambier' },
{ text: 'Pacific/Guadalcanal', value: 'Pacific/Guadalcanal' },
{ text: 'Pacific/Guam', value: 'Pacific/Guam' },
{ text: 'Pacific/Honolulu', value: 'Pacific/Honolulu' },
{ text: 'Pacific/Johnston', value: 'Pacific/Johnston' },
{ text: 'Pacific/Kanton', value: 'Pacific/Kanton' },
{ text: 'Pacific/Kiritimati', value: 'Pacific/Kiritimati' },
{ text: 'Pacific/Kosrae', value: 'Pacific/Kosrae' },
{ text: 'Pacific/Kwajalein', value: 'Pacific/Kwajalein' },
{ text: 'Pacific/Majuro', value: 'Pacific/Majuro' },
{ text: 'Pacific/Marquesas', value: 'Pacific/Marquesas' },
{ text: 'Pacific/Midway', value: 'Pacific/Midway' },
{ text: 'Pacific/Nauru', value: 'Pacific/Nauru' },
{ text: 'Pacific/Niue', value: 'Pacific/Niue' },
{ text: 'Pacific/Norfolk', value: 'Pacific/Norfolk' },
{ text: 'Pacific/Noumea', value: 'Pacific/Noumea' },
{ text: 'Pacific/Pago_Pago', value: 'Pacific/Pago_Pago' },
{ text: 'Pacific/Palau', value: 'Pacific/Palau' },
{ text: 'Pacific/Pitcairn', value: 'Pacific/Pitcairn' },
{ text: 'Pacific/Pohnpei', value: 'Pacific/Pohnpei' },
{ text: 'Pacific/Ponape', value: 'Pacific/Ponape' },
{ text: 'Pacific/Port_Moresby', value: 'Pacific/Port_Moresby' },
{ text: 'Pacific/Rarotonga', value: 'Pacific/Rarotonga' },
{ text: 'Pacific/Saipan', value: 'Pacific/Saipan' },
{ text: 'Pacific/Samoa', value: 'Pacific/Samoa' },
{ text: 'Pacific/Tahiti', value: 'Pacific/Tahiti' },
{ text: 'Pacific/Tarawa', value: 'Pacific/Tarawa' },
{ text: 'Pacific/Tongatapu', value: 'Pacific/Tongatapu' },
{ text: 'Pacific/Truk', value: 'Pacific/Truk' },
{ text: 'Pacific/Wake', value: 'Pacific/Wake' },
{ text: 'Pacific/Wallis', value: 'Pacific/Wallis' },
{ text: 'Pacific/Yap', value: 'Pacific/Yap' },
{ text: 'Poland', value: 'Poland' },
{ text: 'Portugal', value: 'Portugal' },
{ text: 'Singapore', value: 'Singapore' },
{ text: 'Turkey', value: 'Turkey' },
{ text: 'US/Alaska', value: 'US/Alaska' },
{ text: 'US/Aleutian', value: 'US/Aleutian' },
{ text: 'US/Arizona', value: 'US/Arizona' },
{ text: 'US/Central', value: 'US/Central' },
{ text: 'US/East-Indiana', value: 'US/East-Indiana' },
{ text: 'US/Eastern', value: 'US/Eastern' },
{ text: 'US/Hawaii', value: 'US/Hawaii' },
{ text: 'US/Indiana-Starke', value: 'US/Indiana-Starke' },
{ text: 'US/Michigan', value: 'US/Michigan' },
{ text: 'US/Mountain', value: 'US/Mountain' },
{ text: 'US/Pacific', value: 'US/Pacific' },
{ text: 'US/Samoa', value: 'US/Samoa' },
{ text: 'UTC', value: 'UTC' },
{ text: 'Zulu', value: 'Zulu' }
]

View File

@ -71,6 +71,7 @@ return [
'successful_login_on' => 'Successful login on <span class="light-or-darker">:login_at</span>',
'successful_logout_on' => 'Successful logout on <span class="light-or-darker">:login_at</span>',
'failed_login_on' => 'Failed login on <span class="light-or-darker">:login_at</span>',
'viewed_on' => 'Viewed on <span class="light-or-darker">:login_at</span>',
'last_accesses' => 'Last accesses',
'see_full_log' => 'See full log',
'browser_on_platform' => ':browser on :platform',

View File

@ -27,4 +27,10 @@ return [
'connection_details' => 'Here are the details of this connection',
'recommandations' => 'If this was you, you can ignore this alert. If you suspect any suspicious activity on your account, please change your password.'
],
'failed_login' => [
'subject' => 'Failed login to 2FAuth',
'resume' => 'There has been a failed login attempt to your 2FAuth account.',
'connection_details' => 'Here are the details of this connection attempt',
'recommandations' => 'If this was you, you can ignore this alert. If further attempts fail, you should contact the 2FAuth administrator to review security settings and take action against this attacker.'
],
];

View File

@ -52,6 +52,10 @@ return [
'label' => 'Language',
'help' => 'Language used to translate the 2FAuth user interface. Named languages are complete, set the one of your choice to override your browser preference.'
],
'timezone' => [
'label' => 'Time zone',
'help' => 'The time zone applied to all dates and times displayed in the application'
],
'show_otp_as_dot' => [
'label' => 'Show generated <abbr title="One-Time Password">OTP</abbr> as dot',
'help' => 'Replace generated password caracters with *** to ensure confidentiality. Do not affect the copy/paste feature'

View File

@ -0,0 +1,18 @@
@component('mail::message')
@lang('notifications.hello_user', ['username' => $account->name])
<br/><br/>
**@lang('notifications.failed_login.resume')**<br/>
@lang('notifications.failed_login.connection_details'):
<x-mail::panel>
@lang('commons.time'): **{{ $time->toCookieString() }}**<br/>
@lang('commons.ip_address'): **{{ $ipAddress }}**<br/>
@lang('commons.device'): **@lang('admin.browser_on_platform', ['browser' => $browser, 'platform' => $platform])**<br/>
</x-mail::panel>
@lang('notifications.failed_login.recommandations')<br/>
@lang('notifications.regards'),<br/>
{{ config('app.name') }}
@endcomponent

View File

@ -19,7 +19,7 @@ use Laravel\Passport\Http\Controllers\PersonalAccessTokenController;
// use App\Models\User;
// use App\Notifications\SignedInWithNewDevice;
// use Bubka\LaravelAuthenticationLog\Models\AuthenticationLog;
// use App\Models\AuthLog;
/*
|--------------------------------------------------------------------------
@ -95,7 +95,7 @@ Route::get('refresh-csrf', function () {
// Route::get('/notification', function () {
// $user = User::find(1);
// return (new SignedInWithNewDevice(AuthenticationLog::find(9)))
// return (new SignedInWithNewDevice(AuthLog::find(9)))
// ->toMail($user);
// });

View File

@ -89,47 +89,22 @@ class UserControllerTest extends FeatureTestCase
*/
public function test_allPreferences_returns_preferences_with_user_values()
{
$userPrefs = [
'showOtpAsDot' => true,
'closeOtpOnCopy' => true,
'copyOtpOnDisplay' => true,
'useBasicQrcodeReader' => true,
'displayMode' => 'grid',
'showAccountsIcons' => false,
'kickUserAfter' => 5,
'activeGroup' => 1,
'rememberActiveGroup' => false,
'defaultGroup' => 1,
'defaultCaptureMode' => 'advancedForm',
'useDirectCapture' => true,
'useWebauthnOnly' => true,
'getOfficialIcons' => false,
'theme' => 'dark',
'formatPassword' => false,
'formatPasswordBy' => 1,
'lang' => 'fr',
];
$userPrefs = [];
$this->user['preferences->showOtpAsDot'] = $userPrefs['showOtpAsDot'];
$this->user['preferences->closeOtpOnCopy'] = $userPrefs['closeOtpOnCopy'];
$this->user['preferences->copyOtpOnDisplay'] = $userPrefs['copyOtpOnDisplay'];
$this->user['preferences->useBasicQrcodeReader'] = $userPrefs['useBasicQrcodeReader'];
$this->user['preferences->displayMode'] = $userPrefs['displayMode'];
$this->user['preferences->showAccountsIcons'] = $userPrefs['showAccountsIcons'];
$this->user['preferences->kickUserAfter'] = $userPrefs['kickUserAfter'];
$this->user['preferences->activeGroup'] = $userPrefs['activeGroup'];
$this->user['preferences->rememberActiveGroup'] = $userPrefs['rememberActiveGroup'];
$this->user['preferences->defaultGroup'] = $userPrefs['defaultGroup'];
$this->user['preferences->defaultCaptureMode'] = $userPrefs['defaultCaptureMode'];
$this->user['preferences->useDirectCapture'] = $userPrefs['useDirectCapture'];
$this->user['preferences->useWebauthnOnly'] = $userPrefs['useWebauthnOnly'];
$this->user['preferences->getOfficialIcons'] = $userPrefs['getOfficialIcons'];
$this->user['preferences->theme'] = $userPrefs['theme'];
$this->user['preferences->formatPassword'] = $userPrefs['formatPassword'];
$this->user['preferences->formatPasswordBy'] = $userPrefs['formatPasswordBy'];
$this->user['preferences->lang'] = $userPrefs['lang'];
foreach (config('2fauth.preferences') as $pref => $value) {
if (is_numeric($value)) {
$userPrefs[$pref] = $value + 1;
} else if (is_string($value)) {
$userPrefs[$pref] = $value . '_';
} else if (is_bool($value)) {
$userPrefs[$pref] = ! $value;
}
$this->user['preferences->' . $pref] = $userPrefs[$pref];
}
$this->user->save();
$response = $this->actingAs($this->user, 'api-guard')
->json('GET', '/api/v1/user/preferences')
->assertJsonCount(count(config('2fauth.preferences')), $key = null);

View File

@ -4,6 +4,8 @@ namespace Tests\Api\v1\Controllers;
use App\Api\v1\Controllers\UserManagerController;
use App\Api\v1\Resources\UserManagerResource;
use App\Models\AuthLog;
use App\Models\TwoFAccount;
use App\Models\User;
use App\Policies\UserPolicy;
use Database\Factories\UserFactory;
@ -12,7 +14,6 @@ use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Password;
@ -22,7 +23,6 @@ use Laravel\Passport\TokenRepository;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\Data\AuthenticationLogData;
use Tests\FeatureTestCase;
#[CoversClass(UserManagerController::class)]
@ -40,6 +40,11 @@ class UserManagerControllerTest extends FeatureTestCase
protected $anotherUser;
/**
* @var array
*/
protected $defaultPreferences;
private const USERNAME = 'john doe';
private const EMAIL = 'johndoe@example.org';
@ -56,6 +61,11 @@ class UserManagerControllerTest extends FeatureTestCase
$this->admin = User::factory()->administrator()->create();
$this->user = User::factory()->create();
$this->anotherUser = User::factory()->create();
TwoFAccount::factory()->for($this->anotherUser)->create();
foreach (config('2fauth.preferences') as $pref => $value) {
$this->defaultPreferences[$pref] = config('2fauth.preferences.' . $pref);
}
}
/**
@ -79,63 +89,71 @@ class UserManagerControllerTest extends FeatureTestCase
/**
* @test
*/
public function test_index_returns_all_users()
public function test_index_returns_all_users_with_expected_UserManagerResources() : void
{
$this->actingAs($this->admin, 'api-guard')
$response = $this->actingAs($this->admin, 'api-guard')
->json('GET', '/api/v1/users')
->assertOk()
->assertJsonCount(3)
->assertJsonFragment([
'email' => $this->admin->email,
->assertJsonStructure([
'*' => [
"last_seen_at",
"created_at",
]
])
->assertJsonFragment([
'email' => $this->user->email,
"id" => $this->user->id,
"name" => $this->user->name,
"email" => $this->user->email,
"oauth_provider" => null,
"preferences" => $this->defaultPreferences,
"is_admin" => false,
"twofaccounts_count" => 0,
])
->assertJsonFragment([
'email' => $this->anotherUser->email,
"id" => $this->admin->id,
"name" => $this->admin->name,
"email" => $this->admin->email,
"oauth_provider" => null,
"preferences" => $this->defaultPreferences,
"is_admin" => true,
"twofaccounts_count" => 0,
])
->assertJsonFragment([
"id" => $this->anotherUser->id,
"name" => $this->anotherUser->name,
"email" => $this->anotherUser->email,
"oauth_provider" => null,
"preferences" => $this->defaultPreferences,
"is_admin" => false,
"twofaccounts_count" => 1,
]);
}
/**
* @test
*/
public function test_index_succeeds_and_returns_UserManagerResource() : void
{
$path = '/api/v1/users';
$resources = UserManagerResource::collection(User::all());
$request = Request::create($path, 'GET');
$this->actingAs($this->admin, 'api-guard')
->json('GET', $path)
->assertExactJson($resources->response($request)->getData(true));
}
/**
* @test
*/
public function test_show_returns_the_correct_user()
public function test_show_returns_the_expected_UserManagerResource() : void
{
$this->actingAs($this->admin, 'api-guard')
->json('GET', '/api/v1/users/' . $this->user->id)
->assertJsonFragment([
'email' => $this->user->email,
->assertJson([
"info" => [
"id" => $this->user->id,
"name" => $this->user->name,
"email" => $this->user->email,
"oauth_provider" => null,
"preferences" => $this->defaultPreferences,
"is_admin" => false,
"twofaccounts_count" => 0,
"last_seen_at" => "1 second ago",
"created_at" => "1 second ago"
],
"password_reset" => null,
"valid_personal_access_tokens" => 0,
"webauthn_credentials" => 0
]);
}
/**
* @test
*/
public function test_show_returns_UserManagerResource() : void
{
$path = '/api/v1/users/' . $this->user->id;
$resources = UserManagerResource::make($this->user);
$request = Request::create($path, 'GET');
$this->actingAs($this->admin, 'api-guard')
->json('GET', $path)
->assertExactJson($resources->response($request)->getData(true));
}
/**
* @test
*/
@ -526,46 +544,36 @@ class UserManagerControllerTest extends FeatureTestCase
/**
* @test
*/
public function test_authLog_events_are_listened_by_authLog_listeners()
public function test_authentications_returns_all_entries() : void
{
Event::fake();
AuthLog::factory()->for($this->user, 'authenticatable')->beforeLastYear()->create();
AuthLog::factory()->for($this->user, 'authenticatable')->duringLastYear()->create();
AuthLog::factory()->for($this->user, 'authenticatable')->duringLastSixMonth()->create();
AuthLog::factory()->for($this->user, 'authenticatable')->duringLastThreeMonth()->create();
AuthLog::factory()->for($this->user, 'authenticatable')->duringLastMonth()->create();
AuthLog::factory()->for($this->user, 'authenticatable')->logoutOnly()->create();
AuthLog::factory()->for($this->user, 'authenticatable')->failedLogin()->create();
AuthLog::factory()->for($this->user, 'authenticatable')->create();
foreach (config('authentication-log.listeners') as $type => $listenerClass) {
Event::assertListening(
config('authentication-log.events.' . $type),
$listenerClass
);
}
}
/**
* Local feeder because Factory cannot be used here
*/
protected function feedAuthenticationLog() : int
{
// Do not change creation order
$this->user->authentications()->create(AuthenticationLogData::beforeLastYear());
$this->user->authentications()->create(AuthenticationLogData::duringLastYear());
$this->user->authentications()->create(AuthenticationLogData::duringLastSixMonth());
$this->user->authentications()->create(AuthenticationLogData::duringLastThreeMonth());
$this->user->authentications()->create(AuthenticationLogData::duringLastMonth());
$this->user->authentications()->create(AuthenticationLogData::noLogin());
$this->user->authentications()->create(AuthenticationLogData::noLogout());
return 7;
$this->actingAs($this->admin, 'api-guard')
->json('GET', '/api/v1/users/' . $this->user->id . '/authentications')
->assertOk()
->assertJsonCount(8);
}
/**
* @test
*/
public function test_authentications_returns_all_entries() : void
public function test_authentications_returns_user_entries_only() : void
{
$created = $this->feedAuthenticationLog();
AuthLog::factory()->for($this->admin, 'authenticatable')->create();
AuthLog::factory()->for($this->user, 'authenticatable')->create();
$this->actingAs($this->admin, 'api-guard')
$response = $this->actingAs($this->admin, 'api-guard')
->json('GET', '/api/v1/users/' . $this->user->id . '/authentications')
->assertOk()
->assertJsonCount($created);
->assertJsonCount(1);
$this->assertEquals($response->getData()[0]->id, $this->user->id);
}
/**
@ -573,7 +581,7 @@ class UserManagerControllerTest extends FeatureTestCase
*/
public function test_authentications_returns_expected_resource() : void
{
$this->user->authentications()->create(AuthenticationLogData::duringLastMonth());
AuthLog::factory()->for($this->user, 'authenticatable')->create();
$this->actingAs($this->admin, 'api-guard')
->json('GET', '/api/v1/users/' . $this->user->id . '/authentications')
@ -589,6 +597,7 @@ class UserManagerControllerTest extends FeatureTestCase
'logout_at',
'login_successful',
'duration',
'login_method',
],
]);
}
@ -596,12 +605,41 @@ class UserManagerControllerTest extends FeatureTestCase
/**
* @test
*/
public function test_authentications_returns_no_login_entry() : void
public function test_authentications_returns_resource_with_timezoned_dates() : void
{
$this->user->authentications()->create(AuthenticationLogData::noLogin());
$timezone = 'Europe/Paris';
$this->admin['preferences->timezone'] = $timezone;
$this->admin->save();
$now = now();
$timezonedNow = now($timezone);
AuthLog::factory()->for($this->user, 'authenticatable')->create([
'login_at' => $now,
'logout_at' => $now,
]);
$response = $this->actingAs($this->admin, 'api-guard')
->json('GET', '/api/v1/users/' . $this->user->id . '/authentications');
$login_at = Carbon::parse($response->getData()[0]->login_at);
$logout_at = Carbon::parse($response->getData()[0]->logout_at);
$this->assertTrue($login_at->isSameHour($timezonedNow));
$this->assertTrue($login_at->isSameMinute($timezonedNow));
$this->assertTrue($logout_at->isSameHour($timezonedNow));
$this->assertTrue($logout_at->isSameMinute($timezonedNow));
}
/**
* @test
*/
public function test_authentications_returns_loginless_entries() : void
{
$this->logUserOut();
$this->actingAs($this->admin, 'api-guard')
->json('GET', '/api/v1/users/' . $this->user->id . '/authentications?period=1')
->json('GET', '/api/v1/users/' . $this->user->id . '/authentications')
->assertJsonCount(1)
->assertJsonFragment([
'login_at' => null,
@ -611,12 +649,12 @@ class UserManagerControllerTest extends FeatureTestCase
/**
* @test
*/
public function test_authentications_returns_no_logout_entry() : void
public function test_authentications_returns_logoutless_entries() : void
{
$this->user->authentications()->create(AuthenticationLogData::noLogout());
$this->logUserIn();
$this->actingAs($this->admin, 'api-guard')
->json('GET', '/api/v1/users/' . $this->user->id . '/authentications?period=1')
->json('GET', '/api/v1/users/' . $this->user->id . '/authentications')
->assertJsonCount(1)
->assertJsonFragment([
'logout_at' => null,
@ -628,14 +666,15 @@ class UserManagerControllerTest extends FeatureTestCase
*/
public function test_authentications_returns_failed_entry() : void
{
$this->user->authentications()->create(AuthenticationLogData::failedLogin());
$expected = Carbon::parse(AuthenticationLogData::failedLogin()['login_at'])->toDayDateTimeString();
$this->json('POST', '/user/login', [
'email' => $this->user->email,
'password' => 'wrong_password',
]);
$this->actingAs($this->admin, 'api-guard')
->json('GET', '/api/v1/users/' . $this->user->id . '/authentications?period=1')
->assertJsonCount(1)
->assertJsonFragment([
'login_at' => $expected,
'login_successful' => false,
]);
}
@ -645,15 +684,16 @@ class UserManagerControllerTest extends FeatureTestCase
*/
public function test_authentications_returns_last_month_entries() : void
{
$this->feedAuthenticationLog();
$expected = Carbon::parse(AuthenticationLogData::duringLastMonth()['login_at'])->toDayDateTimeString();
$this->travel(-2)->months();
$this->logUserInAndOut();
$this->travelBack();
$this->logUserIn();
$this->actingAs($this->admin, 'api-guard')
$response = $this->actingAs($this->admin, 'api-guard')
->json('GET', '/api/v1/users/' . $this->user->id . '/authentications?period=1')
->assertJsonCount(3)
->assertJsonFragment([
'login_at' => $expected,
]);
->assertJsonCount(1);
$this->assertTrue(Carbon::parse($response->getData()[0]->login_at)->isSameDay(now()));
}
/**
@ -661,19 +701,18 @@ class UserManagerControllerTest extends FeatureTestCase
*/
public function test_authentications_returns_last_three_months_entries() : void
{
$this->feedAuthenticationLog();
$expectedOneMonth = Carbon::parse(AuthenticationLogData::duringLastMonth()['login_at'])->toDayDateTimeString();
$expectedThreeMonth = Carbon::parse(AuthenticationLogData::duringLastThreeMonth()['login_at'])->toDayDateTimeString();
$this->travel(-100)->days();
$this->logUserInAndOut();
$this->travelBack();
$this->travel(-80)->days();
$this->logUserIn();
$this->travelBack();
$this->actingAs($this->admin, 'api-guard')
$response = $this->actingAs($this->admin, 'api-guard')
->json('GET', '/api/v1/users/' . $this->user->id . '/authentications?period=3')
->assertJsonCount(4)
->assertJsonFragment([
'login_at' => $expectedOneMonth,
])
->assertJsonFragment([
'login_at' => $expectedThreeMonth,
]);
->assertJsonCount(1);
$this->assertTrue(Carbon::parse($response->getData()[0]->login_at)->isSameDay(now()->subDays(80)));
}
/**
@ -681,23 +720,18 @@ class UserManagerControllerTest extends FeatureTestCase
*/
public function test_authentications_returns_last_six_months_entries() : void
{
$this->feedAuthenticationLog();
$expectedOneMonth = Carbon::parse(AuthenticationLogData::duringLastMonth()['login_at'])->toDayDateTimeString();
$expectedThreeMonth = Carbon::parse(AuthenticationLogData::duringLastThreeMonth()['login_at'])->toDayDateTimeString();
$expectedSixMonth = Carbon::parse(AuthenticationLogData::duringLastSixMonth()['login_at'])->toDayDateTimeString();
$this->travel(-7)->months();
$this->logUserInAndOut();
$this->travelBack();
$this->travel(-5)->months();
$this->logUserIn();
$this->travelBack();
$this->actingAs($this->admin, 'api-guard')
$response = $this->actingAs($this->admin, 'api-guard')
->json('GET', '/api/v1/users/' . $this->user->id . '/authentications?period=6')
->assertJsonCount(5)
->assertJsonFragment([
'login_at' => $expectedOneMonth,
])
->assertJsonFragment([
'login_at' => $expectedThreeMonth,
])
->assertJsonFragment([
'login_at' => $expectedSixMonth,
]);
->assertJsonCount(1);
$this->assertTrue(Carbon::parse($response->getData()[0]->login_at)->isSameDay(now()->subMonths(5)));
}
/**
@ -705,27 +739,18 @@ class UserManagerControllerTest extends FeatureTestCase
*/
public function test_authentications_returns_last_year_entries() : void
{
$this->feedAuthenticationLog();
$expectedOneMonth = Carbon::parse(AuthenticationLogData::duringLastMonth()['login_at'])->toDayDateTimeString();
$expectedThreeMonth = Carbon::parse(AuthenticationLogData::duringLastThreeMonth()['login_at'])->toDayDateTimeString();
$expectedSixMonth = Carbon::parse(AuthenticationLogData::duringLastSixMonth()['login_at'])->toDayDateTimeString();
$expectedYear = Carbon::parse(AuthenticationLogData::duringLastYear()['login_at'])->toDayDateTimeString();
$this->travel(-13)->months();
$this->logUserInAndOut();
$this->travelBack();
$this->travel(-11)->months();
$this->logUserIn();
$this->travelBack();
$this->actingAs($this->admin, 'api-guard')
$response = $this->actingAs($this->admin, 'api-guard')
->json('GET', '/api/v1/users/' . $this->user->id . '/authentications?period=12')
->assertJsonCount(6)
->assertJsonFragment([
'login_at' => $expectedOneMonth,
])
->assertJsonFragment([
'login_at' => $expectedThreeMonth,
])
->assertJsonFragment([
'login_at' => $expectedSixMonth,
])
->assertJsonFragment([
'login_at' => $expectedYear,
]);
->assertJsonCount(1);
$this->assertTrue(Carbon::parse($response->getData()[0]->login_at)->isSameDay(now()->subMonths(11)));
}
/**
@ -734,7 +759,10 @@ class UserManagerControllerTest extends FeatureTestCase
#[DataProvider('LimitProvider')]
public function test_authentications_returns_limited_entries($limit) : void
{
$this->feedAuthenticationLog();
AuthLog::factory()->for($this->user, 'authenticatable')->duringLastYear()->create();
AuthLog::factory()->for($this->user, 'authenticatable')->duringLastSixMonth()->create();
AuthLog::factory()->for($this->user, 'authenticatable')->duringLastThreeMonth()->create();
AuthLog::factory()->for($this->user, 'authenticatable')->duringLastMonth()->create();
$this->actingAs($this->admin, 'api-guard')
->json('GET', '/api/v1/users/' . $this->user->id . '/authentications?limit=' . $limit)
@ -743,7 +771,7 @@ class UserManagerControllerTest extends FeatureTestCase
}
/**
* Provide various limit
* Provide various limits
*/
public static function LimitProvider()
{
@ -751,6 +779,7 @@ class UserManagerControllerTest extends FeatureTestCase
'limited to 1' => [1],
'limited to 2' => [2],
'limited to 3' => [3],
'limited to 4' => [4],
];
}
@ -759,13 +788,9 @@ class UserManagerControllerTest extends FeatureTestCase
*/
public function test_authentications_returns_expected_ip_and_useragent_chunks() : void
{
$this->user->authentications()->create([
AuthLog::factory()->for($this->user, 'authenticatable')->create([
'ip_address' => '127.0.0.1',
'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
'login_at' => now(),
'login_successful' => true,
'logout_at' => null,
'location' => null,
]);
$this->actingAs($this->admin, 'api-guard')
@ -786,7 +811,7 @@ class UserManagerControllerTest extends FeatureTestCase
{
$this->actingAs($this->admin, 'api-guard')
->json('GET', '/api/v1/users/' . $this->user->id . '/authentications?limit=' . $limit)
->assertStatus(422);
->assertInvalid(['limit']);
}
/**
@ -797,7 +822,7 @@ class UserManagerControllerTest extends FeatureTestCase
{
$this->actingAs($this->admin, 'api-guard')
->json('GET', '/api/v1/users/' . $this->user->id . '/authentications?period=' . $period)
->assertStatus(422);
->assertInvalid(['period']);
}
/**
@ -813,4 +838,33 @@ class UserManagerControllerTest extends FeatureTestCase
'array' => ['[]'],
];
}
/**
* Makes a request to login the user in
*/
protected function logUserIn() : void
{
$this->json('POST', '/user/login', [
'email' => $this->user->email,
'password' => self::PASSWORD,
]);
}
/**
* Makes a request to login the user out
*/
protected function logUserOut() : void
{
$this->actingAs($this->user, 'web-guard')
->json('GET', '/user/logout');
}
/**
* Makes a request to login the user out
*/
protected function logUserInAndOut() : void
{
$this->logUserIn();
$this->logUserOut();
}
}

View File

@ -1,159 +0,0 @@
<?php
namespace Tests\Data;
class AuthenticationLogData
{
/**
* Indicate that the model should have login date.
*
* @return array
*/
public static function failedLogin()
{
$loginDate = now()->subDays(15);
return [
'ip_address' => fake()->ipv4(),
'user_agent' => fake()->userAgent(),
'login_at' => $loginDate,
'login_successful' => false,
'logout_at' => null,
'location' => null,
];
}
/**
* Indicate that the model should have no login date
*
* @return array
*/
public static function noLogin()
{
return [
'ip_address' => fake()->ipv4(),
'user_agent' => fake()->userAgent(),
'login_at' => null,
'login_successful' => false,
'logout_at' => now(),
'location' => null,
];
}
/**
* Indicate that the model should have no logout date
*
* @return array
*/
public static function noLogout()
{
return [
'ip_address' => fake()->ipv4(),
'user_agent' => fake()->userAgent(),
'login_at' => now(),
'login_successful' => true,
'logout_at' => null,
'location' => null,
];
}
/**
* Indicate that the model should have login during last month
*
* @return array
*/
public static function duringLastMonth()
{
$loginDate = now()->subDays(15);
$logoutDate = $loginDate->addHours(1);
return [
'ip_address' => '127.0.0.1',
'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
'login_at' => $loginDate,
'login_successful' => true,
'logout_at' => $logoutDate,
'location' => null,
];
}
/**
* Indicate that the model should have login during last 3 month
*
* @return array
*/
public static function duringLastThreeMonth()
{
$loginDate = now()->subMonths(2);
$logoutDate = $loginDate->addHours(1);
return [
'ip_address' => fake()->ipv4(),
'user_agent' => fake()->userAgent(),
'login_at' => $loginDate,
'login_successful' => true,
'logout_at' => $logoutDate,
'location' => null,
];
}
/**
* Indicate that the model should have login during last 6 month
*
* @return array
*/
public static function duringLastSixMonth()
{
$loginDate = now()->subMonths(4);
$logoutDate = $loginDate->addHours(1);
return [
'ip_address' => fake()->ipv4(),
'user_agent' => fake()->userAgent(),
'login_at' => $loginDate,
'login_successful' => true,
'logout_at' => $logoutDate,
'location' => null,
];
}
/**
* Indicate that the model should have login during last month
*
* @return array
*/
public static function duringLastYear()
{
$loginDate = now()->subMonths(10);
$logoutDate = $loginDate->addHours(1);
return [
'ip_address' => fake()->ipv4(),
'user_agent' => fake()->userAgent(),
'login_at' => $loginDate,
'login_successful' => true,
'logout_at' => $logoutDate,
'location' => null,
];
}
/**
* Indicate that the model should have login during last month
*
* @return array
*/
public static function beforeLastYear()
{
$loginDate = now()->subYears(2);
$logoutDate = $loginDate->addHours(1);
return [
'ip_address' => fake()->ipv4(),
'user_agent' => fake()->userAgent(),
'login_at' => $loginDate,
'login_successful' => true,
'logout_at' => $logoutDate,
'location' => null,
];
}
}

View File

@ -7,9 +7,14 @@ use App\Http\Middleware\RejectIfAuthenticated;
use App\Http\Middleware\RejectIfDemoMode;
use App\Http\Middleware\RejectIfReverseProxy;
use App\Http\Middleware\SkipIfAuthenticated;
use App\Listeners\Authentication\FailedLoginListener;
use App\Listeners\Authentication\LoginListener;
use App\Models\User;
use App\Notifications\FailedLogin;
use App\Notifications\SignedInWithNewDevice;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Notification;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\FeatureTestCase;
@ -21,6 +26,8 @@ use Tests\FeatureTestCase;
#[CoversClass(RejectIfReverseProxy::class)]
#[CoversClass(RejectIfDemoMode::class)]
#[CoversClass(SkipIfAuthenticated::class)]
#[CoversClass(LoginListener::class)]
#[CoversClass(FailedLoginListener::class)]
class LoginTest extends FeatureTestCase
{
/**
@ -70,6 +77,63 @@ class LoginTest extends FeatureTestCase
]);
}
/**
* @test
*/
public function test_login_send_new_device_notification()
{
Notification::fake();
$this->json('POST', '/user/login', [
'email' => $this->user->email,
'password' => self::PASSWORD,
])->assertOk();
$this->actingAs($this->user, 'web-guard')
->json('GET', '/user/logout');
$this->travel(1)->minute();
$this->json('POST', '/user/login', [
'email' => $this->user->email,
'password' => self::PASSWORD,
], [
'HTTP_USER_AGENT' => 'NotSymfony'
])->assertOk();
Notification::assertSentTo($this->user, SignedInWithNewDevice::class);
}
/**
* @test
*/
public function test_login_does_not_send_new_device_notification()
{
Notification::fake();
$this->user['preferences->notifyOnNewAuthDevice'] = 0;
$this->user->save();
$this->json('POST', '/user/login', [
'email' => $this->user->email,
'password' => self::PASSWORD,
])->assertOk();
$this->actingAs($this->user, 'web-guard')
->json('GET', '/user/logout');
$this->travel(1)->minute();
$this->json('POST', '/user/login', [
'email' => $this->user->email,
'password' => self::PASSWORD,
], [
'HTTP_USER_AGENT' => 'NotSymfony'
])->assertOk();
Notification::assertNothingSentTo($this->user);
}
/**
* @test
*/
@ -164,6 +228,39 @@ class LoginTest extends FeatureTestCase
]);
}
/**
* @test
*/
public function test_login_with_invalid_credentials_send_failed_login_notification()
{
Notification::fake();
$this->json('POST', '/user/login', [
'email' => $this->user->email,
'password' => self::WRONG_PASSWORD,
])->assertStatus(401);
Notification::assertSentTo($this->user, FailedLogin::class);
}
/**
* @test
*/
public function test_login_with_invalid_credentials_does_not_send_new_device_notification()
{
Notification::fake();
$this->user['preferences->notifyOnFailedLogin'] = 0;
$this->user->save();
$this->json('POST', '/user/login', [
'email' => $this->user->email,
'password' => self::WRONG_PASSWORD,
])->assertStatus(401);
Notification::assertNothingSentTo($this->user);
}
/**
* @test
*/

View File

@ -2,6 +2,7 @@
namespace Tests\Feature\Models;
use App\Models\AuthLog;
use App\Models\Group;
use App\Models\TwoFAccount;
use App\Models\User;
@ -113,6 +114,7 @@ class UserModelTest extends FeatureTestCase
{
$user = User::factory()->create();
TwoFAccount::factory()->for($user)->create();
AuthLog::factory()->for($user, 'authenticatable')->create();
Group::factory()->for($user)->create();
DB::table('webauthn_credentials')->insert([
@ -154,6 +156,9 @@ class UserModelTest extends FeatureTestCase
$this->assertDatabaseMissing(config('auth.passwords.users.table'), [
'email' => $user->email,
]);
$this->assertDatabaseMissing('auth_logs', [
'authenticatable_id' => $user->id,
]);
}
/**

View File

@ -0,0 +1,29 @@
<?php
namespace Tests\Unit\Listeners\Authentication;
use App\Listeners\Authentication\FailedLoginListener;
use Illuminate\Auth\Events\Failed;
use Illuminate\Support\Facades\Event;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCase;
/**
* FailedLoginListenerTest test class
*/
#[CoversClass(FailedLoginListener::class)]
class FailedLoginListenerTest extends TestCase
{
/**
* @test
*/
public function test_FailedLoginListener_listen_to_Failed_event()
{
Event::fake();
Event::assertListening(
Failed::class,
FailedLoginListener::class
);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace Tests\Unit\Listeners\Authentication;
use App\Listeners\Authentication\LoginListener;
use App\Models\User;
use Illuminate\Auth\Events\Login;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Mockery;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCase;
/**
* LoginListenerTest test class
*/
#[CoversClass(LoginListener::class)]
class LoginListenerTest extends TestCase
{
/**
* @test
*/
public function test_LoginListener_listen_to_Login_event()
{
Event::fake();
Event::assertListening(
Login::class,
LoginListener::class
);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace Tests\Unit\Listeners\Authentication;
use App\Listeners\Authentication\LogoutListener;
use Illuminate\Auth\Events\Logout;
use Illuminate\Support\Facades\Event;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCase;
/**
* LogoutListenerTest test class
*/
#[CoversClass(LogoutListener::class)]
class LogoutListenerTest extends TestCase
{
/**
* @test
*/
public function test_LogoutListener_listen_to_Logout_event()
{
Event::fake();
Event::assertListening(
Logout::class,
LogoutListener::class
);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace Tests\Unit\Listeners\Authentication;
use App\Events\VisitedByProxyUser;
use App\Listeners\Authentication\VisitedByProxyUserListener;
use Illuminate\Support\Facades\Event;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCase;
/**
* VisitedByProxyUserListenerTest test class
*/
#[CoversClass(VisitedByProxyUserListener::class)]
class VisitedByProxyUserListenerTest extends TestCase
{
/**
* @test
*/
public function test_VisitedByProxyUserListener_listen_to_VisitedByProxyUser_event()
{
Event::fake();
Event::assertListening(
VisitedByProxyUser::class,
VisitedByProxyUserListener::class
);
}
}

10
vite.config.js vendored
View File

@ -78,9 +78,9 @@ export default defineConfig({
},
},
},
server: {
watch: {
followSymlinks: false,
}
}
// server: {
// watch: {
// followSymlinks: false,
// }
// }
});