Completed
Pull Request — master (#13)
by Robbie
03:42
created

AuditHook::afterMemberLoggedOut()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 6
Ratio 100 %

Importance

Changes 0
Metric Value
dl 6
loc 6
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 4
nc 1
nop 0
1
<?php
2
3
namespace SilverStripe\Auditor;
4
5
use SilverStripe\CMS\Model\SiteTreeExtension;
6
use SilverStripe\Control\Email\Email;
7
use SilverStripe\Core\Injector\Injector;
8
use SilverStripe\ORM\Connect\Database;
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 SiteTreeExtension
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
    public static function bind_manipulation_capture()
38
    {
39
        $current = DB::get_conn();
40
        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...
41
            return;
42
        } // If not yet set, or its already captured, just return
43
44
        $type = get_class($current);
45
        $sanitisedType = str_replace('\\', '_', $type);
46
        $file = TEMP_FOLDER . "/.cache.CLC.$sanitisedType";
47
        $dbClass = 'AuditLoggerManipulateCapture_' . $sanitisedType;
48
49
        if (!is_file($file)) {
50
            file_put_contents($file, "<?php
51
				class $dbClass extends $type
52
                {
53
					public \$isManipulationLoggingCapture = true;
54
55
					public function manipulate(\$manipulation)
56
                    {
57
						\SilverStripe\Auditor\AuditHook::handle_manipulation(\$manipulation);
58
						return parent::manipulate(\$manipulation);
59
					}
60
				}
61
			");
62
        }
63
64
        require_once $file;
65
66
        /** @var Database $captured */
67
        $captured = new $dbClass();
68
69
        $captured->setConnector($current->getConnector());
70
        $captured->setQueryBuilder($current->getQueryBuilder());
71
        $captured->setSchemaManager($current->getSchemaManager());
72
73
        // The connection might have had it's name changed (like if we're currently in a test)
74
        $captured->selectDatabase($current->getConnector()->getSelectedDatabase());
75
76
        DB::set_conn($captured);
77
    }
78
79
    public static function handle_manipulation($manipulation)
80
    {
81
        $auditLogger = Injector::inst()->get('AuditLogger');
82
83
        $currentMember = Security::getCurrentUser();
84
        if (!($currentMember && $currentMember->exists())) {
85
            return false;
86
        }
87
88
        /** @var DataObjectSchema $schema */
89
        $schema = DataObject::getSchema();
90
91
        // The tables that we watch for manipulation on
92
        $watchedTables = [
93
            $schema->tableName(Member::class),
94
            $schema->tableName(Group::class),
95
            $schema->tableName(PermissionRole::class),
96
        ];
97
98
        foreach ($manipulation as $table => $details) {
99
            if (!in_array($details['command'], array('update', 'insert'))) {
100
                continue;
101
            }
102
103
            // logging writes to specific tables (just not when logging in, as it's noise)
104
            if (in_array($table, $watchedTables)
105
                && !preg_match('/Security/', @$_SERVER['REQUEST_URI'])
106
            ) {
107
                $className = $schema->tableClass($table);
108
109
                $data = $className::get()->byId($details['id']);
110
                if (!$data) {
111
                    continue;
112
                }
113
                $actionText = 'modified '.$table;
114
115
                $extendedText = '';
116 View Code Duplication
                if ($table === $schema->tableName(Group::class)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
117
                    $extendedText = sprintf(
118
                        'Effective permissions: %s',
119
                        implode(array_values($data->Permissions()->map('ID', 'Code')->toArray()), ', ')
120
                    );
121
                }
122
                if ($table === $schema->tableName(PermissionRole::class)) {
123
                    $extendedText = sprintf(
124
                        'Effective groups: %s, Effective permissions: %s',
125
                        implode(array_values($data->Groups()->map('ID', 'Title')->toArray()), ', '),
126
                        implode(array_values($data->Codes()->map('ID', 'Code')->toArray()), ', ')
127
                    );
128
                }
129 View Code Duplication
                if ($table === $schema->tableName(Member::class)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
130
                    $extendedText = sprintf(
131
                        'Effective groups: %s',
132
                        implode(array_values($data->Groups()->map('ID', 'Title')->toArray()), ', ')
133
                    );
134
                }
135
136
                $auditLogger->info(sprintf(
137
                    '"%s" (ID: %s) %s (ID: %s, ClassName: %s, Title: "%s", %s)',
138
                    $currentMember->Email ?: $currentMember->Title,
139
                    $currentMember->ID,
140
                    $actionText,
141
                    $details['id'],
142
                    $data->ClassName,
143
                    $data->Title,
144
                    $extendedText
145
                ));
146
            }
147
148
            // log PermissionRole being added to a Group
149 View Code Duplication
            if ($table === $schema->tableName(Group::class) . '_Roles') {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
150
                $role = PermissionRole::get()->byId($details['fields']['PermissionRoleID']);
151
                $group = Group::get()->byId($details['fields']['GroupID']);
152
153
                // if the permission role isn't already applied to the group
154
                if (!DB::query(sprintf(
155
                    'SELECT "ID" FROM "Group_Roles" WHERE "GroupID" = %s AND "PermissionRoleID" = %s',
156
                    $details['fields']['GroupID'],
157
                    $details['fields']['PermissionRoleID']
158
                ))->value()) {
159
                    $auditLogger->info(sprintf(
160
                        '"%s" (ID: %s) added PermissionRole "%s" (ID: %s) to Group "%s" (ID: %s)',
161
                        $currentMember->Email ?: $currentMember->Title,
162
                        $currentMember->ID,
163
                        $role->Title,
164
                        $role->ID,
165
                        $group->Title,
166
                        $group->ID
167
                    ));
168
                }
169
            }
170
171
            // log Member added to a Group
172 View Code Duplication
            if ($table === $schema->tableName(Group::class) . '_Members') {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
173
                $member = Member::get()->byId($details['fields']['MemberID']);
174
                $group = Group::get()->byId($details['fields']['GroupID']);
175
176
                // if the user isn't already in the group, log they've been added
177
                if (!DB::query(sprintf(
178
                    'SELECT "ID" FROM "Group_Members" WHERE "GroupID" = %s AND "MemberID" = %s',
179
                    $details['fields']['GroupID'],
180
                    $details['fields']['MemberID']
181
                ))->value()) {
182
                    $auditLogger->info(sprintf(
183
                        '"%s" (ID: %s) added Member "%s" (ID: %s) to Group "%s" (ID: %s)',
184
                        $currentMember->Email ?: $currentMember->Title,
185
                        $currentMember->ID,
186
                        $member->Email ?: $member->Title,
187
                        $member->ID,
188
                        $group->Title,
189
                        $group->ID
190
                    ));
191
                }
192
            }
193
        }
194
    }
195
196
    /**
197
     * Log a record being published.
198
     */
199
    public function onAfterPublish(&$original)
200
    {
201
        $member = Security::getcurrentUser();
202
        if (!$member || !$member->exists()) {
203
            return false;
204
        }
205
206
        $effectiveViewerGroups = '';
207
        if ($this->owner->CanViewType === 'OnlyTheseUsers') {
208
            $effectiveViewerGroups = implode(
209
                ', ',
210
                array_values($original->ViewerGroups()->map('ID', 'Title')->toArray())
211
            );
212
        }
213
        if (!$effectiveViewerGroups) {
214
            $effectiveViewerGroups = $this->owner->CanViewType;
215
        }
216
217
        $effectiveEditorGroups = '';
218
        if ($this->owner->CanEditType === 'OnlyTheseUsers' && $original->EditorGroups()->exists()) {
219
            $groups = [];
220
            foreach ($original->EditorGroups() as $group) {
221
                $groups[$group->ID] = $group->Title;
222
            }
223
            $effectiveEditorGroups = implode(', ', array_values($groups));
224
        }
225
        if (!$effectiveEditorGroups) {
226
            $effectiveEditorGroups = $this->owner->CanEditType;
227
        }
228
229
        $this->getAuditLogger()->info(sprintf(
230
            '"%s" (ID: %s) published %s "%s" (ID: %s, Version: %s, ClassName: %s, Effective ViewerGroups: %s, '
231
            . 'Effective EditorGroups: %s)',
232
            $member->Email ?: $member->Title,
233
            $member->ID,
234
            $this->owner->singular_name(),
235
            $this->owner->Title,
236
            $this->owner->ID,
237
            $this->owner->Version,
238
            $this->owner->ClassName,
239
            $effectiveViewerGroups,
240
            $effectiveEditorGroups
241
        ));
242
    }
243
244
    /**
245
     * Log a record being unpublished.
246
     */
247 View Code Duplication
    public function onAfterUnpublish()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
248
    {
249
        $member = Security::getCurrentUser();
250
        if (!$member || !$member->exists()) {
251
            return false;
252
        }
253
254
        $this->getAuditLogger()->info(sprintf(
255
            '"%s" (ID: %s) unpublished %s "%s" (ID: %s)',
256
            $member->Email ?: $member->Title,
257
            $member->ID,
258
            $this->owner->singular_name(),
259
            $this->owner->Title,
260
            $this->owner->ID
261
        ));
262
    }
263
264
    /**
265
     * Log a record being reverted to live.
266
     */
267 View Code Duplication
    public function onAfterRevertToLive()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
268
    {
269
        $member = Security::getCurrentUser();
270
        if (!$member || !$member->exists()) {
271
            return false;
272
        }
273
274
        $this->getAuditLogger()->info(sprintf(
275
            '"%s" (ID: %s) reverted %s "%s" (ID: %s) to it\'s live version (#%d)',
276
            $member->Email ?: $member->Title,
277
            $member->ID,
278
            $this->owner->singular_name(),
279
            $this->owner->Title,
280
            $this->owner->ID,
281
            $this->owner->Version
282
        ));
283
    }
284
285
    /**
286
     * Log a record being duplicated.
287
     */
288 View Code Duplication
    public function onAfterDuplicate()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
289
    {
290
        $member = Security::getCurrentUser();
291
        if (!$member || !$member->exists()) {
292
            return false;
293
        }
294
295
        $this->getAuditLogger()->info(sprintf(
296
            '"%s" (ID: %s) duplicated %s "%s" (ID: %s)',
297
            $member->Email ?: $member->Title,
298
            $member->ID,
299
            $this->owner->singular_name(),
300
            $this->owner->Title,
301
            $this->owner->ID
302
        ));
303
    }
304
305
    /**
306
     * Log a record being deleted.
307
     */
308 View Code Duplication
    public function onAfterDelete()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
309
    {
310
        $member = Security::getCurrentUser();
311
        if (!$member || !$member->exists()) {
312
            return false;
313
        }
314
315
        $this->getAuditLogger()->info(sprintf(
316
            '"%s" (ID: %s) deleted %s "%s" (ID: %s)',
317
            $member->Email ?: $member->Title,
318
            $member->ID,
319
            $this->owner->singular_name(),
320
            $this->owner->Title,
321
            $this->owner->ID
322
        ));
323
    }
324
325
    /**
326
     * Log a record being restored to stage.
327
     */
328 View Code Duplication
    public function onAfterRestoreToStage()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
329
    {
330
        $member = Security::getCurrentUser();
331
        if (!$member || !$member->exists()) {
332
            return false;
333
        }
334
335
        $this->getAuditLogger()->info(sprintf(
336
            '"%s" (ID: %s) restored %s "%s" to stage (ID: %s)',
337
            $member->Email ?: $member->Title,
338
            $member->ID,
339
            $this->owner->singular_name(),
340
            $this->owner->Title,
341
            $this->owner->ID
342
        ));
343
    }
344
345
    /**
346
     * Log successful login attempts.
347
     */
348 View Code Duplication
    public function afterMemberLoggedIn()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
349
    {
350
        $this->getAuditLogger()->info(sprintf(
351
            '"%s" (ID: %s) successfully logged in',
352
            $this->owner->Email ?: $this->owner->Title,
353
            $this->owner->ID
354
        ));
355
    }
356
357
    /**
358
     * Log successfully restored sessions from "remember me" cookies ("auto login").
359
     */
360 View Code Duplication
    public function memberAutoLoggedIn()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
361
    {
362
        $this->getAuditLogger()->info(sprintf(
363
            '"%s" (ID: %s) successfully restored autologin session',
364
            $this->owner->Email ?: $this->owner->Title,
365
            $this->owner->ID
366
        ));
367
    }
368
369
    /**
370
     * Log failed login attempts.
371
     */
372
    public function authenticationFailed($data)
373
    {
374
        // LDAP authentication uses a "Login" POST field instead of Email.
375
        $login = isset($data['Login'])
376
            ? $data['Login']
377
            : (isset($data[Email::class]) ? $data[Email::class] : '');
378
379
        if (empty($login)) {
380
            return $this->getAuditLogger()->warning(
381
                'Could not determine username/email of failed authentication. '.
382
                'This could be due to login form not using Email or Login field for POST data.'
383
            );
384
        }
385
386
        $this->getAuditLogger()->info(sprintf('Failed login attempt using email "%s"', $login));
387
    }
388
389
    public function onBeforeInit()
390
    {
391
        self::bind_manipulation_capture();
392
    }
393
394
    /**
395
     * Log permission failures (where the status is set after init of page).
396
     */
397
    public function onAfterInit()
398
    {
399
        // Suppress errors if dev/build necessary
400
        if (!Security::database_is_ready()) {
401
            return false;
402
        }
403
        $currentMember = Security::getCurrentUser();
404
        if (!$currentMember || !$currentMember->exists()) {
405
            return false;
406
        }
407
408
        $statusCode = $this->owner->getResponse()->getStatusCode();
409
410
        if (substr($statusCode, 0, 1) == '4') {
411
            $this->logPermissionDenied($statusCode, $currentMember);
412
        }
413
    }
414
415
    protected function logPermissionDenied($statusCode, $member)
416
    {
417
        $this->getAuditLogger()->info(sprintf(
418
            'HTTP code %s - "%s" (ID: %s) is denied access to %s',
419
            $statusCode,
420
            $member->Email ?: $member->Title,
421
            $member->ID,
422
            $_SERVER['REQUEST_URI']
423
        ));
424
    }
425
426
    /**
427
     * Log successful logout.
428
     */
429 View Code Duplication
    public function afterMemberLoggedOut()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
430
    {
431
        $this->getAuditLogger()->info(sprintf(
432
            '"%s" (ID: %s) successfully logged out',
433
            $this->owner->Email ?: $this->owner->Title,
434
            $this->owner->ID
435
        ));
436
    }
437
}
438