diff --git a/bin/quickstart.sh b/bin/quickstart.sh --- a/bin/quickstart.sh +++ b/bin/quickstart.sh @@ -66,10 +66,11 @@ 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 +./artisan passport:keys --force + if [ ! -z "$(rpm -qv chromium 2>/dev/null)" ]; then chver=$(rpmquery --queryformat="%{VERSION}" chromium | awk -F'.' '{print $1}') diff --git a/src/.env.example b/src/.env.example --- a/src/.env.example +++ b/src/.env.example @@ -128,8 +128,8 @@ MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" -JWT_SECRET= -JWT_TTL=60 +PROXY_OAUTH_CLIENT_ID=1 +PROXY_OAUTH_CLIENT_SECRET=JF4pL68ucLuMupaOviTeG8EJeQpjtZtcGLp4f0dq COMPANY_NAME= COMPANY_ADDRESS= diff --git a/src/app/Auth/SecondFactor.php b/src/app/Auth/SecondFactor.php --- a/src/app/Auth/SecondFactor.php +++ b/src/app/Auth/SecondFactor.php @@ -35,40 +35,50 @@ /** * 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; } /** diff --git a/src/app/Exceptions/Handler.php b/src/app/Exceptions/Handler.php --- a/src/app/Exceptions/Handler.php +++ b/src/app/Exceptions/Handler.php @@ -15,7 +15,8 @@ * @var array */ protected $dontReport = [ - // + \Laravel\Passport\Exceptions\OAuthServerException::class, + \League\OAuth2\Server\Exception\OAuthServerException::class ]; /** diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php --- a/src/app/Http/Controllers/API/AuthController.php +++ b/src/app/Http/Controllers/API/AuthController.php @@ -7,6 +7,8 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Validator; +use Laravel\Passport\TokenRepository; +use Laravel\Passport\RefreshTokenRepository; class AuthController extends Controller { @@ -20,9 +22,8 @@ $user = Auth::guard()->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); @@ -32,20 +33,31 @@ * 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. * @@ -66,22 +78,11 @@ 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); - - return $this->respondWithToken($token, $response); + $user = \App\User::where('email', $request->email)->first(); + 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); } /** @@ -91,8 +92,16 @@ */ 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') @@ -104,26 +113,58 @@ * * @return \Illuminate\Http\JsonResponse */ - public function refresh() + public function refresh(Request $request) + { + 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 = []) { - // @phpstan-ignore-next-line - return $this->respondWithToken(Auth::guard()->refresh()); + $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 --- a/src/app/Http/Controllers/API/PasswordResetController.php +++ b/src/app/Http/Controllers/API/PasswordResetController.php @@ -138,6 +138,6 @@ // 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 --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -263,7 +263,7 @@ DB::commit(); - return AuthController::logonResponse($user); + return AuthController::logonResponse($user, $request->password); } /** diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\ServiceProvider; +use Laravel\Passport\Passport; class AppServiceProvider extends ServiceProvider { @@ -16,7 +17,23 @@ */ public function register() { - // + Passport::ignoreMigrations(); + } + + /** + * Serialize a bindings array to a string. + * + * @return string + */ + private static function serializeSQLBindings(array $array): string + { + $serialized = array_map(function ($entry) { + if ($entry instanceof \DateTime) { + return $entry->format('Y-m-d h:i:s'); + } + return $entry; + }, $array); + return implode(', ', $serialized); } /** @@ -48,7 +65,7 @@ // Log SQL queries in debug mode if (\config('app.debug')) { DB::listen(function ($query) { - \Log::debug(sprintf('[SQL] %s [%s]', $query->sql, implode(', ', $query->bindings))); + \Log::debug(sprintf('[SQL] %s [%s]', $query->sql, self::serializeSQLBindings($query->bindings))); }); } diff --git a/src/app/Providers/AuthServiceProvider.php b/src/app/Providers/AuthServiceProvider.php --- a/src/app/Providers/AuthServiceProvider.php +++ b/src/app/Providers/AuthServiceProvider.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; +use Laravel\Passport\Passport; class AuthServiceProvider extends ServiceProvider { @@ -33,5 +34,18 @@ 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(); + } + ); + + 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/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -9,9 +9,11 @@ use App\Traits\SettingsTrait; use App\Wallet; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Facades\Hash; use Illuminate\Foundation\Auth\User as Authenticatable; use Iatstuti\Database\Support\NullableFields; -use Tymon\JWTAuth\Contracts\JWTSubject; +use Laravel\Passport\HasApiTokens; +use League\OAuth2\Server\Exception\OAuthServerException; /** * The eloquent definition of a User. @@ -21,12 +23,13 @@ * @property string $password * @property int $status */ -class User extends Authenticatable implements JWTSubject +class User extends Authenticatable { use NullableFields; use UserAliasesTrait; use SettingsTrait; use SoftDeletes; + use HasApiTokens; // a new user, default on creation public const STATUS_NEW = 1 << 0; @@ -374,7 +377,7 @@ * @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 { @@ -401,15 +404,7 @@ return null; } - public function getJWTIdentifier() - { - return $this->getKey(); - } - public function getJWTCustomClaims() - { - return []; - } /** * Return groups controlled by the current user. @@ -718,4 +713,66 @@ $this->attributes['status'] = $new_status; } + + /** + * 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 (empty($password)) { + return ['reason' => 'credentials', 'errorMessage' => "Invalid password."]; + } + + $result = Hash::check($password, $user->password); + + if (!$result) { + 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 \OAuthServerException + * + * @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 --- a/src/composer.json +++ b/src/composer.json @@ -22,6 +22,7 @@ "kolab/net_ldap3": "dev-master", "laravel/framework": "6.*", "laravel/horizon": "^3", + "laravel/passport": "^9", "laravel/tinker": "^2.4", "mollie/laravel-mollie": "^2.9", "morrislaptop/laravel-queue-clear": "^1.2", @@ -29,8 +30,7 @@ "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-dump-server": "^1.0", diff --git a/src/config/app.php b/src/config/app.php --- a/src/config/app.php +++ b/src/config/app.php @@ -183,6 +183,7 @@ * Package Service Providers... */ Barryvdh\DomPDF\ServiceProvider::class, + Laravel\Passport\PassportServiceProvider::class, /* * Application Service Providers... diff --git a/src/config/auth.php b/src/config/auth.php --- a/src/config/auth.php +++ b/src/config/auth.php @@ -42,7 +42,7 @@ ], 'api' => [ - 'driver' => 'jwt', + 'driver' => 'passport', 'provider' => 'users', ], ], @@ -99,4 +99,25 @@ ], ], + + /* + |-------------------------------------------------------------------------- + | 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('PROXY_OAUTH_CLIENT_ID'), + 'client_secret' => env('PROXY_OAUTH_CLIENT_SECRET'), + ], + + 'token_expiry_minutes' => env('OAUTH_TOKEN_EXPIRY', 1 * 24 * 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 --- 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/swoole_http.php b/src/config/swoole_http.php --- a/src/config/swoole_http.php +++ b/src/config/swoole_http.php @@ -101,7 +101,9 @@ '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. + Laravel\Passport\PassportServiceProvider::class, ], /* 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 --- /dev/null +++ b/src/database/migrations/2021_04_28_090011_create_oauth_tables.php @@ -0,0 +1,92 @@ +bigIncrements('id'); + $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->bigInteger('client_id'); + $table->timestamps(); + }); + + Schema::create('oauth_auth_codes', function (Blueprint $table) { + $table->string('id', 100)->primary(); + $table->bigInteger('user_id')->index(); + $table->bigInteger('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'); + }); + + Schema::create('oauth_access_tokens', function (Blueprint $table) { + $table->string('id', 100)->primary(); + $table->bigInteger('user_id')->nullable()->index(); + $table->bigInteger('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'); + }); + + 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 --- a/src/database/seeds/DatabaseSeeder.php +++ b/src/database/seeds/DatabaseSeeder.php @@ -21,6 +21,7 @@ 'PlanSeeder', 'UserSeeder', 'OpenViduRoomSeeder', + 'OauthClientSeeder', ]; $env = ucfirst(App::environment()); diff --git a/src/database/seeds/local/OauthClientSeeder.php b/src/database/seeds/local/OauthClientSeeder.php new file mode 100644 --- /dev/null +++ b/src/database/seeds/local/OauthClientSeeder.php @@ -0,0 +1,32 @@ +forceFill([ + 'user_id' => null, + 'name' => "Kolab Password Grant Client", + 'secret' => 'JF4pL68ucLuMupaOviTeG8EJeQpjtZtcGLp4f0dq', + 'provider' => 'users', + 'redirect' => 'http://localhost', + 'personal_access_client' => 0, + 'password_client' => 1, + 'revoked' => false, + ]); + + $client->save(); + } +} diff --git a/src/resources/js/app.js b/src/resources/js/app.js --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -137,6 +137,7 @@ } localStorage.setItem('token', response.access_token) + localStorage.setItem('refreshToken', response.refresh_token) axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token if (response.email) { @@ -162,7 +163,7 @@ // 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) @@ -171,6 +172,7 @@ logoutUser(redirect) { store.commit('logoutUser') localStorage.setItem('token', '') + localStorage.setItem('refreshToken', '') delete axios.defaults.headers.common.Authorization if (redirect !== false) { diff --git a/src/resources/vue/App.vue b/src/resources/vue/App.vue --- a/src/resources/vue/App.vue +++ b/src/resources/vue/App.vue @@ -24,7 +24,7 @@ this.$root.startLoading() axios.defaults.headers.common.Authorization = 'Bearer ' + token - axios.get('/api/auth/info?refresh_token=1') + axios.post('/api/auth/info?refresh=1', {refresh_token: localStorage.getItem("refreshToken")}) .then(response => { this.$root.loginUser(response.data, false) this.$root.stopLoading() diff --git a/src/resources/vue/PasswordReset.vue b/src/resources/vue/PasswordReset.vue --- a/src/resources/vue/PasswordReset.vue +++ b/src/resources/vue/PasswordReset.vue @@ -50,6 +50,16 @@ +
+ +
+ + + + +
+ Second factor code is optional for users with no 2-Factor Authentication setup. +
@@ -67,6 +77,7 @@ short_code: '', password: '', password_confirmation: '', + secondFactor: '', fromEmail: window.config['mail.from.address'] } }, @@ -128,7 +139,8 @@ code: this.code, short_code: this.short_code, password: this.password, - password_confirmation: this.password_confirmation + password_confirmation: this.password_confirmation, + secondfactor: this.secondFactor }).then(response => { // auto-login and goto dashboard this.$root.loginUser(response.data) diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -27,6 +27,7 @@ ['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'); } diff --git a/src/tests/Feature/Controller/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php --- a/src/tests/Feature/Controller/AuthTest.php +++ b/src/tests/Feature/Controller/AuthTest.php @@ -8,6 +8,27 @@ class AuthTest extends TestCase { + + /** + * Reset all authentication guards to clear any cache users + */ + protected function resetAuth() + { + $guards = array_keys(config('auth.guards')); + + foreach ($guards as $guard) { + $guard = $this->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} */ @@ -57,12 +78,13 @@ // 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(); @@ -108,7 +130,7 @@ $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); - $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']); + $this->assertEquals(\config('auth.token_expiry_minutes') * 60, $json['expires_in']); $this->assertEquals('bearer', $json['token_type']); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); @@ -123,7 +145,7 @@ $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); - $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']); + $this->assertEquals(\config('auth.token_expiry_minutes') * 60, $json['expires_in']); $this->assertEquals('bearer', $json['token_type']); // TODO: We have browser tests for 2FA but we should probably also test it here @@ -146,6 +168,10 @@ $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); @@ -154,6 +180,7 @@ $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"); @@ -180,15 +207,17 @@ $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->assertEquals(\config('auth.token_expiry_minutes') * 60, $json['expires_in']); $this->assertEquals('bearer', $json['token_type']); $new_token = $json['access_token'];