mirror of
				https://github.com/BookStackApp/BookStack.git
				synced 2025-11-03 02:13:16 +03:00 
			
		
		
		
	Updated functionality for logging failed access
- Added testing to cover. - Linked logging into Laravel's monolog logging system and made log channel configurable. - Updated env var names to be specific to login access. - Added extra locations as to where failed logins would be captured. Related to #1881 and #728
This commit is contained in:
		@@ -272,8 +272,10 @@ API_MAX_ITEM_COUNT=500
 | 
				
			|||||||
# The number of API requests that can be made per minute by a single user.
 | 
					# The number of API requests that can be made per minute by a single user.
 | 
				
			||||||
API_REQUESTS_PER_MIN=180
 | 
					API_REQUESTS_PER_MIN=180
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Failed access
 | 
					# Enable the logging of failed email+password logins with the given message
 | 
				
			||||||
# message to log into webserver logs in case of failed access, for further processing by tools like Fail2Ban
 | 
					# The defaul log channel below uses the php 'error_log' function which commonly
 | 
				
			||||||
# Apache users should use : user "%u" authentication failure for "BookStack"
 | 
					# results in messages being output to the webserver error logs.
 | 
				
			||||||
# Nginx  users should use : user "%u" was not found in "BookStack"
 | 
					# The message can contain a %u parameter which will be replaced with the login
 | 
				
			||||||
FAILED_ACCESS_MESSAGE=''
 | 
					# user identifier (Username or email).
 | 
				
			||||||
 | 
					LOG_FAILED_LOGIN_MESSAGE=false
 | 
				
			||||||
 | 
					LOG_FAILED_LOGIN_CHANNEL=errorlog_plain_webserver
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,6 +4,7 @@ use BookStack\Auth\Permissions\PermissionService;
 | 
				
			|||||||
use BookStack\Auth\User;
 | 
					use BookStack\Auth\User;
 | 
				
			||||||
use BookStack\Entities\Entity;
 | 
					use BookStack\Entities\Entity;
 | 
				
			||||||
use Illuminate\Support\Collection;
 | 
					use Illuminate\Support\Collection;
 | 
				
			||||||
 | 
					use Illuminate\Support\Facades\Log;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ActivityService
 | 
					class ActivityService
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
@@ -49,7 +50,7 @@ class ActivityService
 | 
				
			|||||||
    protected function newActivityForUser(string $key, ?int $bookId = null): Activity
 | 
					    protected function newActivityForUser(string $key, ?int $bookId = null): Activity
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        return $this->activity->newInstance()->forceFill([
 | 
					        return $this->activity->newInstance()->forceFill([
 | 
				
			||||||
            'key' => strtolower($key),
 | 
					            'key'     => strtolower($key),
 | 
				
			||||||
            'user_id' => $this->user->id,
 | 
					            'user_id' => $this->user->id,
 | 
				
			||||||
            'book_id' => $bookId ?? 0,
 | 
					            'book_id' => $bookId ?? 0,
 | 
				
			||||||
        ]);
 | 
					        ]);
 | 
				
			||||||
@@ -64,8 +65,8 @@ class ActivityService
 | 
				
			|||||||
    {
 | 
					    {
 | 
				
			||||||
        $activities = $entity->activity()->get();
 | 
					        $activities = $entity->activity()->get();
 | 
				
			||||||
        $entity->activity()->update([
 | 
					        $entity->activity()->update([
 | 
				
			||||||
            'extra' => $entity->name,
 | 
					            'extra'       => $entity->name,
 | 
				
			||||||
            'entity_id' => 0,
 | 
					            'entity_id'   => 0,
 | 
				
			||||||
            'entity_type' => '',
 | 
					            'entity_type' => '',
 | 
				
			||||||
        ]);
 | 
					        ]);
 | 
				
			||||||
        return $activities;
 | 
					        return $activities;
 | 
				
			||||||
@@ -99,7 +100,7 @@ class ActivityService
 | 
				
			|||||||
            $query = $this->activity->newQuery()->where('entity_type', '=', $entity->getMorphClass())
 | 
					            $query = $this->activity->newQuery()->where('entity_type', '=', $entity->getMorphClass())
 | 
				
			||||||
                ->where('entity_id', '=', $entity->id);
 | 
					                ->where('entity_id', '=', $entity->id);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        
 | 
					
 | 
				
			||||||
        $activity = $this->permissionService
 | 
					        $activity = $this->permissionService
 | 
				
			||||||
            ->filterRestrictedEntityRelations($query, 'activities', 'entity_id', 'entity_type')
 | 
					            ->filterRestrictedEntityRelations($query, 'activities', 'entity_id', 'entity_type')
 | 
				
			||||||
            ->orderBy('created_at', 'desc')
 | 
					            ->orderBy('created_at', 'desc')
 | 
				
			||||||
@@ -161,19 +162,18 @@ class ActivityService
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Log failed accesses, for further processing by tools like Fail2Ban
 | 
					     * Log out a failed login attempt, Providing the given username
 | 
				
			||||||
     *
 | 
					     * as part of the message if the '%u' string is used.
 | 
				
			||||||
     * @param username
 | 
					     */
 | 
				
			||||||
     * @return void
 | 
					    public function logFailedLogin(string $username)
 | 
				
			||||||
      */
 | 
					 | 
				
			||||||
    public function logFailedAccess($username)
 | 
					 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        $log_msg = config('logging.failed_access_message');
 | 
					        $message = config('logging.failed_login.message');
 | 
				
			||||||
 | 
					        if (!$message) {
 | 
				
			||||||
        if (!is_string($username) || !is_string($log_msg) || strlen($log_msg)<1)
 | 
					 | 
				
			||||||
            return;
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        $log_msg = str_replace("%u", $username, $log_msg);
 | 
					        $message = str_replace("%u", $username, $message);
 | 
				
			||||||
        error_log($log_msg, 4);
 | 
					        $channel = config('logging.failed_login.channel');
 | 
				
			||||||
 | 
					        Log::channel($channel)->warning($message);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,7 @@
 | 
				
			|||||||
<?php
 | 
					<?php
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use Monolog\Formatter\LineFormatter;
 | 
				
			||||||
 | 
					use Monolog\Handler\ErrorLogHandler;
 | 
				
			||||||
use Monolog\Handler\NullHandler;
 | 
					use Monolog\Handler\NullHandler;
 | 
				
			||||||
use Monolog\Handler\StreamHandler;
 | 
					use Monolog\Handler\StreamHandler;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -73,6 +75,19 @@ return [
 | 
				
			|||||||
            'level' => 'debug',
 | 
					            'level' => 'debug',
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Custom errorlog implementation that logs out a plain,
 | 
				
			||||||
 | 
					        // non-formatted message intended for the webserver log.
 | 
				
			||||||
 | 
					        'errorlog_plain_webserver' => [
 | 
				
			||||||
 | 
					            'driver' => 'monolog',
 | 
				
			||||||
 | 
					            'level' => 'debug',
 | 
				
			||||||
 | 
					            'handler' => ErrorLogHandler::class,
 | 
				
			||||||
 | 
					            'handler_with' => [4],
 | 
				
			||||||
 | 
					            'formatter' => LineFormatter::class,
 | 
				
			||||||
 | 
					            'formatter_with' => [
 | 
				
			||||||
 | 
					                'format' => "%message%",
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        'null' => [
 | 
					        'null' => [
 | 
				
			||||||
            'driver' => 'monolog',
 | 
					            'driver' => 'monolog',
 | 
				
			||||||
            'handler' => NullHandler::class,
 | 
					            'handler' => NullHandler::class,
 | 
				
			||||||
@@ -86,9 +101,12 @@ return [
 | 
				
			|||||||
        ],
 | 
					        ],
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Failed Access Message
 | 
					
 | 
				
			||||||
    // Defines the message to log into webserver logs in case of failed access,
 | 
					    // Failed Login Message
 | 
				
			||||||
    // for further processing by tools like Fail2Ban.
 | 
					    // Allows a configurable message to be logged when a login request fails.
 | 
				
			||||||
    'failed_access_message' => env('FAILED_ACCESS_MESSAGE', ''),
 | 
					    'failed_login' => [
 | 
				
			||||||
 | 
					        'message' => env('LOG_FAILED_LOGIN_MESSAGE', null),
 | 
				
			||||||
 | 
					        'channel' => env('LOG_FAILED_LOGIN_CHANNEL', 'errorlog_plain_webserver'),
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
];
 | 
					];
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -99,6 +99,7 @@ class LoginController extends Controller
 | 
				
			|||||||
    public function login(Request $request)
 | 
					    public function login(Request $request)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        $this->validateLogin($request);
 | 
					        $this->validateLogin($request);
 | 
				
			||||||
 | 
					        $username = $request->get($this->username());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // If the class is using the ThrottlesLogins trait, we can automatically throttle
 | 
					        // 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 login attempts for this application. We'll key this by the username and
 | 
				
			||||||
@@ -107,9 +108,7 @@ class LoginController extends Controller
 | 
				
			|||||||
            $this->hasTooManyLoginAttempts($request)) {
 | 
					            $this->hasTooManyLoginAttempts($request)) {
 | 
				
			||||||
            $this->fireLockoutEvent($request);
 | 
					            $this->fireLockoutEvent($request);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Also log some error message
 | 
					            Activity::logFailedLogin($username);
 | 
				
			||||||
            Activity::logFailedAccess($request->get($this->username()));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            return $this->sendLockoutResponse($request);
 | 
					            return $this->sendLockoutResponse($request);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -118,6 +117,7 @@ class LoginController extends Controller
 | 
				
			|||||||
                return $this->sendLoginResponse($request);
 | 
					                return $this->sendLoginResponse($request);
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        } catch (LoginAttemptException $exception) {
 | 
					        } catch (LoginAttemptException $exception) {
 | 
				
			||||||
 | 
					            Activity::logFailedLogin($username);
 | 
				
			||||||
            return $this->sendLoginAttemptExceptionResponse($exception, $request);
 | 
					            return $this->sendLoginAttemptExceptionResponse($exception, $request);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -126,9 +126,7 @@ class LoginController extends Controller
 | 
				
			|||||||
        // user surpasses their maximum number of attempts they will get locked out.
 | 
					        // user surpasses their maximum number of attempts they will get locked out.
 | 
				
			||||||
        $this->incrementLoginAttempts($request);
 | 
					        $this->incrementLoginAttempts($request);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Also log some error message
 | 
					        Activity::logFailedLogin($username);
 | 
				
			||||||
        Activity::logFailedAccess($request->get($this->username()));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return $this->sendFailedLoginResponse($request);
 | 
					        return $this->sendFailedLoginResponse($request);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -51,5 +51,7 @@
 | 
				
			|||||||
        <server name="DEBUGBAR_ENABLED" value="false"/>
 | 
					        <server name="DEBUGBAR_ENABLED" value="false"/>
 | 
				
			||||||
        <server name="SAML2_ENABLED" value="false"/>
 | 
					        <server name="SAML2_ENABLED" value="false"/>
 | 
				
			||||||
        <server name="API_REQUESTS_PER_MIN" value="180"/>
 | 
					        <server name="API_REQUESTS_PER_MIN" value="180"/>
 | 
				
			||||||
 | 
					        <server name="LOG_FAILED_LOGIN_MESSAGE" value=""/>
 | 
				
			||||||
 | 
					        <server name="LOG_FAILED_LOGIN_CHANNEL" value="testing"/>
 | 
				
			||||||
    </php>
 | 
					    </php>
 | 
				
			||||||
</phpunit>
 | 
					</phpunit>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -401,6 +401,18 @@ class AuthTest extends BrowserKitTest
 | 
				
			|||||||
        $this->assertFalse(auth('saml2')->check());
 | 
					        $this->assertFalse(auth('saml2')->check());
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public function test_failed_logins_are_logged_when_message_configured()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        $log = $this->withTestLogger();
 | 
				
			||||||
 | 
					        config()->set(['logging.failed_login.message' => 'Failed login for %u']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        $this->post('/login', ['email' => 'admin@example.com', 'password' => 'cattreedog']);
 | 
				
			||||||
 | 
					        $this->assertTrue($log->hasWarningThatContains('Failed login for admin@example.com'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        $this->post('/login', ['email' => 'admin@admin.com', 'password' => 'password']);
 | 
				
			||||||
 | 
					        $this->assertFalse($log->hasWarningThatContains('Failed login for admin@admin.com'));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Perform a login
 | 
					     * Perform a login
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -593,4 +593,17 @@ class LdapTest extends BrowserKitTest
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        $this->see('A user with the email tester@example.com already exists but with different credentials');
 | 
					        $this->see('A user with the email tester@example.com already exists but with different credentials');
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public function test_failed_logins_are_logged_when_message_configured()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        $log = $this->withTestLogger();
 | 
				
			||||||
 | 
					        config()->set(['logging.failed_login.message' => 'Failed login for %u']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        $this->commonLdapMocks(1, 1, 1, 1, 1);
 | 
				
			||||||
 | 
					        $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
 | 
				
			||||||
 | 
					            ->andReturn(['count' => 0]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        $this->post('/login', ['username' => 'timmyjenkins', 'password' => 'cattreedog']);
 | 
				
			||||||
 | 
					        $this->assertTrue($log->hasWarningThatContains('Failed login for timmyjenkins'));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,6 @@
 | 
				
			|||||||
<?php namespace Tests\Unit;
 | 
					<?php namespace Tests\Unit;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use Illuminate\Support\Facades\Log;
 | 
				
			||||||
use Tests\TestCase;
 | 
					use Tests\TestCase;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
@@ -36,6 +37,28 @@ class ConfigTest extends TestCase
 | 
				
			|||||||
        $this->checkEnvConfigResult('APP_URL', $oldDefault, 'app.url', '');
 | 
					        $this->checkEnvConfigResult('APP_URL', $oldDefault, 'app.url', '');
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public function test_errorlog_plain_webserver_channel()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        // We can't full test this due to it being targeted for the SAPI logging handler
 | 
				
			||||||
 | 
					        // so we just overwrite that component so we can capture the error log output.
 | 
				
			||||||
 | 
					        config()->set([
 | 
				
			||||||
 | 
					            'logging.channels.errorlog_plain_webserver.handler_with' => [0],
 | 
				
			||||||
 | 
					        ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        $temp = tempnam(sys_get_temp_dir(), 'bs-test');
 | 
				
			||||||
 | 
					        $original = ini_set( 'error_log', $temp);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Log::channel('errorlog_plain_webserver')->info('Aww, look, a cute puppy');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        ini_set( 'error_log', $original);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        $output = file_get_contents($temp);
 | 
				
			||||||
 | 
					        $this->assertStringContainsString('Aww, look, a cute puppy', $output);
 | 
				
			||||||
 | 
					        $this->assertStringNotContainsString('INFO', $output);
 | 
				
			||||||
 | 
					        $this->assertStringNotContainsString('info', $output);
 | 
				
			||||||
 | 
					        $this->assertStringNotContainsString('testing', $output);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Set an environment variable of the given name and value
 | 
					     * Set an environment variable of the given name and value
 | 
				
			||||||
     * then check the given config key to see if it matches the given result.
 | 
					     * then check the given config key to see if it matches the given result.
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user