2025-11-16 11:25:27 +00:00
|
|
|
import 'dart:convert';
|
2025-12-13 13:27:05 +00:00
|
|
|
import 'dart:io';
|
2025-11-16 11:25:27 +00:00
|
|
|
import 'dart:math';
|
|
|
|
|
|
|
|
|
|
import 'package:crypto/crypto.dart';
|
2025-12-14 00:38:56 +00:00
|
|
|
import 'package:drift/drift.dart';
|
2025-12-13 23:35:14 +00:00
|
|
|
import 'package:drift_postgres/drift_postgres.dart';
|
2025-11-16 11:25:27 +00:00
|
|
|
import 'package:injectable/injectable.dart';
|
2025-12-13 13:27:05 +00:00
|
|
|
import 'package:mnemo_cards_backend/database/database.dart';
|
2025-11-16 11:25:27 +00:00
|
|
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
|
|
|
|
|
|
|
|
|
/// Service for JWT token generation and verification
|
|
|
|
|
///
|
|
|
|
|
/// Generates access tokens (short-lived) and refresh tokens (long-lived)
|
|
|
|
|
@lazySingleton
|
|
|
|
|
class JwtService {
|
2025-12-13 13:27:05 +00:00
|
|
|
// Read secrets from environment variables
|
2025-12-20 18:26:15 +00:00
|
|
|
static final String _jwtSecret =
|
|
|
|
|
Platform.environment['JWT_SECRET'] ??
|
2025-12-13 13:27:05 +00:00
|
|
|
'dev-jwt-secret-change-me-in-production';
|
2025-12-20 18:26:15 +00:00
|
|
|
static final String _jwtRefreshSecret =
|
|
|
|
|
Platform.environment['JWT_REFRESH_SECRET'] ??
|
2025-12-13 13:27:05 +00:00
|
|
|
'dev-refresh-secret-change-me-in-production';
|
2025-12-20 18:26:15 +00:00
|
|
|
|
2025-11-16 11:25:27 +00:00
|
|
|
static const int _accessTokenExpirySeconds = 3600; // 1 hour
|
|
|
|
|
static const int _refreshTokenExpirySeconds = 2592000; // 30 days
|
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
final AppDatabase _db;
|
|
|
|
|
|
|
|
|
|
JwtService(this._db);
|
2025-11-16 11:25:27 +00:00
|
|
|
|
|
|
|
|
/// Generate access and refresh tokens for a user
|
|
|
|
|
Future<JwtTokens> generateTokens(UserModel user) async {
|
|
|
|
|
final now = DateTime.now();
|
|
|
|
|
final jti = _generateJti();
|
|
|
|
|
|
|
|
|
|
// Access token payload
|
|
|
|
|
final accessTokenPayload = {
|
|
|
|
|
'sub': user.id.toString(),
|
|
|
|
|
'iat': now.millisecondsSinceEpoch ~/ 1000,
|
|
|
|
|
'exp': (now.millisecondsSinceEpoch ~/ 1000) + _accessTokenExpirySeconds,
|
|
|
|
|
'type': 'access',
|
|
|
|
|
'userId': user.id,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Refresh token payload
|
|
|
|
|
final refreshTokenPayload = {
|
|
|
|
|
'sub': user.id.toString(),
|
|
|
|
|
'iat': now.millisecondsSinceEpoch ~/ 1000,
|
|
|
|
|
'exp': (now.millisecondsSinceEpoch ~/ 1000) + _refreshTokenExpirySeconds,
|
|
|
|
|
'type': 'refresh',
|
|
|
|
|
'userId': user.id,
|
|
|
|
|
'jti': jti, // JWT ID for token invalidation
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Create JWT tokens with proper HMAC-SHA256 signing
|
|
|
|
|
final accessToken = _createSimpleJwt(accessTokenPayload);
|
|
|
|
|
final refreshToken = _createSimpleJwt(refreshTokenPayload);
|
|
|
|
|
|
|
|
|
|
// Store refresh token for invalidation
|
|
|
|
|
final userId = user.id;
|
|
|
|
|
if (userId == null) {
|
|
|
|
|
throw Exception('Cannot create tokens for user without ID');
|
|
|
|
|
}
|
|
|
|
|
final expiresAt = now.add(Duration(seconds: _refreshTokenExpirySeconds));
|
|
|
|
|
await _storeRefreshToken(jti, userId, now, expiresAt);
|
|
|
|
|
|
|
|
|
|
return JwtTokens(
|
|
|
|
|
accessToken: accessToken,
|
|
|
|
|
refreshToken: refreshToken,
|
|
|
|
|
expiresIn: _accessTokenExpirySeconds,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 22:05:05 +00:00
|
|
|
// Store last verification failure reason for debugging
|
|
|
|
|
String? lastVerificationError;
|
|
|
|
|
|
2025-11-16 11:25:27 +00:00
|
|
|
/// Verify access token and return user ID
|
2025-12-11 21:54:21 +00:00
|
|
|
String? verifyAccessToken(String token, {bool debug = false}) {
|
2025-12-11 22:05:05 +00:00
|
|
|
lastVerificationError = null;
|
2025-11-16 11:25:27 +00:00
|
|
|
try {
|
2025-12-11 21:54:21 +00:00
|
|
|
final payload = _verifySimpleJwt(token, debug: debug);
|
2025-12-11 21:38:21 +00:00
|
|
|
if (payload == null) {
|
2025-12-20 18:26:15 +00:00
|
|
|
lastVerificationError =
|
|
|
|
|
lastVerificationError ?? 'Signature verification failed';
|
2025-12-11 21:38:21 +00:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-16 11:25:27 +00:00
|
|
|
// Check if token is expired
|
|
|
|
|
final exp = payload['exp'] as int?;
|
|
|
|
|
if (exp != null) {
|
|
|
|
|
final expiryTime = DateTime.fromMillisecondsSinceEpoch(exp * 1000);
|
2025-12-11 21:44:18 +00:00
|
|
|
if (DateTime.now().isAfter(expiryTime)) {
|
2025-12-11 22:05:05 +00:00
|
|
|
lastVerificationError = 'Token expired at $expiryTime';
|
2025-11-16 11:25:27 +00:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check token type
|
2025-12-11 21:44:18 +00:00
|
|
|
if (payload['type'] != 'access') {
|
2025-12-11 22:05:05 +00:00
|
|
|
lastVerificationError = 'Wrong token type: ${payload['type']}';
|
2025-11-16 11:25:27 +00:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 21:44:18 +00:00
|
|
|
return payload['userId']?.toString();
|
2025-11-16 11:25:27 +00:00
|
|
|
} catch (e) {
|
2025-12-11 22:05:05 +00:00
|
|
|
lastVerificationError = 'Exception: $e';
|
2025-11-16 11:25:27 +00:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Verify refresh token and return user ID
|
|
|
|
|
Future<String?> verifyRefreshToken(String token) async {
|
|
|
|
|
try {
|
|
|
|
|
final payload = _verifySimpleJwt(token);
|
|
|
|
|
if (payload == null) return null;
|
|
|
|
|
|
|
|
|
|
// Check if token is expired
|
|
|
|
|
final exp = payload['exp'] as int?;
|
|
|
|
|
if (exp != null) {
|
|
|
|
|
final expiryTime = DateTime.fromMillisecondsSinceEpoch(exp * 1000);
|
|
|
|
|
if (DateTime.now().isAfter(expiryTime)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check token type
|
|
|
|
|
if (payload['type'] != 'refresh') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if token is blacklisted
|
|
|
|
|
final jti = payload['jti'] as String?;
|
|
|
|
|
if (jti != null) {
|
|
|
|
|
final isBlacklisted = await _isTokenBlacklisted(jti);
|
|
|
|
|
if (isBlacklisted) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return payload['userId']?.toString();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Create a simple JWT (base64 encoded JSON)
|
|
|
|
|
/// Note: This is a simplified implementation
|
|
|
|
|
/// For production, use a proper JWT library with proper signing
|
|
|
|
|
String _createSimpleJwt(Map<String, dynamic> payload) {
|
|
|
|
|
final header = {'alg': 'HS256', 'typ': 'JWT'};
|
|
|
|
|
final headerB64 = base64UrlEncode(utf8.encode(jsonEncode(header)));
|
|
|
|
|
final payloadB64 = base64UrlEncode(utf8.encode(jsonEncode(payload)));
|
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
// Use different secrets for access and refresh tokens
|
|
|
|
|
final isRefreshToken = payload['type'] == 'refresh';
|
|
|
|
|
final secret = isRefreshToken ? _jwtRefreshSecret : _jwtSecret;
|
|
|
|
|
|
|
|
|
|
// Create signature with HMAC-SHA256
|
2025-11-16 11:25:27 +00:00
|
|
|
final signatureInput = '$headerB64.$payloadB64';
|
2025-12-13 13:27:05 +00:00
|
|
|
final signature = _hmacSha256(utf8.encode(signatureInput), secret);
|
2025-11-16 11:25:27 +00:00
|
|
|
final signatureB64 = base64UrlEncode(signature);
|
|
|
|
|
|
|
|
|
|
return '$headerB64.$payloadB64.$signatureB64';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Verify a simple JWT
|
2025-12-11 21:54:21 +00:00
|
|
|
Map<String, dynamic>? _verifySimpleJwt(String token, {bool debug = false}) {
|
2025-11-16 11:25:27 +00:00
|
|
|
try {
|
|
|
|
|
final parts = token.split('.');
|
2025-12-11 21:38:21 +00:00
|
|
|
if (parts.length != 3) {
|
2025-12-11 22:05:05 +00:00
|
|
|
lastVerificationError = 'Invalid token format: ${parts.length} parts';
|
2025-12-11 21:38:21 +00:00
|
|
|
return null;
|
|
|
|
|
}
|
2025-11-16 11:25:27 +00:00
|
|
|
|
|
|
|
|
final headerB64 = parts[0];
|
|
|
|
|
final payloadB64 = parts[1];
|
|
|
|
|
final signatureB64 = parts[2];
|
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
// Decode payload first to determine token type
|
|
|
|
|
final payloadJson = utf8.decode(base64Url.decode(payloadB64));
|
|
|
|
|
final payload = jsonDecode(payloadJson) as Map<String, dynamic>;
|
|
|
|
|
|
|
|
|
|
// Use different secrets for access and refresh tokens
|
|
|
|
|
final isRefreshToken = payload['type'] == 'refresh';
|
|
|
|
|
final secret = isRefreshToken ? _jwtRefreshSecret : _jwtSecret;
|
|
|
|
|
|
|
|
|
|
// Verify signature with appropriate secret
|
2025-11-16 11:25:27 +00:00
|
|
|
final signatureInput = '$headerB64.$payloadB64';
|
2025-12-20 18:26:15 +00:00
|
|
|
final expectedSignature = _hmacSha256(
|
|
|
|
|
utf8.encode(signatureInput),
|
|
|
|
|
secret,
|
|
|
|
|
);
|
2025-11-16 11:25:27 +00:00
|
|
|
final expectedSignatureB64 = base64UrlEncode(expectedSignature);
|
|
|
|
|
|
|
|
|
|
if (signatureB64 != expectedSignatureB64) {
|
2025-12-20 18:26:15 +00:00
|
|
|
lastVerificationError =
|
|
|
|
|
'Signature mismatch: got ${signatureB64.substring(0, 10)}..., '
|
2025-12-11 22:05:05 +00:00
|
|
|
'expected ${expectedSignatureB64.substring(0, 10)}...';
|
2025-11-16 11:25:27 +00:00
|
|
|
return null; // Invalid signature
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 13:27:05 +00:00
|
|
|
return payload;
|
2025-11-16 11:25:27 +00:00
|
|
|
} catch (e) {
|
2025-12-11 22:05:05 +00:00
|
|
|
lastVerificationError = 'Parse exception: $e';
|
2025-11-16 11:25:27 +00:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Extract payload from JWT without verification (for extracting jti during logout)
|
|
|
|
|
Map<String, dynamic>? extractPayload(String token) {
|
|
|
|
|
try {
|
|
|
|
|
final parts = token.split('.');
|
|
|
|
|
if (parts.length != 3) return null;
|
|
|
|
|
|
|
|
|
|
final payloadB64 = parts[1];
|
|
|
|
|
final payloadJson = utf8.decode(base64Url.decode(payloadB64));
|
|
|
|
|
return jsonDecode(payloadJson) as Map<String, dynamic>;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// HMAC-SHA256 implementation using crypto package
|
|
|
|
|
List<int> _hmacSha256(List<int> data, String key) {
|
|
|
|
|
final keyBytes = utf8.encode(key);
|
|
|
|
|
final hmac = Hmac(sha256, keyBytes);
|
|
|
|
|
final digest = hmac.convert(data);
|
|
|
|
|
return digest.bytes;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _generateJti() {
|
|
|
|
|
final random = Random();
|
2025-12-20 18:26:15 +00:00
|
|
|
return base64UrlEncode(List<int>.generate(16, (_) => random.nextInt(256)));
|
2025-11-16 11:25:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _storeRefreshToken(
|
|
|
|
|
String jti,
|
2025-12-13 20:55:50 +00:00
|
|
|
String userId,
|
2025-11-16 11:25:27 +00:00
|
|
|
DateTime createdAt,
|
|
|
|
|
DateTime expiresAt,
|
|
|
|
|
) async {
|
2025-12-13 13:27:05 +00:00
|
|
|
await _db.transaction(() async {
|
2025-11-16 11:25:27 +00:00
|
|
|
// Delete old token if exists (shouldn't happen due to unique index, but be safe)
|
2025-12-13 13:27:05 +00:00
|
|
|
final existing = await _db.userDao.getRefreshTokenByJti(jti);
|
2025-11-16 11:25:27 +00:00
|
|
|
if (existing != null) {
|
2025-12-13 13:27:05 +00:00
|
|
|
await _db.userDao.revokeRefreshToken(existing.id);
|
2025-11-16 11:25:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Store new token
|
2025-12-14 00:38:56 +00:00
|
|
|
final now = DateTime.now();
|
2025-12-13 13:27:05 +00:00
|
|
|
await _db.userDao.createRefreshToken(
|
|
|
|
|
RefreshTokensCompanion.insert(
|
2025-11-16 11:25:27 +00:00
|
|
|
jti: jti,
|
|
|
|
|
userId: userId,
|
2025-12-14 00:38:56 +00:00
|
|
|
expiresAt: PgDateTime(expiresAt),
|
|
|
|
|
isBlacklisted: Value(false),
|
|
|
|
|
createdAt: Value(PgDateTime(now)),
|
2025-11-16 11:25:27 +00:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<bool> _isTokenBlacklisted(String jti) async {
|
2025-12-13 13:27:05 +00:00
|
|
|
final token = await _db.userDao.getRefreshTokenByJti(jti);
|
2025-11-16 11:25:27 +00:00
|
|
|
|
|
|
|
|
if (token == null) {
|
|
|
|
|
return true; // Token not found, consider it invalid
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if expired
|
2025-12-13 23:35:14 +00:00
|
|
|
if (token.expiresAt.dateTime.isBefore(DateTime.now())) {
|
2025-11-16 11:25:27 +00:00
|
|
|
return true; // Expired tokens are considered invalid
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return token.isBlacklisted;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Blacklist a refresh token (for logout)
|
|
|
|
|
Future<void> blacklistRefreshToken(String jti) async {
|
2025-12-13 13:27:05 +00:00
|
|
|
await _db.userDao.revokeRefreshTokenByJti(jti);
|
2025-11-16 11:25:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Clean up expired tokens (should be called periodically)
|
|
|
|
|
Future<void> cleanupExpiredTokens() async {
|
2025-12-13 13:27:05 +00:00
|
|
|
await _db.userDao.deleteExpiredRefreshTokens();
|
2025-11-16 11:25:27 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// JWT tokens result
|
|
|
|
|
class JwtTokens {
|
|
|
|
|
final String accessToken;
|
|
|
|
|
final String refreshToken;
|
|
|
|
|
final int expiresIn; // seconds
|
|
|
|
|
|
|
|
|
|
JwtTokens({
|
|
|
|
|
required this.accessToken,
|
|
|
|
|
required this.refreshToken,
|
|
|
|
|
required this.expiresIn,
|
|
|
|
|
});
|
|
|
|
|
}
|