diff --git a/bin/quickstart.sh b/bin/quickstart.sh index f77316df..40c25b6f 100755 --- a/bin/quickstart.sh +++ b/bin/quickstart.sh @@ -1,96 +1,104 @@ #!/bin/bash set -e function die() { echo "$1" exit 1 } rpm -qv composer >/dev/null 2>&1 || \ test ! -z "$(which composer 2>/dev/null)" || \ die "Is composer installed?" rpm -qv docker-compose >/dev/null 2>&1 || \ test ! -z "$(which docker-compose 2>/dev/null)" || \ die "Is docker-compose installed?" rpm -qv npm >/dev/null 2>&1 || \ test ! -z "$(which npm 2>/dev/null)" || \ die "Is npm installed?" rpm -qv php >/dev/null 2>&1 || \ test ! -z "$(which php 2>/dev/null)" || \ die "Is php installed?" rpm -qv php-ldap >/dev/null 2>&1 || \ test ! -z "$(php --ini | grep ldap)" || \ die "Is php-ldap installed?" rpm -qv php-mysqlnd >/dev/null 2>&1 || \ test ! -z "$(php --ini | grep mysql)" || \ die "Is php-mysqlnd installed?" test ! -z "$(php --modules | grep swoole)" || \ die "Is swoole installed?" base_dir=$(dirname $(dirname $0)) docker pull docker.io/kolab/centos7:latest docker-compose down --remove-orphans docker-compose build pushd ${base_dir}/src/ -if [ ! -f ".env" ]; then - cp .env.example .env -fi +# Always reset .env with .env.example +cp .env.example .env if [ -f ".env.local" ]; then # Ensure there's a line ending echo "" >> .env cat .env.local >> .env fi popd bin/regen-certs docker-compose up -d coturn kolab mariadb openvidu kurento-media-server pdns-sql proxy redis pushd ${base_dir}/src/ rm -rf vendor/ composer.lock php -dmemory_limit=-1 /bin/composer install npm install find bootstrap/cache/ -type f ! -name ".gitignore" -delete ./artisan key:generate -./artisan jwt:secret -f ./artisan clear-compiled ./artisan cache:clear ./artisan horizon:install +if [ ! -f storage/oauth-public.key -o ! -f storage/oauth-private.key ]; then + ./artisan passport:keys --force +fi + +cat >> .env << EOF +PASSPORT_PRIVATE_KEY="$(cat storage/oauth-private.key)" +PASSPORT_PUBLIC_KEY="$(cat storage/oauth-public.key)" +EOF + + if [ ! -z "$(rpm -qv chromium 2>/dev/null)" ]; then chver=$(rpmquery --queryformat="%{VERSION}" chromium | awk -F'.' '{print $1}') ./artisan dusk:chrome-driver ${chver} fi if [ ! -f 'resources/countries.php' ]; then ./artisan data:countries fi npm run dev popd docker-compose up -d worker nginx pushd ${base_dir}/src/ rm -rf database/database.sqlite ./artisan db:ping --wait php -dmemory_limit=512M ./artisan migrate:refresh --seed ./artisan data:import ./artisan swoole:http stop >/dev/null 2>&1 || : ./artisan swoole:http start popd diff --git a/src/.env.example b/src/.env.example index b084509e..4cdd981c 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,162 +1,166 @@ APP_NAME=Kolab APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://127.0.0.1:8000 #APP_PASSPHRASE= APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com APP_THEME=default APP_TENANT_ID=5 APP_LOCALE=en APP_LOCALES=en,de APP_WITH_ADMIN=1 APP_WITH_RESELLER=1 APP_WITH_SERVICES=1 ASSET_URL=http://127.0.0.1:8000 WEBMAIL_URL=/apps SUPPORT_URL=/support SUPPORT_EMAIL= LOG_CHANNEL=stack LOG_SLOW_REQUESTS=5 DB_CONNECTION=mysql DB_DATABASE=kolabdev DB_HOST=127.0.0.1 DB_PASSWORD=kolab DB_PORT=3306 DB_USERNAME=kolabdev BROADCAST_DRIVER=redis CACHE_DRIVER=redis QUEUE_CONNECTION=redis SESSION_DRIVER=file SESSION_LIFETIME=120 OPENEXCHANGERATES_API_KEY="from openexchangerates.org" MFA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube MFA_TOTP_DIGITS=6 MFA_TOTP_INTERVAL=30 MFA_TOTP_DIGEST=sha1 IMAP_URI=ssl://127.0.0.1:11993 IMAP_ADMIN_LOGIN=cyrus-admin IMAP_ADMIN_PASSWORD=Welcome2KolabSystems IMAP_VERIFY_HOST=false IMAP_VERIFY_PEER=false LDAP_BASE_DN="dc=mgmt,dc=com" LDAP_DOMAIN_BASE_DN="ou=Domains,dc=mgmt,dc=com" LDAP_HOSTS=127.0.0.1 LDAP_PORT=389 LDAP_SERVICE_BIND_DN="uid=kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_SERVICE_BIND_PW="Welcome2KolabSystems" LDAP_USE_SSL=false LDAP_USE_TLS=false # Administrative LDAP_ADMIN_BIND_DN="cn=Directory Manager" LDAP_ADMIN_BIND_PW="Welcome2KolabSystems" LDAP_ADMIN_ROOT_DN="dc=mgmt,dc=com" # Hosted (public registration) LDAP_HOSTED_BIND_DN="uid=hosted-kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_HOSTED_BIND_PW="Welcome2KolabSystems" LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com" OPENVIDU_API_PASSWORD=MY_SECRET OPENVIDU_API_URL=http://localhost:8080/api/ OPENVIDU_API_USERNAME=OPENVIDUAPP OPENVIDU_API_VERIFY_TLS=true OPENVIDU_COTURN_IP=127.0.0.1 OPENVIDU_COTURN_REDIS_DATABASE=2 OPENVIDU_COTURN_REDIS_IP=127.0.0.1 OPENVIDU_COTURN_REDIS_PASSWORD=turn # Used as COTURN_IP, TURN_PUBLIC_IP, for KMS_TURN_URL OPENVIDU_PUBLIC_IP=127.0.0.1 OPENVIDU_PUBLIC_PORT=3478 OPENVIDU_SERVER_PORT=8080 OPENVIDU_WEBHOOK=true OPENVIDU_WEBHOOK_ENDPOINT=http://127.0.0.1:8000/webhooks/meet/openvidu # "CDR" events, see https://docs.openvidu.io/en/2.13.0/reference-docs/openvidu-server-cdr/ #OPENVIDU_WEBHOOK_EVENTS=[sessionCreated,sessionDestroyed,participantJoined,participantLeft,webrtcConnectionCreated,webrtcConnectionDestroyed,recordingStatusChanged,filterEventDispatched,mediaNodeStatusChanged] #OPENVIDU_WEBHOOK_HEADERS=[\"Authorization:\ Basic\ SOMETHING\"] PGP_ENABLED= PGP_BINARY= PGP_AGENT= PGP_GPGCONF= PGP_LENGTH= REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 SWOOLE_HOT_RELOAD_ENABLE=true SWOOLE_HTTP_ACCESS_LOG=true SWOOLE_HTTP_HOST=127.0.0.1 SWOOLE_HTTP_PORT=8000 SWOOLE_HTTP_REACTOR_NUM=1 SWOOLE_HTTP_WEBSOCKET=true SWOOLE_HTTP_WORKER_NUM=1 SWOOLE_OB_OUTPUT=true PAYMENT_PROVIDER= MOLLIE_KEY= STRIPE_KEY= STRIPE_PUBLIC_KEY= STRIPE_WEBHOOK_SECRET= MAIL_DRIVER=smtp MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="noreply@example.com" MAIL_FROM_NAME="Example.com" MAIL_REPLYTO_ADDRESS="replyto@example.com" MAIL_REPLYTO_NAME=null DNS_TTL=3600 DNS_SPF="v=spf1 mx -all" DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com." DNS_COPY_FROM=null AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= PUSHER_APP_ID= PUSHER_APP_KEY= PUSHER_APP_SECRET= PUSHER_APP_CLUSTER=mt1 MIX_ASSET_PATH='/' MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" -JWT_SECRET= -JWT_TTL=60 +# Generate with ./artisan passport:client --password +#PASSPORT_PROXY_OAUTH_CLIENT_ID= +#PASSPORT_PROXY_OAUTH_CLIENT_SECRET= + +PASSPORT_PRIVATE_KEY= +PASSPORT_PUBLIC_KEY= COMPANY_NAME= COMPANY_ADDRESS= COMPANY_DETAILS= COMPANY_EMAIL= COMPANY_LOGO= COMPANY_FOOTER= VAT_COUNTRIES=CH,LI VAT_RATE=7.7 KB_ACCOUNT_DELETE= KB_ACCOUNT_SUSPENDED= diff --git a/src/app/Auth/LDAPUserProvider.php b/src/app/Auth/LDAPUserProvider.php index 6c9a21c2..3ee1d3e7 100644 --- a/src/app/Auth/LDAPUserProvider.php +++ b/src/app/Auth/LDAPUserProvider.php @@ -1,101 +1,54 @@ get(); $count = $entries->count(); if ($count == 1) { return $entries->first(); } if ($count > 1) { \Log::warning("Multiple entries for {$credentials['email']}"); } else { \Log::warning("No entries for {$credentials['email']}"); } return null; } /** * Validate the credentials for a user. * * @param Authenticatable $user The user. * @param array $credentials The credentials. * * @return bool */ public function validateCredentials(Authenticatable $user, array $credentials): bool { - $authenticated = false; - - if ($user->email === \strtolower($credentials['email'])) { - if (!empty($user->password)) { - if (Hash::check($credentials['password'], $user->password)) { - $authenticated = true; - } - } elseif (!empty($user->password_ldap)) { - if (substr($user->password_ldap, 0, 6) == "{SSHA}") { - $salt = substr(base64_decode(substr($user->password_ldap, 6)), 20); - - $hash = '{SSHA}' . base64_encode( - sha1($credentials['password'] . $salt, true) . $salt - ); - - if ($hash == $user->password_ldap) { - $authenticated = true; - } - } elseif (substr($user->password_ldap, 0, 9) == "{SSHA512}") { - $salt = substr(base64_decode(substr($user->password_ldap, 9)), 64); - - $hash = '{SSHA512}' . base64_encode( - pack('H*', hash('sha512', $credentials['password'] . $salt)) . $salt - ); - - if ($hash == $user->password_ldap) { - $authenticated = true; - } - } - } else { - \Log::error("Incomplete credentials for {$user->email}"); - } - } - - if ($authenticated) { - \Log::info("Successful authentication for {$user->email}"); - - // TODO: update last login time - if (empty($user->password) || empty($user->password_ldap)) { - $user->password = $credentials['password']; - $user->save(); - } - } else { - // TODO: Try actual LDAP? - \Log::info("Authentication failed for {$user->email}"); - } - - return $authenticated; + return $user->validateCredentials($credentials['email'], $credentials['password']); } } diff --git a/src/app/Auth/SecondFactor.php b/src/app/Auth/SecondFactor.php index af928e5c..ca066d6a 100644 --- a/src/app/Auth/SecondFactor.php +++ b/src/app/Auth/SecondFactor.php @@ -1,311 +1,321 @@ [], ]; /** * Class constructor * * @param \App\User $user User object */ public function __construct($user) { $this->user = $user; parent::__construct(); } /** * Validate 2-factor authentication code * - * @param \Illuminate\Http\Request $request The API request. + * @param string $secondfactor The 2-factor authentication code. * - * @return \Illuminate\Http\JsonResponse|null + * @throws \Exception on validation failure */ - public function requestHandler($request) + public function validate($secondfactor): void { // get list of configured authentication factors $factors = $this->factors(); // do nothing if no factors configured if (empty($factors)) { - return null; + return; } - if (empty($request->secondfactor) || !is_string($request->secondfactor)) { - $errors = ['secondfactor' => \trans('validation.2fareq')]; - return response()->json(['status' => 'error', 'errors' => $errors], 422); + if (empty($secondfactor) || !is_string($secondfactor)) { + throw new \Exception(\trans('validation.2fareq')); } // try to verify each configured factor foreach ($factors as $factor) { // verify the submitted code - // if (strpos($factor, 'dummy:') === 0 && (\app('env') != 'production') { - // if ($request->secondfactor === 'dummy') { - // return null; - // } - // } else - if ($this->verify($factor, $request->secondfactor)) { - return null; + if ($this->verify($factor, $secondfactor)) { + return; } } + throw new \Exception(\trans('validation.2fainvalid')); + } - $errors = ['secondfactor' => \trans('validation.2fainvalid')]; - return response()->json(['status' => 'error', 'errors' => $errors], 422); + /** + * Validate 2-factor authentication code + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse|null + */ + public function requestHandler(\Illuminate\Http\Request $request) + { + try { + $this->validate($request->secondfactor); + } catch (\Exception $e) { + $errors = ['secondfactor' => $e->getMessage()]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + return null; } /** * Remove all configured 2FA methods for the current user * * @return bool True on success, False otherwise */ public function removeFactors(): bool { $this->cache = []; $prefs = []; $prefs[$this->key2property('blob')] = null; $prefs[$this->key2property('factors')] = null; return $this->savePrefs($prefs); } /** * Returns a list of 2nd factor methods configured for the user */ public function factors(): array { // First check if the user has the 2FA SKU if ($this->user->hasSku('2fa')) { $factors = (array) $this->enumerate(); $factors = array_unique($factors); return $factors; } return []; } /** * Helper method to verify the given method/code tuple * * @param string $factor Factor identifier (:) * @param string $code Authentication code * * @return bool True on successful validation */ protected function verify($factor, $code): bool { $driver = $this->getDriver($factor); return $driver->verify($code, time()); } /** * Load driver class for the given authentication factor * * @param string $factor Factor identifier (:) * * @return \Kolab2FA\Driver\Base */ protected function getDriver(string $factor) { list($method) = explode(':', $factor, 2); $config = \config('2fa.' . $method, []); $driver = \Kolab2FA\Driver\Base::factory($factor, $config); // configure driver $driver->storage = $this; $driver->username = $this->user->email; return $driver; } /** * Helper for seeding a Roundcube account with 2FA setup * for testing. * * @param string $email Email address */ public static function seed(string $email): void { $config = [ 'kolab_2fa_blob' => [ 'totp:8132a46b1f741f88de25f47e' => [ 'label' => 'Mobile app (TOTP)', 'created' => 1584573552, 'secret' => 'UAF477LDHZNWVLNA', 'active' => true, ], // 'dummy:dummy' => [ // 'active' => true, // ], ], 'kolab_2fa_factors' => [ 'totp:8132a46b1f741f88de25f47e', // 'dummy:dummy', ] ]; self::dbh()->table('users')->updateOrInsert( ['username' => $email, 'mail_host' => '127.0.0.1'], ['preferences' => serialize($config)] ); } /** * Helper for generating current TOTP code for a test user * * @param string $email Email address * * @return string Generated code */ public static function code(string $email): string { $sf = new self(\App\User::where('email', $email)->first()); $driver = $sf->getDriver('totp:8132a46b1f741f88de25f47e'); return (string) $driver->get_code(); } //****************************************************** // Methods required by Kolab2FA Storage Base //****************************************************** /** * Initialize the storage driver with the given config options */ public function init(array $config) { $this->config = array_merge($this->config, $config); } /** * List methods activated for this user */ public function enumerate() { if ($factors = $this->getFactors()) { return array_keys(array_filter($factors, function ($prop) { return !empty($prop['active']); })); } return []; } /** * Read data for the given key */ public function read($key) { if (!isset($this->cache[$key])) { $factors = $this->getFactors(); $this->cache[$key] = isset($factors[$key]) ? $factors[$key] : null; } return $this->cache[$key]; } /** * Save data for the given key */ public function write($key, $value) { \Log::debug(__METHOD__ . ' ' . @json_encode($value)); // TODO: Not implemented return false; } /** * Remove the data stored for the given key */ public function remove($key) { return $this->write($key, null); } /** * */ protected function getFactors(): array { $prefs = $this->getPrefs(); $key = $this->key2property('blob'); return isset($prefs[$key]) ? (array) $prefs[$key] : []; } /** * */ protected function key2property($key) { // map key to configured property name if (is_array($this->config['keymap']) && isset($this->config['keymap'][$key])) { return $this->config['keymap'][$key]; } // default return 'kolab_2fa_' . $key; } /** * Gets user preferences from Roundcube users table */ protected function getPrefs() { $user = $this->dbh()->table('users') ->select('preferences') ->where('username', strtolower($this->user->email)) ->first(); return $user ? (array) unserialize($user->preferences) : null; } /** * Saves user preferences in Roundcube users table. * This will merge into old preferences */ protected function savePrefs($prefs) { $old_prefs = $this->getPrefs(); if (!is_array($old_prefs)) { return false; } $prefs = array_merge($old_prefs, $prefs); $this->dbh()->table('users') ->where('username', strtolower($this->user->email)) ->update(['preferences' => serialize($prefs)]); return true; } /** * Init connection to the Roundcube database */ public static function dbh() { return \App\Backends\Roundcube::dbh(); } } diff --git a/src/app/Exceptions/Handler.php b/src/app/Exceptions/Handler.php index 31aa564c..725cbd70 100644 --- a/src/app/Exceptions/Handler.php +++ b/src/app/Exceptions/Handler.php @@ -1,77 +1,78 @@ 0) { DB::rollBack(); } return parent::render($request, $exception); } /** * Convert an authentication exception into a response. * * @param \Illuminate\Http\Request $request * @param \Illuminate\Auth\AuthenticationException $exception * * @return \Symfony\Component\HttpFoundation\Response */ protected function unauthenticated($request, AuthenticationException $exception) { if ($request->expectsJson()) { return response()->json(['message' => $exception->getMessage()], 401); } abort(401); } } diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php index b92194c2..4c39c5e9 100644 --- a/src/app/Http/Controllers/API/AuthController.php +++ b/src/app/Http/Controllers/API/AuthController.php @@ -1,130 +1,174 @@ user(); $response = V4\UsersController::userResponse($user); - if (!empty(request()->input('refresh_token'))) { - // @phpstan-ignore-next-line - return $this->respondWithToken(Auth::guard()->refresh(), $response); + if (!empty(request()->input('refresh'))) { + return $this->refreshAndRespond(request(), $response); } return response()->json($response); } /** * Helper method for other controllers with user auto-logon * functionality * - * @param \App\User $user User model object + * @param \App\User $user User model object + * @param string $password Plain text password + * @param string|null $secondFactor Second factor code if available */ - public static function logonResponse(User $user) + public static function logonResponse(User $user, string $password, string $secondFactor = null) { - // @phpstan-ignore-next-line - $token = Auth::guard()->login($user); + $proxyRequest = Request::create('/oauth/token', 'POST', [ + 'username' => $user->email, + 'password' => $password, + 'grant_type' => 'password', + 'client_id' => \config('auth.proxy.client_id'), + 'client_secret' => \config('auth.proxy.client_secret'), + 'scopes' => '[*]', + 'secondfactor' => $secondFactor + ]); + + $tokenResponse = app()->handle($proxyRequest); + $response = V4\UsersController::userResponse($user); $response['status'] = 'success'; - return self::respondWithToken($token, $response); + return self::respondWithToken($tokenResponse, $response); } /** - * Get a JWT token via given credentials. + * Get an oauth token via given credentials. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse */ public function login(Request $request) { // TODO: Redirect to dashboard if authenticated. $v = Validator::make( $request->all(), [ 'email' => 'required|min:2', 'password' => 'required|min:4', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } - $credentials = $request->only('email', 'password'); - - if ($token = Auth::guard()->attempt($credentials)) { - $user = Auth::guard()->user(); - $sf = new \App\Auth\SecondFactor($user); - - if ($response = $sf->requestHandler($request)) { - return $response; - } - - $response = V4\UsersController::userResponse($user); + $user = \App\User::where('email', $request->email)->first(); - return $this->respondWithToken($token, $response); + if (!$user) { + return response()->json(['status' => 'error', 'message' => __('auth.failed')], 401); } - return response()->json(['status' => 'error', 'message' => __('auth.failed')], 401); + return self::logonResponse($user, $request->password, $request->secondfactor); } /** * Log the user out (Invalidate the token) * * @return \Illuminate\Http\JsonResponse */ public function logout() { - Auth::guard()->logout(); + $tokenId = Auth::user()->token()->id; + $tokenRepository = app(TokenRepository::class); + $refreshTokenRepository = app(RefreshTokenRepository::class); + + // Revoke an access token... + $tokenRepository->revokeAccessToken($tokenId); + + // Revoke all of the token's refresh tokens... + $refreshTokenRepository->revokeRefreshTokensByAccessTokenId($tokenId); return response()->json([ 'status' => 'success', 'message' => __('auth.logoutsuccess') ]); } /** * Refresh a token. * * @return \Illuminate\Http\JsonResponse */ - public function refresh() + public function refresh(Request $request) { - // @phpstan-ignore-next-line - return $this->respondWithToken(Auth::guard()->refresh()); + return self::refreshAndRespond($request); + } + + /** + * Refresh the token and respond with it. + * + * @param \Illuminate\Http\Request $request The API request. + * @param array $response Additional response data + * + * @return \Illuminate\Http\JsonResponse + */ + protected static function refreshAndRespond(Request $request, array $response = []) + { + $proxyRequest = Request::create('/oauth/token', 'POST', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $request->refresh_token, + 'client_id' => \config('auth.proxy.client_id'), + 'client_secret' => \config('auth.proxy.client_secret'), + ]); + + $tokenResponse = app()->handle($proxyRequest); + + return self::respondWithToken($tokenResponse, $response); } /** * Get the token array structure. * - * @param string $token Respond with this token. - * @param array $response Additional response data + * @param \Illuminate\Http\JsonResponse $tokenResponse The response containing the token. + * @param array $response Additional response data * * @return \Illuminate\Http\JsonResponse */ - protected static function respondWithToken($token, array $response = []) + protected static function respondWithToken($tokenResponse, array $response = []) { - $response['access_token'] = $token; + $data = json_decode($tokenResponse->getContent()); + + if ($tokenResponse->getStatusCode() != 200) { + if (isset($data->error) && $data->error == 'secondfactor') { + $errors = ['secondfactor' => $data['error_description']]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + return response()->json(['status' => 'error', 'message' => __('auth.failed')], 401); + } + + $response['access_token'] = $data->access_token; + $response['refresh_token'] = $data->refresh_token; $response['token_type'] = 'bearer'; - // @phpstan-ignore-next-line - $response['expires_in'] = Auth::guard()->factory()->getTTL() * 60; + $response['expires_in'] = $data->expires_in; return response()->json($response); } } diff --git a/src/app/Http/Controllers/API/PasswordResetController.php b/src/app/Http/Controllers/API/PasswordResetController.php index ba80fc3f..1676ec5e 100644 --- a/src/app/Http/Controllers/API/PasswordResetController.php +++ b/src/app/Http/Controllers/API/PasswordResetController.php @@ -1,143 +1,143 @@ all(), ['email' => 'required|email']); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Find a user by email $user = User::findByEmail($request->email); if (!$user) { $errors = ['email' => __('validation.usernotexists')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if (!$user->getSetting('external_email')) { $errors = ['email' => __('validation.noextemail')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Generate the verification code $code = new VerificationCode(['mode' => 'password-reset']); $user->verificationcodes()->save($code); // Send email/sms message PasswordResetEmail::dispatch($code); return response()->json(['status' => 'success', 'code' => $code->code]); } /** * Validation of the verification code. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function verify(Request $request) { // Validate the request args $v = Validator::make( $request->all(), [ 'code' => 'required', 'short_code' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate the verification code $code = VerificationCode::find($request->code); if ( empty($code) || $code->isExpired() || $code->mode !== 'password-reset' || Str::upper($request->short_code) !== Str::upper($code->short_code) ) { $errors = ['short_code' => "The code is invalid or expired."]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // For last-step remember the code object, so we can delete it // with single SQL query (->delete()) instead of two (::destroy()) $this->code = $code; // Return user name and email/phone from the codes database on success return response()->json(['status' => 'success']); } /** * Password change * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function reset(Request $request) { // Validate the request args $v = Validator::make( $request->all(), [ 'password' => 'required|min:4|confirmed', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $v = $this->verify($request); if ($v->status() !== 200) { return $v; } $user = $this->code->user; // Change the user password $user->setPasswordAttribute($request->password); $user->save(); // Remove the verification code $this->code->delete(); - return AuthController::logonResponse($user); + return AuthController::logonResponse($user, $request->password); } } diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php index d552b1c1..2c5cb7f5 100644 --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -1,448 +1,448 @@ orderByDesc('title')->get() ->map(function ($plan) use (&$plans) { $plans[] = [ 'title' => $plan->title, 'name' => $plan->name, 'button' => __('app.planbutton', ['plan' => $plan->name]), 'description' => $plan->description, ]; }); return response()->json(['status' => 'success', 'plans' => $plans]); } /** * Starts signup process. * * Verifies user name and email/phone, sends verification email/sms message. * Returns the verification code. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function init(Request $request) { // Check required fields $v = Validator::make( $request->all(), [ 'email' => 'required', 'first_name' => 'max:128', 'last_name' => 'max:128', 'plan' => 'nullable|alpha_num|max:128', 'voucher' => 'max:32', ] ); $is_phone = false; $errors = $v->fails() ? $v->errors()->toArray() : []; // Validate user email (or phone) if (empty($errors['email'])) { if ($error = $this->validatePhoneOrEmail($request->email, $is_phone)) { $errors['email'] = $error; } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Generate the verification code $code = SignupCode::create([ 'email' => $request->email, 'first_name' => $request->first_name, 'last_name' => $request->last_name, 'plan' => $request->plan, 'voucher' => $request->voucher, ]); // Send email/sms message if ($is_phone) { SignupVerificationSMS::dispatch($code); } else { SignupVerificationEmail::dispatch($code); } return response()->json(['status' => 'success', 'code' => $code->code]); } /** * Returns signup invitation information. * * @param string $id Signup invitation identifier * * @return \Illuminate\Http\JsonResponse|void */ public function invitation($id) { $invitation = SignupInvitation::withEnvTenantContext()->find($id); if (empty($invitation) || $invitation->isCompleted()) { return $this->errorResponse(404); } $has_domain = $this->getPlan()->hasDomain(); $result = [ 'id' => $id, 'is_domain' => $has_domain, 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]; return response()->json($result); } /** * Validation of the verification code. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function verify(Request $request) { // Validate the request args $v = Validator::make( $request->all(), [ 'code' => 'required', 'short_code' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate the verification code $code = SignupCode::find($request->code); if ( empty($code) || $code->isExpired() || Str::upper($request->short_code) !== Str::upper($code->short_code) ) { $errors = ['short_code' => "The code is invalid or expired."]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // For signup last-step mode remember the code object, so we can delete it // with single SQL query (->delete()) instead of two (::destroy()) $this->code = $code; $has_domain = $this->getPlan()->hasDomain(); // Return user name and email/phone/voucher from the codes database, // domains list for selection and "plan type" flag return response()->json([ 'status' => 'success', 'email' => $code->email, 'first_name' => $code->first_name, 'last_name' => $code->last_name, 'voucher' => $code->voucher, 'is_domain' => $has_domain, 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]); } /** * Finishes the signup process by creating the user account. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function signup(Request $request) { // Validate input $v = Validator::make( $request->all(), [ 'login' => 'required|min:2', 'password' => 'required|min:4|confirmed', 'domain' => 'required', 'voucher' => 'max:32', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Signup via invitation if ($request->invitation) { $invitation = SignupInvitation::withEnvTenantContext()->find($request->invitation); if (empty($invitation) || $invitation->isCompleted()) { return $this->errorResponse(404); } // Check required fields $v = Validator::make( $request->all(), [ 'first_name' => 'max:128', 'last_name' => 'max:128', 'voucher' => 'max:32', ] ); $errors = $v->fails() ? $v->errors()->toArray() : []; if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $settings = [ 'external_email' => $invitation->email, 'first_name' => $request->first_name, 'last_name' => $request->last_name, ]; } else { // Validate verification codes (again) $v = $this->verify($request); if ($v->status() !== 200) { return $v; } // Get user name/email from the verification code database $code_data = $v->getData(); $settings = [ 'external_email' => $code_data->email, 'first_name' => $code_data->first_name, 'last_name' => $code_data->last_name, ]; } // Find the voucher discount if ($request->voucher) { $discount = Discount::where('code', \strtoupper($request->voucher)) ->where('active', true)->first(); if (!$discount) { $errors = ['voucher' => \trans('validation.voucherinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } } // Get the plan $plan = $this->getPlan(); $is_domain = $plan->hasDomain(); $login = $request->login; $domain_name = $request->domain; // Validate login if ($errors = self::validateLogin($login, $domain_name, $is_domain)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // We allow only ASCII, so we can safely lower-case the email address $login = Str::lower($login); $domain_name = Str::lower($domain_name); $domain = null; DB::beginTransaction(); // Create domain record if ($is_domain) { $domain = Domain::create([ 'namespace' => $domain_name, 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); } // Create user record $user = User::create([ 'email' => $login . '@' . $domain_name, 'password' => $request->password, ]); if (!empty($discount)) { $wallet = $user->wallets()->first(); $wallet->discount()->associate($discount); $wallet->save(); } $user->assignPlan($plan, $domain); // Save the external email and plan in user settings $user->setSettings($settings); // Update the invitation if (!empty($invitation)) { $invitation->status = SignupInvitation::STATUS_COMPLETED; $invitation->user_id = $user->id; $invitation->save(); } // Remove the verification code if ($this->code) { $this->code->delete(); } DB::commit(); - return AuthController::logonResponse($user); + return AuthController::logonResponse($user, $request->password); } /** * Returns plan for the signup process * * @returns \App\Plan Plan object selected for current signup process */ protected function getPlan() { if (!$this->plan) { // Get the plan if specified and exists... if ($this->code && $this->code->plan) { $plan = Plan::withEnvTenantContext()->where('title', $this->code->plan)->first(); } // ...otherwise use the default plan if (empty($plan)) { // TODO: Get default plan title from config $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); } $this->plan = $plan; } return $this->plan; } /** * Checks if the input string is a valid email address or a phone number * * @param string $input Email address or phone number * @param bool $is_phone Will have been set to True if the string is valid phone number * * @return string Error message on validation error */ protected static function validatePhoneOrEmail($input, &$is_phone = false): ?string { $is_phone = false; $v = Validator::make( ['email' => $input], ['email' => ['required', 'string', new ExternalEmail()]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // TODO: Phone number support /* $input = str_replace(array('-', ' '), '', $input); if (!preg_match('/^\+?[0-9]{9,12}$/', $input)) { return \trans('validation.noemailorphone'); } $is_phone = true; */ return null; } /** * Login (kolab identity) validation * * @param string $login Login (local part of an email address) * @param string $domain Domain name * @param bool $external Enables additional checks for domain part * * @return array Error messages on validation error */ protected static function validateLogin($login, $domain, $external = false): ?array { // Validate login part alone $v = Validator::make( ['login' => $login], ['login' => ['required', 'string', new UserEmailLocal($external)]] ); if ($v->fails()) { return ['login' => $v->errors()->toArray()['login'][0]]; } $domains = $external ? null : Domain::getPublicDomains(); // Validate the domain $v = Validator::make( ['domain' => $domain], ['domain' => ['required', 'string', new UserEmailDomain($domains)]] ); if ($v->fails()) { return ['domain' => $v->errors()->toArray()['domain'][0]]; } $domain = Str::lower($domain); // Check if domain is already registered with us if ($external) { if (Domain::where('namespace', $domain)->first()) { return ['domain' => \trans('validation.domainexists')]; } } // Check if user with specified login already exists $email = $login . '@' . $domain; if (User::emailExists($email) || User::aliasExists($email) || \App\Group::emailExists($email)) { return ['login' => \trans('validation.loginexists')]; } return null; } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index 850962db..73539e90 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -1,148 +1,165 @@ format('Y-m-d h:i:s'); + } + return $entry; + }, $array); + return implode(', ', $serialized); } /** * Bootstrap any application services. * * @return void */ public function boot() { \App\Discount::observe(\App\Observers\DiscountObserver::class); \App\Domain::observe(\App\Observers\DomainObserver::class); \App\Entitlement::observe(\App\Observers\EntitlementObserver::class); \App\Group::observe(\App\Observers\GroupObserver::class); \App\OpenVidu\Connection::observe(\App\Observers\OpenVidu\ConnectionObserver::class); \App\Package::observe(\App\Observers\PackageObserver::class); \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\Plan::observe(\App\Observers\PlanObserver::class); \App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class); \App\SignupCode::observe(\App\Observers\SignupCodeObserver::class); \App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class); \App\Sku::observe(\App\Observers\SkuObserver::class); \App\Transaction::observe(\App\Observers\TransactionObserver::class); \App\User::observe(\App\Observers\UserObserver::class); \App\UserAlias::observe(\App\Observers\UserAliasObserver::class); \App\UserSetting::observe(\App\Observers\UserSettingObserver::class); \App\VerificationCode::observe(\App\Observers\VerificationCodeObserver::class); \App\Wallet::observe(\App\Observers\WalletObserver::class); \App\PowerDNS\Domain::observe(\App\Observers\PowerDNS\DomainObserver::class); \App\PowerDNS\Record::observe(\App\Observers\PowerDNS\RecordObserver::class); Schema::defaultStringLength(191); // Log SQL queries in debug mode if (\config('app.debug')) { DB::listen(function ($query) { \Log::debug( sprintf( '[SQL] %s [%s]: %.4f sec.', $query->sql, - implode(', ', $query->bindings), + self::serializeSQLBindings($query->bindings), $query->time / 1000 ) ); }); } // Register some template helpers Blade::directive( 'theme_asset', function ($path) { $path = trim($path, '/\'"'); return ""; } ); Builder::macro( 'withEnvTenantContext', function (string $table = null) { $tenantId = \config('app.tenant_id'); if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); Builder::macro( 'withObjectTenantContext', function (object $object, string $table = null) { $tenantId = $object->tenant_id; if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); Builder::macro( 'withSubjectTenantContext', function (string $table = null) { if ($user = auth()->user()) { $tenantId = $user->tenant_id; } else { $tenantId = \config('app.tenant_id'); } if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); // Query builder 'whereLike' mocro Builder::macro( 'whereLike', function (string $column, string $search, int $mode = 0) { $search = addcslashes($search, '%_'); switch ($mode) { case 2: $search .= '%'; break; case 1: $search = '%' . $search; break; default: $search = '%' . $search . '%'; } /** @var Builder $this */ return $this->where($column, 'like', $search); } ); } } diff --git a/src/app/Providers/AuthServiceProvider.php b/src/app/Providers/AuthServiceProvider.php index 41e8c870..a046289b 100644 --- a/src/app/Providers/AuthServiceProvider.php +++ b/src/app/Providers/AuthServiceProvider.php @@ -1,37 +1,57 @@ 'App\Policies\ModelPolicy', ]; /** * Register any authentication / authorization services. * * @return void */ public function boot() { $this->registerPolicies(); Auth::provider( 'ldap', function ($app, array $config) { return new LDAPUserProvider($app['hash'], $config['model']); } ); + + // Hashes all secrets and thus makes them non-recoverable + /* Passport::hashClientSecrets(); */ + // Only enable routes for access tokens + Passport::routes( + function ($router) { + $router->forAccessTokens(); + + // Override the default route to avoid rate-limiting. + \Route::post('/token', [ + 'uses' => 'AccessTokenController@issueToken', + 'as' => 'passport.token', + ]); + } + ); + + Passport::tokensExpireIn(now()->addMinutes(\config('auth.token_expiry_minutes'))); + Passport::refreshTokensExpireIn(now()->addMinutes(\config('auth.refresh_token_expiry_minutes'))); + Passport::personalAccessTokensExpireIn(now()->addMonths(6)); } } diff --git a/src/app/Providers/PassportServiceProvider.php b/src/app/Providers/PassportServiceProvider.php new file mode 100644 index 00000000..5e94b860 --- /dev/null +++ b/src/app/Providers/PassportServiceProvider.php @@ -0,0 +1,53 @@ +app->make(Bridge\ClientRepository::class), + $this->app->make(Bridge\AccessTokenRepository::class), + $this->app->make(Bridge\ScopeRepository::class), + $this->makeCryptKey('private'), + $this->makeEncryptionKey(app('encrypter')->getKey()) + ); + } + + + /** + * Create a Key instance for encrypting the refresh token + * + * Based on https://github.com/laravel/passport/pull/820 + * + * @param string $keyBytes + * @return \Defuse\Crypto\Key + */ + private function makeEncryptionKey($keyBytes) + { + // First, we will encode Laravel's encryption key into a format that the Defuse\Crypto\Key class can use, + // so we can instantiate a new Key object. We need to do this as the Key class has a private constructor method + // which means we cannot directly instantiate the class based on our Laravel encryption key. + $encryptionKeyAscii = EncryptionEncoding::saveBytesToChecksummedAsciiSafeString( + EncryptionKey::KEY_CURRENT_VERSION, + $keyBytes + ); + + // Instantiate a Key object so we can take advantage of significantly faster encryption/decryption + // from https://github.com/thephpleague/oauth2-server/pull/814. The improvement is 200x-300x faster. + return EncryptionKey::loadFromAsciiSafeString($encryptionKeyAscii); + } +} diff --git a/src/app/User.php b/src/app/User.php index a94d2d22..d40e7c16 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,799 +1,913 @@ belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Email aliases of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function aliases() { return $this->hasMany('App\UserAlias', 'user_id'); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } $wallet_id = $this->wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'fee' => $sku->pivot->fee(), 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] ); } } return $user; } /** * Assign a package plan to a user. * * @param \App\Plan $plan The plan to assign * @param \App\Domain $domain Optional domain object * * @return \App\User Self */ public function assignPlan($plan, $domain = null): User { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } return $this; } /** * Assign a Sku to a user. * * @param \App\Sku $sku The sku to assign. * @param int $count Count of entitlements to add * * @return \App\User Self * @throws \Exception */ public function assignSku(Sku $sku, int $count = 1): User { // TODO: I guess wallet could be parametrized in future $wallet = $this->wallet(); $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); while ($count > 0) { \App\Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, 'entitleable_type' => User::class ]); $exists++; $count--; } return $this; } /** * Check if current user can delete another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canDelete($object): bool { if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); // TODO: For now controller can delete/update the account owner, // this may change in future, controllers are not 0-regression feature return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); } /** * Check if current user can read data of another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { if ($this->role == 'admin') { return true; } if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } if ($object instanceof Wallet) { return $object->user_id == $this->id || $object->controllers->contains($this); } if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); return $wallet && ($this->wallets->contains($wallet) || $this->accounts->contains($wallet)); } /** * Check if current user can update data of another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'admin') { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } return $this->canDelete($object); } /** * Return the \App\Domain for this user. * * @return \App\Domain|null */ public function domain() { list($local, $domainName) = explode('@', $this->email); $domain = \App\Domain::withTrashed()->where('namespace', $domainName)->first(); return $domain; } /** * List the domains to which this user is entitled. * Note: Active public domains are also returned (for the user tenant). * * @return Domain[] List of Domain objects */ public function domains(): array { if ($this->tenant_id) { $domains = Domain::where('tenant_id', $this->tenant_id); } else { $domains = Domain::withEnvTenantContext(); } $domains = $domains->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) ->whereRaw(sprintf('(status & %s)', Domain::STATUS_ACTIVE)) ->get() ->all(); foreach ($this->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domains[] = $entitlement->entitleable; } } foreach ($this->accounts as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domains[] = $entitlement->entitleable; } } return $domains; } /** * The user entitlement. * * @return \Illuminate\Database\Eloquent\Relations\MorphOne */ public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Entitlements for this user. * * Note that these are entitlements that apply to the user account, and not entitlements that * this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement', 'entitleable_id', 'id') ->where('entitleable_type', User::class); } /** * Find whether an email address exists as a user (including deleted users). * * @param string $email Email address * @param bool $return_user Return User instance instead of boolean * * @return \App\User|bool True or User model object if found, False otherwise */ public static function emailExists(string $email, bool $return_user = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); $user = self::withTrashed()->where('email', $email)->first(); if ($user) { return $return_user ? $user : true; } return false; } /** * Helper to find user by email address, whether it is * main email address, alias or an external email. * * If there's more than one alias NULL will be returned. * * @param string $email Email address * @param bool $external Search also for an external email * - * @return \App\User User model object if found + * @return \App\User|null User model object if found */ public static function findByEmail(string $email, bool $external = false): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $aliases = UserAlias::where('alias', $email)->get(); if (count($aliases) == 1) { return $aliases->first()->user; } // TODO: External email return null; } - public function getJWTIdentifier() - { - return $this->getKey(); - } - public function getJWTCustomClaims() - { - return []; - } /** * Return groups controlled by the current user. * * @param bool $with_accounts Include groups assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function groups($with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return Group::select(['groups.*', 'entitlements.wallet_id']) ->distinct() ->join('entitlements', 'entitlements.entitleable_id', '=', 'groups.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', Group::class); } /** * Check if user has an entitlement for the specified SKU. * * @param string $title The SKU title * * @return bool True if specified SKU entitlement exists */ public function hasSku(string $title): bool { $sku = Sku::withObjectTenantContext($this)->where('title', $title)->first(); if (!$sku) { return false; } return $this->entitlements()->where('sku_id', $sku->id)->count() > 0; } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** * Returns whether this user is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this user is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * A shortcut to get the user name. * * @param bool $fallback Return " User" if there's no name * * @return string Full user name */ public function name(bool $fallback = false): string { $settings = $this->getSettings(['first_name', 'last_name']); $name = trim($settings['first_name'] . ' ' . $settings['last_name']); if (empty($name) && $fallback) { return trim(\trans('app.siteuser', ['site' => \App\Tenant::getConfig($this->tenant_id, 'app.name')])); } return $name; } /** * Remove a number of entitlements for the SKU. * * @param \App\Sku $sku The SKU * @param int $count The number of entitlements to remove * * @return User Self */ public function removeSku(Sku $sku, int $count = 1): User { $entitlements = $this->entitlements() ->where('sku_id', $sku->id) ->orderBy('cost', 'desc') ->orderBy('created_at') ->get(); $entitlements_count = count($entitlements); foreach ($entitlements as $entitlement) { if ($entitlements_count <= $sku->units_free) { continue; } if ($count > 0) { $entitlement->delete(); $entitlements_count--; $count--; } } return $this; } public function senderPolicyFrameworkWhitelist($clientName) { $setting = $this->getSetting('spf_whitelist'); if (!$setting) { return false; } $whitelist = json_decode($setting); $matchFound = false; foreach ($whitelist as $entry) { if (substr($entry, 0, 1) == '/') { $match = preg_match($entry, $clientName); if ($match) { $matchFound = true; } continue; } if (substr($entry, 0, 1) == '.') { if (substr($clientName, (-1 * strlen($entry))) == $entry) { $matchFound = true; } continue; } if ($entry == $clientName) { $matchFound = true; continue; } } return $matchFound; } /** * Any (additional) properties of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= User::STATUS_SUSPENDED; $this->save(); } /** * The tenant for this user account. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function tenant() { return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); } /** * Unsuspend this domain. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= User::STATUS_SUSPENDED; $this->save(); } /** * Return users controlled by the current user. * * @param bool $with_accounts Include users assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function users($with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return $this->select(['users.*', 'entitlements.wallet_id']) ->distinct() ->leftJoin('entitlements', 'entitlements.entitleable_id', '=', 'users.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', User::class); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Returns the wallet by which the user is controlled * * @return ?\App\Wallet A wallet object */ public function wallet(): ?Wallet { $entitlement = $this->entitlement()->withTrashed()->orderBy('created_at', 'desc')->first(); // TODO: No entitlement should not happen, but in tests we have // such cases, so we fallback to the user's wallet in this case return $entitlement ? $entitlement->wallet : $this->wallets()->first(); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordLdapAttribute($password) { $this->setPasswordAttribute($password); } /** * User status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_IMAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid user status: {$status}"); } $this->attributes['status'] = $new_status; } + + /** + * Validate the user credentials + * + * @param string $username The username. + * @param string $password The password in plain text. + * @param bool $updatePassword Store the password if currently empty + * + * @return bool true on success + */ + public function validateCredentials(string $username, string $password, bool $updatePassword = true): bool + { + $authenticated = false; + + if ($this->email === \strtolower($username)) { + if (!empty($this->password)) { + if (Hash::check($password, $this->password)) { + $authenticated = true; + } + } elseif (!empty($this->password_ldap)) { + if (substr($this->password_ldap, 0, 6) == "{SSHA}") { + $salt = substr(base64_decode(substr($this->password_ldap, 6)), 20); + + $hash = '{SSHA}' . base64_encode( + sha1($password . $salt, true) . $salt + ); + + if ($hash == $this->password_ldap) { + $authenticated = true; + } + } elseif (substr($this->password_ldap, 0, 9) == "{SSHA512}") { + $salt = substr(base64_decode(substr($this->password_ldap, 9)), 64); + + $hash = '{SSHA512}' . base64_encode( + pack('H*', hash('sha512', $password . $salt)) . $salt + ); + + if ($hash == $this->password_ldap) { + $authenticated = true; + } + } + } else { + \Log::error("Incomplete credentials for {$this->email}"); + } + } + + if ($authenticated) { + \Log::info("Successful authentication for {$this->email}"); + + // TODO: update last login time + if ($updatePassword && (empty($this->password) || empty($this->password_ldap))) { + $this->password = $password; + $this->save(); + } + } else { + // TODO: Try actual LDAP? + \Log::info("Authentication failed for {$this->email}"); + } + + return $authenticated; + } + + /** + * Retrieve and authenticate a user + * + * @param string $username The username. + * @param string $password The password in plain text. + * @param string $secondFactor The second factor (secondfactor from current request is used as fallback). + * + * @return array ['user', 'reason', 'errorMessage'] + */ + public static function findAndAuthenticate($username, $password, $secondFactor = null): ?array + { + $user = User::where('email', $username)->first(); + if (!$user) { + return ['reason' => 'notfound', 'errorMessage' => "User not found."]; + } + + if (!$user->validateCredentials($username, $password)) { + return ['reason' => 'credentials', 'errorMessage' => "Invalid password."]; + } + + + + if (!$secondFactor) { + // Check the request if there is a second factor provided + // as fallback. + $secondFactor = request()->secondfactor; + } + + try { + (new \App\Auth\SecondFactor($user))->validate($secondFactor); + } catch (\Exception $e) { + return ['reason' => 'secondfactor', 'errorMessage' => $e->getMessage()]; + } + + return ['user' => $user]; + } + + /** + * Hook for passport + * + * @throws \Throwable + * + * @return \App\User User model object if found + */ + public function findAndValidateForPassport($username, $password): User + { + $result = self::findAndAuthenticate($username, $password); + + if (isset($result['reason'])) { + if ($result['reason'] == 'secondfactor') { + // This results in a json response of {'error': 'secondfactor', 'error_description': '$errorMessage'} + throw new OAuthServerException($result['errorMessage'], 6, 'secondfactor', 401); + } + throw OAuthServerException::invalidCredentials(); + } + return $result['user']; + } } diff --git a/src/composer.json b/src/composer.json index da982cf5..fa399f1f 100644 --- a/src/composer.json +++ b/src/composer.json @@ -1,85 +1,86 @@ { "name": "laravel/laravel", "type": "project", "description": "The Laravel Framework.", "keywords": [ "framework", "laravel" ], "license": "MIT", "repositories": [ { "type": "vcs", "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git" } ], "require": { "php": "^7.3", "barryvdh/laravel-dompdf": "^0.8.6", "doctrine/dbal": "^2.13", "dyrynda/laravel-nullable-fields": "*", "fideloper/proxy": "^4.0", "guzzlehttp/guzzle": "^7.3", "kolab/net_ldap3": "dev-master", "laravel/framework": "6.*", "laravel/horizon": "^3", + "laravel/passport": "^9", "laravel/tinker": "^2.4", "mlocati/spf-lib": "^3.0", "mollie/laravel-mollie": "^2.9", + "moontoast/math": "^1.2", "morrislaptop/laravel-queue-clear": "^1.2", "pear/crypt_gpg": "dev-master", "silviolleite/laravelpwa": "^2.0", "spatie/laravel-translatable": "^4.2", "spomky-labs/otphp": "~4.0.0", "stripe/stripe-php": "^7.29", - "swooletw/laravel-swoole": "^2.6", - "tymon/jwt-auth": "^1.0" + "swooletw/laravel-swoole": "^2.6" }, "require-dev": { "beyondcode/laravel-er-diagram-generator": "^1.3", "code-lts/doctum": "^5.1", "kirschbaum-development/mail-intercept": "^0.2.4", "laravel/dusk": "~6.15.0", "nunomaduro/larastan": "^0.7", "phpstan/phpstan": "^0.12", "phpunit/phpunit": "^9" }, "config": { "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true }, "extra": { "laravel": { "dont-discover": [] } }, "autoload": { "psr-4": { "App\\": "app/" }, "classmap": [ "database/seeds", "include" ] }, "autoload-dev": { "psr-4": { "Tests\\": "tests/" } }, "minimum-stability": "dev", "prefer-stable": true, "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" ], "post-create-project-cmd": [ "@php artisan key:generate --ansi" ] } } diff --git a/src/config/app.php b/src/config/app.php index c175dc57..8927641b 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -1,286 +1,287 @@ env('APP_NAME', 'Laravel'), /* |-------------------------------------------------------------------------- | Application Environment |-------------------------------------------------------------------------- | | This value determines the "environment" your application is currently | running in. This may determine how you prefer to configure various | services the application utilizes. Set this in your ".env" file. | */ 'env' => env('APP_ENV', 'production'), /* |-------------------------------------------------------------------------- | Application Debug Mode |-------------------------------------------------------------------------- | | When your application is in debug mode, detailed error messages with | stack traces will be shown on every error that occurs within your | application. If disabled, a simple generic error page is shown. | */ 'debug' => env('APP_DEBUG', false), /* |-------------------------------------------------------------------------- | Application URL |-------------------------------------------------------------------------- | | This URL is used by the console to properly generate URLs when using | the Artisan command line tool. You should set this to the root of | your application so that it is used when running Artisan tasks. */ 'url' => env('APP_URL', 'http://localhost'), 'passphrase' => env('APP_PASSPHRASE', null), 'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')), 'asset_url' => env('ASSET_URL', null), 'support_url' => env('SUPPORT_URL', null), 'support_email' => env('SUPPORT_EMAIL', null), 'webmail_url' => env('WEBMAIL_URL', null), 'theme' => env('APP_THEME', 'default'), 'tenant_id' => env('APP_TENANT_ID', null), 'currency' => \strtoupper(env('APP_CURRENCY', 'CHF')), /* |-------------------------------------------------------------------------- | Application Domain |-------------------------------------------------------------------------- | | System domain used for user signup (kolab identity) */ 'domain' => env('APP_DOMAIN', 'domain.tld'), 'website_domain' => env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld')), /* |-------------------------------------------------------------------------- | Application Timezone |-------------------------------------------------------------------------- | | Here you may specify the default timezone for your application, which | will be used by the PHP date and date-time functions. We have gone | ahead and set this to a sensible default for you out of the box. | */ 'timezone' => 'UTC', /* |-------------------------------------------------------------------------- | Application Locale Configuration |-------------------------------------------------------------------------- | | The application locale determines the default locale that will be used | by the translation service provider. You are free to set this value | to any of the locales which will be supported by the application. | */ 'locale' => env('APP_LOCALE', 'en'), /* |-------------------------------------------------------------------------- | Application Fallback Locale |-------------------------------------------------------------------------- | | The fallback locale determines the locale to use when the current one | is not available. You may change the value to correspond to any of | the language folders that are provided through your application. | */ 'fallback_locale' => 'en', /* |-------------------------------------------------------------------------- | Faker Locale |-------------------------------------------------------------------------- | | This locale will be used by the Faker PHP library when generating fake | data for your database seeds. For example, this will be used to get | localized telephone numbers, street address information and more. | */ 'faker_locale' => 'en_US', /* |-------------------------------------------------------------------------- | Encryption Key |-------------------------------------------------------------------------- | | This key is used by the Illuminate encrypter service and should be set | to a random, 32 character string, otherwise these encrypted strings | will not be safe. Please do this before deploying an application! | */ 'key' => env('APP_KEY'), 'cipher' => 'AES-256-CBC', /* |-------------------------------------------------------------------------- | Autoloaded Service Providers |-------------------------------------------------------------------------- | | The service providers listed here will be automatically loaded on the | request to your application. Feel free to add your own services to | this array to grant expanded functionality to your applications. | */ 'providers' => [ /* * Laravel Framework Service Providers... */ Illuminate\Auth\AuthServiceProvider::class, Illuminate\Broadcasting\BroadcastServiceProvider::class, Illuminate\Bus\BusServiceProvider::class, Illuminate\Cache\CacheServiceProvider::class, Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, Illuminate\Cookie\CookieServiceProvider::class, Illuminate\Database\DatabaseServiceProvider::class, Illuminate\Encryption\EncryptionServiceProvider::class, Illuminate\Filesystem\FilesystemServiceProvider::class, Illuminate\Foundation\Providers\FoundationServiceProvider::class, Illuminate\Hashing\HashServiceProvider::class, Illuminate\Mail\MailServiceProvider::class, Illuminate\Notifications\NotificationServiceProvider::class, Illuminate\Pagination\PaginationServiceProvider::class, Illuminate\Pipeline\PipelineServiceProvider::class, Illuminate\Queue\QueueServiceProvider::class, Illuminate\Redis\RedisServiceProvider::class, Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, Illuminate\Session\SessionServiceProvider::class, Illuminate\Translation\TranslationServiceProvider::class, Illuminate\Validation\ValidationServiceProvider::class, Illuminate\View\ViewServiceProvider::class, /* * Package Service Providers... */ Barryvdh\DomPDF\ServiceProvider::class, /* * Application Service Providers... */ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, // App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\HorizonServiceProvider::class, + App\Providers\PassportServiceProvider::class, App\Providers\RouteServiceProvider::class, ], /* |-------------------------------------------------------------------------- | Class Aliases |-------------------------------------------------------------------------- | | This array of class aliases will be registered when this application | is started. However, feel free to register as many as you wish as | the aliases are "lazy" loaded so they don't hinder performance. | */ 'aliases' => [ 'App' => Illuminate\Support\Facades\App::class, 'Arr' => Illuminate\Support\Arr::class, 'Artisan' => Illuminate\Support\Facades\Artisan::class, 'Auth' => Illuminate\Support\Facades\Auth::class, 'Blade' => Illuminate\Support\Facades\Blade::class, 'Broadcast' => Illuminate\Support\Facades\Broadcast::class, 'Bus' => Illuminate\Support\Facades\Bus::class, 'Cache' => Illuminate\Support\Facades\Cache::class, 'Config' => Illuminate\Support\Facades\Config::class, 'Cookie' => Illuminate\Support\Facades\Cookie::class, 'Crypt' => Illuminate\Support\Facades\Crypt::class, 'DB' => Illuminate\Support\Facades\DB::class, 'Eloquent' => Illuminate\Database\Eloquent\Model::class, 'Event' => Illuminate\Support\Facades\Event::class, 'File' => Illuminate\Support\Facades\File::class, 'Gate' => Illuminate\Support\Facades\Gate::class, 'Hash' => Illuminate\Support\Facades\Hash::class, 'Lang' => Illuminate\Support\Facades\Lang::class, 'Log' => Illuminate\Support\Facades\Log::class, 'Mail' => Illuminate\Support\Facades\Mail::class, 'Notification' => Illuminate\Support\Facades\Notification::class, 'Password' => Illuminate\Support\Facades\Password::class, 'PDF' => Barryvdh\DomPDF\Facade::class, 'Queue' => Illuminate\Support\Facades\Queue::class, 'Redirect' => Illuminate\Support\Facades\Redirect::class, 'Redis' => Illuminate\Support\Facades\Redis::class, 'Request' => Illuminate\Support\Facades\Request::class, 'Response' => Illuminate\Support\Facades\Response::class, 'Route' => Illuminate\Support\Facades\Route::class, 'Schema' => Illuminate\Support\Facades\Schema::class, 'Session' => Illuminate\Support\Facades\Session::class, 'Storage' => Illuminate\Support\Facades\Storage::class, 'Str' => Illuminate\Support\Str::class, 'URL' => Illuminate\Support\Facades\URL::class, 'Validator' => Illuminate\Support\Facades\Validator::class, 'View' => Illuminate\Support\Facades\View::class, ], // Locations of knowledge base articles 'kb' => [ // An article about suspended accounts 'account_suspended' => env('KB_ACCOUNT_SUSPENDED'), // An article about a way to delete an owned account 'account_delete' => env('KB_ACCOUNT_DELETE'), ], 'company' => [ 'name' => env('COMPANY_NAME'), 'address' => env('COMPANY_ADDRESS'), 'details' => env('COMPANY_DETAILS'), 'email' => env('COMPANY_EMAIL'), 'logo' => env('COMPANY_LOGO'), 'footer' => env('COMPANY_FOOTER', env('COMPANY_DETAILS')), ], 'vat' => [ 'countries' => env('VAT_COUNTRIES'), 'rate' => (float) env('VAT_RATE'), ], 'payment' => [ 'methods_oneoff' => env('PAYMENT_METHODS_ONEOFF', "creditcard,paypal,banktransfer"), 'methods_recurring' => env('PAYMENT_METHODS_RECURRING', "creditcard"), ], 'with_admin' => (bool) env('APP_WITH_ADMIN', false), 'with_reseller' => (bool) env('APP_WITH_RESELLER', false), 'with_services' => (bool) env('APP_WITH_SERVICES', false), ]; diff --git a/src/config/auth.php b/src/config/auth.php index 729f3f9c..d72f2dc1 100644 --- a/src/config/auth.php +++ b/src/config/auth.php @@ -1,102 +1,123 @@ [ 'guard' => 'api', 'passwords' => 'users', ], /* |-------------------------------------------------------------------------- | Authentication Guards |-------------------------------------------------------------------------- | | Next, you may define every authentication guard for your application. | Of course, a great default configuration has been defined for you | here which uses session storage and the Eloquent user provider. | | All authentication drivers have a user provider. This defines how the | users are actually retrieved out of your database or other storage | mechanisms used by this application to persist your user's data. | | Supported: "session", "token" | */ 'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'users', ], 'api' => [ - 'driver' => 'jwt', + 'driver' => 'passport', 'provider' => 'users', ], ], /* |-------------------------------------------------------------------------- | User Providers |-------------------------------------------------------------------------- | | All authentication drivers have a user provider. This defines how the | users are actually retrieved out of your database or other storage | mechanisms used by this application to persist your user's data. | | If you have multiple user tables or models you may configure multiple | sources which represent each model / table. These sources may then | be assigned to any extra authentication guards you have defined. | | Supported: "database", "eloquent" | */ 'providers' => [ 'users' => [ 'driver' => 'ldap', 'model' => App\User::class, ], // 'users' => [ // 'driver' => 'database', // 'table' => 'users', // ], ], /* |-------------------------------------------------------------------------- | Resetting Passwords |-------------------------------------------------------------------------- | | You may specify multiple password reset configurations if you have more | than one user table or model in the application and you want to have | separate password reset settings based on the specific user types. | | The expire time is the number of minutes that the reset token should be | considered valid. This security feature keeps tokens short-lived so | they have less time to be guessed. You may change this as needed. | */ 'passwords' => [ 'users' => [ 'provider' => 'users', 'table' => 'password_resets', 'expire' => 60, ], ], + + /* + |-------------------------------------------------------------------------- + | OAuth Proxy Authentication + |-------------------------------------------------------------------------- + | + | If you are planning to use your application to self-authenticate as a + | proxy, you can define the client and grant type to use here. This is + | sometimes the case when a trusted Single Page Application doesn't + | use a backend to send the authentication request, but instead + | relies on the API to handle proxying the request to itself. + | + */ + + 'proxy' => [ + 'client_id' => env('PASSPORT_PROXY_OAUTH_CLIENT_ID'), + 'client_secret' => env('PASSPORT_PROXY_OAUTH_CLIENT_SECRET'), + ], + + 'token_expiry_minutes' => env('OAUTH_TOKEN_EXPIRY', 60), + 'refresh_token_expiry_minutes' => env('OAUTH_REFRESH_TOKEN_EXPIRY', 30 * 24 * 60), ]; diff --git a/src/config/jwt.php b/src/config/jwt.php deleted file mode 100644 index 8b7843b6..00000000 --- a/src/config/jwt.php +++ /dev/null @@ -1,304 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -return [ - - /* - |-------------------------------------------------------------------------- - | JWT Authentication Secret - |-------------------------------------------------------------------------- - | - | Don't forget to set this in your .env file, as it will be used to sign - | your tokens. A helper command is provided for this: - | `php artisan jwt:secret` - | - | Note: This will be used for Symmetric algorithms only (HMAC), - | since RSA and ECDSA use a private/public key combo (See below). - | - */ - - 'secret' => env('JWT_SECRET'), - - /* - |-------------------------------------------------------------------------- - | JWT Authentication Keys - |-------------------------------------------------------------------------- - | - | The algorithm you are using, will determine whether your tokens are - | signed with a random string (defined in `JWT_SECRET`) or using the - | following public & private keys. - | - | Symmetric Algorithms: - | HS256, HS384 & HS512 will use `JWT_SECRET`. - | - | Asymmetric Algorithms: - | RS256, RS384 & RS512 / ES256, ES384 & ES512 will use the keys below. - | - */ - - 'keys' => [ - - /* - |-------------------------------------------------------------------------- - | Public Key - |-------------------------------------------------------------------------- - | - | A path or resource to your public key. - | - | E.g. 'file://path/to/public/key' - | - */ - - 'public' => env('JWT_PUBLIC_KEY'), - - /* - |-------------------------------------------------------------------------- - | Private Key - |-------------------------------------------------------------------------- - | - | A path or resource to your private key. - | - | E.g. 'file://path/to/private/key' - | - */ - - 'private' => env('JWT_PRIVATE_KEY'), - - /* - |-------------------------------------------------------------------------- - | Passphrase - |-------------------------------------------------------------------------- - | - | The passphrase for your private key. Can be null if none set. - | - */ - - 'passphrase' => env('JWT_PASSPHRASE'), - - ], - - /* - |-------------------------------------------------------------------------- - | JWT time to live - |-------------------------------------------------------------------------- - | - | Specify the length of time (in minutes) that the token will be valid for. - | Defaults to 1 hour. - | - | You can also set this to null, to yield a never expiring token. - | Some people may want this behaviour for e.g. a mobile app. - | This is not particularly recommended, so make sure you have appropriate - | systems in place to revoke the token if necessary. - | Notice: If you set this to null you should remove 'exp' element from 'required_claims' list. - | - */ - - 'ttl' => env('JWT_TTL', 60), - - /* - |-------------------------------------------------------------------------- - | Refresh time to live - |-------------------------------------------------------------------------- - | - | Specify the length of time (in minutes) that the token can be refreshed - | within. I.E. The user can refresh their token within a 2 week window of - | the original token being created until they must re-authenticate. - | Defaults to 2 weeks. - | - | You can also set this to null, to yield an infinite refresh time. - | Some may want this instead of never expiring tokens for e.g. a mobile app. - | This is not particularly recommended, so make sure you have appropriate - | systems in place to revoke the token if necessary. - | - */ - - 'refresh_ttl' => env('JWT_REFRESH_TTL', 20160), - - /* - |-------------------------------------------------------------------------- - | JWT hashing algorithm - |-------------------------------------------------------------------------- - | - | Specify the hashing algorithm that will be used to sign the token. - | - | See here: https://github.com/namshi/jose/tree/master/src/Namshi/JOSE/Signer/OpenSSL - | for possible values. - | - */ - - 'algo' => env('JWT_ALGO', 'HS256'), - - /* - |-------------------------------------------------------------------------- - | Required Claims - |-------------------------------------------------------------------------- - | - | Specify the required claims that must exist in any token. - | A TokenInvalidException will be thrown if any of these claims are not - | present in the payload. - | - */ - - 'required_claims' => [ - 'iss', - 'iat', - 'exp', - 'nbf', - 'sub', - 'jti', - ], - - /* - |-------------------------------------------------------------------------- - | Persistent Claims - |-------------------------------------------------------------------------- - | - | Specify the claim keys to be persisted when refreshing a token. - | `sub` and `iat` will automatically be persisted, in - | addition to the these claims. - | - | Note: If a claim does not exist then it will be ignored. - | - */ - - 'persistent_claims' => [ - // 'foo', - // 'bar', - ], - - /* - |-------------------------------------------------------------------------- - | Lock Subject - |-------------------------------------------------------------------------- - | - | This will determine whether a `prv` claim is automatically added to - | the token. The purpose of this is to ensure that if you have multiple - | authentication models e.g. `App\User` & `App\OtherPerson`, then we - | should prevent one authentication request from impersonating another, - | if 2 tokens happen to have the same id across the 2 different models. - | - | Under specific circumstances, you may want to disable this behaviour - | e.g. if you only have one authentication model, then you would save - | a little on token size. - | - */ - - 'lock_subject' => true, - - /* - |-------------------------------------------------------------------------- - | Leeway - |-------------------------------------------------------------------------- - | - | This property gives the jwt timestamp claims some "leeway". - | Meaning that if you have any unavoidable slight clock skew on - | any of your servers then this will afford you some level of cushioning. - | - | This applies to the claims `iat`, `nbf` and `exp`. - | - | Specify in seconds - only if you know you need it. - | - */ - - 'leeway' => env('JWT_LEEWAY', 0), - - /* - |-------------------------------------------------------------------------- - | Blacklist Enabled - |-------------------------------------------------------------------------- - | - | In order to invalidate tokens, you must have the blacklist enabled. - | If you do not want or need this functionality, then set this to false. - | - */ - - 'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true), - - /* - | ------------------------------------------------------------------------- - | Blacklist Grace Period - | ------------------------------------------------------------------------- - | - | When multiple concurrent requests are made with the same JWT, - | it is possible that some of them fail, due to token regeneration - | on every request. - | - | Set grace period in seconds to prevent parallel request failure. - | - */ - - 'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD', 0), - - /* - |-------------------------------------------------------------------------- - | Cookies encryption - |-------------------------------------------------------------------------- - | - | By default Laravel encrypt cookies for security reason. - | If you decide to not decrypt cookies, you will have to configure Laravel - | to not encrypt your cookie token by adding its name into the $except - | array available in the middleware "EncryptCookies" provided by Laravel. - | see https://laravel.com/docs/master/responses#cookies-and-encryption - | for details. - | - | Set it to true if you want to decrypt cookies. - | - */ - - 'decrypt_cookies' => false, - - /* - |-------------------------------------------------------------------------- - | Providers - |-------------------------------------------------------------------------- - | - | Specify the various providers used throughout the package. - | - */ - - 'providers' => [ - - /* - |-------------------------------------------------------------------------- - | JWT Provider - |-------------------------------------------------------------------------- - | - | Specify the provider that is used to create and decode the tokens. - | - */ - - 'jwt' => Tymon\JWTAuth\Providers\JWT\Lcobucci::class, - - /* - |-------------------------------------------------------------------------- - | Authentication Provider - |-------------------------------------------------------------------------- - | - | Specify the provider that is used to authenticate users. - | - */ - - 'auth' => Tymon\JWTAuth\Providers\Auth\Illuminate::class, - - /* - |-------------------------------------------------------------------------- - | Storage Provider - |-------------------------------------------------------------------------- - | - | Specify the provider that is used to store tokens in the blacklist. - | - */ - - 'storage' => Tymon\JWTAuth\Providers\Storage\Illuminate::class, - - ], - -]; diff --git a/src/config/passport.php b/src/config/passport.php new file mode 100644 index 00000000..d0fd84ef --- /dev/null +++ b/src/config/passport.php @@ -0,0 +1,66 @@ + env('PASSPORT_PRIVATE_KEY'), + + 'public_key' => env('PASSPORT_PUBLIC_KEY'), + + /* + |-------------------------------------------------------------------------- + | Client UUIDs + |-------------------------------------------------------------------------- + | + | By default, Passport uses auto-incrementing primary keys when assigning + | IDs to clients. However, if Passport is installed using the provided + | --uuids switch, this will be set to "true" and UUIDs will be used. + | + */ + + 'client_uuids' => true, + + /* + |-------------------------------------------------------------------------- + | Personal Access Client + |-------------------------------------------------------------------------- + | + | If you enable client hashing, you should set the personal access client + | ID and unhashed secret within your environment file. The values will + | get used while issuing fresh personal access tokens to your users. + | + */ + + 'personal_access_client' => [ + 'id' => env('PASSPORT_PERSONAL_ACCESS_CLIENT_ID'), + 'secret' => env('PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET'), + ], + + /* + |-------------------------------------------------------------------------- + | Passport Storage Driver + |-------------------------------------------------------------------------- + | + | This configuration value allows you to customize the storage options + | for Passport, such as the database connection that should be used + | by Passport's internal database models which store tokens, etc. + | + */ + + 'storage' => [ + 'database' => [ + 'connection' => env('DB_CONNECTION', 'mysql'), + ], + ], + +]; diff --git a/src/config/swoole_http.php b/src/config/swoole_http.php index 27a27ace..2431c1c7 100644 --- a/src/config/swoole_http.php +++ b/src/config/swoole_http.php @@ -1,139 +1,141 @@ [ 'host' => env('SWOOLE_HTTP_HOST', '127.0.0.1'), 'port' => env('SWOOLE_HTTP_PORT', '1215'), 'public_path' => base_path('public'), // Determine if to use swoole to respond request for static files 'handle_static_files' => env('SWOOLE_HANDLE_STATIC', true), 'access_log' => env('SWOOLE_HTTP_ACCESS_LOG', false), // You must add --enable-openssl while compiling Swoole // Put `SWOOLE_SOCK_TCP | SWOOLE_SSL` if you want to enable SSL 'socket_type' => SWOOLE_SOCK_TCP, 'process_type' => SWOOLE_PROCESS, 'options' => [ 'pid_file' => env('SWOOLE_HTTP_PID_FILE', base_path('storage/logs/swoole_http.pid')), 'log_file' => env('SWOOLE_HTTP_LOG_FILE', base_path('storage/logs/swoole_http.log')), 'daemonize' => env('SWOOLE_HTTP_DAEMONIZE', false), // Normally this value should be 1~4 times larger according to your cpu cores. 'reactor_num' => env('SWOOLE_HTTP_REACTOR_NUM', swoole_cpu_num()), 'worker_num' => env('SWOOLE_HTTP_WORKER_NUM', swoole_cpu_num()), 'task_worker_num' => env('SWOOLE_HTTP_TASK_WORKER_NUM', swoole_cpu_num()), // The data to receive can't be larger than buffer_output_size. 'package_max_length' => 20 * 1024 * 1024, // The data to send can't be larger than buffer_output_size. 'buffer_output_size' => 10 * 1024 * 1024, // Max buffer size for socket connections 'socket_buffer_size' => 128 * 1024 * 1024, // Worker will restart after processing this number of requests 'max_request' => 3000, // Enable coroutine send 'send_yield' => true, // You must add --enable-openssl while compiling Swoole 'ssl_cert_file' => null, 'ssl_key_file' => null, ], ], /* |-------------------------------------------------------------------------- | Enable to turn on websocket server. |-------------------------------------------------------------------------- */ 'websocket' => [ 'enabled' => env('SWOOLE_HTTP_WEBSOCKET', false), ], /* |-------------------------------------------------------------------------- | Hot reload configuration |-------------------------------------------------------------------------- */ 'hot_reload' => [ 'enabled' => env('SWOOLE_HOT_RELOAD_ENABLE', false), 'recursively' => env('SWOOLE_HOT_RELOAD_RECURSIVELY', true), 'directory' => env('SWOOLE_HOT_RELOAD_DIRECTORY', base_path()), 'log' => env('SWOOLE_HOT_RELOAD_LOG', true), 'filter' => env('SWOOLE_HOT_RELOAD_FILTER', '.php'), ], /* |-------------------------------------------------------------------------- | Console output will be transferred to response content if enabled. |-------------------------------------------------------------------------- */ 'ob_output' => env('SWOOLE_OB_OUTPUT', true), /* |-------------------------------------------------------------------------- | Pre-resolved instances here will be resolved when sandbox created. |-------------------------------------------------------------------------- */ 'pre_resolved' => [ 'view', 'files', 'session', 'session.store', 'routes', 'db', 'db.factory', 'cache', 'cache.store', 'config', 'cookie', 'encrypter', 'hash', 'router', 'translator', 'url', 'log', ], /* |-------------------------------------------------------------------------- | Instances here will be cleared on every request. |-------------------------------------------------------------------------- */ 'instances' => [ 'auth', ], /* |-------------------------------------------------------------------------- | Providers here will be registered on every request. |-------------------------------------------------------------------------- */ 'providers' => [ Illuminate\Pagination\PaginationServiceProvider::class, App\Providers\AuthServiceProvider::class, - Tymon\JWTAuth\Providers\LaravelServiceProvider::class, + //Without this passport will sort of work, + //but PassportServiceProvider will not contain a valid app instance. + App\Providers\PassportServiceProvider::class, ], /* |-------------------------------------------------------------------------- | Resetters for sandbox app. |-------------------------------------------------------------------------- */ 'resetters' => [ SwooleTW\Http\Server\Resetters\ResetConfig::class, SwooleTW\Http\Server\Resetters\ResetSession::class, SwooleTW\Http\Server\Resetters\ResetCookie::class, SwooleTW\Http\Server\Resetters\ClearInstances::class, SwooleTW\Http\Server\Resetters\BindRequest::class, SwooleTW\Http\Server\Resetters\RebindKernelContainer::class, SwooleTW\Http\Server\Resetters\RebindRouterContainer::class, SwooleTW\Http\Server\Resetters\RebindViewContainer::class, SwooleTW\Http\Server\Resetters\ResetProviders::class, ], /* |-------------------------------------------------------------------------- | Define your swoole tables here. | | @see https://www.swoole.co.uk/docs/modules/swoole-table |-------------------------------------------------------------------------- */ 'tables' => [ // 'table_name' => [ // 'size' => 1024, // 'columns' => [ // ['name' => 'column_name', 'type' => Table::TYPE_STRING, 'size' => 1024], // ] // ], ], ]; diff --git a/src/database/migrations/2021_04_28_090011_create_oauth_tables.php b/src/database/migrations/2021_04_28_090011_create_oauth_tables.php new file mode 100644 index 00000000..109098de --- /dev/null +++ b/src/database/migrations/2021_04_28_090011_create_oauth_tables.php @@ -0,0 +1,122 @@ +uuid('id')->primary(); + $table->bigInteger('user_id')->nullable()->index(); + $table->string('name'); + $table->string('secret', 100)->nullable(); + $table->string('provider')->nullable(); + $table->text('redirect'); + $table->boolean('personal_access_client'); + $table->boolean('password_client'); + $table->boolean('revoked'); + $table->timestamps(); + + $table->foreign('user_id') + ->references('id')->on('users') + ->onDelete('cascade') + ->onUpdate('cascade'); + } + ); + + Schema::create( + 'oauth_personal_access_clients', + function (Blueprint $table) { + $table->bigIncrements('id'); + $table->uuid('client_id'); + $table->timestamps(); + + $table->foreign('client_id') + ->references('id')->on('oauth_clients') + ->onDelete('cascade') + ->onUpdate('cascade'); + } + ); + + Schema::create( + 'oauth_auth_codes', + function (Blueprint $table) { + $table->string('id', 100)->primary(); + $table->bigInteger('user_id')->index(); + $table->uuid('client_id'); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + + $table->foreign('user_id') + ->references('id')->on('users') + ->onDelete('cascade') + ->onUpdate('cascade'); + + $table->foreign('client_id') + ->references('id')->on('oauth_clients') + ->onDelete('cascade') + ->onUpdate('cascade'); + } + ); + + Schema::create( + 'oauth_access_tokens', + function (Blueprint $table) { + $table->string('id', 100)->primary(); + $table->bigInteger('user_id')->nullable()->index(); + $table->uuid('client_id'); + $table->string('name')->nullable(); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->timestamps(); + $table->dateTime('expires_at')->nullable(); + + $table->foreign('user_id') + ->references('id')->on('users') + ->onDelete('cascade') + ->onUpdate('cascade'); + + $table->foreign('client_id') + ->references('id')->on('oauth_clients') + ->onDelete('cascade') + ->onUpdate('cascade'); + } + ); + + Schema::create( + 'oauth_refresh_tokens', + function (Blueprint $table) { + $table->string('id', 100)->primary(); + $table->string('access_token_id', 100)->index(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('oauth_auth_codes'); + Schema::dropIfExists('oauth_refresh_tokens'); + Schema::dropIfExists('oauth_access_tokens'); + Schema::dropIfExists('oauth_personal_access_clients'); + Schema::dropIfExists('oauth_clients'); + } +} diff --git a/src/database/seeds/DatabaseSeeder.php b/src/database/seeds/DatabaseSeeder.php index fdd10515..9b05c0b1 100644 --- a/src/database/seeds/DatabaseSeeder.php +++ b/src/database/seeds/DatabaseSeeder.php @@ -1,41 +1,42 @@ $name) { $class = "Database\\Seeds\\$env\\$name"; $seeders[$idx] = class_exists($class) ? $class : null; } $seeders = array_filter($seeders); $this->call($seeders); } } diff --git a/src/database/seeds/local/OauthClientSeeder.php b/src/database/seeds/local/OauthClientSeeder.php new file mode 100644 index 00000000..6e9550c4 --- /dev/null +++ b/src/database/seeds/local/OauthClientSeeder.php @@ -0,0 +1,34 @@ +forceFill([ + 'user_id' => null, + 'name' => "Kolab Password Grant Client", + 'secret' => \config('auth.proxy.client_secret'), + 'provider' => 'users', + 'redirect' => 'https://' . \config('app.website_domain'), + 'personal_access_client' => 0, + 'password_client' => 1, + 'revoked' => false, + ]); + + $client->id = \config('auth.proxy.client_id'); + + $client->save(); + } +} diff --git a/src/resources/js/app.js b/src/resources/js/app.js index e41d9739..14bebaea 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,523 +1,525 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') import AppComponent from '../vue/App' import MenuComponent from '../vue/Widgets/Menu' import SupportForm from '../vue/Widgets/SupportForm' import store from './store' import { Tab } from 'bootstrap' import { loadLangAsync, i18n } from './locale' const loader = '
Loading
' let isLoading = 0 // Lock the UI with the 'loading...' element const startLoading = () => { isLoading++ let loading = $('#app > .app-loader').removeClass('fadeOut') if (!loading.length) { $('#app').append($(loader)) } } // Hide "loading" overlay const stopLoading = () => { if (isLoading > 0) { $('#app > .app-loader').addClass('fadeOut') isLoading--; } } let loadingRoute // Note: This has to be before the app is created // Note: You cannot use app inside of the function window.router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.meta.requiresAuth && !store.state.isLoggedIn) { // remember the original request, to use after login store.state.afterLogin = to; // redirect to login page next({ name: 'login' }) return } if (to.meta.loading) { startLoading() loadingRoute = to.name } next() }) window.router.afterEach((to, from) => { if (to.name && loadingRoute === to.name) { stopLoading() loadingRoute = null } // When changing a page remove old: // - error page // - modal backdrop $('#error-page,.modal-backdrop.show').remove() $('body').css('padding', 0) // remove padding added by unclosed modal }) const app = new Vue({ components: { AppComponent, MenuComponent, }, i18n, store, router: window.router, data() { return { isUser: !window.isAdmin && !window.isReseller, appName: window.config['app.name'], appUrl: window.config['app.url'], themeDir: '/themes/' + window.config['app.theme'] } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, hasPermission(type) { const authInfo = store.state.authInfo const key = 'enable' + type.charAt(0).toUpperCase() + type.slice(1) return !!(authInfo && authInfo.statusInfo[key]) }, hasRoute(name) { return this.$router.resolve({ name: name }).resolved.matched.length > 0 }, hasSKU(name) { const authInfo = store.state.authInfo return authInfo.statusInfo.skus && authInfo.statusInfo.skus.indexOf(name) != -1 }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(response, dashboard, update) { if (!update) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') } localStorage.setItem('token', response.access_token) + localStorage.setItem('refreshToken', response.refresh_token) axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token if (response.email) { store.state.authInfo = response } if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null // Refresh the token before it expires let timeout = response.expires_in || 0 // We'll refresh 60 seconds before the token expires if (timeout > 60) { timeout -= 60 } // TODO: We probably should try a few times in case of an error // TODO: We probably should prevent axios from doing any requests // while the token is being refreshed this.refreshTimeout = setTimeout(() => { - axios.post('/api/auth/refresh').then(response => { + axios.post('/api/auth/refresh', {'refresh_token': response.refresh_token}).then(response => { this.loginUser(response.data, false, true) }) }, timeout * 1000) }, // Set user state to "not logged in" logoutUser(redirect) { store.commit('logoutUser') localStorage.setItem('token', '') + localStorage.setItem('refreshToken', '') delete axios.defaults.headers.common.Authorization if (redirect !== false) { this.$router.push({ name: 'login' }) } clearTimeout(this.refreshTimeout) }, logo(mode) { let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png' return `${this.appName}` }, // Display "loading" overlay inside of the specified element addLoader(elem, small = true, style = null) { if (style) { $(elem).css(style) } else { $(elem).css('position', 'relative') } $(elem).append(small ? $(loader).addClass('small') : $(loader)) }, // Remove loader element added in addLoader() removeLoader(elem) { $(elem).find('.app-loader').remove() }, startLoading, stopLoading, isLoading() { return isLoading > 0 }, tab(e) { e.preventDefault() new Tab(e.target).show() }, errorPage(code, msg, hint) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". if (!msg) msg = this.$te('error.' + code) ? this.$t('error.' + code) : this.$t('error.unknown') if (!hint) hint = '' const error_page = '
' + `
${code}
${msg}
${hint}
` + '
' $('#error-page').remove() $('#app').append(error_page) app.updateBodyClass('error') }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { // Remember requested route to come back to it after log in if (this.$route.meta.requiresAuth) { store.state.afterLogin = this.$route this.logoutUser() } else { this.logoutUser(false) } } else { this.errorPage(error.response.status, error.response.statusText) } }, downloadFile(url) { // TODO: This might not be a best way for big files as the content // will be stored (temporarily) in browser memory // TODO: This method does not show the download progress in the browser // but it could be implemented in the UI, axios has 'progress' property axios.get(url, { responseType: 'blob' }) .then(response => { const link = document.createElement('a') const contentDisposition = response.headers['content-disposition'] let filename = 'unknown' if (contentDisposition) { const match = contentDisposition.match(/filename="(.+)"/); if (match.length === 2) { filename = match[1]; } } link.href = window.URL.createObjectURL(response.data) link.download = filename link.click() }) }, price(price, currency) { // TODO: Set locale argument according to the currently used locale return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, priceLabel(cost, discount) { let index = '' if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost) + '/month' + index }, clickRecord(event) { if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) { $(event.target).closest('tr').find('a').trigger('click') } }, domainStatusClass(domain) { if (domain.isDeleted) { return 'text-muted' } if (domain.isSuspended) { return 'text-warning' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'text-danger' } return 'text-success' }, domainStatusText(domain) { if (domain.isDeleted) { return this.$t('status.deleted') } if (domain.isSuspended) { return this.$t('status.suspended') } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return this.$t('status.notready') } return this.$t('status.active') }, distlistStatusClass(list) { if (list.isDeleted) { return 'text-muted' } if (list.isSuspended) { return 'text-warning' } if (!list.isLdapReady) { return 'text-danger' } return 'text-success' }, distlistStatusText(list) { if (list.isDeleted) { return this.$t('status.deleted') } if (list.isSuspended) { return this.$t('status.suspended') } if (!list.isLdapReady) { return this.$t('status.notready') } return this.$t('status.active') }, pageName(path) { let page = this.$route.path // check if it is a "menu page", find the page name // otherwise we'll use the real path as page name window.config.menu.every(item => { if (item.location == page && item.page) { page = item.page return false } }) page = page.replace(/^\//, '') return page ? page : '404' }, supportDialog(container) { let dialog = $('#support-dialog')[0] if (!dialog) { // FIXME: Find a nicer way of doing this SupportForm.i18n = i18n let form = new Vue(SupportForm) form.$mount($('
').appendTo(container)[0]) form.$root = this form.$toast = this.$toast dialog = form.$el } dialog.__vue__.showDialog() }, userStatusClass(user) { if (user.isDeleted) { return 'text-muted' } if (user.isSuspended) { return 'text-warning' } if (!user.isImapReady || !user.isLdapReady) { return 'text-danger' } return 'text-success' }, userStatusText(user) { if (user.isDeleted) { return this.$t('status.deleted') } if (user.isSuspended) { return this.$t('status.suspended') } if (!user.isImapReady || !user.isLdapReady) { return this.$t('status.notready') } return this.$t('status.active') }, updateBodyClass(name) { // Add 'class' attribute to the body, different for each page // so, we can apply page-specific styles document.body.className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '') } } }) // Fetch the locale file and the start the app loadLangAsync().then(() => app.$mount('#app')) // Add a axios request interceptor window.axios.interceptors.request.use( config => { // This is the only way I found to change configuration options // on a running application. We need this for browser testing. config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider return config }, error => { // Do something with request error return Promise.reject(error) } ) // Add a axios response interceptor for general/validation error handler window.axios.interceptors.response.use( response => { if (response.config.onFinish) { response.config.onFinish() } return response }, error => { let error_msg let status = error.response ? error.response.status : 200 // Do not display the error in a toast message, pass the error as-is if (error.config.ignoreErrors) { return Promise.reject(error) } if (error.config.onFinish) { error.config.onFinish() } if (error.response && status == 422) { error_msg = "Form validation error" const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) $.each(error.response.data.errors || {}, (idx, msg) => { const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx let input = form.find('#' + input_name) if (!input.length) { input = form.find('[name="' + input_name + '"]'); } if (input.length) { // Create an error message // API responses can use a string, array or object let msg_text = '' if (typeof(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.list-input')) { // List input widget let controls = input.children(':not(:first-child)') if (!controls.length && typeof msg == 'string') { // this is an empty list (the main input only) // and the error message is not an array input.find('.main-input').addClass('is-invalid') } else { controls.each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) } input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } } }) form.find('.is-invalid:not(.listinput-widget)').first().focus() }) } else if (error.response && error.response.data) { error_msg = error.response.data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toast.error(error_msg || app.$t('error.server')) // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/vue/App.vue b/src/resources/vue/App.vue index f6262c8d..f2790fca 100644 --- a/src/resources/vue/App.vue +++ b/src/resources/vue/App.vue @@ -1,113 +1,113 @@ diff --git a/src/resources/vue/PasswordReset.vue b/src/resources/vue/PasswordReset.vue index 8facb5ca..8b6816ed 100644 --- a/src/resources/vue/PasswordReset.vue +++ b/src/resources/vue/PasswordReset.vue @@ -1,156 +1,168 @@ diff --git a/src/routes/api.php b/src/routes/api.php index be359519..1d1a5d74 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,235 +1,236 @@ 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); + Route::post('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::post('signup/init', 'API\SignupController@init'); Route::get('signup/invitations/{id}', 'API\SignupController@invitation'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::post('domains/{id}/config', 'API\V4\DomainsController@setConfig'); Route::apiResource('groups', API\V4\GroupsController::class); Route::get('groups/{id}/status', 'API\V4\GroupsController@status'); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::post('users/{id}/config', 'API\V4\UsersController@setConfig'); Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); Route::post('payments', 'API\V4\PaymentsController@store'); //Route::delete('payments', 'API\V4\PaymentsController@cancel'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods'); Route::get('payments/pending', 'API\V4\PaymentsController@payments'); Route::get('payments/has-pending', 'API\V4\PaymentsController@hasPayments'); Route::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => $prefix . 'api/v4' ], function () { Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => $prefix . 'api/webhooks' ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); } ); if (\config('app.with_services')) { Route::group( [ 'domain' => 'services.' . \config('app.website_domain'), 'prefix' => $prefix . 'api/webhooks/policy' ], function () { Route::post('greylist', 'API\V4\PolicyController@greylist'); Route::post('ratelimit', 'API\V4\PolicyController@ratelimit'); Route::post('spf', 'API\V4\PolicyController@senderPolicyFramework'); } ); } if (\config('app.with_admin')) { Route::group( [ 'domain' => 'admin.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); Route::apiResource('groups', API\V4\Admin\GroupsController::class); Route::post('groups/{id}/suspend', 'API\V4\Admin\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Admin\GroupsController@unsuspend'); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); } if (\config('app.with_reseller')) { Route::group( [ 'domain' => 'reseller.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); Route::post('domains/{id}/suspend', 'API\V4\Reseller\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Reseller\DomainsController@unsuspend'); Route::apiResource('groups', API\V4\Reseller\GroupsController::class); Route::post('groups/{id}/suspend', 'API\V4\Reseller\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Reseller\GroupsController@unsuspend'); Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class); Route::post('invitations/{id}/resend', 'API\V4\Reseller\InvitationsController@resend'); Route::post('payments', 'API\V4\Reseller\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\Reseller\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateDelete'); Route::get('payments/methods', 'API\V4\Reseller\PaymentsController@paymentMethods'); Route::get('payments/pending', 'API\V4\Reseller\PaymentsController@payments'); Route::get('payments/has-pending', 'API\V4\Reseller\PaymentsController@hasPayments'); Route::apiResource('skus', API\V4\Reseller\SkusController::class); Route::apiResource('users', API\V4\Reseller\UsersController::class); Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); Route::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Reseller\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Reseller\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff'); Route::get('wallets/{id}/receipts', 'API\V4\Reseller\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\Reseller\WalletsController@receiptDownload'); Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions'); Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart'); } ); } diff --git a/src/tests/Feature/Controller/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php index 87e5fd23..ac6a20f2 100644 --- a/src/tests/Feature/Controller/AuthTest.php +++ b/src/tests/Feature/Controller/AuthTest.php @@ -1,201 +1,242 @@ app['auth']->guard($guard); + + if ($guard instanceof \Illuminate\Auth\SessionGuard) { + $guard->logout(); + } + } + + $protectedProperty = new \ReflectionProperty($this->app['auth'], 'guards'); + $protectedProperty->setAccessible(true); + $protectedProperty->setValue($this->app['auth'], []); + } + /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); + + $this->expectedExpiry = \config('auth.token_expiry_minutes') * 60; } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); parent::tearDown(); } /** * Test fetching current user info (/api/auth/info) */ public function testInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $response = $this->actingAs($user)->get("api/auth/info"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertEquals(User::STATUS_NEW | User::STATUS_ACTIVE, $json['status']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(is_array($json['aliases'])); $this->assertTrue(!isset($json['access_token'])); // Note: Details of the content are tested in testUserResponse() // Test token refresh via the info request - // First we log in as we need the token (actingAs() will not work) + // First we log in to get the refresh token $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; + $user = $this->getTestUser('john@kolab.org'); $response = $this->post("api/auth/login", $post); $json = $response->json(); - $response = $this->withHeaders(['Authorization' => 'Bearer ' . $json['access_token']]) - ->get("api/auth/info?refresh_token=1"); + $response = $this->actingAs($user) + ->post("api/auth/info?refresh=1", ['refresh_token' => $json['refresh_token']]); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('john@kolab.org', $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(is_array($json['aliases'])); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue(!empty($json['expires_in'])); } /** * Test /api/auth/login */ public function testLogin(): string { // Request with no data $response = $this->post("api/auth/login", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Request with invalid password $post = ['email' => 'john@kolab.org', 'password' => 'wrong']; $response = $this->post("api/auth/login", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('Invalid username or password.', $json['message']); // Valid user+password $user = $this->getTestUser('john@kolab.org'); $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); - $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']); + $this->assertTrue( + ($this->expectedExpiry - 5) < $json['expires_in'] && + $json['expires_in'] < ($this->expectedExpiry + 5) + ); $this->assertEquals('bearer', $json['token_type']); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(is_array($json['aliases'])); // Valid user+password (upper-case) $post = ['email' => 'John@Kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); - $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']); + $this->assertTrue( + ($this->expectedExpiry - 5) < $json['expires_in'] && + $json['expires_in'] < ($this->expectedExpiry + 5) + ); $this->assertEquals('bearer', $json['token_type']); // TODO: We have browser tests for 2FA but we should probably also test it here return $json['access_token']; } /** * Test /api/auth/logout * * @depends testLogin */ public function testLogout($token): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/logout"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/logout", []); $response->assertStatus(401); + // Request with invalid token + $response = $this->withHeaders(['Authorization' => 'Bearer ' . "foobar"])->post("api/auth/logout"); + $response->assertStatus(401); + // Request with valid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('Successfully logged out.', $json['message']); + $this->resetAuth(); // Check if it really destroyed the token? $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info"); $response->assertStatus(401); } /** * Test /api/auth/refresh */ public function testRefresh(): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/refresh"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/refresh", []); $response->assertStatus(401); // Login the user to get a valid token $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $response->assertStatus(200); $json = $response->json(); $token = $json['access_token']; + $user = $this->getTestUser('john@kolab.org'); + // Request with a valid token - $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/refresh"); + $response = $this->actingAs($user)->post("api/auth/refresh", ['refresh_token' => $json['refresh_token']]); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue($json['access_token'] != $token); - $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']); + $this->assertTrue( + ($this->expectedExpiry - 5) < $json['expires_in'] && + $json['expires_in'] < ($this->expectedExpiry + 5) + ); $this->assertEquals('bearer', $json['token_type']); $new_token = $json['access_token']; // TODO: Shall we invalidate the old token? // And if the new token is working $response = $this->withHeaders(['Authorization' => 'Bearer ' . $new_token])->get("api/auth/info"); $response->assertStatus(200); } } diff --git a/src/tests/Unit/UserTest.php b/src/tests/Unit/UserTest.php index 100c9110..1eeda627 100644 --- a/src/tests/Unit/UserTest.php +++ b/src/tests/Unit/UserTest.php @@ -1,89 +1,114 @@ 'user@email.com']); $user->password = 'test'; $ssh512 = "{SSHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ" . "6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w=="; $this->assertMatchesRegularExpression('/^\$2y\$12\$[0-9a-zA-Z\/.]{53}$/', $user->password); $this->assertSame($ssh512, $user->password_ldap); } /** * Test User password mutator */ public function testSetPasswordLdapAttribute(): void { $user = new User(['email' => 'user@email.com']); $user->password_ldap = 'test'; $ssh512 = "{SSHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ" . "6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w=="; $this->assertMatchesRegularExpression('/^\$2y\$12\$[0-9a-zA-Z\/.]{53}$/', $user->password); $this->assertSame($ssh512, $user->password_ldap); } + /** + * Test User password validation + */ + public function testPasswordValidation(): void + { + $user = new User(['email' => 'user@email.com']); + $user->password = 'test'; + + $this->assertSame(true, $user->validateCredentials('user@email.com', 'test')); + $this->assertSame(false, $user->validateCredentials('user@email.com', 'wrong')); + $this->assertSame(true, $user->validateCredentials('User@Email.Com', 'test')); + $this->assertSame(false, $user->validateCredentials('wrong', 'test')); + + // Ensure the fallback to the ldap_password works if the current password is empty + $ssh512 = "{SSHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ" + . "6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w=="; + $ldapUser = new User(['email' => 'user2@email.com']); + $ldapUser->setRawAttributes(['password' => '', 'password_ldap' => $ssh512, 'email' => 'user2@email.com']); + $this->assertSame($ldapUser->password, ''); + $this->assertSame($ldapUser->password_ldap, $ssh512); + + $this->assertSame(true, $ldapUser->validateCredentials('user2@email.com', 'test', false)); + $ldapUser->delete(); + } + /** * Test basic User funtionality */ public function testStatus(): void { $statuses = [ User::STATUS_NEW, User::STATUS_ACTIVE, User::STATUS_SUSPENDED, User::STATUS_DELETED, User::STATUS_IMAP_READY, User::STATUS_LDAP_READY, ]; $users = \App\Utils::powerSet($statuses); foreach ($users as $user_statuses) { $user = new User( [ 'email' => 'user@email.com', 'status' => \array_sum($user_statuses), ] ); $this->assertTrue($user->isNew() === in_array(User::STATUS_NEW, $user_statuses)); $this->assertTrue($user->isActive() === in_array(User::STATUS_ACTIVE, $user_statuses)); $this->assertTrue($user->isSuspended() === in_array(User::STATUS_SUSPENDED, $user_statuses)); $this->assertTrue($user->isDeleted() === in_array(User::STATUS_DELETED, $user_statuses)); $this->assertTrue($user->isLdapReady() === in_array(User::STATUS_LDAP_READY, $user_statuses)); $this->assertTrue($user->isImapReady() === in_array(User::STATUS_IMAP_READY, $user_statuses)); } } /** * Test setStatusAttribute exception */ public function testStatusInvalid(): void { $this->expectException(\Exception::class); $user = new User( [ 'email' => 'user@email.com', 'status' => 1234567, ] ); } }