Passed
Pull Request — master (#27)
by Robbie
03:47 queued 01:48
created

AuditHook::invalidToken()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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

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