fix(auth): revoke JWT on user deactivation, unregister FCM token on logout, and purge tokens on password reset
This commit is contained in:
1 parent
ee47158e74
commit
95671e39b7
10 files changed
+110
-10
No files matched your search
@@ -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}`);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user