Skip to content

Commit 7d70ed3

Browse files
authored
Merge pull request #9 from headlines-toolkit/refactor_migarte_to_context_aware_aware_and_secure_auth_system
Refactor migarte to context aware aware and secure auth system
2 parents 8bf722f + fd3dc6a commit 7d70ed3

File tree

5 files changed

+168
-160
lines changed

5 files changed

+168
-160
lines changed

README.md

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ management dashboard](https://github.com/headlines-toolkit/ht-dashboard).
1616

1717
## ✨ Key Capabilities
1818

19-
* 🔒 **Effortless User Authentication:** Provide secure and seamless user access
20-
with flexible flows including passwordless sign-in, anonymous access, and
21-
the ability to easily link anonymous accounts to permanent ones. Focus on
22-
user experience while `ht_api` handles the security complexities.
19+
* 🔒 **Flexible & Secure Authentication:** Provide seamless user access with
20+
a unified system supporting passwordless sign-in, anonymous guest
21+
accounts, and a secure, context-aware login flow for privileged dashboard
22+
users (e.g., 'admin', 'publisher').
2323

2424
* ⚡️ **Flexible Role-Based Access Control (RBAC):** Implement granular
2525
permissions with a flexible, multi-role system. Assign multiple roles to
@@ -33,13 +33,9 @@ management dashboard](https://github.com/headlines-toolkit/ht-dashboard).
3333
* 👤 **Personalized User Preferences:** Enable richer user interactions by
3434
managing and syncing user-specific data such as saved headlines, followed sources, or other personalized content tailored to individual users.
3535

36-
* 💾 **Robust Data Management:** Securely manage core news application data,
37-
including headlines, categories, and sources, through a well-structured
38-
and protected API.
39-
40-
* 🔀 **Flexible Data Sorting:** Order lists of headlines, sources, and other
41-
data by various fields in ascending or descending order, allowing for
42-
dynamic and user-driven content presentation.
36+
* 💾 **Robust Data Management:** Securely manage core news data (headlines,
37+
categories, sources) through a well-structured API that supports flexible
38+
querying and sorting for dynamic content presentation.
4339

4440
* 📊 **Dynamic Dashboard Summary:** Access real-time, aggregated metrics on
4541
key data points like total headlines, categories, and sources, providing

lib/src/fixtures/user_fixtures.dart

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import 'package:ht_shared/ht_shared.dart';
2+
3+
/// A list of initial user data to be loaded into the in-memory user repository.
4+
///
5+
/// This list includes a pre-configured administrator user, which is essential
6+
/// for accessing the dashboard in a development environment.
7+
final List<User> userFixtures = [
8+
// The initial administrator user.
9+
User(
10+
id: 'admin-user-id', // A fixed, predictable ID for the admin.
11+
12+
roles: const [UserRoles.standardUser, UserRoles.admin],
13+
createdAt: DateTime.now().toUtc(),
14+
),
15+
// Add other initial users for testing if needed.
16+
// Example: A standard user
17+
// User( ... ),
18+
];

lib/src/services/auth_service.dart

Lines changed: 115 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,65 @@ class AuthService {
4141

4242
/// Initiates the email sign-in process.
4343
///
44-
/// Generates a verification code, stores it, and sends it via email.
45-
/// Throws [InvalidInputException] for invalid email format (via email client).
46-
/// Throws [OperationFailedException] if code generation/storage/email fails.
47-
Future<void> initiateEmailSignIn(String email) async {
44+
/// This method is context-aware based on the [isDashboardLogin] flag.
45+
///
46+
/// - For the user-facing app (`isDashboardLogin: false`), it generates and
47+
/// sends a verification code to the given [email] without pre-validation,
48+
/// supporting a unified sign-in/sign-up flow.
49+
/// - For the dashboard (`isDashboardLogin: true`), it performs a strict
50+
/// login-only check. It verifies that a user with the given [email] exists
51+
/// and has either the 'admin' or 'publisher' role *before* sending a code.
52+
///
53+
/// - [email]: The email address to send the code to.
54+
/// - [isDashboardLogin]: A flag to indicate if this is a login attempt from
55+
/// the dashboard, which enforces stricter checks.
56+
///
57+
/// Throws [UnauthorizedException] if `isDashboardLogin` is true and the user
58+
/// does not exist.
59+
/// Throws [ForbiddenException] if `isDashboardLogin` is true and the user
60+
/// exists but lacks the required roles.
61+
Future<void> initiateEmailSignIn(
62+
String email, {
63+
bool isDashboardLogin = false,
64+
}) async {
4865
try {
66+
// For dashboard login, first validate the user exists and has permissions.
67+
if (isDashboardLogin) {
68+
print('Dashboard login initiated for $email. Verifying user...');
69+
User? user;
70+
try {
71+
final query = {'email': email};
72+
final response = await _userRepository.readAllByQuery(query);
73+
if (response.items.isNotEmpty) {
74+
user = response.items.first;
75+
}
76+
} on HtHttpException catch (e) {
77+
print('Repository error while verifying dashboard user $email: $e');
78+
rethrow;
79+
}
80+
81+
if (user == null) {
82+
print('Dashboard login failed: User $email not found.');
83+
throw const UnauthorizedException(
84+
'This email address is not registered for dashboard access.',
85+
);
86+
}
87+
88+
final hasRequiredRole =
89+
user.roles.contains(UserRoles.admin) ||
90+
user.roles.contains(UserRoles.publisher);
91+
92+
if (!hasRequiredRole) {
93+
print(
94+
'Dashboard login failed: User ${user.id} lacks required roles.',
95+
);
96+
throw const ForbiddenException(
97+
'Your account does not have the required permissions to sign in.',
98+
);
99+
}
100+
print('Dashboard user ${user.id} verified successfully.');
101+
}
102+
49103
// Generate and store the code for standard sign-in
50104
final code = await _verificationCodeStorageService
51105
.generateAndStoreSignInCode(email);
@@ -67,16 +121,23 @@ class AuthService {
67121

68122
/// Completes the email sign-in process by verifying the code.
69123
///
70-
/// If the code is valid, finds or creates the user, generates an auth token.
71-
/// Returns the authenticated User and the generated token.
124+
/// This method is context-aware based on the [isDashboardLogin] flag.
125+
///
126+
/// - For the dashboard (`isDashboardLogin: true`), it validates the code and
127+
/// logs in the existing user. It will not create a new user in this flow.
128+
/// - For the user-facing app (`isDashboardLogin: false`), it validates the
129+
/// code and either logs in the existing user or creates a new one with a
130+
/// 'standardUser' role if they don't exist.
131+
///
132+
/// Returns the authenticated [User] and a new authentication token.
133+
///
72134
/// Throws [InvalidInputException] if the code is invalid or expired.
73-
/// Throws [AuthenticationException] for specific code mismatch.
74-
/// Throws [OperationFailedException] for user lookup/creation or token errors.
75135
Future<({User user, String token})> completeEmailSignIn(
76136
String email,
77137
String code, {
78-
User? currentAuthUser, // Parameter for potential future linking logic
79-
String? clientType, // e.g., 'dashboard', 'mobile_app'
138+
// Flag to indicate if this is a login attempt from the dashboard,
139+
// which enforces stricter checks.
140+
bool isDashboardLogin = false,
80141
}) async {
81142
// 1. Validate the code for standard sign-in
82143
final isValidCode = await _verificationCodeStorageService
@@ -97,146 +158,63 @@ class AuthService {
97158
);
98159
}
99160

100-
// 2. Find or create the user, and migrate data if anonymous
161+
// 2. Find or create the user based on the context
101162
User user;
102163
try {
103-
if (currentAuthUser != null &&
104-
currentAuthUser.roles.contains(UserRoles.guestUser)) {
105-
// This is an anonymous user linking their account.
106-
// Migrate their existing data to the new permanent user.
107-
print(
108-
'Anonymous user ${currentAuthUser.id} is linking email $email. '
109-
'Migrating data...',
110-
);
164+
// Attempt to find user by email
165+
final query = {'email': email};
166+
final paginatedResponse = await _userRepository.readAllByQuery(query);
111167

112-
// Fetch existing settings and preferences for the anonymous user
113-
UserAppSettings? existingAppSettings;
114-
UserContentPreferences? existingUserPreferences;
115-
try {
116-
existingAppSettings = await _userAppSettingsRepository.read(
117-
id: currentAuthUser.id,
118-
userId: currentAuthUser.id,
119-
);
120-
existingUserPreferences = await _userContentPreferencesRepository
121-
.read(id: currentAuthUser.id, userId: currentAuthUser.id);
122-
print(
123-
'Fetched existing settings and preferences for anonymous user '
124-
'${currentAuthUser.id}.',
125-
);
126-
} on NotFoundException {
127-
print(
128-
'No existing settings/preferences found for anonymous user '
129-
'${currentAuthUser.id}. Creating new ones.',
130-
);
131-
// If not found, proceed to create new ones later.
132-
} catch (e) {
168+
if (paginatedResponse.items.isNotEmpty) {
169+
user = paginatedResponse.items.first;
170+
print('Found existing user: ${user.id} for email $email');
171+
} else {
172+
// User not found.
173+
if (isDashboardLogin) {
174+
// This should not happen if the request-code flow is correct.
175+
// It's a safeguard.
133176
print(
134-
'Error fetching existing settings/preferences for anonymous user '
135-
'${currentAuthUser.id}: $e',
177+
'Error: Dashboard login verification failed for non-existent user $email.',
136178
);
137-
// Log and continue, new defaults will be created.
179+
throw const UnauthorizedException('User account does not exist.');
138180
}
139181

140-
// Update the existing anonymous user to be permanent
141-
user = currentAuthUser.copyWith(
182+
// Create a new user for the standard app flow.
183+
print('User not found for $email, creating new user.');
184+
185+
// All new users created via the public API get the standard role.
186+
// Admin users must be provisioned out-of-band (e.g., via fixtures).
187+
final roles = [UserRoles.standardUser];
188+
189+
user = User(
190+
id: _uuid.v4(),
142191
email: email,
143-
roles: [UserRoles.standardUser],
192+
roles: roles,
144193
);
145-
user = await _userRepository.update(id: user.id, item: user);
146-
print(
147-
'Updated anonymous user ${user.id} to permanent with email $email.',
194+
user = await _userRepository.create(item: user);
195+
print('Created new user: ${user.id} with roles: ${user.roles}');
196+
197+
// Create default UserAppSettings for the new user
198+
final defaultAppSettings = UserAppSettings(id: user.id);
199+
await _userAppSettingsRepository.create(
200+
item: defaultAppSettings,
201+
userId: user.id,
148202
);
203+
print('Created default UserAppSettings for user: ${user.id}');
149204

150-
// Update or create UserAppSettings for the now-permanent user
151-
if (existingAppSettings != null) {
152-
// Update existing settings with the new user ID (though it's the same)
153-
// and persist.
154-
await _userAppSettingsRepository.update(
155-
id: existingAppSettings.id,
156-
item: existingAppSettings.copyWith(id: user.id),
157-
userId: user.id,
158-
);
159-
print('Migrated UserAppSettings for user: ${user.id}');
160-
} else {
161-
// Create default settings if none existed for the anonymous user
162-
final defaultAppSettings = UserAppSettings(id: user.id);
163-
await _userAppSettingsRepository.create(
164-
item: defaultAppSettings,
165-
userId: user.id,
166-
);
167-
print('Created default UserAppSettings for user: ${user.id}');
168-
}
169-
170-
// Update or create UserContentPreferences for the now-permanent user
171-
if (existingUserPreferences != null) {
172-
// Update existing preferences with the new user ID (though it's the same)
173-
// and persist.
174-
await _userContentPreferencesRepository.update(
175-
id: existingUserPreferences.id,
176-
item: existingUserPreferences.copyWith(id: user.id),
177-
userId: user.id,
178-
);
179-
print('Migrated UserContentPreferences for user: ${user.id}');
180-
} else {
181-
// Create default preferences if none existed for the anonymous user
182-
final defaultUserPreferences = UserContentPreferences(id: user.id);
183-
await _userContentPreferencesRepository.create(
184-
item: defaultUserPreferences,
185-
userId: user.id,
186-
);
187-
print('Created default UserContentPreferences for user: ${user.id}');
188-
}
189-
} else {
190-
// Standard sign-in/sign-up flow (not anonymous linking)
191-
// Attempt to find user by email
192-
final query = {'email': email};
193-
final paginatedResponse = await _userRepository.readAllByQuery(query);
194-
195-
if (paginatedResponse.items.isNotEmpty) {
196-
user = paginatedResponse.items.first;
197-
print('Found existing user: ${user.id} for email $email');
198-
} else {
199-
// User not found, create a new one
200-
print('User not found for $email, creating new user.');
201-
// Assign roles based on client type. New users from the dashboard
202-
// could be granted publisher rights, for example.
203-
final roles = (clientType == 'dashboard')
204-
? [UserRoles.standardUser, UserRoles.publisher]
205-
: [UserRoles.standardUser];
206-
user = User(
207-
id: _uuid.v4(), // Generate new ID
208-
email: email,
209-
roles: roles,
210-
);
211-
user = await _userRepository.create(item: user); // Save the new user
212-
print('Created new user: ${user.id}');
213-
214-
// Create default UserAppSettings for the new user
215-
final defaultAppSettings = UserAppSettings(id: user.id);
216-
await _userAppSettingsRepository.create(
217-
item: defaultAppSettings,
218-
userId: user.id, // Pass user ID for scoping
219-
);
220-
print('Created default UserAppSettings for user: ${user.id}');
221-
222-
// Create default UserContentPreferences for the new user
223-
final defaultUserPreferences = UserContentPreferences(id: user.id);
224-
await _userContentPreferencesRepository.create(
225-
item: defaultUserPreferences,
226-
userId: user.id, // Pass user ID for scoping
227-
);
228-
print('Created default UserContentPreferences for user: ${user.id}');
229-
}
205+
// Create default UserContentPreferences for the new user
206+
final defaultUserPreferences = UserContentPreferences(id: user.id);
207+
await _userContentPreferencesRepository.create(
208+
item: defaultUserPreferences,
209+
userId: user.id,
210+
);
211+
print('Created default UserContentPreferences for user: ${user.id}');
230212
}
231213
} on HtHttpException catch (e) {
232-
print('Error finding/creating/migrating user for $email: $e');
233-
throw const OperationFailedException(
234-
'Failed to find, create, or migrate user account.',
235-
);
214+
print('Error finding/creating user for $email: $e');
215+
throw const OperationFailedException('Failed to find or create user account.');
236216
} catch (e) {
237-
print(
238-
'Unexpected error during user lookup/creation/migration for $email: $e',
239-
);
217+
print('Unexpected error during user lookup/creation for $email: $e');
240218
throw const OperationFailedException('Failed to process user account.');
241219
}
242220

@@ -250,7 +228,7 @@ class AuthService {
250228
throw const OperationFailedException(
251229
'Failed to generate authentication token.',
252230
);
253-
}
231+
}
254232
}
255233

256234
/// Performs anonymous sign-in.

routes/api/v1/auth/request-code.dart

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@ import 'package:ht_shared/ht_shared.dart'; // For exceptions
66

77
/// Handles POST requests to `/api/v1/auth/request-code`.
88
///
9-
/// Initiates the email sign-in process by requesting a verification code
10-
/// be sent to the provided email address.
9+
/// Initiates an email-based sign-in process. This endpoint is context-aware.
10+
///
11+
/// - For the user-facing app, it sends a verification code to the provided
12+
/// email, supporting both sign-in and sign-up.
13+
/// - For the dashboard, the request body must include `"is_dashboard_login": true`.
14+
/// In this mode, it first verifies the user exists and has 'admin' or
15+
/// 'publisher' roles before sending a code, effectively acting as a
16+
/// login-only gate.
1117
Future<Response> onRequest(RequestContext context) async {
1218
// Ensure this is a POST request
1319
if (context.request.method != HttpMethod.post) {
@@ -37,6 +43,9 @@ Future<Response> onRequest(RequestContext context) async {
3743
);
3844
}
3945

46+
// Check for the optional dashboard login flag. Default to false if not present.
47+
final isDashboardLogin = (body['is_dashboard_login'] as bool?) ?? false;
48+
4049
// Basic email format check (more robust validation can be added)
4150
// Using a slightly more common regex pattern
4251
final emailRegex = RegExp(
@@ -48,8 +57,11 @@ Future<Response> onRequest(RequestContext context) async {
4857
}
4958

5059
try {
51-
// Call the AuthService to handle the logic
52-
await authService.initiateEmailSignIn(email);
60+
// Call the AuthService to handle the logic, passing the context flag.
61+
await authService.initiateEmailSignIn(
62+
email,
63+
isDashboardLogin: isDashboardLogin,
64+
);
5365

5466
// Return 202 Accepted: The request is accepted for processing,
5567
// but the processing (email sending) hasn't necessarily completed.

0 commit comments

Comments
 (0)