Passed
Pull Request — master (#27)
by Simon
02:56
created

AuditHook::staleSession()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 0
1
<?php
2
3
namespace SilverStripe\Auditor;
4
5
use SilverStripe\Control\Email\Email;
6
use SilverStripe\Core\Injector\Injector;
7
use SilverStripe\ORM\Connect\Database;
8
use SilverStripe\ORM\DataExtension;
9
use SilverStripe\ORM\DataObject;
10
use SilverStripe\ORM\DataObjectSchema;
11
use SilverStripe\ORM\DB;
12
use SilverStripe\Security\Group;
13
use SilverStripe\Security\Member;
14
use SilverStripe\Security\PermissionRole;
15
use SilverStripe\Security\Security;
16
17
/**
18
 * Provides logging hooks that are inserted into Framework objects.
19
 */
20
class AuditHook extends DataExtension
21
{
22
    protected function getAuditLogger()
23
    {
24
        // We cannot use the 'dependencies' private property, because this will prevent us
25
        // from injecting a mock logger for testing. This is because by the time the testing framework
26
        // is instantiated, the part of the object graph where AuditLogger lives has already been created.
27
        // In other words, Framework does not permit hooking in early enough to adjust the graph when
28
        // 'dependencies' is used :-(
29
        return Injector::inst()->get('AuditLogger');
30
    }
31
32
    /**
33
     * This will bind a new class dynamically so we can hook into manipulation
34
     * and capture it. It creates a new PHP file in the temp folder, then
35
     * loads it and sets it as the active DB class.
36
     *
37
     * @deprecated 2.1...3.0 Please use ProxyDBExtension with the tractorcow/silverstripe-proxy-db module instead
38
     */
39
    public static function bind_manipulation_capture()
40
    {
41
        $current = DB::get_conn();
42
        if (!$current || !$current->getConnector()->getSelectedDatabase() || @$current->isManipulationLoggingCapture) {
0 ignored issues
show
Bug introduced by
The property isManipulationLoggingCapture does not seem to exist on SilverStripe\ORM\Connect\Database.
Loading history...
introduced by
$current is of type SilverStripe\ORM\Connect\Database, thus it always evaluated to true.
Loading history...
43
            return;
44
        } // If not yet set, or its already captured, just return
45
46
        $type = get_class($current);
47
        $sanitisedType = str_replace('\\', '_', $type);
48
        $file = TEMP_FOLDER . "/.cache.CLC.$sanitisedType";
49
        $dbClass = 'AuditLoggerManipulateCapture_' . $sanitisedType;
50
51
        if (!is_file($file)) {
52
            file_put_contents($file, "<?php
53
				class $dbClass extends $type
54
                {
55
					public \$isManipulationLoggingCapture = true;
56
57
					public function manipulate(\$manipulation)
58
                    {
59
						\SilverStripe\Auditor\AuditHook::handle_manipulation(\$manipulation);
60
						return parent::manipulate(\$manipulation);
61
					}
62
				}
63
			");
64
        }
65
66
        require_once $file;
67
68
        /** @var Database $captured */
69
        $captured = new $dbClass();
70
71
        $captured->setConnector($current->getConnector());
72
        $captured->setQueryBuilder($current->getQueryBuilder());
73
        $captured->setSchemaManager($current->getSchemaManager());
74
75
        // The connection might have had it's name changed (like if we're currently in a test)
76
        $captured->selectDatabase($current->getConnector()->getSelectedDatabase());
77
78
        DB::set_conn($captured);
79
    }
80
81
    public static function handle_manipulation($manipulation)
82
    {
83
        $auditLogger = Injector::inst()->get('AuditLogger');
84
85
        $currentMember = Security::getCurrentUser();
86
        if (!($currentMember && $currentMember->exists())) {
87
            return false;
88
        }
89
90
        /** @var DataObjectSchema $schema */
91
        $schema = DataObject::getSchema();
92
93
        // The tables that we watch for manipulation on
94
        $watchedTables = [
95
            $schema->tableName(Member::class),
96
            $schema->tableName(Group::class),
97
            $schema->tableName(PermissionRole::class),
98
        ];
99
100
        foreach ($manipulation as $table => $details) {
101
            if (!in_array($details['command'], array('update', 'insert'))) {
102
                continue;
103
            }
104
105
            // logging writes to specific tables (just not when logging in, as it's noise)
106
            if (in_array($table, $watchedTables)
107
                && !preg_match('/Security/', @$_SERVER['REQUEST_URI'])
108
            ) {
109
                $className = $schema->tableClass($table);
110
111
                $data = $className::get()->byId($details['id']);
112
                if (!$data) {
113
                    continue;
114
                }
115
                $actionText = 'modified '.$table;
116
117
                $extendedText = '';
118
                if ($table === $schema->tableName(Group::class)) {
119
                    $extendedText = sprintf(
120
                        'Effective permissions: %s',
121
                        implode($data->Permissions()->column('Code'), ', ')
0 ignored issues
show
Bug introduced by
', ' of type string is incompatible with the type array expected by parameter $pieces of implode(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

121
                        implode($data->Permissions()->column('Code'), /** @scrutinizer ignore-type */ ', ')
Loading history...
122
                    );
123
                }
124
                if ($table === $schema->tableName(PermissionRole::class)) {
125
                    $extendedText = sprintf(
126
                        'Effective groups: %s, Effective permissions: %s',
127
                        implode($data->Groups()->column('Title'), ', '),
128
                        implode($data->Codes()->column('Code'), ', ')
129
                    );
130
                }
131
                if ($table === $schema->tableName(Member::class)) {
132
                    $extendedText = sprintf(
133
                        'Effective groups: %s',
134
                        implode($data->Groups()->column('Title'), ', ')
135
                    );
136
                }
137
138
                $auditLogger->info(sprintf(
139
                    '"%s" (ID: %s) %s (ID: %s, ClassName: %s, Title: "%s", %s)',
140
                    $currentMember->Email ?: $currentMember->Title,
141
                    $currentMember->ID,
142
                    $actionText,
143
                    $details['id'],
144
                    $data->ClassName,
145
                    $data->Title,
146
                    $extendedText
147
                ));
148
            }
149
150
            // log PermissionRole being added to a Group
151
            if ($table === $schema->tableName(Group::class) . '_Roles') {
152
                $role = PermissionRole::get()->byId($details['fields']['PermissionRoleID']);
153
                $group = Group::get()->byId($details['fields']['GroupID']);
154
155
                // if the permission role isn't already applied to the group
156
                if (!DB::query(sprintf(
157
                    'SELECT "ID" FROM "Group_Roles" WHERE "GroupID" = %s AND "PermissionRoleID" = %s',
158
                    $details['fields']['GroupID'],
159
                    $details['fields']['PermissionRoleID']
160
                ))->value()) {
161
                    $auditLogger->info(sprintf(
162
                        '"%s" (ID: %s) added PermissionRole "%s" (ID: %s) to Group "%s" (ID: %s)',
163
                        $currentMember->Email ?: $currentMember->Title,
164
                        $currentMember->ID,
165
                        $role->Title,
166
                        $role->ID,
167
                        $group->Title,
168
                        $group->ID
169
                    ));
170
                }
171
            }
172
173
            // log Member added to a Group
174
            if ($table === $schema->tableName(Group::class) . '_Members') {
175
                $member = Member::get()->byId($details['fields']['MemberID']);
176
                $group = Group::get()->byId($details['fields']['GroupID']);
177
178
                // if the user isn't already in the group, log they've been added
179
                if (!DB::query(sprintf(
180
                    'SELECT "ID" FROM "Group_Members" WHERE "GroupID" = %s AND "MemberID" = %s',
181
                    $details['fields']['GroupID'],
182
                    $details['fields']['MemberID']
183
                ))->value()) {
184
                    $auditLogger->info(sprintf(
185
                        '"%s" (ID: %s) added Member "%s" (ID: %s) to Group "%s" (ID: %s)',
186
                        $currentMember->Email ?: $currentMember->Title,
187
                        $currentMember->ID,
188
                        $member->Email ?: $member->Title,
189
                        $member->ID,
190
                        $group->Title,
191
                        $group->ID
192
                    ));
193
                }
194
            }
195
        }
196
    }
197
198
    /**
199
     * Log a record being published.
200
     */
201
    public function onAfterPublish(&$original)
202
    {
203
        $member = Security::getCurrentUser();
204
        if (!$member || !$member->exists()) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
205
            return false;
206
        }
207
208
        $effectiveViewerGroups = '';
209
        if ($this->owner->CanViewType === 'OnlyTheseUsers') {
210
            $originalViewerGroups = $original ? $original->ViewerGroups()->column('Title') : [];
211
            $effectiveViewerGroups = implode(', ', $originalViewerGroups);
212
        }
213
        if (!$effectiveViewerGroups) {
214
            $effectiveViewerGroups = $this->owner->CanViewType;
215
        }
216
217
        $effectiveEditorGroups = '';
218
        if ($this->owner->CanEditType === 'OnlyTheseUsers') {
219
            $originalEditorGroups =  $original ? $original->EditorGroups()->column('Title') : [];
220
            $effectiveEditorGroups = implode(', ', $originalEditorGroups);
221
        }
222
        if (!$effectiveEditorGroups) {
223
            $effectiveEditorGroups = $this->owner->CanEditType;
224
        }
225
226
        $this->getAuditLogger()->info(sprintf(
227
            '"%s" (ID: %s) published %s "%s" (ID: %s, Version: %s, ClassName: %s, Effective ViewerGroups: %s, '
228
            . 'Effective EditorGroups: %s)',
229
            $member->Email ?: $member->Title,
230
            $member->ID,
231
            $this->owner->singular_name(),
232
            $this->owner->Title,
233
            $this->owner->ID,
234
            $this->owner->Version,
235
            $this->owner->ClassName,
236
            $effectiveViewerGroups,
237
            $effectiveEditorGroups
238
        ));
239
    }
240
241
    /**
242
     * Log a record being unpublished.
243
     */
244
    public function onAfterUnpublish()
245
    {
246
        $member = Security::getCurrentUser();
247
        if (!$member || !$member->exists()) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
248
            return false;
249
        }
250
251
        $this->getAuditLogger()->info(sprintf(
252
            '"%s" (ID: %s) unpublished %s "%s" (ID: %s)',
253
            $member->Email ?: $member->Title,
254
            $member->ID,
255
            $this->owner->singular_name(),
256
            $this->owner->Title,
257
            $this->owner->ID
258
        ));
259
    }
260
261
    /**
262
     * Log a record being reverted to live.
263
     */
264
    public function onAfterRevertToLive()
265
    {
266
        $member = Security::getCurrentUser();
267
        if (!$member || !$member->exists()) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
268
            return false;
269
        }
270
271
        $this->getAuditLogger()->info(sprintf(
272
            '"%s" (ID: %s) reverted %s "%s" (ID: %s) to it\'s live version (#%d)',
273
            $member->Email ?: $member->Title,
274
            $member->ID,
275
            $this->owner->singular_name(),
276
            $this->owner->Title,
277
            $this->owner->ID,
278
            $this->owner->Version
279
        ));
280
    }
281
282
    /**
283
     * Log a record being duplicated.
284
     */
285
    public function onAfterDuplicate()
286
    {
287
        $member = Security::getCurrentUser();
288
        if (!$member || !$member->exists()) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
289
            return false;
290
        }
291
292
        $this->getAuditLogger()->info(sprintf(
293
            '"%s" (ID: %s) duplicated %s "%s" (ID: %s)',
294
            $member->Email ?: $member->Title,
295
            $member->ID,
296
            $this->owner->singular_name(),
297
            $this->owner->Title,
298
            $this->owner->ID
299
        ));
300
    }
301
302
    /**
303
     * Log a record being deleted.
304
     */
305
    public function onAfterDelete()
306
    {
307
        $member = Security::getCurrentUser();
308
        if (!$member || !$member->exists()) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
309
            return false;
310
        }
311
312
        $this->getAuditLogger()->info(sprintf(
313
            '"%s" (ID: %s) deleted %s "%s" (ID: %s)',
314
            $member->Email ?: $member->Title,
315
            $member->ID,
316
            $this->owner->singular_name(),
317
            $this->owner->Title,
318
            $this->owner->ID
319
        ));
320
    }
321
322
    /**
323
     * Log a record being restored to stage.
324
     */
325
    public function onAfterRestoreToStage()
326
    {
327
        $member = Security::getCurrentUser();
328
        if (!$member || !$member->exists()) {
0 ignored issues
show
introduced by
$member is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
329
            return false;
330
        }
331
332
        $this->getAuditLogger()->info(sprintf(
333
            '"%s" (ID: %s) restored %s "%s" to stage (ID: %s)',
334
            $member->Email ?: $member->Title,
335
            $member->ID,
336
            $this->owner->singular_name(),
337
            $this->owner->Title,
338
            $this->owner->ID
339
        ));
340
    }
341
342
    /**
343
     * Log successful login attempts.
344
     */
345
    public function afterMemberLoggedIn()
346
    {
347
        $this->getAuditLogger()->info(sprintf(
348
            '"%s" (ID: %s) successfully logged in',
349
            $this->owner->Email ?: $this->owner->Title,
350
            $this->owner->ID
351
        ));
352
    }
353
354
    /**
355
     * Log successfully restored sessions from "remember me" cookies ("auto login").
356
     */
357
    public function memberAutoLoggedIn()
358
    {
359
        $this->getAuditLogger()->info(sprintf(
360
            '"%s" (ID: %s) successfully restored autologin session',
361
            $this->owner->Email ?: $this->owner->Title,
362
            $this->owner->ID
363
        ));
364
    }
365
366
    /**
367
     * Log failed login attempts.
368
     */
369
    public function authenticationFailed($data)
370
    {
371
        // LDAP authentication uses a "Login" POST field instead of Email.
372
        $login = isset($data['Login'])
373
            ? $data['Login']
374
            : (isset($data[Email::class]) ? $data[Email::class] : '');
375
376
        if (empty($login)) {
377
            return $this->getAuditLogger()->warning(
378
                'Could not determine username/email of failed authentication. '.
379
                'This could be due to login form not using Email or Login field for POST data.'
380
            );
381
        }
382
383
        $this->getAuditLogger()->info(sprintf('Failed login attempt using email "%s"', $login));
384
    }
385
386
    /**
387
     * Login during grace period
388
     * @param Member $member
389
     */
390
    public function gracePeriodLogin($member)
391
    {
392
        $this->getAuditLogger()->info(sprintf(
393
            '%s (ID: %s) Successfully logged in while in grace period',
394
            $member->Email ?: $member->Title,
395
            $member->ID
396
        ));
397
    }
398
399
    /**
400
     * Successful pre-MFA authentication
401
     * @param Member $member
402
     */
403
    public function mfaPreLoginSuccess($member)
404
    {
405
        $this->getAuditLogger()->info(sprintf(
406
            '%s (ID: %s) Successfully completed first step of MFA login',
407
            $member->Email ?: $member->Title,
408
            $member->ID
409
        ));
410
    }
411
412
    /**
413
     * Member session expired
414
     */
415
    public function staleSession()
416
    {
417
        $this->getAuditLogger()->info('Member waited too long before entering MFA');
418
    }
419
420
    /**
421
     * @param string $method
422
     * @param Member $member
423
     */
424
    public function invalidAuthenticationMethod($method, $member)
425
    {
426
        $this->getAuditLogger()->warning(sprintf(
427
            '%s (ID: %s) Attempted to log in with invalid authenticator %s',
428
            $member->Email ?: $member->Title,
429
            $member->ID,
430
            $method
431
        ));
432
    }
433
434
    /**
435
     * @param string $method
436
     * @param Member $member
437
     */
438
    public function afterMFALogin($method, $member)
439
    {
440
        $this->getAuditLogger()->info(sprintf(
441
            '%s (ID: %s) Successful log in with MFA authenticator %s',
442
            $member->Email ?: $member->Title,
443
            $member->ID,
444
            $method
445
        ));
446
    }
447
448
    /**
449
     * @param string $method
450
     * @param Member $member
451
     */
452
    public function failedMFALogin($method, $member)
453
    {
454
        $this->getAuditLogger()->warning(sprintf(
455
            '%s (ID: %s) Failed to log in with MFA authenticator %s',
456
            $member->Email ?: $member->Title,
457
            $member->ID,
458
            $method
459
        ));
460
    }
461
462
    /**
463
     * @param string $method
464
     */
465
    public function invalidToken($method)
466
    {
467
        $this->getAuditLogger()->warning(sprintf(
468
            'Security token expired for MFA login with authenticator %s',
469
            $method
470
        ));
471
    }
472
473
    public function invalidAuthenticator($method)
474
    {
475
        $this->getAuditLogger()->warning(sprintf(
476
            'Attempted MFA login with invalid authenticator %s',
477
            $method
478
        ));
479
    }
480
481
    /**
482
     * @deprecated 2.1...3.0 Use tractorcow/silverstripe-proxy-db instead
483
     */
484
    public function onBeforeInit()
485
    {
486
        // no-op
487
    }
488
489
    /**
490
     * Log permission failures (where the status is set after init of page).
491
     */
492
    public function onAfterInit()
493
    {
494
        // Suppress errors if dev/build necessary
495
        if (!Security::database_is_ready()) {
496
            return false;
497
        }
498
        $currentMember = Security::getCurrentUser();
499
        if (!$currentMember || !$currentMember->exists()) {
0 ignored issues
show
introduced by
$currentMember is of type SilverStripe\Security\Member, thus it always evaluated to true.
Loading history...
500
            return false;
501
        }
502
503
        $statusCode = $this->owner->getResponse()->getStatusCode();
504
505
        if (substr($statusCode, 0, 1) == '4') {
506
            $this->logPermissionDenied($statusCode, $currentMember);
507
        }
508
    }
509
510
    protected function logPermissionDenied($statusCode, $member)
511
    {
512
        $this->getAuditLogger()->info(sprintf(
513
            'HTTP code %s - "%s" (ID: %s) is denied access to %s',
514
            $statusCode,
515
            $member->Email ?: $member->Title,
516
            $member->ID,
517
            $_SERVER['REQUEST_URI']
518
        ));
519
    }
520
521
    /**
522
     * Log successful logout.
523
     */
524
    public function afterMemberLoggedOut()
525
    {
526
        $this->getAuditLogger()->info(sprintf(
527
            '"%s" (ID: %s) successfully logged out',
528
            $this->owner->Email ?: $this->owner->Title,
529
            $this->owner->ID
530
        ));
531
    }
532
}
533