Skip to content

Commit 6040abf

Browse files
committed
feat: Implement authentication flow
- Added AuthService and related services - Implemented email/anon sign-in routes - Added auth middleware - Added token service
1 parent c4bc31a commit 6040abf

File tree

11 files changed

+844
-13
lines changed

11 files changed

+844
-13
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import 'package:dart_frog/dart_frog.dart';
2+
import 'package:ht_api/src/services/auth_token_service.dart';
3+
import 'package:ht_shared/ht_shared.dart';
4+
5+
/// Middleware to handle authentication by verifying Bearer tokens.
6+
///
7+
/// It extracts the token from the 'Authorization' header, validates it using
8+
/// the [AuthTokenService], and provides the resulting [User] object (or null)
9+
/// into the request context via `context.read<User?>()`.
10+
///
11+
/// If a route requires authentication (determined by where this middleware is
12+
/// applied) and the token is missing or invalid, it should ideally throw an
13+
/// [UnauthorizedException] to be caught by the [errorHandler].
14+
///
15+
/// **Usage:** Apply this middleware to routes or groups of routes that require
16+
/// access to the authenticated user's identity or need protection.
17+
Middleware authenticationProvider() {
18+
return (handler) {
19+
return (context) async {
20+
// Read the AuthTokenService provided by earlier middleware
21+
final tokenService = context.read<AuthTokenService>();
22+
User? user; // Initialize user as null
23+
24+
// Extract the Authorization header
25+
final authHeader = context.request.headers['Authorization'];
26+
27+
if (authHeader != null && authHeader.startsWith('Bearer ')) {
28+
// Extract the token string
29+
final token = authHeader.substring(7); // Length of 'Bearer '
30+
try {
31+
// Validate the token using the service
32+
user = await tokenService.validateToken(token);
33+
if (user != null) {
34+
print('Authentication successful for user: ${user.id}');
35+
} else {
36+
print('Invalid token provided.');
37+
// Optional: Could throw UnauthorizedException here if *all* routes
38+
// using this middleware strictly require a valid token.
39+
// However, providing null allows routes to handle optional auth.
40+
}
41+
} on HtHttpException catch (e) {
42+
// Log token validation errors from the service
43+
print('Token validation failed: $e');
44+
// Let the error propagate if needed, or handle specific cases.
45+
// For now, we treat validation errors as resulting in no user.
46+
user = null;
47+
} catch (e) {
48+
// Catch unexpected errors during validation
49+
print('Unexpected error during token validation: $e');
50+
user = null;
51+
}
52+
} else {
53+
print('No valid Authorization header found.');
54+
}
55+
56+
// Provide the User object (or null) into the context
57+
// This makes `context.read<User?>()` available downstream.
58+
return handler(context.provide<User?>(() => user));
59+
};
60+
};
61+
}
62+
63+
/// Middleware factory that ensures a valid authenticated user exists.
64+
///
65+
/// Use this for routes that *strictly require* a logged-in user.
66+
/// It reads the `User?` provided by `authenticationProvider` and throws
67+
/// [UnauthorizedException] if the user is null.
68+
Middleware requireAuthentication() {
69+
return (handler) {
70+
return (context) {
71+
final user = context.read<User?>();
72+
if (user == null) {
73+
print(
74+
'Authentication required but no valid user found. Denying access.',);
75+
// Throwing allows the central errorHandler to create the 401 response.
76+
throw const UnauthorizedException('Authentication required.');
77+
}
78+
// If user exists, proceed to the handler
79+
print('Authentication check passed for user: ${user.id}');
80+
return handler(context);
81+
};
82+
};
83+
}

lib/src/services/auth_service.dart

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import 'package:ht_data_repository/ht_data_repository.dart';
2+
import 'package:ht_email_repository/ht_email_repository.dart';
3+
import 'package:ht_shared/ht_shared.dart';
4+
import 'package:uuid/uuid.dart';
5+
6+
import 'package:ht_api/src/services/auth_token_service.dart';
7+
import 'package:ht_api/src/services/verification_code_storage_service.dart';
8+
9+
/// {@template auth_service}
10+
/// Service responsible for orchestrating authentication logic on the backend.
11+
///
12+
/// It coordinates interactions between user data storage, token generation,
13+
/// verification code management, and email sending.
14+
/// {@endtemplate}
15+
class AuthService {
16+
/// {@macro auth_service}
17+
const AuthService({
18+
required HtDataRepository<User> userRepository,
19+
required AuthTokenService authTokenService,
20+
required VerificationCodeStorageService verificationCodeStorageService,
21+
required HtEmailRepository emailRepository,
22+
required Uuid uuidGenerator,
23+
}) : _userRepository = userRepository,
24+
_authTokenService = authTokenService,
25+
_verificationCodeStorageService = verificationCodeStorageService,
26+
_emailRepository = emailRepository,
27+
_uuid = uuidGenerator;
28+
29+
final HtDataRepository<User> _userRepository;
30+
final AuthTokenService _authTokenService;
31+
final VerificationCodeStorageService _verificationCodeStorageService;
32+
final HtEmailRepository _emailRepository;
33+
final Uuid _uuid;
34+
35+
/// Initiates the email sign-in process.
36+
///
37+
/// Generates a verification code, stores it, and sends it via email.
38+
/// Throws [InvalidInputException] for invalid email format (via email client).
39+
/// Throws [OperationFailedException] if code generation/storage/email fails.
40+
Future<void> initiateEmailSignIn(String email) async {
41+
try {
42+
// Generate and store the code
43+
final code = await _verificationCodeStorageService.generateAndStoreCode(
44+
email,
45+
);
46+
47+
// Send the code via email
48+
await _emailRepository.sendOtpEmail(
49+
recipientEmail: email,
50+
otpCode: code,
51+
);
52+
print('Initiated email sign-in for $email, code sent.');
53+
} on HtHttpException {
54+
// Propagate known exceptions from dependencies
55+
rethrow;
56+
} catch (e) {
57+
// Catch unexpected errors during orchestration
58+
print('Error during initiateEmailSignIn for $email: $e');
59+
throw const OperationFailedException(
60+
'Failed to initiate email sign-in process.',
61+
);
62+
}
63+
}
64+
65+
/// Completes the email sign-in process by verifying the code.
66+
///
67+
/// If the code is valid, finds or creates the user, generates an auth token.
68+
/// Returns the authenticated User and the generated token.
69+
/// Throws [InvalidInputException] if the code is invalid or expired.
70+
/// Throws [AuthenticationException] for specific code mismatch.
71+
/// Throws [OperationFailedException] for user lookup/creation or token errors.
72+
Future<({User user, String token})> completeEmailSignIn(
73+
String email,
74+
String code,
75+
) async {
76+
// 1. Validate the code
77+
final isValidCode = await _verificationCodeStorageService.validateCode(
78+
email,
79+
code,
80+
);
81+
if (!isValidCode) {
82+
// Consider distinguishing between expired and simply incorrect codes
83+
// For now, treat both as invalid input.
84+
throw const InvalidInputException(
85+
'Invalid or expired verification code.',);
86+
}
87+
88+
// 2. Find or create the user
89+
User user;
90+
try {
91+
// Attempt to find user by email (assuming a query method exists)
92+
// NOTE: HtDataRepository<User> currently lacks findByEmail.
93+
// We'll simulate this by querying all and filtering for now.
94+
// Replace with a proper query when available.
95+
final query = {'email': email}; // Hypothetical query
96+
final paginatedResponse = await _userRepository.readAllByQuery(query);
97+
98+
if (paginatedResponse.items.isNotEmpty) {
99+
user = paginatedResponse.items.first;
100+
print('Found existing user: ${user.id} for email $email');
101+
} else {
102+
// User not found, create a new one
103+
print('User not found for $email, creating new user.');
104+
user = User(
105+
id: _uuid.v4(), // Generate new ID
106+
email: email,
107+
isAnonymous: false, // Email verified user is not anonymous
108+
);
109+
user = await _userRepository.create(user); // Save the new user
110+
print('Created new user: ${user.id}');
111+
}
112+
} on HtHttpException catch (e) {
113+
print('Error finding/creating user for $email: $e');
114+
throw const OperationFailedException(
115+
'Failed to find or create user account.',);
116+
} catch (e) {
117+
print('Unexpected error during user lookup/creation for $email: $e');
118+
throw const OperationFailedException('Failed to process user account.');
119+
}
120+
121+
// 3. Generate authentication token
122+
try {
123+
final token = await _authTokenService.generateToken(user);
124+
print('Generated token for user ${user.id}');
125+
return (user: user, token: token);
126+
} catch (e) {
127+
print('Error generating token for user ${user.id}: $e');
128+
throw const OperationFailedException(
129+
'Failed to generate authentication token.',);
130+
}
131+
}
132+
133+
/// Performs anonymous sign-in.
134+
///
135+
/// Creates a new anonymous user record and generates an auth token.
136+
/// Returns the anonymous User and the generated token.
137+
/// Throws [OperationFailedException] if user creation or token generation fails.
138+
Future<({User user, String token})> performAnonymousSignIn() async {
139+
// 1. Create anonymous user
140+
User user;
141+
try {
142+
user = User(
143+
id: _uuid.v4(), // Generate new ID
144+
isAnonymous: true,
145+
email: null, // Anonymous users don't have an email initially
146+
);
147+
user = await _userRepository.create(user);
148+
print('Created anonymous user: ${user.id}');
149+
} on HtHttpException catch (e) {
150+
print('Error creating anonymous user: $e');
151+
throw const OperationFailedException('Failed to create anonymous user.');
152+
} catch (e) {
153+
print('Unexpected error during anonymous user creation: $e');
154+
throw const OperationFailedException(
155+
'Failed to process anonymous sign-in.',);
156+
}
157+
158+
// 2. Generate token
159+
try {
160+
final token = await _authTokenService.generateToken(user);
161+
print('Generated token for anonymous user ${user.id}');
162+
return (user: user, token: token);
163+
} catch (e) {
164+
print('Error generating token for anonymous user ${user.id}: $e');
165+
throw const OperationFailedException(
166+
'Failed to generate authentication token.',);
167+
}
168+
}
169+
170+
/// Performs sign-out actions (currently placeholder).
171+
///
172+
/// In a real implementation, this might involve invalidating the token
173+
/// on the server-side (e.g., adding to a blacklist if using JWTs).
174+
/// The primary sign-out action (clearing local token) happens client-side.
175+
Future<void> performSignOut({required String userId}) async {
176+
// Placeholder: Server-side token invalidation logic would go here.
177+
// For the current SimpleAuthTokenService, there's nothing server-side
178+
// to invalidate easily. A real JWT implementation might use a blacklist.
179+
print('Performing server-side sign-out actions for user $userId (if any).');
180+
await Future<void>.delayed(Duration.zero); // Simulate async
181+
// No exceptions thrown here unless invalidation fails.
182+
}
183+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import 'package:ht_shared/ht_shared.dart';
2+
3+
/// {@template auth_token_service}
4+
/// Service responsible for generating and validating authentication tokens.
5+
///
6+
/// Implementations will handle the specifics of token creation (e.g., JWT
7+
/// signing with a secret key) and validation (signature, expiry, claims).
8+
/// {@endtemplate}
9+
abstract class AuthTokenService {
10+
/// {@macro auth_token_service}
11+
const AuthTokenService();
12+
13+
/// Generates an authentication token for the given user.
14+
///
15+
/// Returns the generated token string.
16+
/// Throws [OperationFailedException] if token generation fails.
17+
Future<String> generateToken(User user);
18+
19+
/// Validates the given token string.
20+
///
21+
/// Returns the [User] associated with the token if valid.
22+
/// Returns `null` if the token is invalid, expired, or malformed.
23+
/// Throws [OperationFailedException] for unexpected validation errors.
24+
Future<User?> validateToken(String token);
25+
26+
// Potential future methods:
27+
// Future<void> invalidateToken(String token); // For token blacklisting
28+
}
29+
30+
/// A basic implementation of [AuthTokenService].
31+
///
32+
/// **Note:** This is a placeholder and **not secure** for production.
33+
/// It does not perform real cryptographic signing or validation.
34+
/// Replace with a proper JWT implementation (e.g., using `dart_jsonwebtoken`).
35+
class SimpleAuthTokenService implements AuthTokenService {
36+
/// {@macro simple_auth_token_service}
37+
const SimpleAuthTokenService({
38+
// In a real implementation, you'd inject a secret key here.
39+
String secretKey = 'very-secret-key-replace-me',
40+
}) : _secretKey = secretKey;
41+
42+
// ignore: unused_field
43+
final String _secretKey;
44+
45+
// Placeholder for storing "valid" tokens in this insecure example.
46+
// A real implementation validates cryptographically.
47+
static final Map<String, User> _validTokens = {};
48+
49+
@override
50+
Future<String> generateToken(User user) async {
51+
// Insecure placeholder: Generate a simple token string.
52+
// A real implementation would create a JWT with claims and sign it.
53+
final token =
54+
'token_for_${user.id}_${DateTime.now().millisecondsSinceEpoch}';
55+
_validTokens[token] = user; // Store for simple validation
56+
print('Generated token (INSECURE): $token for user ${user.id}');
57+
await Future<void>.delayed(Duration.zero); // Simulate async
58+
return token;
59+
}
60+
61+
@override
62+
Future<User?> validateToken(String token) async {
63+
// Insecure placeholder: Check if the token exists in our map.
64+
// A real implementation would verify JWT signature, expiry, issuer, etc.
65+
print('Validating token (INSECURE): $token');
66+
final user = _validTokens[token];
67+
await Future<void>.delayed(Duration.zero); // Simulate async
68+
if (user != null) {
69+
print('Token valid for user ${user.id}');
70+
return user;
71+
} else {
72+
print('Token invalid');
73+
return null;
74+
}
75+
}
76+
}

0 commit comments

Comments
 (0)