mnemo_cards/mnemo_cards_backend/lib/api/v2/jwt_service.dart
Dmitry 1ab5071345
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (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
f
2025-12-14 02:35:14 +03:00

293 lines
9.1 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:drift_postgres/drift_postgres.dart';
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart';
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 {
// Read secrets from environment variables
static final String _jwtSecret = Platform.environment['JWT_SECRET'] ??
'dev-jwt-secret-change-me-in-production';
static final String _jwtRefreshSecret = Platform.environment['JWT_REFRESH_SECRET'] ??
'dev-refresh-secret-change-me-in-production';
static const int _accessTokenExpirySeconds = 3600; // 1 hour
static const int _refreshTokenExpirySeconds = 2592000; // 30 days
final AppDatabase _db;
JwtService(this._db);
/// 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,
);
}
// Store last verification failure reason for debugging
String? lastVerificationError;
/// Verify access token and return user ID
String? verifyAccessToken(String token, {bool debug = false}) {
lastVerificationError = null;
try {
final payload = _verifySimpleJwt(token, debug: debug);
if (payload == null) {
lastVerificationError = lastVerificationError ?? 'Signature verification failed';
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)) {
lastVerificationError = 'Token expired at $expiryTime';
return null;
}
}
// Check token type
if (payload['type'] != 'access') {
lastVerificationError = 'Wrong token type: ${payload['type']}';
return null;
}
return payload['userId']?.toString();
} catch (e) {
lastVerificationError = '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)));
// Use different secrets for access and refresh tokens
final isRefreshToken = payload['type'] == 'refresh';
final secret = isRefreshToken ? _jwtRefreshSecret : _jwtSecret;
// Create signature with HMAC-SHA256
final signatureInput = '$headerB64.$payloadB64';
final signature = _hmacSha256(utf8.encode(signatureInput), secret);
final signatureB64 = base64UrlEncode(signature);
return '$headerB64.$payloadB64.$signatureB64';
}
/// Verify a simple JWT
Map<String, dynamic>? _verifySimpleJwt(String token, {bool debug = false}) {
try {
final parts = token.split('.');
if (parts.length != 3) {
lastVerificationError = 'Invalid token format: ${parts.length} parts';
return null;
}
final headerB64 = parts[0];
final payloadB64 = parts[1];
final signatureB64 = parts[2];
// 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
final signatureInput = '$headerB64.$payloadB64';
final expectedSignature = _hmacSha256(utf8.encode(signatureInput), secret);
final expectedSignatureB64 = base64UrlEncode(expectedSignature);
if (signatureB64 != expectedSignatureB64) {
lastVerificationError = 'Signature mismatch: got ${signatureB64.substring(0, 10)}..., '
'expected ${expectedSignatureB64.substring(0, 10)}...';
return null; // Invalid signature
}
return payload;
} catch (e) {
lastVerificationError = 'Parse exception: $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,
String userId,
DateTime createdAt,
DateTime expiresAt,
) async {
await _db.transaction(() async {
// Delete old token if exists (shouldn't happen due to unique index, but be safe)
final existing = await _db.userDao.getRefreshTokenByJti(jti);
if (existing != null) {
await _db.userDao.revokeRefreshToken(existing.id);
}
// Store new token
await _db.userDao.createRefreshToken(
RefreshTokensCompanion.insert(
jti: jti,
userId: userId,
expiresAt: PgDateTime(expiresAt), // required field - raw DateTime
// createdAt and isBlacklisted use defaults from table
),
);
});
}
Future<bool> _isTokenBlacklisted(String jti) async {
final token = await _db.userDao.getRefreshTokenByJti(jti);
if (token == null) {
return true; // Token not found, consider it invalid
}
// Check if expired
if (token.expiresAt.dateTime.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 _db.userDao.revokeRefreshTokenByJti(jti);
}
/// Clean up expired tokens (should be called periodically)
Future<void> cleanupExpiredTokens() async {
await _db.userDao.deleteExpiredRefreshTokens();
}
}
/// 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,
});
}