diff --git a/app/Api/ApiToken.php b/app/Api/ApiToken.php index cdcb33a7b..523c3b8b8 100644 --- a/app/Api/ApiToken.php +++ b/app/Api/ApiToken.php @@ -3,6 +3,7 @@ use BookStack\Auth\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Carbon; class ApiToken extends Model { @@ -18,4 +19,13 @@ class ApiToken extends Model { return $this->belongsTo(User::class); } + + /** + * Get the default expiry value for an API token. + * Set to 100 years from now. + */ + public static function defaultExpiry(): string + { + return Carbon::now()->addYears(100)->format('Y-m-d'); + } } diff --git a/app/Api/ApiTokenGuard.php b/app/Api/ApiTokenGuard.php new file mode 100644 index 000000000..b347e536a --- /dev/null +++ b/app/Api/ApiTokenGuard.php @@ -0,0 +1,135 @@ +request = $request; + } + + + /** + * @inheritDoc + */ + public function user() + { + // Return the user if we've already retrieved them. + // Effectively a request-instance cache for this method. + if (!is_null($this->user)) { + return $this->user; + } + + $user = null; + try { + $user = $this->getAuthorisedUserFromRequest(); + } catch (ApiAuthException $exception) { + $this->lastAuthException = $exception; + } + + $this->user = $user; + return $user; + } + + /** + * Determine if current user is authenticated. If not, throw an exception. + * + * @return \Illuminate\Contracts\Auth\Authenticatable + * + * @throws ApiAuthException + */ + public function authenticate() + { + if (! is_null($user = $this->user())) { + return $user; + } + + if ($this->lastAuthException) { + throw $this->lastAuthException; + } + + throw new ApiAuthException('Unauthorized'); + } + + /** + * Check the API token in the request and fetch a valid authorised user. + * @throws ApiAuthException + */ + protected function getAuthorisedUserFromRequest(): Authenticatable + { + $authToken = trim($this->request->headers->get('Authorization', '')); + if (empty($authToken)) { + throw new ApiAuthException(trans('errors.api_no_authorization_found')); + } + + if (strpos($authToken, ':') === false || strpos($authToken, 'Token ') !== 0) { + throw new ApiAuthException(trans('errors.api_bad_authorization_format')); + } + + [$id, $secret] = explode(':', str_replace('Token ', '', $authToken)); + $token = ApiToken::query() + ->where('token_id', '=', $id) + ->with(['user'])->first(); + + if ($token === null) { + throw new ApiAuthException(trans('errors.api_user_token_not_found')); + } + + if (!Hash::check($secret, $token->secret)) { + throw new ApiAuthException(trans('errors.api_incorrect_token_secret')); + } + + if (!$token->user->can('access-api')) { + throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403); + } + + return $token->user; + } + + /** + * @inheritDoc + */ + public function validate(array $credentials = []) + { + if (empty($credentials['id']) || empty($credentials['secret'])) { + return false; + } + + $token = ApiToken::query() + ->where('token_id', '=', $credentials['id']) + ->with(['user'])->first(); + + if ($token === null) { + return false; + } + + return Hash::check($credentials['secret'], $token->secret); + } + +} \ No newline at end of file diff --git a/app/Config/auth.php b/app/Config/auth.php index 5535a6f9c..b3e22c7e1 100644 --- a/app/Config/auth.php +++ b/app/Config/auth.php @@ -34,9 +34,7 @@ return [ ], 'api' => [ - 'driver' => 'token', - 'provider' => 'users', - 'hash' => false, + 'driver' => 'api-token', ], ], diff --git a/app/Exceptions/ApiAuthException.php b/app/Exceptions/ApiAuthException.php new file mode 100644 index 000000000..0851dfa4a --- /dev/null +++ b/app/Exceptions/ApiAuthException.php @@ -0,0 +1,17 @@ +findOrFail($userId); $secret = Str::random(32); - $expiry = $request->get('expires_at', null); - if (empty($expiry)) { - $expiry = Carbon::now()->addYears(100)->format('Y-m-d'); - } - $token = (new ApiToken())->forceFill([ 'name' => $request->get('name'), 'token_id' => Str::random(32), 'secret' => Hash::make($secret), 'user_id' => $user->id, - 'expires_at' => $expiry + 'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(), ]); while (ApiToken::query()->where('token_id', '=', $token->token_id)->exists()) { @@ -59,7 +54,6 @@ class UserApiTokenController extends Controller } $token->save(); - $token->refresh(); session()->flash('api-token-secret:' . $token->id, $secret); $this->showSuccessNotification(trans('settings.user_api_token_create_success')); @@ -87,18 +81,17 @@ class UserApiTokenController extends Controller */ public function update(Request $request, int $userId, int $tokenId) { - $requestData = $this->validate($request, [ + $this->validate($request, [ 'name' => 'required|max:250', 'expires_at' => 'date_format:Y-m-d', ]); [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); + $token->fill([ + 'name' => $request->get('name'), + 'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(), + ])->save(); - if (empty($requestData['expires_at'])) { - $requestData['expires_at'] = Carbon::now()->addYears(100)->format('Y-m-d'); - } - - $token->fill($requestData)->save(); $this->showSuccessNotification(trans('settings.user_api_token_update_success')); return redirect($user->getEditUrl('/api-tokens/' . $token->id)); } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 64782fedc..6a6e736b9 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -1,6 +1,5 @@ [ 'throttle:60,1', - \BookStack\Http\Middleware\EncryptCookies::class, - \Illuminate\Session\Middleware\StartSession::class, + \BookStack\Http\Middleware\StartSessionIfCookieExists::class, \BookStack\Http\Middleware\ApiAuthenticate::class, \BookStack\Http\Middleware\ConfirmEmails::class, ], diff --git a/app/Http/Middleware/ApiAuthenticate.php b/app/Http/Middleware/ApiAuthenticate.php index 3e68cb3ae..86fb83d58 100644 --- a/app/Http/Middleware/ApiAuthenticate.php +++ b/app/Http/Middleware/ApiAuthenticate.php @@ -2,10 +2,9 @@ namespace BookStack\Http\Middleware; -use BookStack\Api\ApiToken; +use BookStack\Exceptions\ApiAuthException; use BookStack\Http\Request; use Closure; -use Hash; class ApiAuthenticate { @@ -15,58 +14,29 @@ class ApiAuthenticate */ public function handle(Request $request, Closure $next) { - // TODO - Look to extract a lot of the logic here into a 'Guard' - // Ideally would like to be able to request API via browser without having to boot - // the session middleware (in Kernel). - -// $sessionCookieName = config('session.cookie'); -// if ($request->cookies->has($sessionCookieName)) { -// $sessionCookie = $request->cookies->get($sessionCookieName); -// $sessionCookie = decrypt($sessionCookie, false); -// dd($sessionCookie); -// } - // Return if the user is already found to be signed in via session-based auth. // This is to make it easy to browser the API via browser after just logging into the system. if (signedInUser()) { return $next($request); } - $authToken = trim($request->header('Authorization', '')); - if (empty($authToken)) { - return $this->unauthorisedResponse(trans('errors.api_no_authorization_found')); + // Set our api guard to be the default for this request lifecycle. + auth()->shouldUse('api'); + + // Validate the token and it's users API access + try { + auth()->authenticate(); + } catch (ApiAuthException $exception) { + return $this->unauthorisedResponse($exception->getMessage(), $exception->getCode()); } - if (strpos($authToken, ':') === false || strpos($authToken, 'Token ') !== 0) { - return $this->unauthorisedResponse(trans('errors.api_bad_authorization_format')); - } - - [$id, $secret] = explode(':', str_replace('Token ', '', $authToken)); - $token = ApiToken::query() - ->where('token_id', '=', $id) - ->with(['user'])->first(); - - if ($token === null) { - return $this->unauthorisedResponse(trans('errors.api_user_token_not_found')); - } - - if (!Hash::check($secret, $token->secret)) { - return $this->unauthorisedResponse(trans('errors.api_incorrect_token_secret')); - } - - if (!$token->user->can('access-api')) { - return $this->unauthorisedResponse(trans('errors.api_user_no_api_permission'), 403); - } - - auth()->login($token->user); - return $next($request); } /** * Provide a standard API unauthorised response. */ - protected function unauthorisedResponse(string $message, int $code = 401) + protected function unauthorisedResponse(string $message, int $code) { return response()->json([ 'error' => [ diff --git a/app/Http/Middleware/StartSessionIfCookieExists.php b/app/Http/Middleware/StartSessionIfCookieExists.php new file mode 100644 index 000000000..99553e294 --- /dev/null +++ b/app/Http/Middleware/StartSessionIfCookieExists.php @@ -0,0 +1,39 @@ +cookies->has($sessionCookieName)) { + $this->decryptSessionCookie($request, $sessionCookieName); + return parent::handle($request, $next); + } + + return $next($request); + } + + /** + * Attempt decryption of the session cookie. + */ + protected function decryptSessionCookie(Request $request, string $sessionCookieName) + { + try { + $sessionCookie = $request->cookies->get($sessionCookieName); + $sessionCookie = decrypt($sessionCookie, false); + $request->cookies->set($sessionCookieName, $sessionCookie); + } catch (Exception $e) { + // + } + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 6e5b6ffde..ab7dd5195 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -3,6 +3,7 @@ namespace BookStack\Providers; use Auth; +use BookStack\Api\ApiTokenGuard; use BookStack\Auth\Access\LdapService; use Illuminate\Support\ServiceProvider; @@ -15,7 +16,9 @@ class AuthServiceProvider extends ServiceProvider */ public function boot() { - // + Auth::extend('api-token', function ($app, $name, array $config) { + return new ApiTokenGuard($app['request']); + }); } /**