Skip to content

Harden auth impl #25

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 22 commits into from
Jul 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cedbe20
chore(env): add JWT secret key requirement and update CORS origin
fulleni Jul 20, 2025
f0b2068
feat(config): add JWT secret key environment variable retrieval
fulleni Jul 20, 2025
1639d16
refactor(auth): replace hardcoded secret key with environment variable
fulleni Jul 20, 2025
a1468cb
feat(auth): add MongoDB token blacklist service
fulleni Jul 20, 2025
0d5e933
refactor(config): replace token blacklist service
fulleni Jul 20, 2025
01a2e5e
feat(auth): Add MongoDB verification code storage
fulleni Jul 20, 2025
0cb5741
refactor(mongodb): Improve verification code storage
fulleni Jul 20, 2025
8e74597
refactor(services): Replace in-memory verification code storage
fulleni Jul 20, 2025
1550783
refactor(services): remove unnecessary initialization
fulleni Jul 20, 2025
eb04f61
refactor(services): remove unnecessary init method
fulleni Jul 20, 2025
8e52d81
feat(database): add indexes to verification codes and tokens
fulleni Jul 20, 2025
48cbcfe
refactor(auth): replace print statements with logging
fulleni Jul 20, 2025
76589a3
fix(authorization): replace print statements with logger
fulleni Jul 20, 2025
7349220
fix(error_handler): use logger instead of print
fulleni Jul 20, 2025
2ea1f83
refactor: remove unused auth & verification services
fulleni Jul 20, 2025
74935bf
fix(auth): improve anonymous auth error handling
fulleni Jul 20, 2025
981e366
fix(auth): improve error handling in delete-account
fulleni Jul 20, 2025
205acd8
fix(auth): improve error handling in request-code handler
fulleni Jul 20, 2025
ee01c85
fix(auth): improve sign-out error logging
fulleni Jul 20, 2025
170539f
fix: improve error logging in verify-code handler
fulleni Jul 20, 2025
85a289b
fix(api): replace print statements with logger
fulleni Jul 20, 2025
89e2417
feat(auth): configure JWT issuer and expiry
fulleni Jul 20, 2025
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
16 changes: 15 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,21 @@
# The application cannot start without a database connection.
# DATABASE_URL="mongodb://user:password@localhost:27017/ht_api_db"

# REQUIRED: A secure, randomly generated secret for signing JWTs.
# The application cannot start without this.
# Generate a secure key using: dart pub global run dcli_scripts create_key
# JWT_SECRET_KEY="your-super-secret-and-long-jwt-key"

# REQUIRED FOR PRODUCTION: The specific origin URL of your web client.
# This allows the client (e.g., the HT Dashboard) to make requests to the API.
# For local development, this can be left unset as 'localhost' is allowed by default.
# CORS_ALLOWED_ORIGIN="https://your-dashboard.com"
# CORS_ALLOWED_ORIGIN="https://your-dashboard.com"

# REQUIRED FOR PRODUCTION: The issuer URL for JWTs.
# Defaults to 'http://localhost:8080' for local development if not set.
# For production, this MUST be the public URL of your API.
# JWT_ISSUER="https://api.your-domain.com"

# OPTIONAL: The expiry duration for JWTs in hours.
# Defaults to 1 hour if not set.
# JWT_EXPIRY_HOURS="24"
12 changes: 9 additions & 3 deletions lib/src/config/app_dependencies.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import 'package:ht_api/src/services/dashboard_summary_service.dart';
import 'package:ht_api/src/services/database_seeding_service.dart';
import 'package:ht_api/src/services/default_user_preference_limit_service.dart';
import 'package:ht_api/src/services/jwt_auth_token_service.dart';
import 'package:ht_api/src/services/mongodb_token_blacklist_service.dart';
import 'package:ht_api/src/services/mongodb_verification_code_storage_service.dart';
import 'package:ht_api/src/services/token_blacklist_service.dart';
import 'package:ht_api/src/services/user_preference_limit_service.dart';
import 'package:ht_api/src/services/verification_code_storage_service.dart';
Expand Down Expand Up @@ -169,15 +171,19 @@ class AppDependencies {
emailRepository = const HtEmailRepository(emailClient: emailClient);

// 5. Initialize Services
tokenBlacklistService = InMemoryTokenBlacklistService(
log: Logger('InMemoryTokenBlacklistService'),
tokenBlacklistService = MongoDbTokenBlacklistService(
connectionManager: _mongoDbConnectionManager,
log: Logger('MongoDbTokenBlacklistService'),
);
authTokenService = JwtAuthTokenService(
userRepository: userRepository,
blacklistService: tokenBlacklistService,
log: Logger('JwtAuthTokenService'),
);
verificationCodeStorageService = InMemoryVerificationCodeStorageService();
verificationCodeStorageService = MongoDbVerificationCodeStorageService(
connectionManager: _mongoDbConnectionManager,
log: Logger('MongoDbVerificationCodeStorageService'),
);
permissionService = const PermissionService();
authService = AuthService(
userRepository: userRepository,
Expand Down
34 changes: 34 additions & 0 deletions lib/src/config/environment_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,24 @@ abstract final class EnvironmentConfig {
return dbUrl;
}

/// Retrieves the JWT secret key from the environment.
///
/// The value is read from the `JWT_SECRET_KEY` environment variable.
///
/// Throws a [StateError] if the `JWT_SECRET_KEY` environment variable is not
/// set, as the application cannot function without it.
static String get jwtSecretKey {
final jwtKey = _env['JWT_SECRET_KEY'];
if (jwtKey == null || jwtKey.isEmpty) {
_log.severe('JWT_SECRET_KEY not found in environment variables.');
throw StateError(
'FATAL: JWT_SECRET_KEY environment variable is not set. '
'The application cannot start without a JWT secret.',
);
}
return jwtKey;
}

/// Retrieves the current environment mode (e.g., 'development').
///
/// The value is read from the `ENV` environment variable.
Expand All @@ -90,4 +108,20 @@ abstract final class EnvironmentConfig {
/// This is used to configure CORS for production environments.
/// Returns `null` if the variable is not set.
static String? get corsAllowedOrigin => _env['CORS_ALLOWED_ORIGIN'];

/// Retrieves the JWT issuer URL from the environment.
///
/// The value is read from the `JWT_ISSUER` environment variable.
/// Defaults to 'http://localhost:8080' if not set.
static String get jwtIssuer =>
_env['JWT_ISSUER'] ?? 'http://localhost:8080';

/// Retrieves the JWT expiry duration in hours from the environment.
///
/// The value is read from the `JWT_EXPIRY_HOURS` environment variable.
/// Defaults to 1 hour if not set or if parsing fails.
static Duration get jwtExpiryDuration {
final hours = int.tryParse(_env['JWT_EXPIRY_HOURS'] ?? '1');
return Duration(hours: hours ?? 1);
}
}
49 changes: 26 additions & 23 deletions lib/src/middlewares/authentication_middleware.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import 'package:dart_frog/dart_frog.dart';
import 'package:ht_api/src/services/auth_token_service.dart';
import 'package:ht_shared/ht_shared.dart';
import 'package:logging/logging.dart';

final _log = Logger('AuthMiddleware');

/// Middleware to handle authentication by verifying Bearer tokens.
///
Expand All @@ -17,69 +20,69 @@ import 'package:ht_shared/ht_shared.dart';
Middleware authenticationProvider() {
return (handler) {
return (context) async {
print('[AuthMiddleware] Entered.');
_log.finer('Entered.');
// Read the interface type
AuthTokenService tokenService;
try {
print('[AuthMiddleware] Attempting to read AuthTokenService...');
_log.finer('Attempting to read AuthTokenService...');
tokenService = context.read<AuthTokenService>();
print('[AuthMiddleware] Successfully read AuthTokenService.');
_log.finer('Successfully read AuthTokenService.');
} catch (e, s) {
print('[AuthMiddleware] FAILED to read AuthTokenService: $e\n$s');
_log.severe('FAILED to read AuthTokenService.', e, s);
// Re-throw the error to be caught by the main error handler
rethrow;
}
User? user;

// Extract the Authorization header
print('[AuthMiddleware] Attempting to read Authorization header...');
_log.finer('Attempting to read Authorization header...');
final authHeader = context.request.headers['Authorization'];
print('[AuthMiddleware] Authorization header value: $authHeader');
_log.finer('Authorization header value: $authHeader');

if (authHeader != null && authHeader.startsWith('Bearer ')) {
// Extract the token string
final token = authHeader.substring(7); // Length of 'Bearer '
print('[AuthMiddleware] Extracted Bearer token.');
_log.finer('Extracted Bearer token.');
try {
print('[AuthMiddleware] Attempting to validate token...');
_log.finer('Attempting to validate token...');
// Validate the token using the service
user = await tokenService.validateToken(token);
print(
'[AuthMiddleware] Token validation returned: ${user?.id ?? 'null'}',
_log.finer(
'Token validation returned: ${user?.id ?? 'null'}',
);
if (user != null) {
print(
'[AuthMiddleware] Authentication successful for user: ${user.id}',
);
_log.info('Authentication successful for user: ${user.id}');
} else {
print(
'[AuthMiddleware] Invalid token provided (validateToken returned null).',
_log.warning(
'Invalid token provided (validateToken returned null).',
);
// Optional: Could throw UnauthorizedException here if *all* routes
// using this middleware strictly require a valid token.
// However, providing null allows routes to handle optional auth.
}
} on HtHttpException catch (e) {
// Log token validation errors from the service
print('Token validation failed: $e');
_log.warning('Token validation failed.', e);
// Let the error propagate if needed, or handle specific cases.
// For now, we treat validation errors as resulting in no user.
user = null; // Keep user null if HtHttpException occurred
} catch (e, s) {
// Catch unexpected errors during validation
print(
'[AuthMiddleware] Unexpected error during token validation: $e\n$s',
_log.severe(
'Unexpected error during token validation.',
e,
s,
);
user = null; // Keep user null if unexpected error occurred
}
} else {
print('[AuthMiddleware] No valid Bearer token found in header.');
_log.finer('No valid Bearer token found in header.');
}

// Provide the User object (or null) into the context
// This makes `context.read<User?>()` available downstream.
print(
'[AuthMiddleware] Providing User (${user?.id ?? 'null'}) to context.',
_log.finer(
'Providing User (${user?.id ?? 'null'}) to context.',
);
return handler(context.provide<User?>(() => user));
};
Expand All @@ -96,14 +99,14 @@ Middleware requireAuthentication() {
return (context) {
final user = context.read<User?>();
if (user == null) {
print(
_log.warning(
'Authentication required but no valid user found. Denying access.',
);
// Throwing allows the central errorHandler to create the 401 response.
throw const UnauthorizedException('Authentication required.');
}
// If user exists, proceed to the handler
print('Authentication check passed for user: ${user.id}');
_log.info('Authentication check passed for user: ${user.id}');
return handler(context.provide<User>(() => user));
};
};
Expand Down
15 changes: 9 additions & 6 deletions lib/src/middlewares/authorization_middleware.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import 'package:dart_frog/dart_frog.dart';
import 'package:ht_api/src/rbac/permission_service.dart';
import 'package:ht_api/src/registry/model_registry.dart';
import 'package:ht_shared/ht_shared.dart';
import 'package:logging/logging.dart';

final _log = Logger('AuthorizationMiddleware');

/// {@template authorization_middleware}
/// Middleware to enforce role-based permissions and model-specific access rules.
Expand Down Expand Up @@ -81,9 +84,9 @@ Middleware authorizationMiddleware() {
final permission = requiredPermissionConfig.permission;
if (permission == null) {
// This indicates a configuration error in ModelRegistry
print(
'[AuthorizationMiddleware] Configuration Error: specificPermission '
'type requires a permission string for model "$modelName", method "$method".',
_log.severe(
'Configuration Error: specificPermission type requires a '
'permission string for model "$modelName", method "$method".',
);
throw const OperationFailedException(
'Internal Server Error: Authorization configuration error.',
Expand All @@ -97,9 +100,9 @@ Middleware authorizationMiddleware() {
case RequiredPermissionType.unsupported:
// This action is explicitly marked as not supported via this generic route.
// Return Method Not Allowed.
print(
'[AuthorizationMiddleware] Action for model "$modelName", method "$method" '
'is marked as unsupported via generic route.',
_log.warning(
'Action for model "$modelName", method "$method" is marked as '
'unsupported via generic route.',
);
// Throw ForbiddenException to be caught by the errorHandler
throw ForbiddenException(
Expand Down
11 changes: 7 additions & 4 deletions lib/src/middlewares/error_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import 'package:dart_frog/dart_frog.dart';
import 'package:ht_api/src/config/environment_config.dart';
import 'package:ht_shared/ht_shared.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:logging/logging.dart';

final _log = Logger('ErrorHandler');

/// Middleware that catches errors and converts them into
/// standardized JSON responses.
Expand All @@ -20,7 +23,7 @@ Middleware errorHandler() {
} on HtHttpException catch (e, stackTrace) {
// Handle specific HtHttpExceptions from the client/repository layers
final statusCode = _mapExceptionToStatusCode(e);
print('HtHttpException Caught: $e\n$stackTrace'); // Log for debugging
_log.warning('HtHttpException Caught', e, stackTrace);
return _jsonErrorResponse(
statusCode: statusCode,
exception: e,
Expand All @@ -32,23 +35,23 @@ Middleware errorHandler() {
final message =
'Invalid request body: Field "$field" has an '
'invalid value or is missing. ${e.message}';
print('CheckedFromJsonException Caught: $e\n$stackTrace');
_log.warning('CheckedFromJsonException Caught', e, stackTrace);
return _jsonErrorResponse(
statusCode: HttpStatus.badRequest, // 400
exception: InvalidInputException(message),
context: context,
);
} on FormatException catch (e, stackTrace) {
// Handle data format/parsing errors (often indicates bad client input)
print('FormatException Caught: $e\n$stackTrace'); // Log for debugging
_log.warning('FormatException Caught', e, stackTrace);
return _jsonErrorResponse(
statusCode: HttpStatus.badRequest, // 400
exception: InvalidInputException('Invalid data format: ${e.message}'),
context: context,
);
} catch (e, stackTrace) {
// Handle any other unexpected errors
print('Unhandled Exception Caught: $e\n$stackTrace');
_log.severe('Unhandled Exception Caught', e, stackTrace);
return _jsonErrorResponse(
statusCode: HttpStatus.internalServerError, // 500
exception: const UnknownException(
Expand Down
44 changes: 44 additions & 0 deletions lib/src/services/database_seeding_service.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'package:ht_api/src/services/mongodb_token_blacklist_service.dart';
import 'package:ht_api/src/services/mongodb_verification_code_storage_service.dart';
import 'package:ht_shared/ht_shared.dart';
import 'package:logging/logging.dart';
import 'package:mongo_dart/mongo_dart.dart';
Expand Down Expand Up @@ -143,6 +145,48 @@ class DatabaseSeedingService {
name: 'sources_text_index',
);

// --- TTL and Unique Indexes via runCommand ---
// The following indexes are created using the generic `runCommand` because
// they require specific options not exposed by the simpler `createIndex`
// helper method in the `mongo_dart` library.
// Specifically, `expireAfterSeconds` is needed for TTL indexes.

// Indexes for the verification codes collection
await _db.runCommand({
'createIndexes': kVerificationCodesCollection,
'indexes': [
{
// This is a TTL (Time-To-Live) index. MongoDB will automatically
// delete documents from this collection when the `expiresAt` field's
// value is older than the specified number of seconds (0).
'key': {'expiresAt': 1},
'name': 'expiresAt_ttl_index',
'expireAfterSeconds': 0,
},
{
// This ensures that each email can only have one pending
// verification code at a time, preventing duplicates.
'key': {'email': 1},
'name': 'email_unique_index',
'unique': true,
}
]
});

// Index for the token blacklist collection
await _db.runCommand({
'createIndexes': kBlacklistedTokensCollection,
'indexes': [
{
// This is a TTL index. MongoDB will automatically delete documents
// (blacklisted tokens) when the `expiry` field's value is past.
'key': {'expiry': 1},
'name': 'expiry_ttl_index',
'expireAfterSeconds': 0,
}
]
});

_log.info('Database indexes are set up correctly.');
} on Exception catch (e, s) {
_log.severe('Failed to create database indexes.', e, s);
Expand Down
Loading
Loading