0
0
Fork 0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-04-25 12:59:22 +00:00

Merge branch 'auth_review' into development

This commit is contained in:
Dan Brown 2022-09-27 19:34:48 +01:00
commit bf56254077
No known key found for this signature in database
GPG key ID: 46D9F943C24A2EF9
16 changed files with 537 additions and 555 deletions

View file

@ -5,6 +5,7 @@ namespace BookStack\Auth\Access;
use BookStack\Actions\ActivityType; use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\Mfa\MfaSession; use BookStack\Auth\Access\Mfa\MfaSession;
use BookStack\Auth\User; use BookStack\Auth\User;
use BookStack\Exceptions\LoginAttemptException;
use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Facades\Theme; use BookStack\Facades\Theme;
@ -149,6 +150,7 @@ class LoginService
* May interrupt the flow if extra authentication requirements are imposed. * May interrupt the flow if extra authentication requirements are imposed.
* *
* @throws StoppedAuthenticationException * @throws StoppedAuthenticationException
* @throws LoginAttemptException
*/ */
public function attempt(array $credentials, string $method, bool $remember = false): bool public function attempt(array $credentials, string $method, bool $remember = false): bool
{ {

View file

@ -10,6 +10,7 @@ use BookStack\Exceptions\UserUpdateException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Uploads\UserAvatars; use BookStack\Uploads\UserAvatars;
use Exception; use Exception;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -61,7 +62,7 @@ class UserRepo
$user = new User(); $user = new User();
$user->name = $data['name']; $user->name = $data['name'];
$user->email = $data['email']; $user->email = $data['email'];
$user->password = bcrypt(empty($data['password']) ? Str::random(32) : $data['password']); $user->password = Hash::make(empty($data['password']) ? Str::random(32) : $data['password']);
$user->email_confirmed = $emailConfirmed; $user->email_confirmed = $emailConfirmed;
$user->external_auth_id = $data['external_auth_id'] ?? ''; $user->external_auth_id = $data['external_auth_id'] ?? '';
@ -126,7 +127,7 @@ class UserRepo
} }
if (!empty($data['password'])) { if (!empty($data['password'])) {
$user->password = bcrypt($data['password']); $user->password = Hash::make($data['password']);
} }
if (!empty($data['language'])) { if (!empty($data['language'])) {

View file

@ -14,9 +14,9 @@ use Illuminate\Http\Request;
class ConfirmEmailController extends Controller class ConfirmEmailController extends Controller
{ {
protected $emailConfirmationService; protected EmailConfirmationService $emailConfirmationService;
protected $loginService; protected LoginService $loginService;
protected $userRepo; protected UserRepo $userRepo;
/** /**
* Create a new controller instance. * Create a new controller instance.

View file

@ -4,24 +4,11 @@ namespace BookStack\Http\Controllers\Auth;
use BookStack\Actions\ActivityType; use BookStack\Actions\ActivityType;
use BookStack\Http\Controllers\Controller; use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
class ForgotPasswordController extends Controller class ForgotPasswordController extends Controller
{ {
/*
|--------------------------------------------------------------------------
| Password Reset Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling password reset emails and
| includes a trait which assists in sending these notifications from
| your application to your users. Feel free to explore this trait.
|
*/
use SendsPasswordResetEmails;
/** /**
* Create a new controller instance. * Create a new controller instance.
* *
@ -33,6 +20,14 @@ class ForgotPasswordController extends Controller
$this->middleware('guard:standard'); $this->middleware('guard:standard');
} }
/**
* Display the form to request a password reset link.
*/
public function showLinkRequestForm()
{
return view('auth.passwords.email');
}
/** /**
* Send a reset link to the given user. * Send a reset link to the given user.
* *
@ -49,7 +44,7 @@ class ForgotPasswordController extends Controller
// We will send the password reset link to this user. Once we have attempted // We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we // to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response. // need to show to the user. Finally, we'll send out a proper response.
$response = $this->broker()->sendResetLink( $response = Password::broker()->sendResetLink(
$request->only('email') $request->only('email')
); );

View file

@ -8,31 +8,14 @@ use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException; use BookStack\Exceptions\LoginAttemptException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Http\Controllers\Controller; use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class LoginController extends Controller class LoginController extends Controller
{ {
/* use ThrottlesLogins;
|--------------------------------------------------------------------------
| Login Controller
|--------------------------------------------------------------------------
|
| This controller handles authenticating users for the application and
| redirecting them to your home screen. The controller uses a trait
| to conveniently provide its functionality to your applications.
|
*/
use AuthenticatesUsers {
logout as traitLogout;
}
/**
* Redirection paths.
*/
protected $redirectTo = '/';
protected $redirectPath = '/';
protected SocialAuthService $socialAuthService; protected SocialAuthService $socialAuthService;
protected LoginService $loginService; protected LoginService $loginService;
@ -48,21 +31,6 @@ class LoginController extends Controller
$this->socialAuthService = $socialAuthService; $this->socialAuthService = $socialAuthService;
$this->loginService = $loginService; $this->loginService = $loginService;
$this->redirectPath = url('/');
}
public function username()
{
return config('auth.method') === 'standard' ? 'email' : 'username';
}
/**
* Get the needed authorization credentials from the request.
*/
protected function credentials(Request $request)
{
return $request->only('username', 'email', 'password');
} }
/** /**
@ -98,29 +66,15 @@ class LoginController extends Controller
/** /**
* Handle a login request to the application. * Handle a login request to the application.
*
* @param \Illuminate\Http\Request $request
*
* @throws \Illuminate\Validation\ValidationException
*
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Http\JsonResponse
*/ */
public function login(Request $request) public function login(Request $request)
{ {
$this->validateLogin($request); $this->validateLogin($request);
$username = $request->get($this->username()); $username = $request->get($this->username());
// If the class is using the ThrottlesLogins trait, we can automatically throttle // Check login throttling attempts to see if they've gone over the limit
// the login attempts for this application. We'll key this by the username and if ($this->hasTooManyLoginAttempts($request)) {
// the IP address of the client making these requests into this application.
if (
method_exists($this, 'hasTooManyLoginAttempts') &&
$this->hasTooManyLoginAttempts($request)
) {
$this->fireLockoutEvent($request);
Activity::logFailedLogin($username); Activity::logFailedLogin($username);
return $this->sendLockoutResponse($request); return $this->sendLockoutResponse($request);
} }
@ -134,24 +88,62 @@ class LoginController extends Controller
return $this->sendLoginAttemptExceptionResponse($exception, $request); return $this->sendLoginAttemptExceptionResponse($exception, $request);
} }
// If the login attempt was unsuccessful we will increment the number of attempts // On unsuccessful login attempt, Increment login attempts for throttling and log failed login.
// 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); $this->incrementLoginAttempts($request);
Activity::logFailedLogin($username); Activity::logFailedLogin($username);
return $this->sendFailedLoginResponse($request); // Throw validation failure for failed login
throw ValidationException::withMessages([
$this->username() => [trans('auth.failed')],
])->redirectTo('/login');
}
/**
* Logout user and perform subsequent redirect.
*/
public function logout(Request $request)
{
Auth::guard()->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
$redirectUri = $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
return redirect($redirectUri);
}
/**
* Get the expected username input based upon the current auth method.
*/
protected function username(): string
{
return config('auth.method') === 'standard' ? 'email' : 'username';
}
/**
* Get the needed authorization credentials from the request.
*/
protected function credentials(Request $request): array
{
return $request->only('username', 'email', 'password');
}
/**
* Send the response after the user was authenticated.
* @return RedirectResponse
*/
protected function sendLoginResponse(Request $request)
{
$request->session()->regenerate();
$this->clearLoginAttempts($request);
return redirect()->intended('/');
} }
/** /**
* Attempt to log the user into the application. * Attempt to log the user into the application.
*
* @param \Illuminate\Http\Request $request
*
* @return bool
*/ */
protected function attemptLogin(Request $request) protected function attemptLogin(Request $request): bool
{ {
return $this->loginService->attempt( return $this->loginService->attempt(
$this->credentials($request), $this->credentials($request),
@ -160,29 +152,12 @@ class LoginController extends Controller
); );
} }
/**
* The user has been authenticated.
*
* @param \Illuminate\Http\Request $request
* @param mixed $user
*
* @return mixed
*/
protected function authenticated(Request $request, $user)
{
return redirect()->intended($this->redirectPath());
}
/** /**
* Validate the user login request. * Validate the user login request.
* * @throws ValidationException
* @param \Illuminate\Http\Request $request
*
* @throws \Illuminate\Validation\ValidationException
*
* @return void
*/ */
protected function validateLogin(Request $request) protected function validateLogin(Request $request): void
{ {
$rules = ['password' => ['required', 'string']]; $rules = ['password' => ['required', 'string']];
$authMethod = config('auth.method'); $authMethod = config('auth.method');
@ -216,22 +191,6 @@ class LoginController extends Controller
return redirect('/login'); return redirect('/login');
} }
/**
* Get the failed login response instance.
*
* @param \Illuminate\Http\Request $request
*
* @throws \Illuminate\Validation\ValidationException
*
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function sendFailedLoginResponse(Request $request)
{
throw ValidationException::withMessages([
$this->username() => [trans('auth.failed')],
])->redirectTo('/login');
}
/** /**
* Update the intended URL location from their previous URL. * Update the intended URL location from their previous URL.
* Ignores if not from the current app instance or if from certain * Ignores if not from the current app instance or if from certain
@ -271,20 +230,4 @@ class LoginController extends Controller
return $autoRedirect && count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']); return $autoRedirect && count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
} }
/**
* Logout user and perform subsequent redirect.
*
* @param \Illuminate\Http\Request $request
*
* @return mixed
*/
public function logout(Request $request)
{
$this->traitLogout($request);
$redirectUri = $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
return redirect($redirectUri);
}
} }

View file

@ -5,42 +5,20 @@ namespace BookStack\Http\Controllers\Auth;
use BookStack\Auth\Access\LoginService; use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\RegistrationService; use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\Access\SocialAuthService; use BookStack\Auth\Access\SocialAuthService;
use BookStack\Auth\User;
use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException; use BookStack\Exceptions\UserRegistrationException;
use BookStack\Http\Controllers\Controller; use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\RegistersUsers; use Illuminate\Contracts\Validation\Validator as ValidatorContract;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
class RegisterController extends Controller class RegisterController extends Controller
{ {
/*
|--------------------------------------------------------------------------
| Register Controller
|--------------------------------------------------------------------------
|
| This controller handles the registration of new users as well as their
| validation and creation. By default this controller uses a trait to
| provide this functionality without requiring any additional code.
|
*/
use RegistersUsers;
protected SocialAuthService $socialAuthService; protected SocialAuthService $socialAuthService;
protected RegistrationService $registrationService; protected RegistrationService $registrationService;
protected LoginService $loginService; protected LoginService $loginService;
/**
* Where to redirect users after login / registration.
*
* @var string
*/
protected $redirectTo = '/';
protected $redirectPath = '/';
/** /**
* Create a new controller instance. * Create a new controller instance.
*/ */
@ -55,23 +33,6 @@ class RegisterController extends Controller
$this->socialAuthService = $socialAuthService; $this->socialAuthService = $socialAuthService;
$this->registrationService = $registrationService; $this->registrationService = $registrationService;
$this->loginService = $loginService; $this->loginService = $loginService;
$this->redirectTo = url('/');
$this->redirectPath = url('/');
}
/**
* Get a validator for an incoming registration request.
*
* @return \Illuminate\Contracts\Validation\Validator
*/
protected function validator(array $data)
{
return Validator::make($data, [
'name' => ['required', 'min:2', 'max:100'],
'email' => ['required', 'email', 'max:255', 'unique:users'],
'password' => ['required', Password::default()],
]);
} }
/** /**
@ -114,22 +75,18 @@ class RegisterController extends Controller
$this->showSuccessNotification(trans('auth.register_success')); $this->showSuccessNotification(trans('auth.register_success'));
return redirect($this->redirectPath()); return redirect('/');
} }
/** /**
* Create a new user instance after a valid registration. * Get a validator for an incoming registration request.
*
* @param array $data
*
* @return User
*/ */
protected function create(array $data) protected function validator(array $data): ValidatorContract
{ {
return User::create([ return Validator::make($data, [
'name' => $data['name'], 'name' => ['required', 'min:2', 'max:100'],
'email' => $data['email'], 'email' => ['required', 'email', 'max:255', 'unique:users'],
'password' => Hash::make($data['password']), 'password' => ['required', Password::default()],
]); ]);
} }
} }

View file

@ -3,65 +3,87 @@
namespace BookStack\Http\Controllers\Auth; namespace BookStack\Http\Controllers\Auth;
use BookStack\Actions\ActivityType; use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\LoginService;
use BookStack\Auth\User;
use BookStack\Http\Controllers\Controller; use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ResetsPasswords; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password as PasswordRule;
class ResetPasswordController extends Controller class ResetPasswordController extends Controller
{ {
/* protected LoginService $loginService;
|--------------------------------------------------------------------------
| Password Reset Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling password reset requests
| and uses a simple trait to include this behavior. You're free to
| explore this trait and override any methods you wish to tweak.
|
*/
use ResetsPasswords;
protected $redirectTo = '/'; public function __construct(LoginService $loginService)
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{ {
$this->middleware('guest'); $this->middleware('guest');
$this->middleware('guard:standard'); $this->middleware('guard:standard');
$this->loginService = $loginService;
}
/**
* Display the password reset view for the given token.
* If no token is present, display the link request form.
*/
public function showResetForm(Request $request)
{
$token = $request->route()->parameter('token');
return view('auth.passwords.reset')->with(
['token' => $token, 'email' => $request->email]
);
}
/**
* Reset the given user's password.
*/
public function reset(Request $request)
{
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', PasswordRule::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$credentials = $request->only('email', 'password', 'password_confirmation', 'token');
$response = Password::broker()->reset($credentials, function (User $user, string $password) {
$user->password = Hash::make($password);
$user->setRememberToken(Str::random(60));
$user->save();
$this->loginService->login($user, auth()->getDefaultDriver());
});
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $response === Password::PASSWORD_RESET
? $this->sendResetResponse()
: $this->sendResetFailedResponse($request, $response);
} }
/** /**
* Get the response for a successful password reset. * Get the response for a successful password reset.
*
* @param Request $request
* @param string $response
*
* @return \Illuminate\Http\Response
*/ */
protected function sendResetResponse(Request $request, $response) protected function sendResetResponse(): RedirectResponse
{ {
$message = trans('auth.reset_password_success'); $this->showSuccessNotification(trans('auth.reset_password_success'));
$this->showSuccessNotification($message);
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET_UPDATE, user()); $this->logActivity(ActivityType::AUTH_PASSWORD_RESET_UPDATE, user());
return redirect($this->redirectPath()) return redirect('/');
->with('status', trans($response));
} }
/** /**
* Get the response for a failed password reset. * Get the response for a failed password reset.
*
* @param \Illuminate\Http\Request $request
* @param string $response
*
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
*/ */
protected function sendResetFailedResponse(Request $request, $response) protected function sendResetFailedResponse(Request $request, string $response): RedirectResponse
{ {
// We show invalid users as invalid tokens as to not leak what // We show invalid users as invalid tokens as to not leak what
// users may exist in the system. // users may exist in the system.

View file

@ -9,7 +9,7 @@ use Illuminate\Support\Str;
class Saml2Controller extends Controller class Saml2Controller extends Controller
{ {
protected $samlService; protected Saml2Service $samlService;
/** /**
* Saml2Controller constructor. * Saml2Controller constructor.

View file

@ -16,9 +16,9 @@ use Laravel\Socialite\Contracts\User as SocialUser;
class SocialController extends Controller class SocialController extends Controller
{ {
protected $socialAuthService; protected SocialAuthService $socialAuthService;
protected $registrationService; protected RegistrationService $registrationService;
protected $loginService; protected LoginService $loginService;
/** /**
* SocialController constructor. * SocialController constructor.
@ -28,7 +28,7 @@ class SocialController extends Controller
RegistrationService $registrationService, RegistrationService $registrationService,
LoginService $loginService LoginService $loginService
) { ) {
$this->middleware('guest')->only(['getRegister', 'postRegister']); $this->middleware('guest')->only(['register']);
$this->socialAuthService = $socialAuthService; $this->socialAuthService = $socialAuthService;
$this->registrationService = $registrationService; $this->registrationService = $registrationService;
$this->loginService = $loginService; $this->loginService = $loginService;

View file

@ -0,0 +1,92 @@
<?php
namespace BookStack\Http\Controllers\Auth;
use Illuminate\Cache\RateLimiter;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
trait ThrottlesLogins
{
/**
* Determine if the user has too many failed login attempts.
*/
protected function hasTooManyLoginAttempts(Request $request): bool
{
return $this->limiter()->tooManyAttempts(
$this->throttleKey($request),
$this->maxAttempts()
);
}
/**
* Increment the login attempts for the user.
*/
protected function incrementLoginAttempts(Request $request): void
{
$this->limiter()->hit(
$this->throttleKey($request),
$this->decayMinutes() * 60
);
}
/**
* Redirect the user after determining they are locked out.
* @throws ValidationException
*/
protected function sendLockoutResponse(Request $request): \Symfony\Component\HttpFoundation\Response
{
$seconds = $this->limiter()->availableIn(
$this->throttleKey($request)
);
throw ValidationException::withMessages([
$this->username() => [trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
])],
])->status(Response::HTTP_TOO_MANY_REQUESTS);
}
/**
* Clear the login locks for the given user credentials.
*/
protected function clearLoginAttempts(Request $request): void
{
$this->limiter()->clear($this->throttleKey($request));
}
/**
* Get the throttle key for the given request.
*/
protected function throttleKey(Request $request): string
{
return Str::transliterate(Str::lower($request->input($this->username())) . '|' . $request->ip());
}
/**
* Get the rate limiter instance.
*/
protected function limiter(): RateLimiter
{
return app(RateLimiter::class);
}
/**
* Get the maximum number of attempts to allow.
*/
public function maxAttempts(): int
{
return 5;
}
/**
* Get the number of minutes to throttle for.
*/
public function decayMinutes(): int
{
return 1;
}
}

View file

@ -11,12 +11,13 @@ use Exception;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Redirector; use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
class UserInviteController extends Controller class UserInviteController extends Controller
{ {
protected $inviteService; protected UserInviteService $inviteService;
protected $userRepo; protected UserRepo $userRepo;
/** /**
* Create a new controller instance. * Create a new controller instance.
@ -66,7 +67,7 @@ class UserInviteController extends Controller
} }
$user = $this->userRepo->getById($userId); $user = $this->userRepo->getById($userId);
$user->password = bcrypt($request->get('password')); $user->password = Hash::make($request->get('password'));
$user->email_confirmed = true; $user->email_confirmed = true;
$user->save(); $user->save();

View file

@ -26,7 +26,6 @@
"laravel/framework": "^8.68", "laravel/framework": "^8.68",
"laravel/socialite": "^5.2", "laravel/socialite": "^5.2",
"laravel/tinker": "^2.6", "laravel/tinker": "^2.6",
"laravel/ui": "^3.3",
"league/commonmark": "^1.6", "league/commonmark": "^1.6",
"league/flysystem-aws-s3-v3": "^1.0.29", "league/flysystem-aws-s3-v3": "^1.0.29",
"league/html-to-markdown": "^5.0.0", "league/html-to-markdown": "^5.0.0",

75
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "4a0d254197dda8118685ec1a1eb10edf", "content-hash": "1fed6278d440ef18af1ffa6ca7b29166",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@ -58,16 +58,16 @@
}, },
{ {
"name": "aws/aws-sdk-php", "name": "aws/aws-sdk-php",
"version": "3.236.0", "version": "3.236.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/aws/aws-sdk-php.git", "url": "https://github.com/aws/aws-sdk-php.git",
"reference": "bff1f1ade00c758ea27f498baee1fa16901e5bfd" "reference": "1e8d1abe7582968df16a2e7a87c5dcc51d0dfd1b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/bff1f1ade00c758ea27f498baee1fa16901e5bfd", "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/1e8d1abe7582968df16a2e7a87c5dcc51d0dfd1b",
"reference": "bff1f1ade00c758ea27f498baee1fa16901e5bfd", "reference": "1e8d1abe7582968df16a2e7a87c5dcc51d0dfd1b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -146,9 +146,9 @@
"support": { "support": {
"forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
"issues": "https://github.com/aws/aws-sdk-php/issues", "issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.236.0" "source": "https://github.com/aws/aws-sdk-php/tree/3.236.1"
}, },
"time": "2022-09-26T18:13:07+00:00" "time": "2022-09-27T18:19:10+00:00"
}, },
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@ -2154,67 +2154,6 @@
}, },
"time": "2022-03-23T12:38:24+00:00" "time": "2022-03-23T12:38:24+00:00"
}, },
{
"name": "laravel/ui",
"version": "v3.4.6",
"source": {
"type": "git",
"url": "https://github.com/laravel/ui.git",
"reference": "65ec5c03f7fee2c8ecae785795b829a15be48c2c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/ui/zipball/65ec5c03f7fee2c8ecae785795b829a15be48c2c",
"reference": "65ec5c03f7fee2c8ecae785795b829a15be48c2c",
"shasum": ""
},
"require": {
"illuminate/console": "^8.42|^9.0",
"illuminate/filesystem": "^8.42|^9.0",
"illuminate/support": "^8.82|^9.0",
"illuminate/validation": "^8.42|^9.0",
"php": "^7.3|^8.0"
},
"require-dev": {
"orchestra/testbench": "^6.23|^7.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
},
"laravel": {
"providers": [
"Laravel\\Ui\\UiServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Ui\\": "src/",
"Illuminate\\Foundation\\Auth\\": "auth-backend/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel UI utilities and presets.",
"keywords": [
"laravel",
"ui"
],
"support": {
"source": "https://github.com/laravel/ui/tree/v3.4.6"
},
"time": "2022-05-20T13:38:08+00:00"
},
{ {
"name": "league/commonmark", "name": "league/commonmark",
"version": "1.6.7", "version": "1.6.7",

View file

@ -3,13 +3,7 @@
namespace Tests\Auth; namespace Tests\Auth;
use BookStack\Auth\Access\Mfa\MfaSession; use BookStack\Auth\Access\Mfa\MfaSession;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Notifications\ConfirmEmail;
use BookStack\Notifications\ResetPassword;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Illuminate\Testing\TestResponse; use Illuminate\Testing\TestResponse;
use Tests\TestCase; use Tests\TestCase;
@ -33,68 +27,6 @@ class AuthTest extends TestCase
->assertSee('Log in'); ->assertSee('Log in');
} }
public function test_registration_showing()
{
// Ensure registration form is showing
$this->setSettings(['registration-enabled' => 'true']);
$resp = $this->get('/login');
$this->withHtml($resp)->assertElementContains('a[href="' . url('/register') . '"]', 'Sign up');
}
public function test_normal_registration()
{
// Set settings and get user instance
/** @var Role $registrationRole */
$registrationRole = Role::query()->first();
$this->setSettings(['registration-enabled' => 'true', 'registration-role' => $registrationRole->id]);
/** @var User $user */
$user = User::factory()->make();
// Test form and ensure user is created
$resp = $this->get('/register')
->assertSee('Sign Up');
$this->withHtml($resp)->assertElementContains('form[action="' . url('/register') . '"]', 'Create Account');
$resp = $this->post('/register', $user->only('password', 'name', 'email'));
$resp->assertRedirect('/');
$resp = $this->get('/');
$resp->assertOk();
$resp->assertSee($user->name);
$this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email]);
$user = User::query()->where('email', '=', $user->email)->first();
$this->assertEquals(1, $user->roles()->count());
$this->assertEquals($registrationRole->id, $user->roles()->first()->id);
}
public function test_empty_registration_redirects_back_with_errors()
{
// Set settings and get user instance
$this->setSettings(['registration-enabled' => 'true']);
// Test form and ensure user is created
$this->get('/register');
$this->post('/register', [])->assertRedirect('/register');
$this->get('/register')->assertSee('The name field is required');
}
public function test_registration_validation()
{
$this->setSettings(['registration-enabled' => 'true']);
$this->get('/register');
$resp = $this->followingRedirects()->post('/register', [
'name' => '1',
'email' => '1',
'password' => '1',
]);
$resp->assertSee('The name must be at least 2 characters.');
$resp->assertSee('The email must be a valid email address.');
$resp->assertSee('The password must be at least 8 characters.');
}
public function test_sign_up_link_on_login() public function test_sign_up_link_on_login()
{ {
$this->get('/login')->assertDontSee('Sign up'); $this->get('/login')->assertDontSee('Sign up');
@ -104,108 +36,6 @@ class AuthTest extends TestCase
$this->get('/login')->assertSee('Sign up'); $this->get('/login')->assertSee('Sign up');
} }
public function test_confirmed_registration()
{
// Fake notifications
Notification::fake();
// Set settings and get user instance
$this->setSettings(['registration-enabled' => 'true', 'registration-confirmation' => 'true']);
$user = User::factory()->make();
// Go through registration process
$resp = $this->post('/register', $user->only('name', 'email', 'password'));
$resp->assertRedirect('/register/confirm');
$this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
// Ensure notification sent
/** @var User $dbUser */
$dbUser = User::query()->where('email', '=', $user->email)->first();
Notification::assertSentTo($dbUser, ConfirmEmail::class);
// Test access and resend confirmation email
$resp = $this->login($user->email, $user->password);
$resp->assertRedirect('/register/confirm/awaiting');
$resp = $this->get('/register/confirm/awaiting');
$this->withHtml($resp)->assertElementContains('form[action="' . url('/register/confirm/resend') . '"]', 'Resend');
$this->get('/books')->assertRedirect('/login');
$this->post('/register/confirm/resend', $user->only('email'));
// Get confirmation and confirm notification matches
$emailConfirmation = DB::table('email_confirmations')->where('user_id', '=', $dbUser->id)->first();
Notification::assertSentTo($dbUser, ConfirmEmail::class, function ($notification, $channels) use ($emailConfirmation) {
return $notification->token === $emailConfirmation->token;
});
// Check confirmation email confirmation activation.
$this->get('/register/confirm/' . $emailConfirmation->token)->assertRedirect('/login');
$this->get('/login')->assertSee('Your email has been confirmed! You should now be able to login using this email address.');
$this->assertDatabaseMissing('email_confirmations', ['token' => $emailConfirmation->token]);
$this->assertDatabaseHas('users', ['name' => $dbUser->name, 'email' => $dbUser->email, 'email_confirmed' => true]);
}
public function test_restricted_registration()
{
$this->setSettings(['registration-enabled' => 'true', 'registration-confirmation' => 'true', 'registration-restrict' => 'example.com']);
$user = User::factory()->make();
// Go through registration process
$this->post('/register', $user->only('name', 'email', 'password'))
->assertRedirect('/register');
$resp = $this->get('/register');
$resp->assertSee('That email domain does not have access to this application');
$this->assertDatabaseMissing('users', $user->only('email'));
$user->email = 'barry@example.com';
$this->post('/register', $user->only('name', 'email', 'password'))
->assertRedirect('/register/confirm');
$this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
$this->assertNull(auth()->user());
$this->get('/')->assertRedirect('/login');
$resp = $this->followingRedirects()->post('/login', $user->only('email', 'password'));
$resp->assertSee('Email Address Not Confirmed');
$this->assertNull(auth()->user());
}
public function test_restricted_registration_with_confirmation_disabled()
{
$this->setSettings(['registration-enabled' => 'true', 'registration-confirmation' => 'false', 'registration-restrict' => 'example.com']);
$user = User::factory()->make();
// Go through registration process
$this->post('/register', $user->only('name', 'email', 'password'))
->assertRedirect('/register');
$this->assertDatabaseMissing('users', $user->only('email'));
$this->get('/register')->assertSee('That email domain does not have access to this application');
$user->email = 'barry@example.com';
$this->post('/register', $user->only('name', 'email', 'password'))
->assertRedirect('/register/confirm');
$this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
$this->assertNull(auth()->user());
$this->get('/')->assertRedirect('/login');
$resp = $this->post('/login', $user->only('email', 'password'));
$resp->assertRedirect('/register/confirm/awaiting');
$this->get('/register/confirm/awaiting')->assertSee('Email Address Not Confirmed');
$this->assertNull(auth()->user());
}
public function test_registration_role_unset_by_default()
{
$this->assertFalse(setting('registration-role'));
$resp = $this->asAdmin()->get('/settings/registration');
$this->withHtml($resp)->assertElementContains('select[name="setting-registration-role"] option[value="0"][selected]', '-- None --');
}
public function test_logout() public function test_logout()
{ {
$this->asAdmin()->get('/')->assertOk(); $this->asAdmin()->get('/')->assertOk();
@ -225,96 +55,6 @@ class AuthTest extends TestCase
$this->assertFalse($mfaSession->isVerifiedForUser($user)); $this->assertFalse($mfaSession->isVerifiedForUser($user));
} }
public function test_reset_password_flow()
{
Notification::fake();
$resp = $this->get('/login');
$this->withHtml($resp)->assertElementContains('a[href="' . url('/password/email') . '"]', 'Forgot Password?');
$resp = $this->get('/password/email');
$this->withHtml($resp)->assertElementContains('form[action="' . url('/password/email') . '"]', 'Send Reset Link');
$resp = $this->post('/password/email', [
'email' => 'admin@admin.com',
]);
$resp->assertRedirect('/password/email');
$resp = $this->get('/password/email');
$resp->assertSee('A password reset link will be sent to admin@admin.com if that email address is found in the system.');
$this->assertDatabaseHas('password_resets', [
'email' => 'admin@admin.com',
]);
/** @var User $user */
$user = User::query()->where('email', '=', 'admin@admin.com')->first();
Notification::assertSentTo($user, ResetPassword::class);
$n = Notification::sent($user, ResetPassword::class);
$this->get('/password/reset/' . $n->first()->token)
->assertOk()
->assertSee('Reset Password');
$resp = $this->post('/password/reset', [
'email' => 'admin@admin.com',
'password' => 'randompass',
'password_confirmation' => 'randompass',
'token' => $n->first()->token,
]);
$resp->assertRedirect('/');
$this->get('/')->assertSee('Your password has been successfully reset');
}
public function test_reset_password_flow_shows_success_message_even_if_wrong_password_to_prevent_user_discovery()
{
$this->get('/password/email');
$resp = $this->followingRedirects()->post('/password/email', [
'email' => 'barry@admin.com',
]);
$resp->assertSee('A password reset link will be sent to barry@admin.com if that email address is found in the system.');
$resp->assertDontSee('We can\'t find a user');
$this->get('/password/reset/arandometokenvalue')->assertSee('Reset Password');
$resp = $this->post('/password/reset', [
'email' => 'barry@admin.com',
'password' => 'randompass',
'password_confirmation' => 'randompass',
'token' => 'arandometokenvalue',
]);
$resp->assertRedirect('/password/reset/arandometokenvalue');
$this->get('/password/reset/arandometokenvalue')
->assertDontSee('We can\'t find a user')
->assertSee('The password reset token is invalid for this email address.');
}
public function test_reset_password_page_shows_sign_links()
{
$this->setSettings(['registration-enabled' => 'true']);
$resp = $this->get('/password/email');
$this->withHtml($resp)->assertElementContains('a', 'Log in')
->assertElementContains('a', 'Sign up');
}
public function test_reset_password_request_is_throttled()
{
$editor = $this->getEditor();
Notification::fake();
$this->get('/password/email');
$this->followingRedirects()->post('/password/email', [
'email' => $editor->email,
]);
$resp = $this->followingRedirects()->post('/password/email', [
'email' => $editor->email,
]);
Notification::assertTimesSent(1, ResetPassword::class);
$resp->assertSee('A password reset link will be sent to ' . $editor->email . ' if that email address is found in the system.');
}
public function test_login_redirects_to_initially_requested_url_correctly() public function test_login_redirects_to_initially_requested_url_correctly()
{ {
config()->set('app.url', 'http://localhost'); config()->set('app.url', 'http://localhost');
@ -393,6 +133,19 @@ class AuthTest extends TestCase
$this->assertFalse(auth()->check()); $this->assertFalse(auth()->check());
} }
public function test_login_attempts_are_rate_limited()
{
for ($i = 0; $i < 5; $i++) {
$resp = $this->login('bennynotexisting@example.com', 'pw123');
}
$resp = $this->followRedirects($resp);
$resp->assertSee('These credentials do not match our records.');
// Check the fifth attempt provides a lockout response
$resp = $this->followRedirects($this->login('bennynotexisting@example.com', 'pw123'));
$resp->assertSee('Too many login attempts. Please try again in');
}
/** /**
* Perform a login. * Perform a login.
*/ */

View file

@ -0,0 +1,177 @@
<?php
namespace Tests\Auth;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Notifications\ConfirmEmail;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class RegistrationTest extends TestCase
{
public function test_confirmed_registration()
{
// Fake notifications
Notification::fake();
// Set settings and get user instance
$this->setSettings(['registration-enabled' => 'true', 'registration-confirmation' => 'true']);
$user = User::factory()->make();
// Go through registration process
$resp = $this->post('/register', $user->only('name', 'email', 'password'));
$resp->assertRedirect('/register/confirm');
$this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
// Ensure notification sent
/** @var User $dbUser */
$dbUser = User::query()->where('email', '=', $user->email)->first();
Notification::assertSentTo($dbUser, ConfirmEmail::class);
// Test access and resend confirmation email
$resp = $this->post('/login', ['email' => $user->email, 'password' => $user->password]);
$resp->assertRedirect('/register/confirm/awaiting');
$resp = $this->get('/register/confirm/awaiting');
$this->withHtml($resp)->assertElementContains('form[action="' . url('/register/confirm/resend') . '"]', 'Resend');
$this->get('/books')->assertRedirect('/login');
$this->post('/register/confirm/resend', $user->only('email'));
// Get confirmation and confirm notification matches
$emailConfirmation = DB::table('email_confirmations')->where('user_id', '=', $dbUser->id)->first();
Notification::assertSentTo($dbUser, ConfirmEmail::class, function ($notification, $channels) use ($emailConfirmation) {
return $notification->token === $emailConfirmation->token;
});
// Check confirmation email confirmation activation.
$this->get('/register/confirm/' . $emailConfirmation->token)->assertRedirect('/login');
$this->get('/login')->assertSee('Your email has been confirmed! You should now be able to login using this email address.');
$this->assertDatabaseMissing('email_confirmations', ['token' => $emailConfirmation->token]);
$this->assertDatabaseHas('users', ['name' => $dbUser->name, 'email' => $dbUser->email, 'email_confirmed' => true]);
}
public function test_restricted_registration()
{
$this->setSettings(['registration-enabled' => 'true', 'registration-confirmation' => 'true', 'registration-restrict' => 'example.com']);
$user = User::factory()->make();
// Go through registration process
$this->post('/register', $user->only('name', 'email', 'password'))
->assertRedirect('/register');
$resp = $this->get('/register');
$resp->assertSee('That email domain does not have access to this application');
$this->assertDatabaseMissing('users', $user->only('email'));
$user->email = 'barry@example.com';
$this->post('/register', $user->only('name', 'email', 'password'))
->assertRedirect('/register/confirm');
$this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
$this->assertNull(auth()->user());
$this->get('/')->assertRedirect('/login');
$resp = $this->followingRedirects()->post('/login', $user->only('email', 'password'));
$resp->assertSee('Email Address Not Confirmed');
$this->assertNull(auth()->user());
}
public function test_restricted_registration_with_confirmation_disabled()
{
$this->setSettings(['registration-enabled' => 'true', 'registration-confirmation' => 'false', 'registration-restrict' => 'example.com']);
$user = User::factory()->make();
// Go through registration process
$this->post('/register', $user->only('name', 'email', 'password'))
->assertRedirect('/register');
$this->assertDatabaseMissing('users', $user->only('email'));
$this->get('/register')->assertSee('That email domain does not have access to this application');
$user->email = 'barry@example.com';
$this->post('/register', $user->only('name', 'email', 'password'))
->assertRedirect('/register/confirm');
$this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]);
$this->assertNull(auth()->user());
$this->get('/')->assertRedirect('/login');
$resp = $this->post('/login', $user->only('email', 'password'));
$resp->assertRedirect('/register/confirm/awaiting');
$this->get('/register/confirm/awaiting')->assertSee('Email Address Not Confirmed');
$this->assertNull(auth()->user());
}
public function test_registration_role_unset_by_default()
{
$this->assertFalse(setting('registration-role'));
$resp = $this->asAdmin()->get('/settings/registration');
$this->withHtml($resp)->assertElementContains('select[name="setting-registration-role"] option[value="0"][selected]', '-- None --');
}
public function test_registration_showing()
{
// Ensure registration form is showing
$this->setSettings(['registration-enabled' => 'true']);
$resp = $this->get('/login');
$this->withHtml($resp)->assertElementContains('a[href="' . url('/register') . '"]', 'Sign up');
}
public function test_normal_registration()
{
// Set settings and get user instance
/** @var Role $registrationRole */
$registrationRole = Role::query()->first();
$this->setSettings(['registration-enabled' => 'true', 'registration-role' => $registrationRole->id]);
/** @var User $user */
$user = User::factory()->make();
// Test form and ensure user is created
$resp = $this->get('/register')
->assertSee('Sign Up');
$this->withHtml($resp)->assertElementContains('form[action="' . url('/register') . '"]', 'Create Account');
$resp = $this->post('/register', $user->only('password', 'name', 'email'));
$resp->assertRedirect('/');
$resp = $this->get('/');
$resp->assertOk();
$resp->assertSee($user->name);
$this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email]);
$user = User::query()->where('email', '=', $user->email)->first();
$this->assertEquals(1, $user->roles()->count());
$this->assertEquals($registrationRole->id, $user->roles()->first()->id);
}
public function test_empty_registration_redirects_back_with_errors()
{
// Set settings and get user instance
$this->setSettings(['registration-enabled' => 'true']);
// Test form and ensure user is created
$this->get('/register');
$this->post('/register', [])->assertRedirect('/register');
$this->get('/register')->assertSee('The name field is required');
}
public function test_registration_validation()
{
$this->setSettings(['registration-enabled' => 'true']);
$this->get('/register');
$resp = $this->followingRedirects()->post('/register', [
'name' => '1',
'email' => '1',
'password' => '1',
]);
$resp->assertSee('The name must be at least 2 characters.');
$resp->assertSee('The email must be a valid email address.');
$resp->assertSee('The password must be at least 8 characters.');
}
}

View file

@ -0,0 +1,101 @@
<?php
namespace Tests\Auth;
use BookStack\Auth\User;
use BookStack\Notifications\ResetPassword;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class ResetPasswordTest extends TestCase
{
public function test_reset_flow()
{
Notification::fake();
$resp = $this->get('/login');
$this->withHtml($resp)->assertElementContains('a[href="' . url('/password/email') . '"]', 'Forgot Password?');
$resp = $this->get('/password/email');
$this->withHtml($resp)->assertElementContains('form[action="' . url('/password/email') . '"]', 'Send Reset Link');
$resp = $this->post('/password/email', [
'email' => 'admin@admin.com',
]);
$resp->assertRedirect('/password/email');
$resp = $this->get('/password/email');
$resp->assertSee('A password reset link will be sent to admin@admin.com if that email address is found in the system.');
$this->assertDatabaseHas('password_resets', [
'email' => 'admin@admin.com',
]);
/** @var User $user */
$user = User::query()->where('email', '=', 'admin@admin.com')->first();
Notification::assertSentTo($user, ResetPassword::class);
$n = Notification::sent($user, ResetPassword::class);
$this->get('/password/reset/' . $n->first()->token)
->assertOk()
->assertSee('Reset Password');
$resp = $this->post('/password/reset', [
'email' => 'admin@admin.com',
'password' => 'randompass',
'password_confirmation' => 'randompass',
'token' => $n->first()->token,
]);
$resp->assertRedirect('/');
$this->get('/')->assertSee('Your password has been successfully reset');
}
public function test_reset_flow_shows_success_message_even_if_wrong_password_to_prevent_user_discovery()
{
$this->get('/password/email');
$resp = $this->followingRedirects()->post('/password/email', [
'email' => 'barry@admin.com',
]);
$resp->assertSee('A password reset link will be sent to barry@admin.com if that email address is found in the system.');
$resp->assertDontSee('We can\'t find a user');
$this->get('/password/reset/arandometokenvalue')->assertSee('Reset Password');
$resp = $this->post('/password/reset', [
'email' => 'barry@admin.com',
'password' => 'randompass',
'password_confirmation' => 'randompass',
'token' => 'arandometokenvalue',
]);
$resp->assertRedirect('/password/reset/arandometokenvalue');
$this->get('/password/reset/arandometokenvalue')
->assertDontSee('We can\'t find a user')
->assertSee('The password reset token is invalid for this email address.');
}
public function test_reset_page_shows_sign_links()
{
$this->setSettings(['registration-enabled' => 'true']);
$resp = $this->get('/password/email');
$this->withHtml($resp)->assertElementContains('a', 'Log in')
->assertElementContains('a', 'Sign up');
}
public function test_reset_request_is_throttled()
{
$editor = $this->getEditor();
Notification::fake();
$this->get('/password/email');
$this->followingRedirects()->post('/password/email', [
'email' => $editor->email,
]);
$resp = $this->followingRedirects()->post('/password/email', [
'email' => $editor->email,
]);
Notification::assertTimesSent(1, ResetPassword::class);
$resp->assertSee('A password reset link will be sent to ' . $editor->email . ' if that email address is found in the system.');
}
}