feat(security): implement comprehensive security hardening
- Add CSRF protection with cookie-based tokens - Add cookie-parser and csurf middleware - Create GET /api/csrf-token endpoint - Frontend automatically includes CSRF token in POST/PUT/DELETE requests - Add retry logic for expired CSRF tokens - Implement account lockout mechanism - Add database fields: failedLoginAttempts, lockedUntil - Track failed login attempts and lock accounts after max attempts (configurable) - Auto-unlock after lockout duration expires - Return helpful error messages with remaining time - Add comprehensive security environment variables - Rate limiting configuration (API, auth, email endpoints) - CSRF protection toggle - Password policy requirements - Account lockout settings - Logging levels - Add comprehensive test coverage - 6 new tests for account lockout functionality - 11 new tests for CSRF protection - All tests handle enabled/disabled states gracefully - Update documentation - Add Phase 3 security hardening to SESSION_CONTEXT.md - Document new database fields and migration - Update progress to 85% Files changed: - Backend: app.js, auth controller, security config, new migration - Frontend: api.js with CSRF token handling - Tests: auth.test.js (extended), csrf.test.js (new) - Config: .env examples with security variables - Docs: SESSION_CONTEXT.md updated
This commit is contained in:
@@ -241,4 +241,215 @@ describe('Authentication API Tests', () => {
|
||||
expect(response.body).toHaveProperty('error', 'Unauthorized');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Account Lockout Tests (Phase 3 - Security Hardening)', () => {
|
||||
const lockoutTestUser = {
|
||||
username: 'lockouttest',
|
||||
email: 'lockout@example.com',
|
||||
password: 'TestPassword123!',
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create a test user for lockout tests
|
||||
const passwordHash = await hashPassword(lockoutTestUser.password);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
username: lockoutTestUser.username,
|
||||
email: lockoutTestUser.email,
|
||||
passwordHash,
|
||||
emailVerified: true,
|
||||
avatar: 'https://ui-avatars.com/api/?name=lockouttest',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up lockout test user
|
||||
await prisma.user.deleteMany({
|
||||
where: { email: lockoutTestUser.email },
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset failed attempts before each test
|
||||
await prisma.user.update({
|
||||
where: { email: lockoutTestUser.email },
|
||||
data: {
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should increment failed login attempts on wrong password', async () => {
|
||||
// Skip if account lockout is disabled
|
||||
const securityConfig = require('../config/security');
|
||||
if (!securityConfig.accountLockout.enabled) {
|
||||
console.log('Skipping test: Account lockout is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
email: lockoutTestUser.email,
|
||||
password: 'wrongpassword',
|
||||
})
|
||||
.expect(401);
|
||||
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
expect(response.body).toHaveProperty('error', 'Invalid credentials');
|
||||
expect(response.body).toHaveProperty('attemptsLeft');
|
||||
|
||||
// Check database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: lockoutTestUser.email },
|
||||
});
|
||||
expect(user.failedLoginAttempts).toBe(1);
|
||||
});
|
||||
|
||||
it('should lock account after max failed attempts', async () => {
|
||||
const securityConfig = require('../config/security');
|
||||
if (!securityConfig.accountLockout.enabled) {
|
||||
console.log('Skipping test: Account lockout is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const maxAttempts = securityConfig.accountLockout.maxAttempts;
|
||||
|
||||
// Make max-1 failed attempts
|
||||
for (let i = 0; i < maxAttempts - 1; i++) {
|
||||
await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
email: lockoutTestUser.email,
|
||||
password: 'wrongpassword',
|
||||
});
|
||||
}
|
||||
|
||||
// Final attempt should lock the account
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
email: lockoutTestUser.email,
|
||||
password: 'wrongpassword',
|
||||
})
|
||||
.expect(423);
|
||||
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body.error).toContain('locked');
|
||||
|
||||
// Check database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: lockoutTestUser.email },
|
||||
});
|
||||
expect(user.failedLoginAttempts).toBe(maxAttempts);
|
||||
expect(user.lockedUntil).not.toBeNull();
|
||||
expect(new Date(user.lockedUntil)).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should reject login attempts while account is locked', async () => {
|
||||
const securityConfig = require('../config/security');
|
||||
if (!securityConfig.accountLockout.enabled) {
|
||||
console.log('Skipping test: Account lockout is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Manually lock the account
|
||||
const lockoutDuration = 15 * 60 * 1000; // 15 minutes
|
||||
await prisma.user.update({
|
||||
where: { email: lockoutTestUser.email },
|
||||
data: {
|
||||
lockedUntil: new Date(Date.now() + lockoutDuration),
|
||||
failedLoginAttempts: 5,
|
||||
},
|
||||
});
|
||||
|
||||
// Try to login with correct password
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
email: lockoutTestUser.email,
|
||||
password: lockoutTestUser.password,
|
||||
})
|
||||
.expect(423);
|
||||
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body.error).toContain('locked');
|
||||
expect(response.body).toHaveProperty('lockedUntil');
|
||||
});
|
||||
|
||||
it('should reset failed attempts on successful login', async () => {
|
||||
const securityConfig = require('../config/security');
|
||||
if (!securityConfig.accountLockout.enabled) {
|
||||
console.log('Skipping test: Account lockout is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set some failed attempts
|
||||
await prisma.user.update({
|
||||
where: { email: lockoutTestUser.email },
|
||||
data: {
|
||||
failedLoginAttempts: 3,
|
||||
},
|
||||
});
|
||||
|
||||
// Successful login
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
email: lockoutTestUser.email,
|
||||
password: lockoutTestUser.password,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
expect(response.body.data).toHaveProperty('token');
|
||||
|
||||
// Check database - failed attempts should be reset
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: lockoutTestUser.email },
|
||||
});
|
||||
expect(user.failedLoginAttempts).toBe(0);
|
||||
expect(user.lockedUntil).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow login after lockout period expires', async () => {
|
||||
const securityConfig = require('../config/security');
|
||||
if (!securityConfig.accountLockout.enabled) {
|
||||
console.log('Skipping test: Account lockout is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Lock account with expired lockout time
|
||||
await prisma.user.update({
|
||||
where: { email: lockoutTestUser.email },
|
||||
data: {
|
||||
lockedUntil: new Date(Date.now() - 1000), // 1 second ago
|
||||
failedLoginAttempts: 5,
|
||||
},
|
||||
});
|
||||
|
||||
// Should be able to login now
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
email: lockoutTestUser.email,
|
||||
password: lockoutTestUser.password,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
expect(response.body.data).toHaveProperty('token');
|
||||
|
||||
// Failed attempts should be reset
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: lockoutTestUser.email },
|
||||
});
|
||||
expect(user.failedLoginAttempts).toBe(0);
|
||||
expect(user.lockedUntil).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
253
backend/src/__tests__/csrf.test.js
Normal file
253
backend/src/__tests__/csrf.test.js
Normal file
@@ -0,0 +1,253 @@
|
||||
const request = require('supertest');
|
||||
const app = require('../app');
|
||||
const { prisma } = require('../utils/db');
|
||||
const { hashPassword } = require('../utils/auth');
|
||||
const securityConfig = require('../config/security');
|
||||
|
||||
// Clean up database before and after tests
|
||||
beforeAll(async () => {
|
||||
await prisma.user.deleteMany({});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.user.deleteMany({});
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe('CSRF Protection Tests (Phase 3 - Security Hardening)', () => {
|
||||
// Skip all tests if CSRF is disabled
|
||||
if (!securityConfig.csrf.enabled) {
|
||||
it('CSRF protection is disabled - skipping all tests', () => {
|
||||
console.log('Skipping CSRF tests: CSRF protection is disabled in configuration');
|
||||
expect(securityConfig.csrf.enabled).toBe(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const testUser = {
|
||||
username: 'csrftest',
|
||||
email: 'csrf@example.com',
|
||||
password: 'TestPassword123!',
|
||||
};
|
||||
|
||||
let authToken;
|
||||
let csrfToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create test user
|
||||
const passwordHash = await hashPassword(testUser.password);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
username: testUser.username,
|
||||
email: testUser.email,
|
||||
passwordHash,
|
||||
emailVerified: true,
|
||||
avatar: 'https://ui-avatars.com/api/?name=csrftest',
|
||||
},
|
||||
});
|
||||
|
||||
// Login to get auth token
|
||||
const loginResponse = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
});
|
||||
authToken = loginResponse.body.data.token;
|
||||
});
|
||||
|
||||
describe('GET /api/csrf-token', () => {
|
||||
it('should return CSRF token', async () => {
|
||||
const agent = request.agent(app);
|
||||
|
||||
const response = await agent
|
||||
.get('/api/csrf-token')
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('csrfToken');
|
||||
expect(typeof response.body.csrfToken).toBe('string');
|
||||
expect(response.body.csrfToken).not.toBe('');
|
||||
|
||||
csrfToken = response.body.csrfToken;
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST requests with CSRF protection', () => {
|
||||
it('should reject POST request without CSRF token', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/resend-verification')
|
||||
.send({
|
||||
email: testUser.email,
|
||||
})
|
||||
.expect(403);
|
||||
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
expect(response.body).toHaveProperty('error', 'Invalid CSRF token');
|
||||
});
|
||||
|
||||
it('should accept POST request with valid CSRF token', async () => {
|
||||
const agent = request.agent(app);
|
||||
|
||||
// First, get CSRF token
|
||||
const csrfResponse = await agent.get('/api/csrf-token');
|
||||
const token = csrfResponse.body.csrfToken;
|
||||
|
||||
// Then make POST request with CSRF token
|
||||
const response = await agent
|
||||
.post('/api/auth/resend-verification')
|
||||
.set('X-CSRF-Token', token)
|
||||
.send({
|
||||
email: testUser.email,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
});
|
||||
|
||||
it('should reject POST request with invalid CSRF token', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/resend-verification')
|
||||
.set('X-CSRF-Token', 'invalid-token-12345')
|
||||
.send({
|
||||
email: testUser.email,
|
||||
})
|
||||
.expect(403);
|
||||
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
expect(response.body).toHaveProperty('error', 'Invalid CSRF token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT requests with CSRF protection', () => {
|
||||
it('should reject PUT request without CSRF token', async () => {
|
||||
const response = await request(app)
|
||||
.put('/api/users/me')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
city: 'Test City',
|
||||
})
|
||||
.expect(403);
|
||||
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
expect(response.body).toHaveProperty('error', 'Invalid CSRF token');
|
||||
});
|
||||
|
||||
it('should accept PUT request with valid CSRF token', async () => {
|
||||
const agent = request.agent(app);
|
||||
|
||||
// Get CSRF token
|
||||
const csrfResponse = await agent.get('/api/csrf-token');
|
||||
const token = csrfResponse.body.csrfToken;
|
||||
|
||||
// Make PUT request with CSRF token
|
||||
const response = await agent
|
||||
.put('/api/users/me')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.set('X-CSRF-Token', token)
|
||||
.send({
|
||||
city: 'Test City',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE requests with CSRF protection', () => {
|
||||
let eventSlug;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create a test event
|
||||
const event = await prisma.event.create({
|
||||
data: {
|
||||
name: 'CSRF Test Event',
|
||||
location: 'Test Location',
|
||||
startDate: new Date('2025-12-01'),
|
||||
endDate: new Date('2025-12-03'),
|
||||
},
|
||||
});
|
||||
eventSlug = event.slug;
|
||||
|
||||
// Join the event
|
||||
await prisma.eventParticipant.create({
|
||||
data: {
|
||||
userId: (await prisma.user.findUnique({ where: { email: testUser.email } })).id,
|
||||
eventId: event.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject DELETE request without CSRF token', async () => {
|
||||
const response = await request(app)
|
||||
.delete(`/api/events/${eventSlug}/leave`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(403);
|
||||
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
expect(response.body).toHaveProperty('error', 'Invalid CSRF token');
|
||||
});
|
||||
|
||||
it('should accept DELETE request with valid CSRF token', async () => {
|
||||
const agent = request.agent(app);
|
||||
|
||||
// Get CSRF token
|
||||
const csrfResponse = await agent.get('/api/csrf-token');
|
||||
const token = csrfResponse.body.csrfToken;
|
||||
|
||||
// Make DELETE request with CSRF token
|
||||
const response = await agent
|
||||
.delete(`/api/events/${eventSlug}/leave`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.set('X-CSRF-Token', token)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET requests (should not require CSRF token)', () => {
|
||||
it('should allow GET requests without CSRF token', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/health')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('status', 'ok');
|
||||
});
|
||||
|
||||
it('should allow authenticated GET requests without CSRF token', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/users/me')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSRF token persistence across requests', () => {
|
||||
it('should maintain CSRF token across multiple requests with same agent', async () => {
|
||||
const agent = request.agent(app);
|
||||
|
||||
// Get CSRF token
|
||||
const csrfResponse = await agent.get('/api/csrf-token');
|
||||
const token = csrfResponse.body.csrfToken;
|
||||
|
||||
// Make multiple requests with same token
|
||||
const response1 = await agent
|
||||
.post('/api/auth/resend-verification')
|
||||
.set('X-CSRF-Token', token)
|
||||
.send({ email: testUser.email })
|
||||
.expect(200);
|
||||
|
||||
const response2 = await agent
|
||||
.post('/api/auth/resend-verification')
|
||||
.set('X-CSRF-Token', token)
|
||||
.send({ email: testUser.email })
|
||||
.expect(200);
|
||||
|
||||
expect(response1.body).toHaveProperty('success', true);
|
||||
expect(response2.body).toHaveProperty('success', true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const csrf = require('csurf');
|
||||
const securityConfig = require('./config/security');
|
||||
const { apiLimiter } = require('./middleware/rateLimiter');
|
||||
|
||||
@@ -53,6 +55,40 @@ app.use(cors({
|
||||
app.use(express.json({ limit: securityConfig.bodyLimit }));
|
||||
app.use(express.urlencoded({ extended: true, limit: securityConfig.bodyLimit }));
|
||||
|
||||
// Cookie parser (required for CSRF protection)
|
||||
app.use(cookieParser());
|
||||
|
||||
// CSRF Protection (Phase 3 - Security Hardening)
|
||||
if (securityConfig.csrf.enabled) {
|
||||
const csrfProtection = csrf({
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
|
||||
sameSite: 'strict',
|
||||
}
|
||||
});
|
||||
|
||||
// Apply CSRF protection to all routes
|
||||
app.use(csrfProtection);
|
||||
|
||||
// CSRF token endpoint - provides token to clients
|
||||
app.get('/api/csrf-token', (req, res) => {
|
||||
res.json({ csrfToken: req.csrfToken() });
|
||||
});
|
||||
|
||||
// CSRF error handler
|
||||
app.use((err, req, res, next) => {
|
||||
if (err.code === 'EBADCSRFTOKEN') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Invalid CSRF token',
|
||||
message: 'Form submission failed. Please refresh the page and try again.',
|
||||
});
|
||||
}
|
||||
next(err);
|
||||
});
|
||||
}
|
||||
|
||||
// Request logging middleware
|
||||
app.use((req, res, next) => {
|
||||
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
} = require('../utils/auth');
|
||||
const { sendVerificationEmail, sendWelcomeEmail, sendPasswordResetEmail } = require('../utils/email');
|
||||
const { sanitizeForEmail, timingSafeEqual } = require('../utils/sanitize');
|
||||
const securityConfig = require('../config/security');
|
||||
|
||||
// Register new user (Phase 1.5 - with WSDC support and email verification)
|
||||
async function register(req, res, next) {
|
||||
@@ -104,7 +105,7 @@ async function register(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
// Login user
|
||||
// Login user (with account lockout protection - Phase 3)
|
||||
async function login(req, res, next) {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
@@ -121,16 +122,77 @@ async function login(req, res, next) {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if account is locked (Phase 3 - Account Lockout)
|
||||
if (securityConfig.accountLockout.enabled && user.lockedUntil && user.lockedUntil > new Date()) {
|
||||
const remainingMinutes = Math.ceil((user.lockedUntil - new Date()) / 60000);
|
||||
return res.status(423).json({
|
||||
success: false,
|
||||
error: 'Account is temporarily locked due to too many failed login attempts',
|
||||
message: `Account is locked. Please try again in ${remainingMinutes} minute(s).`,
|
||||
lockedUntil: user.lockedUntil,
|
||||
});
|
||||
}
|
||||
|
||||
// Compare password
|
||||
const isPasswordValid = await comparePassword(password, user.passwordHash);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
// Increment failed login attempts (Phase 3 - Account Lockout)
|
||||
if (securityConfig.accountLockout.enabled) {
|
||||
const newFailedAttempts = user.failedLoginAttempts + 1;
|
||||
const updateData = {
|
||||
failedLoginAttempts: newFailedAttempts,
|
||||
};
|
||||
|
||||
// Lock account if max attempts reached
|
||||
if (newFailedAttempts >= securityConfig.accountLockout.maxAttempts) {
|
||||
const lockoutDuration = securityConfig.accountLockout.lockoutDuration * 60 * 1000; // minutes to ms
|
||||
updateData.lockedUntil = new Date(Date.now() + lockoutDuration);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return res.status(423).json({
|
||||
success: false,
|
||||
error: 'Account has been locked due to too many failed login attempts',
|
||||
message: `Too many failed login attempts. Account is locked for ${securityConfig.accountLockout.lockoutDuration} minutes.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Update failed attempts count
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
const attemptsLeft = securityConfig.accountLockout.maxAttempts - newFailedAttempts;
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid credentials',
|
||||
attemptsLeft: attemptsLeft > 0 ? attemptsLeft : 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Account lockout disabled - return generic error
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid credentials',
|
||||
});
|
||||
}
|
||||
|
||||
// Successful login - reset failed attempts and unlock account (Phase 3)
|
||||
if (securityConfig.accountLockout.enabled && (user.failedLoginAttempts > 0 || user.lockedUntil)) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Generate token
|
||||
const token = generateToken({ userId: user.id });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user