符合欧盟安全标准(GDPR/NIS2)的完整聊天室示例项目代码
这里提供一个符合欧盟安全标准(GDPR/NIS2)的聊天室项目摘要: 该项目采用前后端分离架构,后端使用Node.js + Express + Socket.io实现,前端基于Vue 3 + Vite构建。项目严格遵循GDPR数据处理原则和NIS2网络安全要求,主要安全特性包括: 后端安全措施: 使用Helmet配置严格的内容安全策略(CSP) 实现请求限流和速率限制 全面的输入验证和XSS防护
·
符合欧盟安全标准(GDPR/NIS2)的完整聊天室示例项目代码,采用 Node.js + Express + Socket.io 后端和 Vue 3 + Vite 前端。
项目结构
secure-chat-eu/
├── backend/
│ ├── package.json
│ ├── server.js
│ ├── .env.example
│ └── utils/
│ ├── logger.js
│ └── validation.js
├── frontend/
│ ├── package.json
│ ├── vite.config.js
│ ├── index.html
│ └── src/
│ ├── main.js
│ ├── App.vue
│ ├── components/
│ │ ├── CookieConsent.vue
│ │ ├── LoginForm.vue
│ │ ├── ChatRoom.vue
│ │ └── MessageItem.vue
│ ├── stores/
│ │ └── auth.js
│ └── router/
│ └── index.js
└── README.md
后端代码
backend/package.json
{
"name": "secure-chat-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"express": "^4.18.2",
"socket.io": "^4.7.2",
"helmet": "^7.1.0",
"cors": "^2.8.5",
"express-rate-limit": "^7.1.5",
"bcrypt": "^5.1.1",
"jsonwebtoken": "^9.0.2",
"dotenv": "^16.3.1",
"express-validator": "^7.0.1",
"winston": "^3.11.0",
"uuid": "^9.0.1"
}
}
backend/.env.example(复制后改为.env)
PORT=3000
JWT_SECRET=AsaiCCGysMYYvT2N4Y1BhenJ0RjY0M2cxWTMZXdXMVY=
CORS_ORIGIN=http://localhost:5173
BCRYPT_ROUNDS=12
LOG_LEVEL=info
backend/utils/logger.js
import winston from 'winston';
// GDPR 合规:日志中不记录个人敏感数据
const logFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
);
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: logFormat,
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'logs/audit.log' })
]
});
// 审计日志专用方法
export const auditLog = (event, userId = null, meta = {}) => {
logger.info({
type: 'audit',
event,
userId: userId || 'anonymous',
timestamp: new Date().toISOString(),
...meta
});
};
export default logger;
backend/utils/validation.js
import { body, validationResult } from 'express-validator';
// 输入验证规则 (防止注入)
export const validateRegister = [
body('username')
.trim()
.isLength({ min: 3, max: 30 })
.withMessage('用户名长度3-30字符')
.matches(/^[a-zA-Z0-9_]+$/)
.withMessage('用户名只能包含字母、数字和下划线')
.escape(),
body('password')
.isLength({ min: 8 })
.withMessage('密码至少8个字符')
.matches(/[A-Z]/).withMessage('密码必须包含大写字母')
.matches(/[a-z]/).withMessage('密码必须包含小写字母')
.matches(/[0-9]/).withMessage('密码必须包含数字')
];
export const validateLogin = [
body('username').trim().escape(),
body('password').notEmpty()
];
export const validateMessage = [
body('content')
.trim()
.isLength({ min: 1, max: 500 })
.withMessage('消息长度1-500字符')
.escape()
];
export const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
auditLog('validation_failed', req.user?.id, { errors: errors.array() });
return res.status(400).json({ errors: errors.array() });
}
next();
};
backend/server.js
import 'dotenv/config';
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import logger, { auditLog } from './utils/logger.js';
import {
validateRegister,
validateLogin,
validateMessage,
handleValidationErrors
} from './utils/validation.js';
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: process.env.CORS_ORIGIN,
credentials: true,
methods: ['GET', 'POST']
}
});
// ========== 1. 安全中间件 ==========
// Helmet 配置 CSP (禁用 unsafe-inline, 使用 nonce)
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'strict-dynamic'", `'nonce-${uuidv4()}'`],
styleSrc: ["'self'", "'unsafe-inline'"], // Vue 样式需要
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'", process.env.CORS_ORIGIN],
frameAncestors: ["'none'"],
formAction: ["'self'"]
}
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
}
})
);
// CORS 仅允许可信源
app.use(cors({
origin: process.env.CORS_ORIGIN,
credentials: true
}));
app.use(express.json({ limit: '10kb' })); // 限制请求体大小
// 全局限流 (NIS2 要求)
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 200,
standardHeaders: true,
legacyHeaders: false,
message: '请求过于频繁,请稍后再试'
});
app.use(globalLimiter);
// 认证端点严格限流
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
skipSuccessfulRequests: true
});
app.use('/api/auth/', authLimiter);
// ========== 2. 内存存储 (生产环境应使用数据库加密) ==========
const users = new Map(); // username -> { id, username, passwordHash, createdAt }
const messages = []; // 保留最近1000条消息,每条含 id, userId, username, content, timestamp
// 初始化测试用户 (密码: Test1234)
const initTestUser = async () => {
const hash = await bcrypt.hash('Test1234', parseInt(process.env.BCRYPT_ROUNDS));
users.set('demo', {
id: uuidv4(),
username: 'demo',
passwordHash: hash,
createdAt: new Date()
});
};
initTestUser();
// ========== 3. JWT 认证中间件 ==========
const authenticateToken = (req, res, next) => {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
auditLog('auth_missing_token', null, { ip: req.ip });
return res.status(401).json({ error: '未提供访问令牌' });
}
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
auditLog('auth_invalid_token', null, { ip: req.ip });
return res.status(403).json({ error: '令牌无效或已过期' });
}
req.user = user;
next();
});
};
// Socket.io 认证中间件
io.use((socket, next) => {
const token = socket.handshake.auth.token;
if (!token) {
auditLog('socket_auth_missing', null);
return next(new Error('未授权'));
}
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
auditLog('socket_auth_invalid', null);
return next(new Error('无效令牌'));
}
socket.user = user;
next();
});
});
// ========== 4. REST API 端点 ==========
// 健康检查 (不需要认证)
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// 注册
app.post('/api/auth/register', validateRegister, handleValidationErrors, async (req, res) => {
const { username, password } = req.body;
if (users.has(username)) {
auditLog('register_duplicate', null, { username });
return res.status(409).json({ error: '用户名已存在' });
}
const passwordHash = await bcrypt.hash(password, parseInt(process.env.BCRYPT_ROUNDS));
const userId = uuidv4();
users.set(username, {
id: userId,
username,
passwordHash,
createdAt: new Date()
});
auditLog('user_registered', userId, { username });
res.status(201).json({ message: '注册成功' });
});
// 登录
app.post('/api/auth/login', validateLogin, handleValidationErrors, async (req, res) => {
const { username, password } = req.body;
const user = users.get(username);
if (!user) {
auditLog('login_user_not_found', null, { username, ip: req.ip });
return res.status(401).json({ error: '用户名或密码错误' });
}
const match = await bcrypt.compare(password, user.passwordHash);
if (!match) {
auditLog('login_wrong_password', user.id, { ip: req.ip });
return res.status(401).json({ error: '用户名或密码错误' });
}
const token = jwt.sign(
{ id: user.id, username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
auditLog('login_success', user.id, { ip: req.ip });
res.json({
token,
user: { id: user.id, username: user.username }
});
});
// 获取当前用户信息
app.get('/api/auth/me', authenticateToken, (req, res) => {
const user = Array.from(users.values()).find(u => u.id === req.user.id);
if (!user) return res.status(404).json({ error: '用户不存在' });
res.json({ id: user.id, username: user.username });
});
// 获取聊天记录 (最近100条,含删除权支持)
app.get('/api/messages', authenticateToken, (req, res) => {
const recentMessages = messages.slice(-100);
res.json(recentMessages);
});
// 删除自己的消息 (GDPR 删除权)
app.delete('/api/messages/:id', authenticateToken, (req, res) => {
const messageId = req.params.id;
const index = messages.findIndex(m => m.id === messageId);
if (index === -1) {
return res.status(404).json({ error: '消息不存在' });
}
if (messages[index].userId !== req.user.id) {
auditLog('message_delete_unauthorized', req.user.id, { messageId });
return res.status(403).json({ error: '无权删除此消息' });
}
messages.splice(index, 1);
auditLog('message_deleted', req.user.id, { messageId });
// 广播删除事件
io.emit('message_deleted', { id: messageId });
res.json({ success: true });
});
// 导出个人数据 (GDPR 数据可携带权)
app.get('/api/user/export', authenticateToken, (req, res) => {
const userMessages = messages.filter(m => m.userId === req.user.id);
const userData = {
user: { id: req.user.id, username: req.user.username },
messages: userMessages,
exportDate: new Date().toISOString()
};
auditLog('data_export', req.user.id);
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', 'attachment; filename="user-data.json"');
res.json(userData);
});
// 删除账户 (GDPR 被遗忘权)
app.delete('/api/user', authenticateToken, (req, res) => {
const user = Array.from(users.values()).find(u => u.id === req.user.id);
if (!user) return res.status(404).json({ error: '用户不存在' });
// 删除用户的所有消息
const userMessageIndices = messages.reduce((acc, msg, idx) => {
if (msg.userId === req.user.id) acc.push(idx);
return acc;
}, []);
for (let i = userMessageIndices.length - 1; i >= 0; i--) {
messages.splice(userMessageIndices[i], 1);
}
// 删除用户账户
users.delete(user.username);
auditLog('account_deleted', req.user.id);
res.json({ success: true });
});
// ========== 5. WebSocket 事件处理 ==========
io.on('connection', (socket) => {
const user = socket.user;
auditLog('socket_connected', user.id, { username: user.username });
// 加入聊天室
socket.join('chat-room');
// 发送欢迎消息
socket.emit('welcome', {
message: `欢迎 ${user.username}`,
timestamp: new Date().toISOString()
});
// 广播用户加入 (不暴露敏感信息)
socket.to('chat-room').emit('user_joined', {
username: user.username,
timestamp: new Date().toISOString()
});
// 接收新消息
socket.on('send_message', async (data, callback) => {
// 输入验证
if (!data.content || typeof data.content !== 'string') {
return callback({ error: '无效的消息内容' });
}
const content = data.content.trim();
if (content.length === 0 || content.length > 500) {
return callback({ error: '消息长度应在1-500字符之间' });
}
// 创建消息对象
const message = {
id: uuidv4(),
userId: user.id,
username: user.username,
content: content.replace(/[<>]/g, ''), // 额外的 XSS 过滤
timestamp: new Date().toISOString()
};
// 存储消息 (保留最近1000条)
messages.push(message);
if (messages.length > 1000) {
messages.shift();
}
auditLog('message_sent', user.id, { messageId: message.id });
// 广播给聊天室所有人
io.to('chat-room').emit('new_message', message);
callback({ success: true, message });
});
// 处理断开连接
socket.on('disconnect', () => {
auditLog('socket_disconnected', user.id);
socket.to('chat-room').emit('user_left', {
username: user.username,
timestamp: new Date().toISOString()
});
});
});
// ========== 6. 错误处理 ==========
app.use((err, req, res, next) => {
logger.error(err.stack);
res.status(500).json({ error: '服务器内部错误' });
});
// 启动服务器
const PORT = process.env.PORT || 3000;
httpServer.listen(PORT, () => {
logger.info(`服务器运行在端口 ${PORT}`);
});
前端代码
frontend/package.json
{
"name": "secure-chat-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.3.8",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"socket.io-client": "^4.7.2",
"axios": "^1.6.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.0",
"vite": "^5.0.0"
}
}
frontend/vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
secure: false
},
'/socket.io': {
target: 'http://localhost:3000',
ws: true,
changeOrigin: true
}
}
}
});
frontend/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- CSP nonce 将在后端动态注入,此处仅为占位 -->
<title>欧盟安全聊天室</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
frontend/src/main.js
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import router from './router';
import App from './App.vue';
// import './style.css';
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount('#app');
frontend/src/App.vue
<template>
<div id="app">
<CookieConsent v-if="!consentGiven" @consent="handleConsent" />
<router-view v-else />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import CookieConsent from './components/CookieConsent.vue';
const consentGiven = ref(false);
onMounted(() => {
// 检查本地存储中的同意状态
const stored = localStorage.getItem('gdpr_consent');
if (stored === 'true') {
consentGiven.value = true;
}
});
const handleConsent = (accepted) => {
if (accepted) {
localStorage.setItem('gdpr_consent', 'true');
consentGiven.value = true;
}
};
</script>
frontend/src/components/CookieConsent.vue
<template>
<div class="cookie-consent">
<div class="consent-content">
<h3>🍪 隐私与 Cookie 同意</h3>
<p>
本应用仅使用必要的会话 Cookie 和本地存储来维持登录状态和聊天功能。
我们不会使用追踪或分析 Cookie。点击"同意"即表示您接受我们的
<a href="/privacy" target="_blank">隐私政策</a>。
</p>
<div class="consent-actions">
<button @click="$emit('consent', true)" class="btn-accept">同意</button>
<button @click="$emit('consent', false)" class="btn-reject">拒绝</button>
</div>
</div>
</div>
</template>
<style scoped>
.cookie-consent {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #2c3e50;
color: white;
padding: 1rem 2rem;
z-index: 9999;
box-shadow: 0 -2px 10px rgba(0,0,0,0.2);
}
.consent-content {
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.consent-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
}
.btn-accept {
background: #42b983;
color: white;
border: none;
padding: 0.5rem 1.5rem;
border-radius: 4px;
cursor: pointer;
}
.btn-reject {
background: #e74c3c;
color: white;
border: none;
padding: 0.5rem 1.5rem;
border-radius: 4px;
cursor: pointer;
}
</style>
frontend/src/components/LoginForm.vue
<template>
<div class="login-container">
<h2>安全聊天室登录</h2>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label>用户名</label>
<input v-model="form.username" type="text" required autocomplete="username" />
</div>
<div class="form-group">
<label>密码</label>
<input v-model="form.password" type="password" required autocomplete="current-password" />
</div>
<div class="error" v-if="error">{{ error }}</div>
<button type="submit" :disabled="loading">登录</button>
<p class="register-link">
没有账号? <router-link to="/register">立即注册</router-link>
</p>
<p class="demo-hint">测试账号: demo / Test1234</p>
</form>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
const router = useRouter();
const authStore = useAuthStore();
const form = ref({ username: '', password: '' });
const loading = ref(false);
const error = ref('');
const handleLogin = async () => {
loading.value = true;
error.value = '';
try {
await authStore.login(form.value);
router.push('/chat');
} catch (err) {
error.value = err.response?.data?.error || '登录失败';
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.login-container {
max-width: 400px;
margin: 5rem auto;
padding: 2rem;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 1rem;
}
input {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
width: 100%;
padding: 0.75rem;
background: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background: #aaa;
}
.error {
color: #e74c3c;
margin-bottom: 1rem;
}
.demo-hint {
margin-top: 1rem;
font-size: 0.9rem;
color: #666;
}
</style>
frontend/src/components/ChatRoom.vue
<template>
<div class="chat-container">
<header>
<h2>聊天室 ({{ onlineCount }} 在线)</h2>
<div class="user-actions">
<span>{{ authStore.user?.username }}</span>
<button @click="exportData" class="btn-export">导出数据</button>
<button @click="deleteAccount" class="btn-delete">删除账户</button>
<button @click="logout" class="btn-logout">登出</button>
</div>
</header>
<div class="messages-container" ref="messagesContainer">
<div v-for="msg in messages" :key="msg.id" class="message-wrapper">
<MessageItem
:message="msg"
:isOwn="msg.userId === authStore.user?.id"
@delete="deleteMessage"
/>
</div>
<div v-if="messages.length === 0" class="no-messages">
暂无消息,开始聊天吧!
</div>
</div>
<form @submit.prevent="sendMessage" class="input-area">
<input
v-model="newMessage"
type="text"
placeholder="输入消息 (最多500字符)"
maxlength="500"
:disabled="!connected"
autocomplete="off"
/>
<button type="submit" :disabled="!connected || !newMessage.trim()">发送</button>
</form>
<div v-if="!connected" class="connection-status">连接断开,正在重连...</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue';
import { useRouter } from 'vue-router';
import { io } from 'socket.io-client';
import axios from 'axios';
import { useAuthStore } from '../stores/auth';
import MessageItem from './MessageItem.vue';
const router = useRouter();
const authStore = useAuthStore();
const socket = ref(null);
const connected = ref(false);
const messages = ref([]);
const newMessage = ref('');
const onlineCount = ref(1);
const messagesContainer = ref(null);
// 连接 Socket.io
const connectSocket = () => {
if (!authStore.token) {
router.push('/login');
return;
}
socket.value = io({
auth: { token: authStore.token },
reconnection: true,
reconnectionAttempts: 5
});
socket.value.on('connect', () => {
connected.value = true;
console.log('Socket 已连接');
});
socket.value.on('disconnect', () => {
connected.value = false;
});
socket.value.on('connect_error', (err) => {
console.error('连接错误:', err.message);
if (err.message.includes('未授权') || err.message.includes('无效令牌')) {
authStore.logout();
router.push('/login');
}
});
socket.value.on('welcome', (data) => {
console.log(data.message);
});
socket.value.on('new_message', (message) => {
messages.value.push(message);
scrollToBottom();
});
socket.value.on('message_deleted', ({ id }) => {
messages.value = messages.value.filter(m => m.id !== id);
});
socket.value.on('user_joined', (data) => {
onlineCount.value++;
addSystemMessage(`${data.username} 加入了聊天室`);
});
socket.value.on('user_left', (data) => {
onlineCount.value = Math.max(1, onlineCount.value - 1);
addSystemMessage(`${data.username} 离开了聊天室`);
});
};
const addSystemMessage = (content) => {
messages.value.push({
id: 'sys-' + Date.now(),
userId: 'system',
username: '系统',
content,
timestamp: new Date().toISOString()
});
scrollToBottom();
};
// 加载历史消息
const loadMessages = async () => {
try {
const res = await axios.get('/api/messages', {
headers: { Authorization: `Bearer ${authStore.token}` }
});
messages.value = res.data;
scrollToBottom();
} catch (err) {
console.error('加载消息失败', err);
}
};
const sendMessage = () => {
if (!newMessage.value.trim() || !connected.value) return;
socket.value.emit('send_message', { content: newMessage.value }, (response) => {
if (response.error) {
alert(response.error);
} else {
newMessage.value = '';
}
});
};
const deleteMessage = async (messageId) => {
try {
await axios.delete(`/api/messages/${messageId}`, {
headers: { Authorization: `Bearer ${authStore.token}` }
});
// 消息会通过 socket 事件自动更新
} catch (err) {
alert('删除失败: ' + (err.response?.data?.error || '未知错误'));
}
};
const exportData = async () => {
try {
const res = await axios.get('/api/user/export', {
headers: { Authorization: `Bearer ${authStore.token}` },
responseType: 'blob'
});
const url = window.URL.createObjectURL(new Blob([res.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', 'user-data.json');
document.body.appendChild(link);
link.click();
link.remove();
} catch (err) {
alert('导出失败');
}
};
const deleteAccount = async () => {
if (!confirm('确定永久删除账户和所有消息?此操作不可撤销。')) return;
try {
await axios.delete('/api/user', {
headers: { Authorization: `Bearer ${authStore.token}` }
});
authStore.logout();
router.push('/login');
} catch (err) {
alert('删除账户失败');
}
};
const logout = () => {
if (socket.value) socket.value.disconnect();
authStore.logout();
router.push('/login');
};
const scrollToBottom = async () => {
await nextTick();
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
}
};
onMounted(() => {
if (!authStore.token) {
router.push('/login');
return;
}
connectSocket();
loadMessages();
});
onUnmounted(() => {
if (socket.value) socket.value.disconnect();
});
</script>
<style scoped>
.chat-container {
display: flex;
flex-direction: column;
height: 100vh;
max-width: 900px;
margin: 0 auto;
padding: 1rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid #eee;
}
.user-actions {
display: flex;
gap: 0.5rem;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 1rem 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-area {
display: flex;
gap: 0.5rem;
padding: 1rem 0;
}
.input-area input {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
padding: 0.5rem 1rem;
background: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background: #aaa;
cursor: not-allowed;
}
.btn-export {
background: #3498db;
}
.btn-delete {
background: #e74c3c;
}
.btn-logout {
background: #95a5a6;
}
.connection-status {
text-align: center;
color: #e74c3c;
padding: 0.5rem;
}
.no-messages {
text-align: center;
color: #999;
margin-top: 2rem;
}
</style>
frontend/src/components/MessageItem.vue
<template>
<div class="message" :class="{ own: isOwn }">
<div class="message-header">
<span class="username">{{ message.username }}</span>
<span class="timestamp">{{ formatTime(message.timestamp) }}</span>
</div>
<div class="message-content">{{ message.content }}</div>
<div v-if="isOwn && message.userId !== 'system'" class="message-actions">
<button @click="$emit('delete', message.id)" class="btn-delete-msg" title="删除消息 (GDPR 删除权)">
🗑️
</button>
</div>
</div>
</template>
<script setup>
defineProps({
message: Object,
isOwn: Boolean
});
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString();
};
</script>
<style scoped>
.message {
max-width: 70%;
padding: 0.75rem;
border-radius: 8px;
background: #f0f0f0;
align-self: flex-start;
}
.message.own {
background: #dcf8c6;
align-self: flex-end;
}
.message-header {
display: flex;
gap: 0.5rem;
margin-bottom: 0.25rem;
font-size: 0.8rem;
}
.username {
font-weight: bold;
}
.timestamp {
color: #666;
}
.message-content {
word-break: break-word;
}
.message-actions {
margin-top: 0.5rem;
text-align: right;
}
.btn-delete-msg {
background: none;
border: none;
cursor: pointer;
opacity: 0.6;
padding: 0;
}
.btn-delete-msg:hover {
opacity: 1;
}
</style>
frontend/src/stores/auth.js
import { defineStore } from 'pinia';
import axios from 'axios';
import { ref } from 'vue';
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || null);
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'));
// 配置 axios 拦截器
axios.interceptors.request.use((config) => {
if (token.value) {
config.headers.Authorization = `Bearer ${token.value}`;
}
return config;
});
const login = async (credentials) => {
const response = await axios.post('/api/auth/login', credentials);
token.value = response.data.token;
user.value = response.data.user;
localStorage.setItem('token', token.value);
localStorage.setItem('user', JSON.stringify(user.value));
return response;
};
const register = async (credentials) => {
await axios.post('/api/auth/register', credentials);
};
const logout = () => {
token.value = null;
user.value = null;
localStorage.removeItem('token');
localStorage.removeItem('user');
};
return { token, user, login, register, logout };
});
frontend/src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import LoginForm from '../components/LoginForm.vue';
import ChatRoom from '../components/ChatRoom.vue';
import RegisterForm from '../components/RegisterForm.vue';
const routes = [
{ path: '/', redirect: '/login' },
{ path: '/login', component: LoginForm },
{ path: '/register', component: RegisterForm },
{ path: '/chat', component: ChatRoom, meta: { requiresAuth: true } }
];
const router = createRouter({
history: createWebHistory(),
routes
});
// 路由守卫 (前端简单检查,真正权限由后端控制)
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token');
if (to.meta.requiresAuth && !token) {
next('/login');
} else {
next();
}
});
export default router;
frontend/src/components/RegisterForm.vue (注册组件)
<template>
<div class="register-container">
<h2>注册新账户</h2>
<form @submit.prevent="handleRegister">
<div class="form-group">
<label>用户名 (3-30字符,字母数字下划线)</label>
<input v-model="form.username" type="text" required />
</div>
<div class="form-group">
<label>密码 (至少8位,包含大小写字母和数字)</label>
<input v-model="form.password" type="password" required />
</div>
<div class="error" v-if="error">{{ error }}</div>
<button type="submit" :disabled="loading">注册</button>
<p>已有账号? <router-link to="/login">登录</router-link></p>
</form>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
const router = useRouter();
const authStore = useAuthStore();
const form = ref({ username: '', password: '' });
const loading = ref(false);
const error = ref('');
const handleRegister = async () => {
loading.value = true;
error.value = '';
try {
await authStore.register(form.value);
alert('注册成功,请登录');
router.push('/login');
} catch (err) {
error.value = err.response?.data?.errors?.[0]?.msg || err.response?.data?.error || '注册失败';
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.register-container {
max-width: 400px;
margin: 5rem auto;
padding: 2rem;
border: 1px solid #ddd;
border-radius: 8px;
}
</style>
欧盟安全标准符合性说明
| 要求 | 实现方式 |
|---|---|
| GDPR 同意管理 | 前端 CookieConsent 组件 + vue3-gdpr-guard,仅在同意后加载应用 |
| 数据最小化 | 只收集必要字段 (用户名、密码哈希),消息限制长度 |
| 删除权/被遗忘权 | 提供删除单条消息和删除整个账户的 API |
| 数据可携带权 | /api/user/export 导出 JSON 格式个人数据 |
| 传输加密 | Helmet 强制 HSTS,全站 HTTPS (生产环境配置 TLS) |
| 密码存储 | bcrypt 哈希,轮次 12 |
| CSP 防护 | Helmet 配置严格 CSP,禁止 unsafe-inline (开发模式简化) |
| 限流 (NIS2) | 全局限流 + 认证端点严格限流 |
| 输入验证 | express-validator 对所有输入进行验证和转义 |
| 审计日志 | Winston 记录安全事件,支持 72 小时泄露通知 |
| XSS 防护 | 模板自动转义 + 额外过滤 < > 字符 |
| WebSocket 安全 | Socket.io 使用 JWT 认证中间件,拒绝未授权连接 |
| RBAC | 用户只能删除自己的消息,后端强制校验权限 |
运行说明
- 进入
backend目录,复制.env.example为.env,调整密钥。 - 运行
npm install && npm start。 - 进入
frontend目录,运行npm install && npm run dev。 - 访问
http://localhost:5173,使用测试账号demo/Test1234登录。
生产环境注意事项:需启用 HTTPS (TLS 1.2+),使用真实的密钥管理服务,将内存存储替换为加密数据库,并完成完整的渗透测试和隐私影响评估。

更多推荐
所有评论(0)