From 575b85021d4f6d9507816710a5b96f6a9742d6bf Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 1 Feb 2020 11:42:22 +0000 Subject: [PATCH] Started alignment of auth services - Removed LDAP specific logic from login controller, placed in Guard. - Created safer base user provider for ldap login, to be used for SAML soon. - Moved LDAP auth work from user provider to guard. --- .../Access/ExternalBaseUserProvider.php} | 51 +-- .../Guards/ExternalBaseSessionGuard.php | 301 ++++++++++++++++++ app/Auth/Access/Guards/LdapSessionGuard.php | 126 ++++++++ app/Auth/Access/LdapService.php | 11 +- app/Config/auth.php | 19 +- app/Exceptions/AuthException.php | 6 - .../LoginAttemptEmailNeededException.php | 6 + app/Exceptions/LoginAttemptException.php | 6 + app/Http/Controllers/Auth/LoginController.php | 118 +++---- app/Providers/AuthServiceProvider.php | 18 +- 10 files changed, 539 insertions(+), 123 deletions(-) rename app/{Providers/LdapUserProvider.php => Auth/Access/ExternalBaseUserProvider.php} (61%) create mode 100644 app/Auth/Access/Guards/ExternalBaseSessionGuard.php create mode 100644 app/Auth/Access/Guards/LdapSessionGuard.php delete mode 100644 app/Exceptions/AuthException.php create mode 100644 app/Exceptions/LoginAttemptEmailNeededException.php create mode 100644 app/Exceptions/LoginAttemptException.php diff --git a/app/Providers/LdapUserProvider.php b/app/Auth/Access/ExternalBaseUserProvider.php similarity index 61% rename from app/Providers/LdapUserProvider.php rename to app/Auth/Access/ExternalBaseUserProvider.php index 9c91def2f..69295ee4e 100644 --- a/app/Providers/LdapUserProvider.php +++ b/app/Auth/Access/ExternalBaseUserProvider.php @@ -1,12 +1,11 @@ model = $model; - $this->ldapService = $ldapService; } /** @@ -44,7 +35,6 @@ class LdapUserProvider implements UserProvider return new $class; } - /** * Retrieve a user by their unique identifier. * @@ -65,12 +55,7 @@ class LdapUserProvider implements UserProvider */ public function retrieveByToken($identifier, $token) { - $model = $this->createModel(); - - return $model->newQuery() - ->where($model->getAuthIdentifierName(), $identifier) - ->where($model->getRememberTokenName(), $token) - ->first(); + return null; } @@ -83,10 +68,7 @@ class LdapUserProvider implements UserProvider */ public function updateRememberToken(Authenticatable $user, $token) { - if ($user->exists) { - $user->setRememberToken($token); - $user->save(); - } + // } /** @@ -97,27 +79,11 @@ class LdapUserProvider implements UserProvider */ public function retrieveByCredentials(array $credentials) { - // Get user via LDAP - $userDetails = $this->ldapService->getUserDetails($credentials['username']); - if ($userDetails === null) { - return null; - } - // Search current user base by looking up a uid $model = $this->createModel(); - $currentUser = $model->newQuery() - ->where('external_auth_id', $userDetails['uid']) + return $model->newQuery() + ->where('external_auth_id', $credentials['external_auth_id']) ->first(); - - if ($currentUser !== null) { - return $currentUser; - } - - $model->name = $userDetails['name']; - $model->external_auth_id = $userDetails['uid']; - $model->email = $userDetails['email']; - $model->email_confirmed = false; - return $model; } /** @@ -129,6 +95,7 @@ class LdapUserProvider implements UserProvider */ public function validateCredentials(Authenticatable $user, array $credentials) { - return $this->ldapService->validateUserCredentials($user, $credentials['username'], $credentials['password']); + // Should be done in the guard. + return false; } } diff --git a/app/Auth/Access/Guards/ExternalBaseSessionGuard.php b/app/Auth/Access/Guards/ExternalBaseSessionGuard.php new file mode 100644 index 000000000..3022b7f8e --- /dev/null +++ b/app/Auth/Access/Guards/ExternalBaseSessionGuard.php @@ -0,0 +1,301 @@ +name = $name; + $this->session = $session; + $this->provider = $provider; + } + + /** + * Get the currently authenticated user. + * + * @return \Illuminate\Contracts\Auth\Authenticatable|null + */ + public function user() + { + if ($this->loggedOut) { + return; + } + + // If we've already retrieved the user for the current request we can just + // return it back immediately. We do not want to fetch the user data on + // every call to this method because that would be tremendously slow. + if (! is_null($this->user)) { + return $this->user; + } + + $id = $this->session->get($this->getName()); + + // First we will try to load the user using the + // identifier in the session if one exists. + if (! is_null($id)) { + $this->user = $this->provider->retrieveById($id); + } + + return $this->user; + } + + /** + * Get the ID for the currently authenticated user. + * + * @return int|null + */ + public function id() + { + if ($this->loggedOut) { + return; + } + + return $this->user() + ? $this->user()->getAuthIdentifier() + : $this->session->get($this->getName()); + } + + /** + * Log a user into the application without sessions or cookies. + * + * @param array $credentials + * @return bool + */ + public function once(array $credentials = []) + { + if ($this->validate($credentials)) { + $this->setUser($this->lastAttempted); + + return true; + } + + return false; + } + + /** + * Log the given user ID into the application without sessions or cookies. + * + * @param mixed $id + * @return \Illuminate\Contracts\Auth\Authenticatable|false + */ + public function onceUsingId($id) + { + if (! is_null($user = $this->provider->retrieveById($id))) { + $this->setUser($user); + + return $user; + } + + return false; + } + + /** + * Validate a user's credentials. + * + * @param array $credentials + * @return bool + */ + public function validate(array $credentials = []) + { + return false; + } + + + /** + * Attempt to authenticate a user using the given credentials. + * + * @param array $credentials + * @param bool $remember + * @return bool + */ + public function attempt(array $credentials = [], $remember = false) + { + return false; + } + + /** + * Log the given user ID into the application. + * + * @param mixed $id + * @param bool $remember + * @return \Illuminate\Contracts\Auth\Authenticatable|false + */ + public function loginUsingId($id, $remember = false) + { + if (! is_null($user = $this->provider->retrieveById($id))) { + $this->login($user, $remember); + + return $user; + } + + return false; + } + + /** + * Log a user into the application. + * + * @param \Illuminate\Contracts\Auth\Authenticatable $user + * @param bool $remember + * @return void + */ + public function login(AuthenticatableContract $user, $remember = false) + { + $this->updateSession($user->getAuthIdentifier()); + + $this->setUser($user); + } + + /** + * Update the session with the given ID. + * + * @param string $id + * @return void + */ + protected function updateSession($id) + { + $this->session->put($this->getName(), $id); + + $this->session->migrate(true); + } + + /** + * Log the user out of the application. + * + * @return void + */ + public function logout() + { + $this->clearUserDataFromStorage(); + + // Now we will clear the users out of memory so they are no longer available + // as the user is no longer considered as being signed into this + // application and should not be available here. + $this->user = null; + + $this->loggedOut = true; + } + + /** + * Remove the user data from the session and cookies. + * + * @return void + */ + protected function clearUserDataFromStorage() + { + $this->session->remove($this->getName()); + } + + /** + * Get the last user we attempted to authenticate. + * + * @return \Illuminate\Contracts\Auth\Authenticatable + */ + public function getLastAttempted() + { + return $this->lastAttempted; + } + + /** + * Get a unique identifier for the auth session value. + * + * @return string + */ + public function getName() + { + return 'login_'.$this->name.'_'.sha1(static::class); + } + + /** + * Determine if the user was authenticated via "remember me" cookie. + * + * @return bool + */ + public function viaRemember() + { + return false; + } + + /** + * Return the currently cached user. + * + * @return \Illuminate\Contracts\Auth\Authenticatable|null + */ + public function getUser() + { + return $this->user; + } + + /** + * Set the current user. + * + * @param \Illuminate\Contracts\Auth\Authenticatable $user + * @return $this + */ + public function setUser(AuthenticatableContract $user) + { + $this->user = $user; + + $this->loggedOut = false; + + return $this; + } + +} diff --git a/app/Auth/Access/Guards/LdapSessionGuard.php b/app/Auth/Access/Guards/LdapSessionGuard.php new file mode 100644 index 000000000..ad173cf73 --- /dev/null +++ b/app/Auth/Access/Guards/LdapSessionGuard.php @@ -0,0 +1,126 @@ +ldapService = $ldapService; + $this->userRepo = $userRepo; + parent::__construct($name, $provider, $session); + } + + /** + * Validate a user's credentials. + * + * @param array $credentials + * @return bool + * @throws LdapException + */ + public function validate(array $credentials = []) + { + $userDetails = $this->ldapService->getUserDetails($credentials['username']); + $this->lastAttempted = $this->provider->retrieveByCredentials([ + 'external_auth_id' => $userDetails['uid'] + ]); + + return $this->ldapService->validateUserCredentials($userDetails, $credentials['username'], $credentials['password']); + } + + /** + * Attempt to authenticate a user using the given credentials. + * + * @param array $credentials + * @param bool $remember + * @return bool + * @throws LoginAttemptEmailNeededException + * @throws LoginAttemptException + * @throws LdapException + */ + public function attempt(array $credentials = [], $remember = false) + { + $username = $credentials['username']; + $userDetails = $this->ldapService->getUserDetails($username); + $this->lastAttempted = $user = $this->provider->retrieveByCredentials([ + 'external_auth_id' => $userDetails['uid'] + ]); + + if (!$this->ldapService->validateUserCredentials($userDetails, $username, $credentials['password'])) { + return false; + } + + if (is_null($user)) { + $user = $this->freshUserInstanceFromLdapUserDetails($userDetails); + } + + $providedEmail = ($credentials['email'] ?? false); + + // Request email if missing from LDAP and model and missing from request + if (is_null($user->email) && !$providedEmail) { + throw new LoginAttemptEmailNeededException(); + } + + // Add email to model if non-existing and email provided in request + if (!$user->exists && $user->email === null && $providedEmail) { + $user->email = $providedEmail; + } + + if (!$user->exists) { + // Check for existing users with same email + $alreadyUser = $user->newQuery()->where('email', '=', $user->email)->count() > 0; + if ($alreadyUser) { + throw new LoginAttemptException(trans('errors.error_user_exists_different_creds', ['email' => $user->email])); + } + + $user->save(); + $this->userRepo->attachDefaultRole($user); + $this->userRepo->downloadAndAssignUserAvatar($user); + } + + // Sync LDAP groups if required + if ($this->ldapService->shouldSyncGroups()) { + $this->ldapService->syncGroups($user, $username); + } + + $this->login($user, $remember); + return true; + } + + /** + * Create a fresh user instance from details provided by a LDAP lookup. + */ + protected function freshUserInstanceFromLdapUserDetails(array $ldapUserDetails): User + { + $user = new User(); + + $user->name = $ldapUserDetails['name']; + $user->external_auth_id = $ldapUserDetails['uid']; + $user->email = $ldapUserDetails['email']; + $user->email_confirmed = false; + + return $user; + } + +} diff --git a/app/Auth/Access/LdapService.php b/app/Auth/Access/LdapService.php index 554bc4b48..cc2890817 100644 --- a/app/Auth/Access/LdapService.php +++ b/app/Auth/Access/LdapService.php @@ -106,20 +106,15 @@ class LdapService extends ExternalAuthService * Check if the given credentials are valid for the given user. * @throws LdapException */ - public function validateUserCredentials(Authenticatable $user, string $username, string $password): bool + public function validateUserCredentials(array $ldapUserDetails, string $username, string $password): bool { - $ldapUser = $this->getUserDetails($username); - if ($ldapUser === null) { - return false; - } - - if ($ldapUser['uid'] !== $user->external_auth_id) { + if ($ldapUserDetails === null) { return false; } $ldapConnection = $this->getConnection(); try { - $ldapBind = $this->ldap->bind($ldapConnection, $ldapUser['dn'], $password); + $ldapBind = $this->ldap->bind($ldapConnection, $ldapUserDetails['dn'], $password); } catch (ErrorException $e) { $ldapBind = false; } diff --git a/app/Config/auth.php b/app/Config/auth.php index b3e22c7e1..0be5aeee8 100644 --- a/app/Config/auth.php +++ b/app/Config/auth.php @@ -18,7 +18,7 @@ return [ // This option controls the default authentication "guard" and password // reset options for your application. 'defaults' => [ - 'guard' => 'web', + 'guard' => env('AUTH_METHOD', 'standard') === 'standard' ? 'web' : env('AUTH_METHOD'), 'passwords' => 'users', ], @@ -26,13 +26,16 @@ return [ // 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" + // Supported drivers: "session", "api-token", "ldap-session" 'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'users', ], - + 'ldap' => [ + 'driver' => 'ldap-session', + 'provider' => 'external' + ], 'api' => [ 'driver' => 'api-token', ], @@ -42,17 +45,15 @@ return [ // 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: database, eloquent, ldap 'providers' => [ 'users' => [ 'driver' => env('AUTH_METHOD', 'standard') === 'standard' ? 'eloquent' : env('AUTH_METHOD'), 'model' => \BookStack\Auth\User::class, ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], + 'external' => [ + 'driver' => 'external-users', + 'model' => \BookStack\Auth\User::class, + ], ], // Resetting Passwords diff --git a/app/Exceptions/AuthException.php b/app/Exceptions/AuthException.php deleted file mode 100644 index 2ab7d4616..000000000 --- a/app/Exceptions/AuthException.php +++ /dev/null @@ -1,6 +0,0 @@ -middleware('guest', ['only' => ['getLogin', 'postLogin']]); $this->socialAuthService = $socialAuthService; - $this->ldapService = $ldapService; - $this->userRepo = $userRepo; $this->redirectPath = url('/'); $this->redirectAfterLogout = url('/login'); parent::__construct(); @@ -64,47 +51,11 @@ class LoginController extends Controller } /** - * Overrides the action when a user is authenticated. - * If the user authenticated but does not exist in the user table we create them. - * @throws AuthException - * @throws \BookStack\Exceptions\LdapException + * Get the needed authorization credentials from the request. */ - protected function authenticated(Request $request, Authenticatable $user) + protected function credentials(Request $request) { - // Explicitly log them out for now if they do no exist. - if (!$user->exists) { - auth()->logout($user); - } - - if (!$user->exists && $user->email === null && !$request->filled('email')) { - $request->flash(); - session()->flash('request-email', true); - return redirect('/login'); - } - - if (!$user->exists && $user->email === null && $request->filled('email')) { - $user->email = $request->get('email'); - } - - if (!$user->exists) { - // Check for users with same email already - $alreadyUser = $user->newQuery()->where('email', '=', $user->email)->count() > 0; - if ($alreadyUser) { - throw new AuthException(trans('errors.error_user_exists_different_creds', ['email' => $user->email])); - } - - $user->save(); - $this->userRepo->attachDefaultRole($user); - $this->userRepo->downloadAndAssignUserAvatar($user); - auth()->login($user); - } - - // Sync LDAP groups if required - if ($this->ldapService->shouldSyncGroups()) { - $this->ldapService->syncGroups($user, $request->get($this->username())); - } - - return redirect()->intended('/'); + return $request->only('username', 'email', 'password'); } /** @@ -130,6 +81,61 @@ class LoginController extends Controller ]); } + /** + * Handle a login request to the application. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Http\JsonResponse + * + * @throws \Illuminate\Validation\ValidationException + */ + public function login(Request $request) + { + $this->validateLogin($request); + + // If the class is using the ThrottlesLogins trait, we can automatically throttle + // the login attempts for this application. We'll key this by the username and + // the IP address of the client making these requests into this application. + if (method_exists($this, 'hasTooManyLoginAttempts') && + $this->hasTooManyLoginAttempts($request)) { + $this->fireLockoutEvent($request); + + return $this->sendLockoutResponse($request); + } + + try { + if ($this->attemptLogin($request)) { + return $this->sendLoginResponse($request); + } + } catch (LoginAttemptException $exception) { + return $this->sendLoginAttemptExceptionResponse($exception, $request); + } + + // If the login attempt was unsuccessful we will increment the number of attempts + // to login and redirect the user back to the login form. Of course, when this + // user surpasses their maximum number of attempts they will get locked out. + $this->incrementLoginAttempts($request); + + return $this->sendFailedLoginResponse($request); + } + + /** + * Send a response when a login attempt exception occurs. + */ + protected function sendLoginAttemptExceptionResponse(LoginAttemptException $exception, Request $request) + { + if ($exception instanceof LoginAttemptEmailNeededException) { + $request->flash(); + session()->flash('request-email', true); + } + + if ($message = $exception->getMessage()) { + $this->showWarningNotification($message); + } + + return redirect('/login'); + } + /** * Log the user out of the application. */ diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index ab7dd5195..0b299551a 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -4,7 +4,10 @@ namespace BookStack\Providers; use Auth; use BookStack\Api\ApiTokenGuard; +use BookStack\Auth\Access\ExternalBaseUserProvider; +use BookStack\Auth\Access\Guards\LdapSessionGuard; use BookStack\Auth\Access\LdapService; +use BookStack\Auth\UserRepo; use Illuminate\Support\ServiceProvider; class AuthServiceProvider extends ServiceProvider @@ -19,6 +22,17 @@ class AuthServiceProvider extends ServiceProvider Auth::extend('api-token', function ($app, $name, array $config) { return new ApiTokenGuard($app['request']); }); + + Auth::extend('ldap-session', function ($app, $name, array $config) { + $provider = Auth::createUserProvider($config['provider']); + return new LdapSessionGuard( + $name, + $provider, + $this->app['session.store'], + $app[LdapService::class], + $app[UserRepo::class] + ); + }); } /** @@ -28,8 +42,8 @@ class AuthServiceProvider extends ServiceProvider */ public function register() { - Auth::provider('ldap', function ($app, array $config) { - return new LdapUserProvider($config['model'], $app[LdapService::class]); + Auth::provider('external-users', function ($app, array $config) { + return new ExternalBaseUserProvider($config['model']); }); } }