From 74cb1477bb921a2378ea22a552b71a48c11e0931 Mon Sep 17 00:00:00 2001 From: Marvin Borner Date: Fri, 20 Jul 2018 16:34:32 +0200 Subject: Better API (integrated oauth completely) --- .../Api/Controllers/DefaultApiController.php | 17 +++ infrastructure/Api/routes_public.php | 3 + infrastructure/Auth/AuthServiceProvider.php | 41 +++++++ .../Auth/Controllers/LoginController.php | 38 +++++++ .../Exceptions/InvalidCredentialsException.php | 13 +++ infrastructure/Auth/LoginProxy.php | 126 +++++++++++++++++++++ .../Auth/Middleware/AccessTokenChecker.php | 37 ++++++ infrastructure/Auth/Requests/LoginRequest.php | 21 ++++ infrastructure/Auth/routes_protected.php | 3 + infrastructure/Auth/routes_public.php | 4 + infrastructure/Console/Kernel.php | 31 +++++ infrastructure/Database/Eloquent/Model.php | 9 ++ infrastructure/Database/Eloquent/Repository.php | 9 ++ infrastructure/Events/Event.php | 8 ++ infrastructure/Exceptions/Handler.php | 50 ++++++++ infrastructure/Http/ApiRequest.php | 21 ++++ infrastructure/Http/Controller.php | 10 ++ infrastructure/Http/Kernel.php | 47 ++++++++ infrastructure/Http/Middleware/EncryptCookies.php | 17 +++ infrastructure/Http/RouteServiceProvider.php | 23 ++++ infrastructure/Testing/TestCase.php | 29 +++++ infrastructure/Testing/bootstrap.php | 5 + .../Validation/resources/lang/en/validation.php | 113 ++++++++++++++++++ infrastructure/Version.php | 12 ++ 24 files changed, 687 insertions(+) create mode 100644 infrastructure/Api/Controllers/DefaultApiController.php create mode 100644 infrastructure/Api/routes_public.php create mode 100644 infrastructure/Auth/AuthServiceProvider.php create mode 100644 infrastructure/Auth/Controllers/LoginController.php create mode 100644 infrastructure/Auth/Exceptions/InvalidCredentialsException.php create mode 100644 infrastructure/Auth/LoginProxy.php create mode 100644 infrastructure/Auth/Middleware/AccessTokenChecker.php create mode 100644 infrastructure/Auth/Requests/LoginRequest.php create mode 100644 infrastructure/Auth/routes_protected.php create mode 100644 infrastructure/Auth/routes_public.php create mode 100644 infrastructure/Console/Kernel.php create mode 100644 infrastructure/Database/Eloquent/Model.php create mode 100644 infrastructure/Database/Eloquent/Repository.php create mode 100644 infrastructure/Events/Event.php create mode 100644 infrastructure/Exceptions/Handler.php create mode 100644 infrastructure/Http/ApiRequest.php create mode 100644 infrastructure/Http/Controller.php create mode 100644 infrastructure/Http/Kernel.php create mode 100644 infrastructure/Http/Middleware/EncryptCookies.php create mode 100644 infrastructure/Http/RouteServiceProvider.php create mode 100644 infrastructure/Testing/TestCase.php create mode 100644 infrastructure/Testing/bootstrap.php create mode 100644 infrastructure/Validation/resources/lang/en/validation.php create mode 100644 infrastructure/Version.php (limited to 'infrastructure') diff --git a/infrastructure/Api/Controllers/DefaultApiController.php b/infrastructure/Api/Controllers/DefaultApiController.php new file mode 100644 index 0000000..cb850a5 --- /dev/null +++ b/infrastructure/Api/Controllers/DefaultApiController.php @@ -0,0 +1,17 @@ +json([ + 'title' => 'BEAM-Messenger', + 'version' => Version::getGitTag() + ]); + } +} diff --git a/infrastructure/Api/routes_public.php b/infrastructure/Api/routes_public.php new file mode 100644 index 0000000..3609ea8 --- /dev/null +++ b/infrastructure/Api/routes_public.php @@ -0,0 +1,3 @@ +get('/', 'DefaultApiController@index'); \ No newline at end of file diff --git a/infrastructure/Auth/AuthServiceProvider.php b/infrastructure/Auth/AuthServiceProvider.php new file mode 100644 index 0000000..944f812 --- /dev/null +++ b/infrastructure/Auth/AuthServiceProvider.php @@ -0,0 +1,41 @@ + 'App\Policies\ModelPolicy', + ]; + + /** + * Register any authentication / authorization services. + * + * @return void + */ + public function boot() + { + $this->registerPolicies(); + + Passport::routes(function ($router) { + $router->forAccessTokens(); + // Uncomment for allowing personal access tokens + // $router->forPersonalAccessTokens(); + $router->forTransientTokens(); + }); + + Passport::tokensExpireIn(Carbon::now()->addMinutes(10)); + + Passport::refreshTokensExpireIn(Carbon::now()->addDays(10)); + } +} \ No newline at end of file diff --git a/infrastructure/Auth/Controllers/LoginController.php b/infrastructure/Auth/Controllers/LoginController.php new file mode 100644 index 0000000..a72f8a0 --- /dev/null +++ b/infrastructure/Auth/Controllers/LoginController.php @@ -0,0 +1,38 @@ +loginProxy = $loginProxy; + } + + public function login(LoginRequest $request) + { + $email = $request->get('email'); + $password = $request->get('password'); + + return $this->response($this->loginProxy->attemptLogin($email, $password)); + } + + public function refresh(Request $request) + { + return $this->response($this->loginProxy->attemptRefresh()); + } + + public function logout() + { + $this->loginProxy->logout(); + + return $this->response(null, 204); + } +} \ No newline at end of file diff --git a/infrastructure/Auth/Exceptions/InvalidCredentialsException.php b/infrastructure/Auth/Exceptions/InvalidCredentialsException.php new file mode 100644 index 0000000..45a8b6e --- /dev/null +++ b/infrastructure/Auth/Exceptions/InvalidCredentialsException.php @@ -0,0 +1,13 @@ +userRepository = $userRepository; + + $this->apiConsumer = $app->make('apiconsumer'); + $this->auth = $app->make('auth'); + $this->cookie = $app->make('cookie'); + $this->db = $app->make('db'); + $this->request = $app->make('request'); + } + + /** + * Attempt to create an access token using user credentials + * + * @param string $email + * @param string $password + */ + public function attemptLogin($email, $password) + { + $user = $this->userRepository->getWhere('email', $email)->first(); + + if (!is_null($user)) { + return $this->proxy('password', [ + 'username' => $email, + 'password' => $password + ]); + } + + throw new InvalidCredentialsException(); + } + + /** + * Attempt to refresh the access token used a refresh token that + * has been saved in a cookie + */ + public function attemptRefresh() + { + $refreshToken = $this->request->cookie(self::REFRESH_TOKEN); + + return $this->proxy('refresh_token', [ + 'refresh_token' => $refreshToken + ]); + } + + /** + * Proxy a request to the OAuth server. + * + * @param string $grantType what type of grant type should be proxied + * @param array $data the data to send to the server + */ + public function proxy($grantType, array $data = []) + { + $data = array_merge($data, [ + 'client_id' => env('PASSWORD_CLIENT_ID'), + 'client_secret' => env('PASSWORD_CLIENT_SECRET'), + 'grant_type' => $grantType + ]); + + $response = $this->apiConsumer->post('/oauth/token', $data); + + if (!$response->isSuccessful()) { + throw new InvalidCredentialsException(); + } + + $data = json_decode($response->getContent()); + + // Create a refresh token cookie + $this->cookie->queue( + self::REFRESH_TOKEN, + $data->refresh_token, + 864000, // 10 days + null, + null, + false, + true // HttpOnly + ); + + return [ + 'access_token' => $data->access_token, + 'expires_in' => $data->expires_in + ]; + } + + /** + * Logs out the user. We revoke access token and refresh token. + * Also instruct the client to forget the refresh cookie. + */ + public function logout() + { + $accessToken = $this->auth->user()->token(); + + $refreshToken = $this->db + ->table('oauth_refresh_tokens') + ->where('access_token_id', $accessToken->id) + ->update([ + 'revoked' => true + ]); + + $accessToken->revoke(); + + $this->cookie->queue($this->cookie->forget(self::REFRESH_TOKEN)); + } +} diff --git a/infrastructure/Auth/Middleware/AccessTokenChecker.php b/infrastructure/Auth/Middleware/AccessTokenChecker.php new file mode 100644 index 0000000..f79f5cb --- /dev/null +++ b/infrastructure/Auth/Middleware/AccessTokenChecker.php @@ -0,0 +1,37 @@ +app = $app; + $this->authenticate = $authenticate; + } + + public function handle($request, Closure $next, $scopesString = null) + { + if ($this->app->environment() !== 'testing') { + try { + return $this->authenticate->handle($request, $next, 'api'); + } catch (AuthenticationException $e) { + throw new UnauthorizedHttpException('Challenge'); + } + } + + return $next($request); + } +} diff --git a/infrastructure/Auth/Requests/LoginRequest.php b/infrastructure/Auth/Requests/LoginRequest.php new file mode 100644 index 0000000..5c5a3bb --- /dev/null +++ b/infrastructure/Auth/Requests/LoginRequest.php @@ -0,0 +1,21 @@ + 'required|email', + 'password' => 'required' + ]; + } +} diff --git a/infrastructure/Auth/routes_protected.php b/infrastructure/Auth/routes_protected.php new file mode 100644 index 0000000..0fe814f --- /dev/null +++ b/infrastructure/Auth/routes_protected.php @@ -0,0 +1,3 @@ +post('/logout', 'LoginController@logout'); \ No newline at end of file diff --git a/infrastructure/Auth/routes_public.php b/infrastructure/Auth/routes_public.php new file mode 100644 index 0000000..79f5b51 --- /dev/null +++ b/infrastructure/Auth/routes_public.php @@ -0,0 +1,4 @@ +post('/login', 'LoginController@login'); +$router->post('/login/refresh', 'LoginController@refresh'); \ No newline at end of file diff --git a/infrastructure/Console/Kernel.php b/infrastructure/Console/Kernel.php new file mode 100644 index 0000000..dffc73a --- /dev/null +++ b/infrastructure/Console/Kernel.php @@ -0,0 +1,31 @@ +command('inspire') + // ->hourly(); + } +} diff --git a/infrastructure/Database/Eloquent/Model.php b/infrastructure/Database/Eloquent/Model.php new file mode 100644 index 0000000..a7781f7 --- /dev/null +++ b/infrastructure/Database/Eloquent/Model.php @@ -0,0 +1,9 @@ +errors()->toJson()); + } + + protected function failedAuthorization() + { + throw new HttpException(403); + } +} diff --git a/infrastructure/Http/Controller.php b/infrastructure/Http/Controller.php new file mode 100644 index 0000000..aa567b9 --- /dev/null +++ b/infrastructure/Http/Controller.php @@ -0,0 +1,10 @@ + \Infrastructure\Auth\Middleware\AccessTokenChecker::class, + 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, + 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class, + 'can' => \Illuminate\Auth\Middleware\Authorize::class, + 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, + 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, + ]; +} diff --git a/infrastructure/Http/Middleware/EncryptCookies.php b/infrastructure/Http/Middleware/EncryptCookies.php new file mode 100644 index 0000000..80b8da3 --- /dev/null +++ b/infrastructure/Http/Middleware/EncryptCookies.php @@ -0,0 +1,17 @@ +app->make(Router::class); + + $router->pattern('id', '[0-9]+'); + + parent::boot($router); + } +} diff --git a/infrastructure/Testing/TestCase.php b/infrastructure/Testing/TestCase.php new file mode 100644 index 0000000..1770e1b --- /dev/null +++ b/infrastructure/Testing/TestCase.php @@ -0,0 +1,29 @@ +make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + + return $app; + } +} diff --git a/infrastructure/Testing/bootstrap.php b/infrastructure/Testing/bootstrap.php new file mode 100644 index 0000000..f0aea64 --- /dev/null +++ b/infrastructure/Testing/bootstrap.php @@ -0,0 +1,5 @@ + 'The :attribute must be accepted.', + 'active_url' => 'The :attribute is not a valid URL.', + 'after' => 'The :attribute must be a date after :date.', + 'alpha' => 'The :attribute may only contain letters.', + 'alpha_dash' => 'The :attribute may only contain letters, numbers, and dashes.', + 'alpha_num' => 'The :attribute may only contain letters and numbers.', + 'array' => 'The :attribute must be an array.', + 'before' => 'The :attribute must be a date before :date.', + 'between' => [ + 'numeric' => 'The :attribute must be between :min and :max.', + 'file' => 'The :attribute must be between :min and :max kilobytes.', + 'string' => 'The :attribute must be between :min and :max characters.', + 'array' => 'The :attribute must have between :min and :max items.', + ], + 'boolean' => 'The :attribute must be true or false.', + 'confirmed' => 'The :attribute confirmation does not match.', + 'date' => 'The :attribute is not a valid date.', + 'date_format' => 'The :attribute does not match the format :format.', + 'different' => 'The :attribute and :other must be different.', + 'digits' => 'The :attribute must be :digits digits.', + 'digits_between' => 'The :attribute must be between :min and :max digits.', + 'distinct' => 'The :attribute has a duplicate value.', + 'email' => 'The :attribute must be a valid email address.', + 'exists' => 'The selected :attribute is invalid.', + 'filled' => 'The :attribute is required.', + 'image' => 'The :attribute must be an image.', + 'in' => 'The selected :attribute is invalid.', + 'in_array' => 'The :attribute does not exist in :other.', + 'integer' => 'The :attribute must be an integer.', + 'ip' => 'The :attribute must be a valid IP address.', + 'json' => 'The :attribute must be a valid JSON string.', + 'max' => [ + 'numeric' => 'The :attribute may not be greater than :max.', + 'file' => 'The :attribute may not be greater than :max kilobytes.', + 'string' => 'The :attribute may not be greater than :max characters.', + 'array' => 'The :attribute may not have more than :max items.', + ], + 'mimes' => 'The :attribute must be a file of type: :values.', + 'min' => [ + 'numeric' => 'The :attribute must be at least :min.', + 'file' => 'The :attribute must be at least :min kilobytes.', + 'string' => 'The :attribute must be at least :min characters.', + 'array' => 'The :attribute must have at least :min items.', + ], + 'not_in' => 'The selected :attribute is invalid.', + 'numeric' => 'The :attribute must be a number.', + 'present' => 'The :attribute must be present.', + 'regex' => 'The :attribute format is invalid.', + 'required' => 'The :attribute is required.', + 'required_if' => 'The :attribute is required when :other is :value.', + 'required_unless' => 'The :attribute is required unless :other is in :values.', + 'required_with' => 'The :attribute is required when :values is present.', + 'required_with_all' => 'The :attribute is required when :values is present.', + 'required_without' => 'The :attribute is required when :values is not present.', + 'required_without_all' => 'The :attribute is required when none of :values are present.', + 'same' => 'The :attribute and :other must match.', + 'size' => [ + 'numeric' => 'The :attribute must be :size.', + 'file' => 'The :attribute must be :size kilobytes.', + 'string' => 'The :attribute must be :size characters.', + 'array' => 'The :attribute must contain :size items.', + ], + 'string' => 'The :attribute must be a string.', + 'timezone' => 'The :attribute must be a valid zone.', + 'unique' => 'The :attribute has already been taken.', + 'url' => 'The :attribute format is invalid.', + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'custom-message', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap attribute place-holders + | with something more reader friendly such as E-Mail Address instead + | of "email". This simply helps us make messages a little cleaner. + | + */ + + 'attributes' => [], + +]; diff --git a/infrastructure/Version.php b/infrastructure/Version.php new file mode 100644 index 0000000..9ca7941 --- /dev/null +++ b/infrastructure/Version.php @@ -0,0 +1,12 @@ +