Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
319 lines
9.7 KiB
Dart
319 lines
9.7 KiB
Dart
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:injectable/injectable.dart';
|
|
import 'package:isar/isar.dart';
|
|
import 'package:mnemo_cards_backend/main.dart' as main;
|
|
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 {
|
|
// In production, use environment variables or secure key management
|
|
static const String _secretKey = 'your-secret-key-change-in-production';
|
|
static const int _accessTokenExpirySeconds = 3600; // 1 hour
|
|
static const int _refreshTokenExpirySeconds = 2592000; // 30 days
|
|
|
|
JwtService();
|
|
|
|
/// 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);
|
|
|
|
print('[JWT DEBUG] Generated access token for user ${user.id}');
|
|
print('[JWT DEBUG] Access token payload: $accessTokenPayload');
|
|
print('[JWT DEBUG] Access token length: ${accessToken.length}');
|
|
|
|
// 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,
|
|
);
|
|
}
|
|
|
|
/// Verify access token and return user ID
|
|
String? verifyAccessToken(String token) {
|
|
try {
|
|
final payload = _verifySimpleJwt(token);
|
|
if (payload == null) {
|
|
print('[JWT DEBUG] Token verification failed: payload is null');
|
|
return null;
|
|
}
|
|
|
|
print('[JWT DEBUG] Token payload: $payload');
|
|
|
|
// Check if token is expired
|
|
final exp = payload['exp'] as int?;
|
|
if (exp != null) {
|
|
final expiryTime = DateTime.fromMillisecondsSinceEpoch(exp * 1000);
|
|
final now = DateTime.now();
|
|
print('[JWT DEBUG] Token expiry: $expiryTime, now: $now, expired: ${now.isAfter(expiryTime)}');
|
|
if (now.isAfter(expiryTime)) {
|
|
print('[JWT DEBUG] Token is expired');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Check token type
|
|
final tokenType = payload['type'];
|
|
print('[JWT DEBUG] Token type: $tokenType');
|
|
if (tokenType != 'access') {
|
|
print('[JWT DEBUG] Token type mismatch: expected "access", got "$tokenType"');
|
|
return null;
|
|
}
|
|
|
|
final userId = payload['userId']?.toString();
|
|
print('[JWT DEBUG] Extracted userId: $userId');
|
|
return userId;
|
|
} catch (e) {
|
|
print('[JWT DEBUG] Token verification exception: $e');
|
|
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)));
|
|
|
|
// Create signature (simplified - in production use proper HMAC)
|
|
final signatureInput = '$headerB64.$payloadB64';
|
|
final signature = _hmacSha256(utf8.encode(signatureInput), _secretKey);
|
|
final signatureB64 = base64UrlEncode(signature);
|
|
|
|
return '$headerB64.$payloadB64.$signatureB64';
|
|
}
|
|
|
|
/// Verify a simple JWT
|
|
Map<String, dynamic>? _verifySimpleJwt(String token) {
|
|
try {
|
|
final parts = token.split('.');
|
|
if (parts.length != 3) {
|
|
print('[JWT DEBUG] Invalid token format: expected 3 parts, got ${parts.length}');
|
|
return null;
|
|
}
|
|
|
|
final headerB64 = parts[0];
|
|
final payloadB64 = parts[1];
|
|
final signatureB64 = parts[2];
|
|
|
|
// Verify signature
|
|
final signatureInput = '$headerB64.$payloadB64';
|
|
final expectedSignature =
|
|
_hmacSha256(utf8.encode(signatureInput), _secretKey);
|
|
final expectedSignatureB64 = base64UrlEncode(expectedSignature);
|
|
|
|
if (signatureB64 != expectedSignatureB64) {
|
|
print('[JWT DEBUG] Invalid signature');
|
|
print('[JWT DEBUG] Expected: $expectedSignatureB64');
|
|
print('[JWT DEBUG] Got: $signatureB64');
|
|
return null; // Invalid signature
|
|
}
|
|
|
|
// Decode payload
|
|
final payloadJson = utf8.decode(base64Url.decode(payloadB64));
|
|
final payload = jsonDecode(payloadJson) as Map<String, dynamic>;
|
|
print('[JWT DEBUG] Decoded payload: $payload');
|
|
return payload;
|
|
} catch (e) {
|
|
print('[JWT DEBUG] Exception during token verification: $e');
|
|
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();
|
|
return base64UrlEncode(
|
|
List<int>.generate(16, (_) => random.nextInt(256)),
|
|
);
|
|
}
|
|
|
|
Future<void> _storeRefreshToken(
|
|
String jti,
|
|
int userId,
|
|
DateTime createdAt,
|
|
DateTime expiresAt,
|
|
) async {
|
|
await main.isar.writeTxn(() async {
|
|
// Delete old token if exists (shouldn't happen due to unique index, but be safe)
|
|
final existing = await main.isar.refreshTokenModels
|
|
.filter()
|
|
.jtiEqualTo(jti)
|
|
.findFirst();
|
|
if (existing != null) {
|
|
await main.isar.refreshTokenModels.delete(existing.id!);
|
|
}
|
|
|
|
// Store new token
|
|
await main.isar.refreshTokenModels.put(
|
|
RefreshTokenModel(
|
|
jti: jti,
|
|
userId: userId,
|
|
createdAt: createdAt,
|
|
expiresAt: expiresAt,
|
|
isBlacklisted: false,
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
Future<bool> _isTokenBlacklisted(String jti) async {
|
|
final token =
|
|
await main.isar.refreshTokenModels.filter().jtiEqualTo(jti).findFirst();
|
|
|
|
if (token == null) {
|
|
return true; // Token not found, consider it invalid
|
|
}
|
|
|
|
// Check if expired
|
|
if (token.expiresAt.isBefore(DateTime.now())) {
|
|
return true; // Expired tokens are considered invalid
|
|
}
|
|
|
|
return token.isBlacklisted;
|
|
}
|
|
|
|
/// Blacklist a refresh token (for logout)
|
|
Future<void> blacklistRefreshToken(String jti) async {
|
|
await main.isar.writeTxn(() async {
|
|
final token = await main.isar.refreshTokenModels
|
|
.filter()
|
|
.jtiEqualTo(jti)
|
|
.findFirst();
|
|
if (token != null) {
|
|
await main.isar.refreshTokenModels.put(
|
|
token.copyWith(isBlacklisted: true),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Clean up expired tokens (should be called periodically)
|
|
Future<void> cleanupExpiredTokens() async {
|
|
await main.isar.writeTxn(() async {
|
|
final now = DateTime.now();
|
|
final expired = await main.isar.refreshTokenModels
|
|
.filter()
|
|
.expiresAtLessThan(now)
|
|
.findAll();
|
|
if (expired.isNotEmpty) {
|
|
final ids = expired
|
|
.map((t) => t.id)
|
|
.where((id) => id != null)
|
|
.cast<Id>()
|
|
.toList();
|
|
if (ids.isNotEmpty) {
|
|
await main.isar.refreshTokenModels.deleteAll(ids);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
});
|
|
}
|