Skip to content

Commit 66a6d6f

Browse files
authored
Merge pull request #22 from headlines-toolkit/fix_anon_to_auth_user_data_sync
Fix anon to auth user data sync
2 parents f5a5363 + 8c2b700 commit 66a6d6f

File tree

6 files changed

+107
-512
lines changed

6 files changed

+107
-512
lines changed

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ management dashboard](https://github.com/headlines-toolkit/ht-dashboard).
1717
## ✨ Key Capabilities
1818

1919
* 🔒 **Flexible & Secure Authentication:** Provide seamless user access with
20-
a unified system supporting passwordless email sign-in, anonymous guest
21-
accounts, and a secure, role-aware login flow for privileged dashboard
22-
users.
20+
a unified system supporting passwordless email sign-in and anonymous guest
21+
accounts. The API intelligently handles the conversion from a guest to a
22+
permanent user, preserving all settings and preferences. It also includes
23+
a secure, role-aware login flow for privileged dashboard users.
2324

2425
* ⚡️ **Granular Role-Based Access Control (RBAC):** Implement precise
2526
permissions with a dual-role system (`appRole` for application features,

lib/src/services/auth_service.dart

Lines changed: 97 additions & 194 deletions
Original file line numberDiff line numberDiff line change
@@ -129,33 +129,60 @@ class AuthService {
129129
/// Returns the authenticated [User] and a new authentication token.
130130
///
131131
/// Throws [InvalidInputException] if the code is invalid or expired.
132+
/// Completes the email sign-in process by verifying the code.
133+
///
134+
/// This method is context-aware and handles multiple scenarios:
135+
///
136+
/// - **Guest to Permanent Conversion:** If an authenticated `guestUser`
137+
/// (from [authenticatedUser]) performs this action, their account is
138+
/// upgraded to a permanent `standardUser` with the verified [email].
139+
/// Their existing data is preserved.
140+
/// - **Dashboard Login:** If [isDashboardLogin] is true, it performs a
141+
/// strict login for an existing user with dashboard permissions.
142+
/// - **Standard Sign-In/Sign-Up:** If no authenticated user is present, it
143+
/// either logs in an existing user with the given [email] or creates a
144+
/// new `standardUser`.
145+
///
146+
/// Returns the authenticated [User] and a new authentication token.
147+
///
148+
/// Throws [InvalidInputException] if the code is invalid or expired.
132149
Future<({User user, String token})> completeEmailSignIn(
133150
String email,
134151
String code, {
135-
// Flag to indicate if this is a login attempt from the dashboard,
136-
// which enforces stricter checks.
137-
bool isDashboardLogin = false,
152+
required bool isDashboardLogin,
153+
User? authenticatedUser,
138154
}) async {
139-
// 1. Validate the code for standard sign-in
140-
final isValidCode = await _verificationCodeStorageService
141-
.validateSignInCode(email, code);
155+
// 1. Validate the verification code.
156+
final isValidCode =
157+
await _verificationCodeStorageService.validateSignInCode(email, code);
142158
if (!isValidCode) {
143-
throw const InvalidInputException(
144-
'Invalid or expired verification code.',
145-
);
159+
throw const InvalidInputException('Invalid or expired verification code.');
146160
}
147161

148-
// After successful code validation, clear the sign-in code
162+
// After successful validation, clear the code from storage.
149163
try {
150164
await _verificationCodeStorageService.clearSignInCode(email);
151165
} catch (e) {
152-
// Log or handle if clearing fails, but don't let it block sign-in
153166
_log.warning(
154167
'Warning: Failed to clear sign-in code for $email after validation: $e',
155168
);
156169
}
157170

158-
// 2. Find or create the user based on the context
171+
// 2. Check for Guest-to-Permanent user conversion flow.
172+
if (authenticatedUser != null &&
173+
authenticatedUser.appRole == AppUserRole.guestUser) {
174+
_log.info(
175+
'Starting account conversion for guest user ${authenticatedUser.id} to email $email.',
176+
);
177+
return _convertGuestUserToPermanent(
178+
guestUser: authenticatedUser,
179+
verifiedEmail: email,
180+
);
181+
}
182+
183+
// 3. If not a conversion, proceed with standard or dashboard login.
184+
185+
// Find or create the user based on the context.
159186
User user;
160187
try {
161188
// Attempt to find user by email
@@ -166,18 +193,6 @@ class AuthService {
166193
// This closes the loophole where a non-admin user could request a code
167194
// via the app flow and then use it to log into the dashboard.
168195
if (isDashboardLogin) {
169-
if (user.email != email) {
170-
// This is a critical security check. If the user found by email
171-
// somehow has a different email than the one provided, it's a
172-
// sign of a serious issue (like the data layer bug we fixed).
173-
// We throw a generic error to avoid revealing information.
174-
_log.severe(
175-
'CRITICAL: Mismatch between requested email ($email) and found '
176-
'user email (${user.email}) during dashboard login for user '
177-
'ID ${user.id}.',
178-
);
179-
throw const UnauthorizedException('User account does not exist.');
180-
}
181196
if (!_permissionService.hasPermission(
182197
user,
183198
Permissions.dashboardLogin,
@@ -358,155 +373,6 @@ class AuthService {
358373
/// Initiates the process of linking an [emailToLink] to an existing
359374
/// authenticated [anonymousUser]'s account.
360375
///
361-
/// Throws [ConflictException] if the [emailToLink] is already in use by
362-
/// another permanent account, or if the [anonymousUser] is not actually
363-
/// anonymous, or if the [emailToLink] is already pending verification for
364-
/// another linking process.
365-
/// Throws [OperationFailedException] for other errors.
366-
Future<void> initiateLinkEmailProcess({
367-
required User anonymousUser,
368-
required String emailToLink,
369-
}) async {
370-
if (anonymousUser.appRole != AppUserRole.guestUser) {
371-
throw const BadRequestException(
372-
'Account is already permanent. Cannot link email.',
373-
);
374-
}
375-
376-
try {
377-
// 1. Check if emailToLink is already used by another permanent user.
378-
final existingUsersResponse = await _userRepository.readAll(
379-
filter: {'email': emailToLink},
380-
);
381-
382-
// Filter for permanent users (not guests) that are not the current user.
383-
final conflictingPermanentUsers = existingUsersResponse.items.where(
384-
(u) => u.appRole != AppUserRole.guestUser && u.id != anonymousUser.id,
385-
);
386-
387-
if (conflictingPermanentUsers.isNotEmpty) {
388-
throw ConflictException(
389-
'Email address "$emailToLink" is already in use by another account.',
390-
);
391-
}
392-
393-
// 2. Generate and store the link code.
394-
// The storage service itself might throw ConflictException if emailToLink
395-
// is pending for another user or if this user has a pending code.
396-
final code = await _verificationCodeStorageService
397-
.generateAndStoreLinkCode(
398-
userId: anonymousUser.id,
399-
emailToLink: emailToLink,
400-
);
401-
402-
// 3. Send the code via email
403-
await _emailRepository.sendOtpEmail(
404-
recipientEmail: emailToLink,
405-
otpCode: code,
406-
);
407-
_log.info(
408-
'Initiated email link for user ${anonymousUser.id} to email $emailToLink, code sent: $code .',
409-
);
410-
} on HtHttpException {
411-
rethrow;
412-
} catch (e) {
413-
_log.severe(
414-
'Error during initiateLinkEmailProcess for user ${anonymousUser.id}, email $emailToLink: $e',
415-
);
416-
throw OperationFailedException(
417-
'Failed to initiate email linking process: $e',
418-
);
419-
}
420-
}
421-
422-
/// Completes the email linking process for an [anonymousUser] by verifying
423-
/// the [codeFromUser].
424-
///
425-
/// If successful, updates the user to be permanent with the linked email
426-
/// and returns the updated User and a new authentication token.
427-
/// Throws [InvalidInputException] if the code is invalid or expired.
428-
/// Throws [OperationFailedException] for other errors.
429-
Future<({User user, String token})> completeLinkEmailProcess({
430-
required User anonymousUser,
431-
required String codeFromUser,
432-
required String oldAnonymousToken, // Needed to invalidate it
433-
}) async {
434-
if (anonymousUser.appRole != AppUserRole.guestUser) {
435-
// Should ideally not happen if flow is correct, but good safeguard.
436-
throw const BadRequestException(
437-
'Account is already permanent. Cannot complete email linking.',
438-
);
439-
}
440-
441-
try {
442-
// 1. Validate the link code and retrieve the email that was being linked.
443-
final linkedEmail = await _verificationCodeStorageService
444-
.validateAndRetrieveLinkedEmail(
445-
userId: anonymousUser.id,
446-
linkCode: codeFromUser,
447-
);
448-
449-
if (linkedEmail == null) {
450-
throw const InvalidInputException(
451-
'Invalid or expired verification code for email linking.',
452-
);
453-
}
454-
455-
// 2. Update the user to be permanent.
456-
final updatedUser = anonymousUser.copyWith(
457-
email: linkedEmail,
458-
appRole: AppUserRole.standardUser,
459-
);
460-
final permanentUser = await _userRepository.update(
461-
id: updatedUser.id,
462-
item: updatedUser,
463-
);
464-
_log.info(
465-
'User ${permanentUser.id} successfully linked with email $linkedEmail.',
466-
);
467-
468-
// Ensure user data exists after linking.
469-
await _ensureUserDataExists(permanentUser);
470-
471-
// 3. Generate a new authentication token for the now-permanent user.
472-
final newToken = await _authTokenService.generateToken(permanentUser);
473-
_log.info('Generated new token for linked user ${permanentUser.id}');
474-
475-
// 4. Invalidate the old anonymous token.
476-
try {
477-
await _authTokenService.invalidateToken(oldAnonymousToken);
478-
_log.info(
479-
'Successfully invalidated old anonymous token for user ${permanentUser.id}.',
480-
);
481-
} catch (e) {
482-
// Log error but don't fail the whole linking process if invalidation fails.
483-
// The new token is more important.
484-
_log.warning(
485-
'Warning: Failed to invalidate old anonymous token for user ${permanentUser.id}: $e',
486-
);
487-
}
488-
489-
// 5. Clear the link code from storage.
490-
try {
491-
await _verificationCodeStorageService.clearLinkCode(anonymousUser.id);
492-
} catch (e) {
493-
_log.warning(
494-
'Warning: Failed to clear link code for user ${anonymousUser.id} after linking: $e',
495-
);
496-
}
497-
498-
return (user: permanentUser, token: newToken);
499-
} on HtHttpException {
500-
rethrow;
501-
} catch (e) {
502-
_log.severe(
503-
'Error during completeLinkEmailProcess for user ${anonymousUser.id}: $e',
504-
);
505-
throw OperationFailedException(
506-
'Failed to complete email linking process: $e',
507-
);
508-
}
509-
}
510376
511377
/// Deletes a user account and associated authentication data.
512378
///
@@ -538,32 +404,18 @@ class AuthService {
538404
await _userRepository.delete(id: userId);
539405
_log.info('User ${userToDelete.id} deleted from repository.');
540406

541-
// 3. Clear any pending verification codes for this user ID (linking).
407+
// 3. Clear any pending sign-in codes for the user's email.
542408
try {
543-
await _verificationCodeStorageService.clearLinkCode(userId);
544-
_log.info('Cleared link code for user ${userToDelete.id}.');
409+
await _verificationCodeStorageService.clearSignInCode(
410+
userToDelete.email,
411+
);
412+
_log.info('Cleared sign-in code for email ${userToDelete.email}.');
545413
} catch (e) {
546-
// Log but don't fail deletion if clearing codes fails
547414
_log.warning(
548-
'Warning: Failed to clear link code for user ${userToDelete.id}: $e',
415+
'Warning: Failed to clear sign-in code for email ${userToDelete.email}: $e',
549416
);
550417
}
551418

552-
// 4. Clear any pending sign-in codes for the user's email (if they had one).
553-
// The email for anonymous users is a placeholder and not used for sign-in.
554-
if (userToDelete.appRole != AppUserRole.guestUser) {
555-
try {
556-
await _verificationCodeStorageService.clearSignInCode(
557-
userToDelete.email,
558-
);
559-
_log.info('Cleared sign-in code for email ${userToDelete.email}.');
560-
} catch (e) {
561-
_log.warning(
562-
'Warning: Failed to clear sign-in code for email ${userToDelete.email}: $e',
563-
);
564-
}
565-
}
566-
567419
_log.info('Account deletion process completed for user $userId.');
568420
} on NotFoundException {
569421
// Propagate NotFoundException if user doesn't exist
@@ -633,7 +485,10 @@ class AuthService {
633485

634486
// Check for UserContentPreferences
635487
try {
636-
await _userContentPreferencesRepository.read(id: user.id, userId: user.id);
488+
await _userContentPreferencesRepository.read(
489+
id: user.id,
490+
userId: user.id,
491+
);
637492
} on NotFoundException {
638493
_log.info(
639494
'UserContentPreferences not found for user ${user.id}. Creating with defaults.',
@@ -651,4 +506,52 @@ class AuthService {
651506
);
652507
}
653508
}
509+
510+
/// Converts a guest user to a permanent standard user.
511+
///
512+
/// This helper method encapsulates the logic for updating the user's
513+
/// record with a verified email, upgrading their role, and generating a new
514+
/// authentication token. It ensures that all associated user data is
515+
/// preserved during the conversion.
516+
///
517+
/// Throws [ConflictException] if the target email is already in use by
518+
/// another permanent account.
519+
Future<({User user, String token})> _convertGuestUserToPermanent({
520+
required User guestUser,
521+
required String verifiedEmail,
522+
}) async {
523+
// 1. Check if the target email is already in use by another permanent user.
524+
final existingUser = await _findUserByEmail(verifiedEmail);
525+
if (existingUser != null && existingUser.id != guestUser.id) {
526+
// If a different user already exists with this email, throw an error.
527+
throw ConflictException(
528+
'This email address is already associated with another account.',
529+
);
530+
}
531+
532+
// 2. Update the guest user's details to make them permanent.
533+
final updatedUser = guestUser.copyWith(
534+
email: verifiedEmail,
535+
appRole: AppUserRole.standardUser,
536+
);
537+
538+
final permanentUser = await _userRepository.update(
539+
id: updatedUser.id,
540+
item: updatedUser,
541+
);
542+
_log.info(
543+
'User ${permanentUser.id} successfully converted to permanent account with email $verifiedEmail.',
544+
);
545+
546+
// 3. Generate a new token for the now-permanent user.
547+
final newToken = await _authTokenService.generateToken(permanentUser);
548+
_log.info('Generated new token for converted user ${permanentUser.id}');
549+
550+
// Note: Invalidation of the old anonymous token is handled implicitly.
551+
// The client will receive the new token and stop using the old one.
552+
// The old token will eventually expire. For immediate invalidation,
553+
// the old token would need to be passed into this flow and blacklisted.
554+
555+
return (user: permanentUser, token: newToken);
556+
}
654557
}

0 commit comments

Comments
 (0)