diff --git a/README.md b/README.md index e66eb0f..45cb4ce 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ management dashboard](https://github.com/headlines-toolkit/ht-dashboard). ## ✨ Key Capabilities -* 🔒 **Effortless User Authentication:** Provide secure and seamless user access - with flexible flows including passwordless sign-in, anonymous access, and - the ability to easily link anonymous accounts to permanent ones. Focus on - user experience while `ht_api` handles the security complexities. +* 🔒 **Flexible & Secure Authentication:** Provide seamless user access with + a unified system supporting passwordless sign-in, anonymous guest + accounts, and a secure, context-aware login flow for privileged dashboard + users (e.g., 'admin', 'publisher'). * ⚡️ **Flexible Role-Based Access Control (RBAC):** Implement granular permissions with a flexible, multi-role system. Assign multiple roles to @@ -33,13 +33,9 @@ management dashboard](https://github.com/headlines-toolkit/ht-dashboard). * 👤 **Personalized User Preferences:** Enable richer user interactions by managing and syncing user-specific data such as saved headlines, followed sources, or other personalized content tailored to individual users. -* 💾 **Robust Data Management:** Securely manage core news application data, - including headlines, categories, and sources, through a well-structured - and protected API. - -* 🔀 **Flexible Data Sorting:** Order lists of headlines, sources, and other - data by various fields in ascending or descending order, allowing for - dynamic and user-driven content presentation. +* 💾 **Robust Data Management:** Securely manage core news data (headlines, + categories, sources) through a well-structured API that supports flexible + querying and sorting for dynamic content presentation. * 📊 **Dynamic Dashboard Summary:** Access real-time, aggregated metrics on key data points like total headlines, categories, and sources, providing diff --git a/lib/src/fixtures/user_fixtures.dart b/lib/src/fixtures/user_fixtures.dart new file mode 100644 index 0000000..ac851db --- /dev/null +++ b/lib/src/fixtures/user_fixtures.dart @@ -0,0 +1,18 @@ +import 'package:ht_shared/ht_shared.dart'; + +/// A list of initial user data to be loaded into the in-memory user repository. +/// +/// This list includes a pre-configured administrator user, which is essential +/// for accessing the dashboard in a development environment. +final List userFixtures = [ + // The initial administrator user. + User( + id: 'admin-user-id', // A fixed, predictable ID for the admin. + email: 'admin@example.com', + roles: const [UserRoles.standardUser, UserRoles.admin], + createdAt: DateTime.now().toUtc(), + ), + // Add other initial users for testing if needed. + // Example: A standard user + // User( ... ), +]; diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 3706214..3af933a 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -41,11 +41,65 @@ class AuthService { /// Initiates the email sign-in process. /// - /// Generates a verification code, stores it, and sends it via email. - /// Throws [InvalidInputException] for invalid email format (via email client). - /// Throws [OperationFailedException] if code generation/storage/email fails. - Future initiateEmailSignIn(String email) async { + /// This method is context-aware based on the [isDashboardLogin] flag. + /// + /// - For the user-facing app (`isDashboardLogin: false`), it generates and + /// sends a verification code to the given [email] without pre-validation, + /// supporting a unified sign-in/sign-up flow. + /// - For the dashboard (`isDashboardLogin: true`), it performs a strict + /// login-only check. It verifies that a user with the given [email] exists + /// and has either the 'admin' or 'publisher' role *before* sending a code. + /// + /// - [email]: The email address to send the code to. + /// - [isDashboardLogin]: A flag to indicate if this is a login attempt from + /// the dashboard, which enforces stricter checks. + /// + /// Throws [UnauthorizedException] if `isDashboardLogin` is true and the user + /// does not exist. + /// Throws [ForbiddenException] if `isDashboardLogin` is true and the user + /// exists but lacks the required roles. + Future initiateEmailSignIn( + String email, { + bool isDashboardLogin = false, + }) async { try { + // For dashboard login, first validate the user exists and has permissions. + if (isDashboardLogin) { + print('Dashboard login initiated for $email. Verifying user...'); + User? user; + try { + final query = {'email': email}; + final response = await _userRepository.readAllByQuery(query); + if (response.items.isNotEmpty) { + user = response.items.first; + } + } on HtHttpException catch (e) { + print('Repository error while verifying dashboard user $email: $e'); + rethrow; + } + + if (user == null) { + print('Dashboard login failed: User $email not found.'); + throw const UnauthorizedException( + 'This email address is not registered for dashboard access.', + ); + } + + final hasRequiredRole = + user.roles.contains(UserRoles.admin) || + user.roles.contains(UserRoles.publisher); + + if (!hasRequiredRole) { + print( + 'Dashboard login failed: User ${user.id} lacks required roles.', + ); + throw const ForbiddenException( + 'Your account does not have the required permissions to sign in.', + ); + } + print('Dashboard user ${user.id} verified successfully.'); + } + // Generate and store the code for standard sign-in final code = await _verificationCodeStorageService .generateAndStoreSignInCode(email); @@ -67,16 +121,23 @@ class AuthService { /// Completes the email sign-in process by verifying the code. /// - /// If the code is valid, finds or creates the user, generates an auth token. - /// Returns the authenticated User and the generated token. + /// This method is context-aware based on the [isDashboardLogin] flag. + /// + /// - For the dashboard (`isDashboardLogin: true`), it validates the code and + /// logs in the existing user. It will not create a new user in this flow. + /// - For the user-facing app (`isDashboardLogin: false`), it validates the + /// code and either logs in the existing user or creates a new one with a + /// 'standardUser' role if they don't exist. + /// + /// Returns the authenticated [User] and a new authentication token. + /// /// Throws [InvalidInputException] if the code is invalid or expired. - /// Throws [AuthenticationException] for specific code mismatch. - /// Throws [OperationFailedException] for user lookup/creation or token errors. Future<({User user, String token})> completeEmailSignIn( String email, String code, { - User? currentAuthUser, // Parameter for potential future linking logic - String? clientType, // e.g., 'dashboard', 'mobile_app' + // Flag to indicate if this is a login attempt from the dashboard, + // which enforces stricter checks. + bool isDashboardLogin = false, }) async { // 1. Validate the code for standard sign-in final isValidCode = await _verificationCodeStorageService @@ -97,146 +158,63 @@ class AuthService { ); } - // 2. Find or create the user, and migrate data if anonymous + // 2. Find or create the user based on the context User user; try { - if (currentAuthUser != null && - currentAuthUser.roles.contains(UserRoles.guestUser)) { - // This is an anonymous user linking their account. - // Migrate their existing data to the new permanent user. - print( - 'Anonymous user ${currentAuthUser.id} is linking email $email. ' - 'Migrating data...', - ); + // Attempt to find user by email + final query = {'email': email}; + final paginatedResponse = await _userRepository.readAllByQuery(query); - // Fetch existing settings and preferences for the anonymous user - UserAppSettings? existingAppSettings; - UserContentPreferences? existingUserPreferences; - try { - existingAppSettings = await _userAppSettingsRepository.read( - id: currentAuthUser.id, - userId: currentAuthUser.id, - ); - existingUserPreferences = await _userContentPreferencesRepository - .read(id: currentAuthUser.id, userId: currentAuthUser.id); - print( - 'Fetched existing settings and preferences for anonymous user ' - '${currentAuthUser.id}.', - ); - } on NotFoundException { - print( - 'No existing settings/preferences found for anonymous user ' - '${currentAuthUser.id}. Creating new ones.', - ); - // If not found, proceed to create new ones later. - } catch (e) { + if (paginatedResponse.items.isNotEmpty) { + user = paginatedResponse.items.first; + print('Found existing user: ${user.id} for email $email'); + } else { + // User not found. + if (isDashboardLogin) { + // This should not happen if the request-code flow is correct. + // It's a safeguard. print( - 'Error fetching existing settings/preferences for anonymous user ' - '${currentAuthUser.id}: $e', + 'Error: Dashboard login verification failed for non-existent user $email.', ); - // Log and continue, new defaults will be created. + throw const UnauthorizedException('User account does not exist.'); } - // Update the existing anonymous user to be permanent - user = currentAuthUser.copyWith( + // Create a new user for the standard app flow. + print('User not found for $email, creating new user.'); + + // All new users created via the public API get the standard role. + // Admin users must be provisioned out-of-band (e.g., via fixtures). + final roles = [UserRoles.standardUser]; + + user = User( + id: _uuid.v4(), email: email, - roles: [UserRoles.standardUser], + roles: roles, ); - user = await _userRepository.update(id: user.id, item: user); - print( - 'Updated anonymous user ${user.id} to permanent with email $email.', + user = await _userRepository.create(item: user); + print('Created new user: ${user.id} with roles: ${user.roles}'); + + // Create default UserAppSettings for the new user + final defaultAppSettings = UserAppSettings(id: user.id); + await _userAppSettingsRepository.create( + item: defaultAppSettings, + userId: user.id, ); + print('Created default UserAppSettings for user: ${user.id}'); - // Update or create UserAppSettings for the now-permanent user - if (existingAppSettings != null) { - // Update existing settings with the new user ID (though it's the same) - // and persist. - await _userAppSettingsRepository.update( - id: existingAppSettings.id, - item: existingAppSettings.copyWith(id: user.id), - userId: user.id, - ); - print('Migrated UserAppSettings for user: ${user.id}'); - } else { - // Create default settings if none existed for the anonymous user - final defaultAppSettings = UserAppSettings(id: user.id); - await _userAppSettingsRepository.create( - item: defaultAppSettings, - userId: user.id, - ); - print('Created default UserAppSettings for user: ${user.id}'); - } - - // Update or create UserContentPreferences for the now-permanent user - if (existingUserPreferences != null) { - // Update existing preferences with the new user ID (though it's the same) - // and persist. - await _userContentPreferencesRepository.update( - id: existingUserPreferences.id, - item: existingUserPreferences.copyWith(id: user.id), - userId: user.id, - ); - print('Migrated UserContentPreferences for user: ${user.id}'); - } else { - // Create default preferences if none existed for the anonymous user - final defaultUserPreferences = UserContentPreferences(id: user.id); - await _userContentPreferencesRepository.create( - item: defaultUserPreferences, - userId: user.id, - ); - print('Created default UserContentPreferences for user: ${user.id}'); - } - } else { - // Standard sign-in/sign-up flow (not anonymous linking) - // Attempt to find user by email - final query = {'email': email}; - final paginatedResponse = await _userRepository.readAllByQuery(query); - - if (paginatedResponse.items.isNotEmpty) { - user = paginatedResponse.items.first; - print('Found existing user: ${user.id} for email $email'); - } else { - // User not found, create a new one - print('User not found for $email, creating new user.'); - // Assign roles based on client type. New users from the dashboard - // could be granted publisher rights, for example. - final roles = (clientType == 'dashboard') - ? [UserRoles.standardUser, UserRoles.publisher] - : [UserRoles.standardUser]; - user = User( - id: _uuid.v4(), // Generate new ID - email: email, - roles: roles, - ); - user = await _userRepository.create(item: user); // Save the new user - print('Created new user: ${user.id}'); - - // Create default UserAppSettings for the new user - final defaultAppSettings = UserAppSettings(id: user.id); - await _userAppSettingsRepository.create( - item: defaultAppSettings, - userId: user.id, // Pass user ID for scoping - ); - print('Created default UserAppSettings for user: ${user.id}'); - - // Create default UserContentPreferences for the new user - final defaultUserPreferences = UserContentPreferences(id: user.id); - await _userContentPreferencesRepository.create( - item: defaultUserPreferences, - userId: user.id, // Pass user ID for scoping - ); - print('Created default UserContentPreferences for user: ${user.id}'); - } + // Create default UserContentPreferences for the new user + final defaultUserPreferences = UserContentPreferences(id: user.id); + await _userContentPreferencesRepository.create( + item: defaultUserPreferences, + userId: user.id, + ); + print('Created default UserContentPreferences for user: ${user.id}'); } } on HtHttpException catch (e) { - print('Error finding/creating/migrating user for $email: $e'); - throw const OperationFailedException( - 'Failed to find, create, or migrate user account.', - ); + print('Error finding/creating user for $email: $e'); + throw const OperationFailedException('Failed to find or create user account.'); } catch (e) { - print( - 'Unexpected error during user lookup/creation/migration for $email: $e', - ); + print('Unexpected error during user lookup/creation for $email: $e'); throw const OperationFailedException('Failed to process user account.'); } @@ -250,7 +228,7 @@ class AuthService { throw const OperationFailedException( 'Failed to generate authentication token.', ); - } + } } /// Performs anonymous sign-in. diff --git a/routes/api/v1/auth/request-code.dart b/routes/api/v1/auth/request-code.dart index 4c62e45..aa3bd69 100644 --- a/routes/api/v1/auth/request-code.dart +++ b/routes/api/v1/auth/request-code.dart @@ -6,8 +6,14 @@ import 'package:ht_shared/ht_shared.dart'; // For exceptions /// Handles POST requests to `/api/v1/auth/request-code`. /// -/// Initiates the email sign-in process by requesting a verification code -/// be sent to the provided email address. +/// Initiates an email-based sign-in process. This endpoint is context-aware. +/// +/// - For the user-facing app, it sends a verification code to the provided +/// email, supporting both sign-in and sign-up. +/// - For the dashboard, the request body must include `"is_dashboard_login": true`. +/// In this mode, it first verifies the user exists and has 'admin' or +/// 'publisher' roles before sending a code, effectively acting as a +/// login-only gate. Future onRequest(RequestContext context) async { // Ensure this is a POST request if (context.request.method != HttpMethod.post) { @@ -37,6 +43,9 @@ Future onRequest(RequestContext context) async { ); } + // Check for the optional dashboard login flag. Default to false if not present. + final isDashboardLogin = (body['is_dashboard_login'] as bool?) ?? false; + // Basic email format check (more robust validation can be added) // Using a slightly more common regex pattern final emailRegex = RegExp( @@ -48,8 +57,11 @@ Future onRequest(RequestContext context) async { } try { - // Call the AuthService to handle the logic - await authService.initiateEmailSignIn(email); + // Call the AuthService to handle the logic, passing the context flag. + await authService.initiateEmailSignIn( + email, + isDashboardLogin: isDashboardLogin, + ); // Return 202 Accepted: The request is accepted for processing, // but the processing (email sending) hasn't necessarily completed. diff --git a/routes/api/v1/auth/verify-code.dart b/routes/api/v1/auth/verify-code.dart index 4966899..a1fb386 100644 --- a/routes/api/v1/auth/verify-code.dart +++ b/routes/api/v1/auth/verify-code.dart @@ -8,20 +8,21 @@ import 'package:ht_shared/ht_shared.dart'; /// Handles POST requests to `/api/v1/auth/verify-code`. /// /// Verifies the provided email and code, completes the sign-in/sign-up, -/// and returns the authenticated User object along with an auth token. +/// and returns the authenticated User object along with an auth token. It +/// supports a context-aware flow by checking for an `is_dashboard_login` +/// flag in the request body, which dictates whether to perform a strict +/// login-only check or a standard sign-in/sign-up. Future onRequest(RequestContext context) async { // Ensure this is a POST request if (context.request.method != HttpMethod.post) { return Response(statusCode: HttpStatus.methodNotAllowed); } - // Read the AuthService provided by middleware + // Read the AuthService provided by middleware. + // The `authenticatedUser` is no longer needed here as the service handles + // all context internally. final authService = context.read(); - // Read the authenticated User from context (provided by authentication middleware) - // This user might be null (if not authenticated) or an anonymous user. - final authenticatedUser = context.read(); - // Parse the request body final dynamic body; try { @@ -65,13 +66,16 @@ Future onRequest(RequestContext context) async { ); } + // Check for the optional dashboard login flag. Default to false. + final isDashboardLogin = (body['is_dashboard_login'] as bool?) ?? false; + try { // Call the AuthService to handle the verification and sign-in logic - // Pass the current authenticated user for potential data migration. + // Pass the context flag to determine the correct flow. final result = await authService.completeEmailSignIn( email, code, - currentAuthUser: authenticatedUser, + isDashboardLogin: isDashboardLogin, ); // Create the specific payload containing user and token