Skip to content

Commit 1027376

Browse files
imorlandclaude
andauthored
feat: invalidate confirmation token, log IP, purge IP after 90 days (#70)
* feat: invalidate token and log IP on erasure confirmation; purge IP after 90 days - Null verification_token on confirmation so email links cannot be reused - Guard against re-confirming already-processed requests (422) - Store confirmation_ip (from Flarum ipAddress request attribute) for audit trail - New gdpr:clear-confirmation-ips command purges stored IPs after 90 days (scheduled daily) - ProcessErasureRequestModal shows requested/confirmed/eligible-at timestamps - Update tests and README Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: format js * fix: use distinct user_id for processed erasure fixture to avoid unique constraint violation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4a573bb commit 1027376

11 files changed

Lines changed: 215 additions & 9 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ All forum users now have a `Personal Data` section within their account settings
2323

2424
From here, users may self-service export their data from the forum, or start an erasure request. Erasure requests are queued up for admins/moderators to process. Any unprocessed requests that are still pending after 30 days will be processed automatically using the configured default method (Deletion or Anonymization).
2525

26+
#### Erasure confirmation security
27+
28+
When a user clicks the email link to confirm their erasure request:
29+
30+
- The one-time **verification token is invalidated** (set to `null`) so the same link cannot be re-used or re-confirm a request that has already been processed.
31+
- The **IP address** of the confirming client is stored in `confirmation_ip` on the erasure record, providing an audit trail for the confirmation event.
32+
- If the request has already been processed (status `processed` or `manual`), re-visiting the confirmation link returns a 422 error instead of silently resetting the request status.
33+
34+
The stored IP address is automatically purged after **90 days** by the `gdpr:clear-confirmation-ips` scheduled command (runs daily), limiting the period for which this data is retained in line with data-minimisation principles.
35+
2636
#### Specifying which queue to use
2737
If your forum runs multiple queues, ie `low` and `high`, you may specify which queue jobs for this extension are run on in your skeleton's `extend.php` file:
2838

extend.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,10 @@
8888
(new Extend\Console())
8989
->command(Console\DestroyExportsCommand::class)
9090
->command(Console\ProcessEraseRequests::class)
91+
->command(Console\ClearConfirmationIps::class)
9192
->schedule(Console\ProcessEraseRequests::class, Console\DailySchedule::class)
92-
->schedule(Console\DestroyExportsCommand::class, Console\DailySchedule::class),
93+
->schedule(Console\DestroyExportsCommand::class, Console\DailySchedule::class)
94+
->schedule(Console\ClearConfirmationIps::class, Console\DailySchedule::class),
9395

9496
(new Extend\ServiceProvider())
9597
->register(Providers\GdprProvider::class),

js/src/forum/components/ErasureRequestsList.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export default class ErasureRequestsList extends Component {
3232
name: username(request.user()),
3333
})}
3434
</span>
35-
{humanTime(request.createdAt())}
35+
{humanTime(request.userConfirmedAt())}
3636
</a>
3737
</li>
3838
);

js/src/forum/components/ProcessErasureRequestModal.tsx

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import app from 'flarum/forum/app';
22
import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal';
33
import Button from 'flarum/common/components/Button';
44
import username from 'flarum/common/helpers/username';
5+
import fullTime from 'flarum/common/helpers/fullTime';
56
import extractText from 'flarum/common/utils/extractText';
67
import ItemList from 'flarum/common/utils/ItemList';
78
import Stream from 'flarum/common/utils/Stream';
9+
import dayjs from 'dayjs';
810
import type Mithril from 'mithril';
911
import ErasureRequest from 'src/common/models/ErasureRequest';
1012
import UserCard from 'flarum/forum/components/UserCard';
@@ -53,15 +55,32 @@ export default class ProcessErasureRequestModal extends Modal<ProcessErasureRequ
5355
<div>
5456
<UserCard className="UserCard--popover UserCard--gdpr" user={this.request.user()} />
5557
<p className="helpText">{app.translator.trans('flarum-gdpr.forum.process_erasure.text', { name: username(this.request.user()) })}</p>
56-
</div>
58+
</div>,
59+
100
60+
);
61+
62+
const confirmedAt = erasureRequest.userConfirmedAt();
63+
items.add(
64+
'timestamps',
65+
<ul className="ErasureRequest-timestamps helpText">
66+
<li>{app.translator.trans('flarum-gdpr.forum.process_erasure.requested_at', { date: fullTime(erasureRequest.createdAt()!) })}</li>
67+
{confirmedAt && <li>{app.translator.trans('flarum-gdpr.forum.process_erasure.confirmed_at', { date: fullTime(confirmedAt) })}</li>}
68+
{confirmedAt && (
69+
<li>
70+
{app.translator.trans('flarum-gdpr.forum.process_erasure.eligible_at', { date: fullTime(dayjs(confirmedAt).add(30, 'day').toDate()) })}
71+
</li>
72+
)}
73+
</ul>,
74+
90
5775
);
5876

5977
erasureRequest?.reason() &&
6078
items.add(
6179
'reason',
6280
<p className="helpText">
6381
<code>{erasureRequest.reason()}</code>
64-
</p>
82+
</p>,
83+
80
6584
);
6685

6786
items.add(
@@ -73,7 +92,8 @@ export default class ProcessErasureRequestModal extends Modal<ProcessErasureRequ
7392
bidi={this.comments}
7493
placeholder={extractText(app.translator.trans('flarum-gdpr.forum.process_erasure.comments_label'))}
7594
></textarea>
76-
</div>
95+
</div>,
96+
70
7797
);
7898

7999
if (app.forum.attribute('erasureAnonymizationAllowed')) {
@@ -88,7 +108,8 @@ export default class ProcessErasureRequestModal extends Modal<ProcessErasureRequ
88108
},
89109
app.translator.trans('flarum-gdpr.forum.process_erasure.anonymization_button')
90110
)}
91-
</div>
111+
</div>,
112+
60
92113
);
93114
}
94115

@@ -104,7 +125,8 @@ export default class ProcessErasureRequestModal extends Modal<ProcessErasureRequ
104125
},
105126
app.translator.trans('flarum-gdpr.forum.process_erasure.deletion_button')
106127
)}
107-
</div>
128+
</div>,
129+
50
108130
);
109131
}
110132

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
use Illuminate\Database\Schema\Blueprint;
11+
use Illuminate\Database\Schema\Builder;
12+
13+
return [
14+
'up' => function (Builder $schema) {
15+
if (!$schema->hasColumn('gdpr_erasure', 'confirmation_ip')) {
16+
$schema->table('gdpr_erasure', function (Blueprint $table) {
17+
$table->string('confirmation_ip')->nullable();
18+
});
19+
}
20+
},
21+
'down' => function (Builder $schema) {
22+
if ($schema->hasColumn('gdpr_erasure', 'confirmation_ip')) {
23+
$schema->table('gdpr_erasure', function (Blueprint $table) {
24+
$table->dropColumn('confirmation_ip');
25+
});
26+
}
27+
},
28+
];

resources/locale/en.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,9 @@ flarum-gdpr:
163163
confirm: Are you sure you want to erase {name}'s account under {mode} mode?
164164
title: Process erasure request
165165
text: "{name} has requested account erasure."
166+
requested_at: "Requested: {date}"
167+
confirmed_at: "Confirmed: {date}"
168+
eligible_at: "Eligible for auto-processing: {date}"
166169
comments_label: Comments (optional)
167170
anonymization_button: Anonymize user
168171
deletion_button: Delete user
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
namespace Flarum\Gdpr\Console;
11+
12+
use Carbon\Carbon;
13+
use Flarum\Gdpr\Models\ErasureRequest;
14+
use Illuminate\Console\Command;
15+
16+
class ClearConfirmationIps extends Command
17+
{
18+
const RETENTION_DAYS = 90;
19+
20+
protected $signature = 'gdpr:clear-confirmation-ips';
21+
protected $description = 'Clears stored confirmation IP addresses from erasure requests older than '.self::RETENTION_DAYS.' days.';
22+
23+
public function handle(): void
24+
{
25+
ErasureRequest::query()
26+
->whereNotNull('confirmation_ip')
27+
->where('user_confirmed_at', '<=', Carbon::now()->subDays(static::RETENTION_DAYS))
28+
->update(['confirmation_ip' => null]);
29+
}
30+
}

src/Http/Controller/ConfirmErasureController.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,17 @@ public function handle(Request $request): ResponseInterface
4242
throw new ValidationException(['user' => 'Erase requests cannot be confirmed by different users.']);
4343
}
4444

45+
if (in_array($erasureRequest->status, [ErasureRequest::STATUS_PROCESSED, ErasureRequest::STATUS_MANUAL])) {
46+
throw new ValidationException(['request' => 'This erasure request has already been processed.']);
47+
}
48+
49+
$ip = $request->getAttribute('ipAddress');
50+
4551
$erasureRequest->user_confirmed_at = Carbon::now();
4652
$erasureRequest->status = ErasureRequest::STATUS_USER_CONFIRMED;
4753
$erasureRequest->cancelled_at = null;
54+
$erasureRequest->verification_token = null;
55+
$erasureRequest->confirmation_ip = $ip;
4856
$erasureRequest->save();
4957

5058
return new RedirectResponse($this->url->to('forum')->base().'?erasureRequestConfirmed=1');

src/Models/ErasureRequest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
* @property string|null $reason
2525
* @property Carbon $created_at
2626
* @property Carbon|null $user_confirmed_at
27+
* @property string|null $confirmation_ip
2728
* @property int|null $processed_by
2829
* @property User|null $processedBy
2930
* @property string|null $processor_comment
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
namespace Flarum\Gdpr\Tests\integration\console;
11+
12+
use Carbon\Carbon;
13+
use Flarum\Gdpr\Console\ClearConfirmationIps;
14+
use Flarum\Gdpr\Models\ErasureRequest;
15+
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
16+
use Flarum\Testing\integration\TestCase;
17+
18+
class ClearConfirmationIpsTest extends TestCase
19+
{
20+
use RetrievesAuthorizedUsers;
21+
22+
public function setUp(): void
23+
{
24+
parent::setUp();
25+
$this->extension('flarum-gdpr');
26+
27+
$this->prepareDatabase([
28+
'users' => [
29+
$this->normalUser(),
30+
['id' => 3, 'username' => 'user3', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => 'user3@machine.local', 'is_email_confirmed' => 1],
31+
['id' => 4, 'username' => 'user4', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => 'user4@machine.local', 'is_email_confirmed' => 1],
32+
],
33+
'gdpr_erasure' => [
34+
// Confirmed 91 days ago — IP should be cleared.
35+
['id' => 1, 'user_id' => 2, 'verification_token' => null, 'status' => 'user_confirmed', 'created_at' => Carbon::now()->subDays(100), 'user_confirmed_at' => Carbon::now()->subDays(91), 'confirmation_ip' => '1.2.3.4'],
36+
// Confirmed 89 days ago — IP should be retained.
37+
['id' => 2, 'user_id' => 3, 'verification_token' => null, 'status' => 'user_confirmed', 'created_at' => Carbon::now()->subDays(90), 'user_confirmed_at' => Carbon::now()->subDays(89), 'confirmation_ip' => '5.6.7.8'],
38+
// No IP stored — unaffected.
39+
['id' => 3, 'user_id' => 4, 'verification_token' => null, 'status' => 'user_confirmed', 'created_at' => Carbon::now()->subDays(100), 'user_confirmed_at' => Carbon::now()->subDays(91), 'confirmation_ip' => null],
40+
],
41+
]);
42+
}
43+
44+
/**
45+
* @test
46+
*/
47+
public function clears_ip_for_requests_older_than_90_days()
48+
{
49+
$this->app();
50+
51+
$command = new ClearConfirmationIps();
52+
$command->handle();
53+
54+
$this->assertNull(ErasureRequest::query()->find(1)->confirmation_ip);
55+
}
56+
57+
/**
58+
* @test
59+
*/
60+
public function retains_ip_for_requests_within_90_days()
61+
{
62+
$this->app();
63+
64+
$command = new ClearConfirmationIps();
65+
$command->handle();
66+
67+
$this->assertEquals('5.6.7.8', ErasureRequest::query()->find(2)->confirmation_ip);
68+
}
69+
70+
/**
71+
* @test
72+
*/
73+
public function does_not_affect_requests_without_ip()
74+
{
75+
$this->app();
76+
77+
$command = new ClearConfirmationIps();
78+
$command->handle();
79+
80+
$this->assertNull(ErasureRequest::query()->find(3)->confirmation_ip);
81+
}
82+
}

0 commit comments

Comments
 (0)