fix(auth): revoke JWT on user deactivation, unregister FCM token on logout, and purge tokens on password reset

This commit is contained in:
ssbg04 committed 2026-08-30 00:49:08 +08:00
1 parent ee47158e74
commit 95671e39b7
10 files changed
+110 -10

No files matched your search

+8 -1
View File
@@ -256,7 +256,14 @@ exports.resetPasswordEmailOtp = (req, res) => {
// Mark OTP as used
db.prepare('UPDATE password_reset_otps SET is_used = 1 WHERE id = ?').run(otpRecord.id);
createNotification(user.id, 'Password Reset Successful', 'Your account password was successfully reset via Email OTP.', 'system');
// Purge old FCM tokens on password reset so logged-out devices don't receive push notifications
db.prepare('DELETE FROM fcm_tokens WHERE user_id = ?').run(user.id);
// Record in-app notification in DB (visible when the user logs in) without dispatching push notification
try {
db.prepare('INSERT INTO notifications (user_id, title, message, is_read, category) VALUES (?, ?, ?, 0, ?)')
.run(user.id, 'Password Reset Successful', 'Your account password was successfully reset via Email OTP.', 'system');
} catch (_) {}
res.json({
success: true,
@@ -50,6 +50,21 @@ exports.registerFcmToken = (req, res) => {
}
};
// DELETE /api/notifications/fcm-token OR POST /api/notifications/fcm-token/unregister
exports.unregisterFcmToken = (req, res) => {
const { token } = req.body || {};
try {
if (token) {
db.prepare('DELETE FROM fcm_tokens WHERE token = ?').run(token);
} else if (req.user?.id) {
db.prepare('DELETE FROM fcm_tokens WHERE user_id = ?').run(req.user.id);
}
res.json({ message: 'FCM token unregistered successfully' });
} catch (error) {
res.status(500).json({ message: 'Failed to unregister FCM token', error: error.message });
}
};
// Common WHERE clause fragment for teacher notification scoping.
// Requires 4 bind parameters: [teacherId, teacherId, teacherId, teacherId]
const TEACHER_NOTIF_WHERE_CLAUSE = `
@@ -239,6 +239,11 @@ exports.toggleUserStatus = (req, res) => {
db.prepare("UPDATE users SET is_active = ?, updated_at = (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) WHERE id = ?")
.run(newStatus, id);
if (newStatus === 0) {
// Revoke all FCM push tokens immediately upon deactivation
db.prepare("DELETE FROM fcm_tokens WHERE user_id = ?").run(id);
}
const statusLabel = newStatus === 1 ? 'Activated' : 'Deactivated';
const fullName = [user.first_name, user.last_name].filter(Boolean).join(' ');
logActivity(adminId, 'UPDATE', 'user', id, `${statusLabel} user: ${user.username}`);
+19 -2
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken');
require('dotenv').config();
const db = require('../config/db');
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
@@ -15,8 +16,24 @@ const authenticateToken = (req, res, next) => {
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) return res.status(403).json({ message: 'Invalid or expired token' });
req.user = user;
next();
try {
const dbUser = db.prepare('SELECT id, username, role, is_active FROM users WHERE id = ?').get(user.id);
if (!dbUser) {
return res.status(401).json({ message: 'User account no longer exists.' });
}
if (dbUser.is_active === 0) {
return res.status(403).json({
message: 'Your account has been deactivated. Access revoked.',
isDeactivated: true
});
}
req.user = { ...user, role: dbUser.role, is_active: dbUser.is_active };
next();
} catch (dbErr) {
return res.status(500).json({ message: 'Authentication verification error', error: dbErr.message });
}
});
};
+2
View File
@@ -5,6 +5,8 @@ const { authenticateToken } = require('../middleware/auth');
router.get('/', authenticateToken, notificationController.getNotifications);
router.post('/fcm-token', authenticateToken, notificationController.registerFcmToken);
router.delete('/fcm-token', authenticateToken, notificationController.unregisterFcmToken);
router.post('/fcm-token/unregister', authenticateToken, notificationController.unregisterFcmToken);
router.put('/mark-all-read', authenticateToken, notificationController.markAllRead);
router.put('/:id/read', authenticateToken, notificationController.markRead);
router.delete('/clear', authenticateToken, notificationController.clearNotifications);
+7 -2
View File
@@ -125,8 +125,13 @@ const sendNotification = async ({ userId = null, title, body, category = 'system
let tokens = [];
if (userId) {
// Direct message to a specific user
tokens = db.prepare('SELECT token FROM fcm_tokens WHERE user_id = ?')
// Direct message to a specific active user
tokens = db.prepare(`
SELECT ft.token
FROM fcm_tokens ft
JOIN users u ON ft.user_id = u.id
WHERE ft.user_id = ? AND u.is_active = 1
`)
.all(userId)
.map(r => r.token);
} else {
+4 -4
View File
@@ -402,9 +402,9 @@ Files backup/restore - mirror mode or zip -->
# STUDENT SCREEN
- hover missing document make the design theme color responsive -->
# USER SCREEN
- add y scroll bar line not notes, under filter
<!-- # AUTHENTICATION
- user management -> expired the jwt of deactivated user
- i can receive a push notifications even i logged out -->
---
@@ -415,7 +415,7 @@ Files backup/restore - mirror mode or zip -->
- document screen -> upload document limit document type limit -> duplicate files
- user management -> expired the jwt of deactivated user
- dashboard school -> add missing students by document type kpi
- dashboard screen -> kpi and analytics
@@ -56,6 +56,23 @@ class ApiConstants {
return handler.next(options);
},
onError: (DioException err, handler) async {
final statusCode = err.response?.statusCode;
final isDeactivated = err.response?.data is Map &&
(err.response?.data as Map)['isDeactivated'] == true;
if (statusCode == 401 || (statusCode == 403 && isDeactivated)) {
// Expire / wipe stored JWT token immediately on deactivation or invalidation
const storage = FlutterSecureStorage();
await storage.delete(key: 'jwt_token');
await storage.delete(key: 'remember_me');
final prefs = await SharedPreferences.getInstance();
await prefs.remove('jwt_token');
await prefs.remove('rememberMe');
}
return handler.next(err);
},
),
);
return dio;
@@ -81,4 +81,34 @@ class FcmService {
debugPrint('[FcmService] Token registration failed: $e');
}
}
static Future<void> unregisterToken() async {
try {
if (!_isMobile || Firebase.apps.isEmpty) return;
final token = await FirebaseMessaging.instance.getToken();
final prefs = await SharedPreferences.getInstance();
final rawUrl = prefs.getString('server_url') ?? ApiConstants.baseUrl;
final clean = rawUrl.replaceAll(RegExp(r'/+$'), '');
final baseUrl = clean.endsWith('/api') ? clean : '$clean/api';
final jwtToken = prefs.getString('jwt_token') ?? '';
if (jwtToken.isNotEmpty) {
final dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
headers: {'Authorization': 'Bearer $jwtToken'},
));
await dio.post('/notifications/fcm-token/unregister', data: {'token': token});
}
// Delete FCM token from device instance so no further push notifications arrive
await FirebaseMessaging.instance.deleteToken();
debugPrint('[FcmService] Token unregistered and deleted from device');
} catch (e) {
debugPrint('[FcmService] Token unregistration error: $e');
}
}
}
+3 -1
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/repositories/auth_repository.dart';
import '../../domain/entities/user_model.dart';
@@ -69,6 +70,7 @@ class AuthNotifier extends AsyncNotifier<UserModel?> {
Future<void> logout() async {
ref.read(heartbeatServiceProvider).stop();
ForegroundSyncService.stop();
await FcmService.unregisterToken();
final repository = ref.read(authRepositoryProvider);
await repository.logout();
state = const AsyncData(null);
@@ -106,7 +108,7 @@ class AuthNotifier extends AsyncNotifier<UserModel?> {
state = AsyncData(updatedUser);
} catch (e) {
// Don't emit error state to prevent UI disruptions on background refresh failures
print('Background refresh failed: $e');
debugPrint('Background refresh failed: $e');
}
}