Skip to content

Commit

Permalink
wip: delete user accounts and all associated data.
Browse files Browse the repository at this point in the history
  • Loading branch information
hpoul committed Jan 28, 2024
1 parent 16695f8 commit 47669ea
Show file tree
Hide file tree
Showing 15 changed files with 682 additions and 179 deletions.
2 changes: 1 addition & 1 deletion deps/postgres_utils.dart
6 changes: 6 additions & 0 deletions packages/authpass_cloud_backend/.idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ class AuthPassMigrationsProvider
Migrations(id: 15, up: (db) async => db.tables.fileCloud.migrate15(db)),
Migrations(id: 16, up: (db) async => db.tables.fileCloud.migrate16(db)),
Migrations(id: 17, up: (db) async => db.tables.fileCloud.migrate17(db)),
Migrations(id: 18, up: (db) async => db.tables.user.migrate18(db)),
];
}
}
27 changes: 27 additions & 0 deletions packages/authpass_cloud_backend/lib/src/dao/db_update_util.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import 'package:postgres/postgres.dart';

class DbUpdateTracker {
DbUpdateTracker(this.prefix);
DbUpdateTracker.merge(List<DbUpdateTracker> tracker) : prefix = '' {
results.addEntries(
tracker.expand(
(e) => e.results.entries.map(
(entry) => MapEntry(
[e.prefix, entry.key].join('.'),
entry.value,
),
),
),
);
}

final String prefix;

final Map<String, ({int affectedRowCount})> results = {};

Future<void> track(
String label, Future<PostgreSQLResult> Function() run) async {
final result = await run();
results[label] = (affectedRowCount: result.affectedRowCount);
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:authpass_cloud_backend/src/dao/db_update_util.dart';
import 'package:authpass_cloud_backend/src/dao/tables/user_tables.dart';
import 'package:authpass_cloud_backend/src/service/crypto_service.dart';
import 'package:authpass_cloud_shared/authpass_cloud_shared.dart';
Expand Down Expand Up @@ -382,6 +383,25 @@ class EmailTable extends TableBase with TableConstants {
messageReadCount: message[1] as int,
);
}

Future<DbUpdateTracker> deleteAllForUser(
DatabaseTransactionBase db, UserEntity user) async {
final ret = DbUpdateTracker('email');
await ret.track(
_TABLE_EMAIL_MESSAGE,
() async => await db.query(
'DELETE FROM $_TABLE_EMAIL_MESSAGE WHERE $_COLUMN_MAILBOX_ID IN (SELECT $columnId FROM $_TABLE_EMAIL_MAILBOX WHERE $COLUMN_USER_ID = @userId)',
values: {'userId': user.id},
),
);
await ret.track(
_TABLE_EMAIL_MESSAGE,
() async => await db.query(
'DELETE FROM $_TABLE_EMAIL_MAILBOX WHERE $COLUMN_USER_ID = @userId',
values: {'userId': user.id}),
);
return ret;
}
}

class UserEmailStatusEntity {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:typed_data';

import 'package:authpass_cloud_backend/src/dao/db_update_util.dart';
import 'package:authpass_cloud_backend/src/dao/tables/filecloud_tables_enum.dart';
import 'package:authpass_cloud_backend/src/dao/tables/user_tables.dart';
import 'package:authpass_cloud_backend/src/service/crypto_service.dart';
Expand Down Expand Up @@ -47,7 +48,7 @@ class FileCloudTable extends TableBase with TableConstants {

/// the revision number of this content. see also [_columnLastContentCount]
static const _columnContentCount = 'content_count';
static const _columnDeletedAt = 'deleted_at';
late final _columnDeletedAt = columnDeletedAt;
static const _columnTokenType = 'token_type';
static const _columnLabel = 'label';

Expand Down Expand Up @@ -714,6 +715,40 @@ class FileCloudTable extends TableBase with TableConstants {
timeoutInSeconds: 600,
);
}

Future<DbUpdateTracker> deleteAllForUser(
DatabaseTransactionBase db, UserEntity user) async {
final ret = DbUpdateTracker('filecloud');
await ret.track(
TABLE_ATTACHMENT_TOUCH,
() async => await db.query(
'DELETE FROM $TABLE_ATTACHMENT_TOUCH t WHERE $columnUserId = @userId',
values: {'userId': user.id},
),
);
await ret.track(
TABLE_FILE_TOKEN,
() async => await db.query(
'DELETE FROM $TABLE_FILE_TOKEN WHERE $_columnFileId IN (SELECT $columnId FROM $TABLE_FILE WHERE $columnUserId = @userId)',
values: {'userId': user.id},
),
);
await ret.track(
TABLE_FILE_CONTENT,
() async => await db.query(
'DELETE FROM $TABLE_FILE_CONTENT WHERE $_columnFileId IN (SELECT $columnId FROM $TABLE_FILE WHERE $columnUserId = @userId)',
values: {'userId': user.id},
),
);
await ret.track(
TABLE_FILE,
() async => await db.query(
'DELETE FROM $TABLE_FILE WHERE $columnUserId = @userId',
values: {'userId': user.id},
),
);
return ret;
}
}

@freezed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import 'dart:async';
import 'dart:convert';

import 'package:authpass_cloud_backend/src/dao/db_update_util.dart';
import 'package:authpass_cloud_backend/src/service/crypto_service.dart';
import 'package:authpass_cloud_shared/authpass_cloud_shared.dart';
import 'package:clock/clock.dart';
import 'package:crypto/crypto.dart';
import 'package:logging/logging.dart';
import 'package:postgres_utils/postgres_utils.dart';

Expand Down Expand Up @@ -40,6 +44,8 @@ class UserTable extends TableBase with TableConstants {
static const _COLUMN_CONFIRMED_AT = 'confirmed_at';
static const _TYPE_STATUS = 'AuthTokenStatus';
static const _COLUMN_USER_AGENT = 'user_agent';
// sha-256 hash of email address used when deleting.
static const _columnDeletedBy = 'deleted_by';

final CryptoService cryptoService;

Expand Down Expand Up @@ -97,6 +103,13 @@ class UserTable extends TableBase with TableConstants {
''');
}

Future<void> migrate18(DatabaseTransactionBase db) async {
await db.execute('''
ALTER TABLE $TABLE_USER ADD COLUMN $columnDeletedAt $typeTimestamp NULL;
ALTER TABLE $TABLE_USER ADD COLUMN $_columnDeletedBy VARCHAR NULL;
''');
}

String _normalizeEmail(String email) {
return email.toLowerCase();
}
Expand Down Expand Up @@ -338,6 +351,39 @@ class UserTable extends TableBase with TableConstants {
))
.toList(growable: false);
}

Future<DbUpdateTracker> deleteUserAndAllReferences(DatabaseTransactionBase db,
EmailConfirmEntity emailConfirmEntity, AuthTokenEntity authToken) async {
final user = authToken.user;
final ret = DbUpdateTracker('user');
await ret.track(
_TABLE_EMAIL,
() async => await db.query(
'DELETE FROM $_TABLE_EMAIL WHERE $COLUMN_USER_ID = @userId',
values: {'userId': user.id},
),
);
await ret.track(
_TABLE_AUTH_TOKEN,
() async => db.query(
'DELETE FROM $_TABLE_AUTH_TOKEN WHERE $COLUMN_USER_ID = @userId',
values: {'userId': user.id},
),
);
final now = clock.now().toUtc();
final deletedByEmailBytes =
utf8.encode(emailConfirmEntity.email.emailAddress);
final deletedBySha = sha256.convert(deletedByEmailBytes);
await db.executeUpdate(
TABLE_USER,
set: {
columnDeletedAt: now,
_columnDeletedBy: deletedBySha.toString(),
},
where: {columnId: user.id},
);
return ret;
}
}

class AuthTokenEntity {
Expand Down
29 changes: 28 additions & 1 deletion packages/authpass_cloud_backend/lib/src/dao/user_repository.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import 'package:authpass_cloud_backend/src/dao/database_access.dart';
import 'package:authpass_cloud_backend/src/dao/db_update_util.dart';
import 'package:authpass_cloud_backend/src/dao/tables/user_tables.dart';
import 'package:authpass_cloud_shared/authpass_cloud_shared.dart';
import 'package:clock/clock.dart';

import 'package:logging/logging.dart';
import 'package:openapi_base/openapi_base.dart';

final _logger = Logger('user_repository');

Expand All @@ -16,9 +18,13 @@ class UserRepository {
/// If the email address is already known, just creates a new
/// email confirmation token.
Future<EmailConfirmEntity> createUserOrConfirmEmail(
String email, String userAgent) async {
String email, String userAgent,
{bool requireExistingUser = false}) async {
final userEmail = await db.tables.user.findUserByEmail(db, email);
if (userEmail == null) {
if (requireExistingUser) {
throw NotFoundException('Unable to find user with email address.');
}
return await db.tables.user.insertUser(db, email, userAgent);
}
final authToken =
Expand Down Expand Up @@ -55,6 +61,27 @@ class UserRepository {
return true;
}

Future<EmailConfirmEntity?> deleteAccountForEmailToken(String token) async {
final emailConfirmToken =
await db.tables.user.findEmailConfirmToken(db, token);
if (emailConfirmToken == null) {
throw StateError('Unable to find confirm token. $token');
}
final authToken = emailConfirmToken.authToken;
if (authToken == null) {
return null;
}
_logger.info('Deleting user with id ${authToken.user.id}');
final ret = DbUpdateTracker.merge([
await db.tables.fileCloud.deleteAllForUser(db, authToken.user),
await db.tables.email.deleteAllForUser(db, authToken.user),
await db.tables.user
.deleteUserAndAllReferences(db, emailConfirmToken, authToken),
]);
_logger.info('finished deleting. result: ${ret.results}');
return emailConfirmToken;
}

Future<AuthTokenEntity?> findValidAuthToken(String authToken,
{bool acceptUnconfirmed = false}) async {
final token = await db.tables.user.findAuthToken(db, authToken: authToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -508,24 +508,55 @@ class AuthPassCloudImpl extends AuthPassCloud {
return FilecloudAttachmentUnlinkPostResponse.response200();
}

@override
Future<UserDeleteGetResponse> userDeleteGet() async {
return UserDeleteGetResponse.response200(
deleteUserByEmailFormInput(serviceProvider.env));
}

@override
Future<UserDeleteConfirmGetResponse> userDeleteConfirmGet(
{required String token}) {
// TODO: implement userDeleteConfirmGet
throw UnimplementedError();
{required String token}) async {
if (!await repository.user.isValidEmailConfirmToken(token)) {
return UserDeleteConfirmGetResponse.response400('');
}
return UserDeleteConfirmGetResponse.response200(
deleteUserEmailConfirmationPage(serviceProvider.env, token));
}

@override
Future<UserDeleteConfirmPostResponse> userDeleteConfirmPost(
UserDeleteConfirmPostSchema body) {
// TODO: implement userDeleteConfirmPost
throw UnimplementedError();
UserDeleteConfirmPostSchema body) async {
final success =
await serviceProvider.recaptchaService.verify(body.gRecaptchaResponse);
if (!success) {
return UserDeleteConfirmPostResponse.response400();
}
final token = await repository.user.deleteAccountForEmailToken(body.token);
if (token == null) {
return UserDeleteConfirmPostResponse.response400();
}
return UserDeleteConfirmPostResponse.response200(
deleteUserSuccessPage(serviceProvider.env, token.email));
}

@override
Future<UserDeletePostResponse> userDeletePost(UserDeletePostSchema body) {
// TODO: implement userDeletePost
throw UnimplementedError();
Future<UserDeletePostResponse> userDeletePost(
UserDeletePostSchema body) async {
final emailConfirm = await repository.user.createUserOrConfirmEmail(
body.email,
request.headerParameter(HttpHeaders.userAgentHeader).single,
requireExistingUser: true,
);
final urlResolve = AuthPassCloudUrlResolve();
final url = urlResolve
.userDeleteConfirmGet(token: emailConfirm.token)
.resolveUri(serviceProvider.env.baseUri);
await serviceProvider.emailService.sendEmailConfirmationForUserDeleteToken(
emailConfirm.email.emailAddress, url.toString());
_logger.fine('Deleting user. ${body.email}');
return UserDeletePostResponse.response200(
deleteUserEmailVerificationSent(serviceProvider.env));
}
}

Expand Down
Loading

0 comments on commit 47669ea

Please sign in to comment.