Skip to content

Refactor migarte to context aware aware and secure auth system #9

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions lib/src/fixtures/user_fixtures.dart
Original file line number Diff line number Diff line change
@@ -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<User> userFixtures = [
// The initial administrator user.
User(
id: 'admin-user-id', // A fixed, predictable ID for the admin.
email: '[email protected]',
roles: const [UserRoles.standardUser, UserRoles.admin],
createdAt: DateTime.now().toUtc(),
),
// Add other initial users for testing if needed.
// Example: A standard user
// User( ... ),
];
252 changes: 115 additions & 137 deletions lib/src/services/auth_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> 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<void> 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;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current logic takes the first user found for a given email using response.items.first. If multiple users share the same email (e.g., due to a data integrity issue), and the first one returned by the repository lacks the necessary 'admin' or 'publisher' role, the login will fail. A legitimate privileged user could be blocked if a non-privileged user with the same email is returned first.

The logic should be updated to correctly handle this edge case by prioritizing a user with the required roles from the list of results.

        user = response.items.firstWhere(
          (u) =>
              u.roles.contains(UserRoles.admin) ||
              u.roles.contains(UserRoles.publisher),
          orElse: () => 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);
Expand All @@ -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
Expand All @@ -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.');
}

Expand All @@ -250,7 +228,7 @@ class AuthService {
throw const OperationFailedException(
'Failed to generate authentication token.',
);
}
}
}

/// Performs anonymous sign-in.
Expand Down
20 changes: 16 additions & 4 deletions routes/api/v1/auth/request-code.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> onRequest(RequestContext context) async {
// Ensure this is a POST request
if (context.request.method != HttpMethod.post) {
Expand Down Expand Up @@ -37,6 +43,9 @@ Future<Response> 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(
Expand All @@ -48,8 +57,11 @@ Future<Response> 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.
Expand Down
Loading
Loading