Xây dựng hệ thống đăng nhập an toàn cho Node.js: Hướng dẫn chi tiết và các chiến lược bảo mật hàng đầu
Bài viết này giới thiệu các chiến lược đăng nhập an toàn cho Node.js, triển khai nhiều kỹ thuật bảo mật để bảo vệ thông tin đăng nhập, phiên làm việc và quyền truy cập tài khoản của người dùng. Cùng tìm hiểu các tính năng bảo mật quan trọng và cách triển khai chúng hiệu quả.
Các tính năng bảo mật chính
1. Băm mật khẩu với Salt
Mật khẩu được băm bằng thuật toán băm mạnh (ví dụ: bcrypt) cùng với một salt ngẫu nhiên cho mỗi mật khẩu. Điều này đảm bảo rằng ngay cả khi mật khẩu băm bị xâm phạm, nó cũng không thể dễ dàng bị đảo ngược thành mật khẩu gốc.
Tại sao điều này lại quan trọng:
- Ngăn chặn việc lưu trữ mật khẩu dưới dạng văn bản thuần.
- Việc sử dụng salt khiến kẻ tấn công khó sử dụng các bảng băm được tính toán trước (rainbow tables) để đoán mật khẩu.
VD:
import bcrypt from 'bcrypt';
const hashPassword = async (password: string): Promise<string> => {
const saltRounds = 10;
const salt = await bcrypt.genSalt(saltRounds);
return await bcrypt.hash(password, salt);
};
// Create and save the user in the database
const newUser = new User({ username, password: hashPassword });
await newUser.save();
// Login API
const { username, password } = req.body;
const user = users.find((u) => u.username === username);
if (!user) return res.status(400).send('User not found');
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) return res.status(400).send('Incorrect password');
2. Xác thực dựa trên Token
JWT (JSON Web Token) được sử dụng để xác thực dựa trên token, cho phép xác thực không trạng thái giữa máy khách và máy chủ. Người dùng nhận được JWT khi đăng nhập, sau đó được gửi kèm với mọi yêu cầu tiếp theo trong tiêu đề Authorization.
Tại sao điều này lại quan trọng:
- Không trạng thái: Không cần lưu trữ phiên trên máy chủ.
- Token bảo mật có thể mang các claims (ID người dùng, vai trò) mà không tiết lộ thông tin nhạy cảm.
VD:
import jwt from 'jsonwebtoken';
const generateToken = (userId: string) => {
return jwt.sign({ userId }, process.env.JWT_SECRET, { expiresIn: '1h' });
};
3. Refresh Token
Refresh token được sử dụng để lấy access token mới mà không yêu cầu người dùng phải đăng nhập lại. Điều này đảm bảo trải nghiệm mượt mà cho người dùng mà không ảnh hưởng đến bảo mật.
Tại sao điều này lại quan trọng:
- Giảm thiểu rủi ro bằng cách sử dụng access token tồn tại trong thời gian ngắn.
- Refresh token được lưu trữ an toàn hơn (ví dụ: trong cơ sở dữ liệu hoặc HTTP-only cookie).
VD:
const generateRefreshToken = (userId: string) => {
return jwt.sign({ userId }, process.env.REFRESH_SECRET, { expiresIn: '7d' });
};
4. HTTP-Only Cookies
Lưu trữ các token nhạy cảm như refresh token trong HTTP-only cookies. Điều này ngăn JavaScript truy cập các token, bảo vệ chúng khỏi các cuộc tấn công XSS (Cross-Site Scripting).
Tại sao điều này lại quan trọng:
- Bảo vệ token khỏi bị đánh cắp bởi các đoạn mã độc hại.
- Giảm nguy cơ lộ token thông qua mã phía máy khách.
VD:
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
});
5. Danh sách đen JWT (JWT Blacklisting)
Các token JWT không có cơ chế thu hồi tích hợp. Để vô hiệu hóa token (ví dụ: khi đăng xuất), JWT blacklisting được sử dụng. Cơ sở dữ liệu hoặc Redis được sử dụng để lưu trữ các token không hợp lệ cho đến khi chúng hết hạn.
Tại sao điều này lại quan trọng:
- Ngăn chặn các token đã được cấp trước đó bị sử dụng sau khi đăng xuất hoặc tài khoản bị xâm phạm.
VD:
const blacklistToken = async (token: string) => {
await redisClient.set(token, 'blacklisted', 'EX', tokenExpirationTime);
};
6. Chỉ một phiên hoạt động tại một thời điểm
Đảm bảo rằng chỉ có một phiên hoạt động cho một người dùng tại bất kỳ thời điểm nào. Khi một phiên mới được tạo, hãy vô hiệu hóa các token hoặc phiên trước đó.
Tại sao điều này lại quan trọng:
- Ngăn chặn việc đăng nhập đồng thời từ nhiều thiết bị hoặc vị trí, tăng cường bảo mật tài khoản.
VD:
// Schema for User table:
sessionId: { type: String, unique: true }
//login API
const newSessionId = generateAccessToken(user);
user.sessionId = newSessionId;
await user.save();
// Auth API
const decodedToken: any = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET!); // Replace with your JWT secret key
const sessionIdFromToken = decodedToken.sessionId;
if (user.sessionId !== sessionIdFromToken) {
return res.status(403).json({ message: 'Session invalidated. You are logged in elsewhere.' });
}
7. Thời gian hết hạn phiên lý tưởng
Phiên hết hạn sau một khoảng thời gian không hoạt động. Triển khai bộ hẹn giờ hết hạn phiên để tự động đăng xuất người dùng không hoạt động trong một khoảng thời gian xác định trước.
Tại sao điều này lại quan trọng:
- Giảm nguy cơ người không được phép truy cập vào phiên nếu người dùng quên đăng xuất.
VD:
const sessionExpirationTime = 15 * 60 * 1000; // 15 minutes
const IDLE_TIMEOUT = 15 * 60 * 1000;
// User Schema
lastActivity: { type: Date, default: Date.now }, // Track last activity timestamp
// in auth middleware
const now = new Date();
const idleTime = now - new Date(user.lastActivity);
if (idleTime > IDLE_TIMEOUT) {
// Token is still valid, but session is idle for too long
return res.status(401).json({ message: 'Session expired due to inactivity' });
}
// Else Update last activity timestamp if within idle timeout
user.lastActivity = now;
await user.save();
8. Cơ chế khóa tài khoản
Ngăn chặn các cuộc tấn công brute force bằng cách khóa tài khoản sau một số lần đăng nhập thất bại. Triển khai khóa dựa trên thời gian, trong đó tài khoản bị khóa tạm thời sau nhiều lần thất bại liên tiếp.
Tại sao điều này lại quan trọng:
- Giảm thiểu các cuộc tấn công brute force bằng cách giới hạn số lần đăng nhập.
VD:
// Schema
failedLoginAttempts: { type: Number, default: 0 },
lockoutUntil: { type: Date, default: null }, // Track lockout time
const MAX_ATTEMPTS = 3;
const LOCK_TIME = 15 * 60 \_ 1000; // 15 minutes
// Example for MongoDB with Mongoose
const handleFailedLogin = async (email: string) => {
const user = await User.findOne({ email });
if (!user) return false;
if (user.lockoutUntil && user.lockoutUntil > new Date()) {
return true; // Account is still locked
}
user.failedLoginAttempts += 1;
if (user.failedLoginAttempts >= MAX_ATTEMPTS) {
user.lockoutUntil = new Date(Date.now() + LOCK_TIME); // Lock for 30 mins
}
await user.save();
return user.lockoutUntil && user.lockoutUntil > new Date(); // Return if account is locked
};
// Reset login attempts if the login is successful
9. Quy trình đặt lại mật khẩu an toàn
Người dùng có thể đặt lại mật khẩu của họ thông qua một quy trình an toàn. Sử dụng token dùng một lần (OTP) có giới hạn thời gian được gửi đến email của người dùng cho quy trình đặt lại. Đảm bảo token đặt lại hết hạn sau một khoảng thời gian ngắn.
Tại sao điều này lại quan trọng:
- Cung cấp cho người dùng một cách an toàn để khôi phục tài khoản mà không ảnh hưởng đến bảo mật.
VD:
// User Schema
resetPasswordToken: String,
resetPasswordExpires: Date,
/* Use crypto.randomBytes() to generate a secure hex instead of Math.random(), Math.random() hex are easier for attackers to guess or brute force. */
const generateResetToken = () => {
return crypto.randomBytes(20).toString('hex');
};
const token = generateResetToken();
user.resetPasswordExpires = Date.now() + 3600000; // 1 hour from now
10. Hết hạn mật khẩu và hạn chế 3 mật khẩu gần nhất
Đây là cách hoạt động:
- Lịch sử mật khẩu:
- Hệ thống lưu trữ ba mật khẩu băm gần nhất trong lịch sử mật khẩu của người dùng.
- Khi người dùng cố gắng đặt mật khẩu mới, hệ thống so sánh mật khẩu mới với các mật khẩu trước đó để ngăn chặn việc sử dụng lại.
- Hết hạn mật khẩu:
- Hệ thống theo dõi thời gian người dùng đã sử dụng mật khẩu hiện tại của họ.
- Sau một khoảng thời gian đặt trước (ví dụ: 1 tháng), hệ thống gửi thông báo nhắc người dùng thay đổi mật khẩu của họ.
- Nếu mật khẩu không được cập nhật trong khoảng thời gian gia hạn nhất định, quyền truy cập có thể bị hạn chế cho đến khi mật khẩu mới được đặt.
VD:
// Schema
passwordHistory: [{ type: String }], // Array to store past 3 password hashes
passwordUpdatedAt: { type: Date, default: Date.now }, // When the password was last updated
// You can Check Expiration on Login or have a corn job to find and notify users via email.
11. Triển khai chính sách mật khẩu mạnh
Chính sách mật khẩu mạnh giúp ngăn chặn truy cập trái phép và giảm thiểu nguy cơ tấn công brute-force, tấn công từ điển và nhồi tin.
VD:
import validator from 'validator';
const password = 'User@1234';
if (
!validator.isStrongPassword(password, {
minLength: 8,
minLowercase: 1,
minUppercase: 1,
minNumbers: 1,
minSymbols: 1,
})
) {
throw new Error('Password does not meet complexity requirements.');
}
12. Thu hồi Token cụ thể theo người dùng
Thu hồi token cụ thể theo người dùng là quá trình vô hiệu hóa token của một người dùng duy nhất mà không ảnh hưởng đến những người khác.
Nó hữu ích trong các trường hợp như:
- Phát hiện hoạt động đáng ngờ trên tài khoản
- Người dùng yêu cầu thu hồi token vì lý do bảo mật
- Tài khoản bị vô hiệu hóa hoặc bị tạm ngưng
- Token bị đánh cắp hoặc bị rò rỉ
Cách thức hoạt động:
-
Cấu trúc Token: Token (ví dụ: JWT) chứa dữ liệu người dùng và tokenVersion. TokenVersion được lưu trữ trong cơ sở dữ liệu để theo dõi tính hợp lệ của token cho mỗi người dùng.
-
Xác minh Token: Khi một yêu cầu được thực hiện, máy chủ giải mã token và so sánh tokenVersion trong token với phiên bản được lưu trữ trong cơ sở dữ liệu. Nếu chúng khớp, token hợp lệ; nếu không, token bị vô hiệu hóa.
-
Thu hồi Token: Để thu hồi tất cả token cho một người dùng, chỉ cần tăng tokenVersion trong cơ sở dữ liệu. Tất cả token có phiên bản trước đó sẽ tự động bị vô hiệu hóa.
VD:
// Schema: token_version INT DEFAULT 1
// JWT Creation
const createToken = (user) => {
const payload = { userId: user.id, tokenVersion: user.tokenVersion };
return jwt.sign(payload, SECRET_KEY, { expiresIn: '1h' });
};
// Token Validation
if (user.tokenVersion !== decoded.tokenVersion) {
throw new Error('Invalid token: version mismatch');
}
// To revoke, increment tokenVersion
user.tokenVersion++;
13. Cấu hình chia sẻ tài nguyên xuất chéo (CORS)
Cấu hình CORS để hạn chế nguồn gốc nào được phép truy cập API. Chỉ các tên miền đáng tin cậy mới được phép gửi yêu cầu đến máy chủ của bạn.
Tại sao điều này lại quan trọng:
- Ngăn chặn các trang web trái phép thực hiện yêu cầu đến máy chủ của bạn.
- Bảo vệ chống lại các cuộc tấn công CSRF (Cross-Site Request Forgery).
VD:
import cors from 'cors';
app.use(
cors({
origin: 'https://your-frontend-domain.com',
credentials: true,
})
);
All Rights Reserved