mnemo_cards/mnemo_cards_backend/lib/api/v2/jwt_service.dart
Dmitry 215854e461
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
logs
2025-12-12 00:38:21 +03:00

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,
});
}