chore: sync various improvements and fixes
- Update gitignore and serena config - Improve connection and voting stores - Enhance admin routes and socket handling - Update client-screen views - Add auth middleware Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,13 @@ import { participantService } from './services/participant.service';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
if (!config.adminAccessCode) {
|
||||
logger.warn('ADMIN_ACCESS_CODE 未配置,管理员登录将被拒绝');
|
||||
}
|
||||
if (!config.isDev && config.jwtSecret === 'dev-secret-change-in-production') {
|
||||
logger.error('生产环境禁止使用默认 JWT_SECRET,请配置环境变量');
|
||||
process.exit(1);
|
||||
}
|
||||
// Connect to Database
|
||||
logger.info('Connecting to Database...');
|
||||
await connectDatabase();
|
||||
@@ -79,4 +86,3 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
|
||||
64
packages/server/src/middleware/auth.ts
Normal file
64
packages/server/src/middleware/auth.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { extractBearerToken, verifySessionToken } from '../utils/auth';
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: string;
|
||||
userName?: string;
|
||||
role: 'user' | 'admin' | 'screen';
|
||||
token: string;
|
||||
};
|
||||
}
|
||||
|
||||
function getTokenFromRequest(req: Request): string | null {
|
||||
const bearer = extractBearerToken(req.headers.authorization);
|
||||
if (bearer) return bearer;
|
||||
const headerToken = req.headers['x-session-token'];
|
||||
if (typeof headerToken === 'string' && headerToken.trim()) {
|
||||
return headerToken.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function requireAuth(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> {
|
||||
const token = getTokenFromRequest(req);
|
||||
if (!token) {
|
||||
res.status(401).json({ success: false, error: 'UNAUTHORIZED', message: 'Missing session token' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await verifySessionToken(token);
|
||||
if (!user) {
|
||||
res.status(401).json({ success: false, error: 'UNAUTHORIZED', message: 'Invalid session token' });
|
||||
return;
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
}
|
||||
|
||||
export async function requireAdmin(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> {
|
||||
const token = getTokenFromRequest(req);
|
||||
if (!token) {
|
||||
res.status(401).json({ success: false, error: 'UNAUTHORIZED', message: 'Missing session token' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await verifySessionToken(token);
|
||||
if (!user || user.role !== 'admin') {
|
||||
res.status(403).json({ success: false, error: 'FORBIDDEN', message: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
}
|
||||
|
||||
@@ -4,10 +4,50 @@ import { participantService } from '../services/participant.service';
|
||||
import { prizeConfigService } from '../services/prize-config.service';
|
||||
import { programConfigService } from '../services/program-config.service';
|
||||
import { adminService } from '../services/admin.service';
|
||||
import { requireAdmin } from '../middleware/auth';
|
||||
import { createSessionToken } from '../utils/auth';
|
||||
import { config } from '../config';
|
||||
|
||||
const router: IRouter = Router();
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
/**
|
||||
* POST /api/admin/login
|
||||
* Admin login to obtain session token
|
||||
*/
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const { accessCode } = req.body as { accessCode?: string };
|
||||
if (!accessCode) {
|
||||
return res.status(400).json({ success: false, error: '访问码不能为空' });
|
||||
}
|
||||
if (!config.adminAccessCode) {
|
||||
return res.status(500).json({ success: false, error: '服务端未配置访问码' });
|
||||
}
|
||||
if (accessCode !== config.adminAccessCode) {
|
||||
return res.status(401).json({ success: false, error: '访问码错误' });
|
||||
}
|
||||
|
||||
const sessionToken = await createSessionToken(
|
||||
{ userId: 'admin_main', userName: 'Admin Console', role: 'admin' },
|
||||
config.adminTokenTtlSeconds
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
sessionToken,
|
||||
expiresIn: config.adminTokenTtlSeconds,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Admin auth guard (applies to all routes below)
|
||||
router.use(requireAdmin);
|
||||
|
||||
/**
|
||||
* GET /api/admin/stats
|
||||
* Get system statistics
|
||||
@@ -296,4 +336,3 @@ router.post('/cleanup', async (req, res, next) => {
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -97,19 +97,18 @@ router.post('/scanned', async (req, res, next) => {
|
||||
*/
|
||||
router.post('/confirm', async (req, res, next) => {
|
||||
try {
|
||||
const { scanToken, userName, department } = req.body;
|
||||
const { scanToken, userName } = req.body;
|
||||
|
||||
if (!scanToken || !userName || !department) {
|
||||
if (!scanToken || !userName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'scanToken, userName, and department are required',
|
||||
error: 'scanToken and userName are required',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await scanLoginService.confirmLogin(
|
||||
scanToken,
|
||||
userName,
|
||||
department
|
||||
userName
|
||||
);
|
||||
|
||||
if (result.success && result.data) {
|
||||
@@ -121,7 +120,6 @@ router.post('/confirm', async (req, res, next) => {
|
||||
userInfo: {
|
||||
userId: result.data.userId,
|
||||
userName,
|
||||
department,
|
||||
sessionToken: result.data.sessionToken,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { voteService } from '../services/vote.service';
|
||||
import { voteSubmitSchema } from '@gala/shared/utils';
|
||||
import { VOTE_CATEGORIES } from '@gala/shared/types';
|
||||
import type { VoteCategory } from '@gala/shared/types';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -10,10 +11,9 @@ const router: IRouter = Router();
|
||||
* POST /api/vote/submit
|
||||
* Submit a vote (HTTP fallback for WebSocket)
|
||||
*/
|
||||
router.post('/submit', async (req, res, next) => {
|
||||
router.post('/submit', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
// TODO: Get userId from auth middleware
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
const userId = (req as any).user?.userId as string;
|
||||
if (!userId) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
@@ -121,9 +121,9 @@ router.get('/results', async (_req, res, next) => {
|
||||
* GET /api/vote/status
|
||||
* Get user's vote status
|
||||
*/
|
||||
router.get('/status', async (req, res, next) => {
|
||||
router.get('/status', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
const userId = (req as any).user?.userId as string;
|
||||
if (!userId) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
|
||||
@@ -326,7 +326,6 @@ class AdminService extends EventEmitter {
|
||||
async addVoteStamp(
|
||||
programId: string,
|
||||
userName: string,
|
||||
department: string,
|
||||
ticketType: string,
|
||||
options?: { revokedProgramId?: string }
|
||||
): Promise<{ success: boolean; stamp?: VoteStamp; programVotes?: number; totalVotes?: number; revokedProgramId?: string; revokedProgramVotes?: number }> {
|
||||
@@ -353,7 +352,6 @@ class AdminService extends EventEmitter {
|
||||
const stamp: VoteStamp = {
|
||||
id: `stamp_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
|
||||
userName,
|
||||
department,
|
||||
ticketType,
|
||||
x: 10 + Math.random() * 80, // Random X position (10-90%)
|
||||
y: 10 + Math.random() * 80, // Random Y position (10-90%)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { redis } from '../config/redis';
|
||||
import { config } from '../config';
|
||||
import { logger } from '../utils/logger';
|
||||
import type { ScanTokenData, ScanLoginStatus } from '@gala/shared/types';
|
||||
import { createSessionToken } from '../utils/auth';
|
||||
|
||||
const SCAN_TOKEN_PREFIX = 'scan:';
|
||||
const SESSION_TOKEN_PREFIX = 'session:';
|
||||
const SCAN_TOKEN_TTL = 5 * 60; // 5 minutes
|
||||
const SESSION_TOKEN_TTL = 24 * 60 * 60; // 24 hours
|
||||
|
||||
@@ -145,8 +144,7 @@ class ScanLoginService {
|
||||
*/
|
||||
async confirmLogin(
|
||||
scanToken: string,
|
||||
userName: string,
|
||||
department: string
|
||||
userName: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
@@ -176,22 +174,14 @@ class ScanLoginService {
|
||||
|
||||
// Generate user ID and session token
|
||||
const userId = `user_${Date.now()}_${uuidv4().slice(0, 8)}`;
|
||||
const sessionToken = jwt.sign(
|
||||
{ userId, userName, department },
|
||||
config.jwtSecret || 'gala-secret-key',
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
// Store session
|
||||
await redis.setex(
|
||||
`${SESSION_TOKEN_PREFIX}${sessionToken}`,
|
||||
SESSION_TOKEN_TTL,
|
||||
JSON.stringify({ userId, userName, department })
|
||||
const sessionToken = await createSessionToken(
|
||||
{ userId, userName, role: 'user' },
|
||||
SESSION_TOKEN_TTL
|
||||
);
|
||||
|
||||
// Update scan token status
|
||||
tokenData.status = 'confirmed';
|
||||
tokenData.userInfo = { userId, userName, department };
|
||||
tokenData.userInfo = { userId, userName };
|
||||
|
||||
await redis.setex(
|
||||
`${SCAN_TOKEN_PREFIX}${scanToken}`,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { redis } from '../config/redis';
|
||||
import { config } from '../config';
|
||||
import { logger } from '../utils/logger';
|
||||
@@ -8,9 +7,9 @@ import type {
|
||||
WechatAccessTokenResponse,
|
||||
WechatUserInfo,
|
||||
} from '@gala/shared/types';
|
||||
import { createSessionToken } from '../utils/auth';
|
||||
|
||||
const WECHAT_STATE_PREFIX = 'wechat:state:';
|
||||
const SESSION_TOKEN_PREFIX = 'session:';
|
||||
const STATE_TTL = 5 * 60; // 5 minutes
|
||||
const SESSION_TOKEN_TTL = 24 * 60 * 60; // 24 hours
|
||||
|
||||
@@ -52,7 +51,7 @@ class WechatService {
|
||||
*/
|
||||
async createLoginState(pcSocketId: string): Promise<{
|
||||
success: boolean;
|
||||
data?: { authUrl: string; state: string; expiresAt: number };
|
||||
data?: { appId: string; redirectUri: string; state: string; expiresAt: number };
|
||||
error?: string;
|
||||
}> {
|
||||
if (!this.isConfigured()) {
|
||||
@@ -77,13 +76,16 @@ class WechatService {
|
||||
JSON.stringify(stateData)
|
||||
);
|
||||
|
||||
const authUrl = this.generateAuthUrl(state);
|
||||
|
||||
logger.info({ state, pcSocketId }, 'WeChat login state created');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { authUrl, state, expiresAt },
|
||||
data: {
|
||||
appId: this.appId,
|
||||
redirectUri: this.redirectUri,
|
||||
state,
|
||||
expiresAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to create WeChat login state');
|
||||
@@ -204,17 +206,9 @@ class WechatService {
|
||||
const userId = `wx_${openid.slice(0, 16)}`;
|
||||
const userName = userInfo?.nickname || `微信用户_${openid.slice(-6)}`;
|
||||
|
||||
const sessionToken = jwt.sign(
|
||||
{ userId, userName, openid },
|
||||
config.jwtSecret || 'gala-secret-key',
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
// Store session
|
||||
await redis.setex(
|
||||
`${SESSION_TOKEN_PREFIX}${sessionToken}`,
|
||||
SESSION_TOKEN_TTL,
|
||||
JSON.stringify({ userId, userName, openid })
|
||||
const sessionToken = await createSessionToken(
|
||||
{ userId, userName, openid, role: 'user' },
|
||||
SESSION_TOKEN_TTL
|
||||
);
|
||||
|
||||
// Delete used state
|
||||
|
||||
@@ -8,6 +8,7 @@ import { voteService } from '../services/vote.service';
|
||||
import { votingEngine } from '../services/voting.engine';
|
||||
import { adminService } from '../services/admin.service';
|
||||
import { SOCKET_EVENTS, SOCKET_ROOMS, TICKET_TYPES, VOTE_KEYS, type TicketType } from '@gala/shared/constants';
|
||||
import { extractBearerToken, verifySessionToken } from '../utils/auth';
|
||||
import type {
|
||||
ServerToClientEvents,
|
||||
ClientToServerEvents,
|
||||
@@ -32,6 +33,16 @@ export type GalaServer = Server<ClientToServerEvents, ServerToClientEvents, Inte
|
||||
|
||||
let io: GalaServer;
|
||||
|
||||
function getTokenFromSocket(socket: GalaSocket, payloadToken?: string): string | null {
|
||||
if (payloadToken) return payloadToken;
|
||||
const authToken = (socket.handshake.auth as any)?.token;
|
||||
if (typeof authToken === 'string' && authToken.trim()) return authToken.trim();
|
||||
const headerToken = socket.handshake.headers['x-session-token'];
|
||||
if (typeof headerToken === 'string' && headerToken.trim()) return headerToken.trim();
|
||||
const bearer = extractBearerToken(socket.handshake.headers.authorization);
|
||||
return bearer || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Socket.io server
|
||||
*/
|
||||
@@ -191,32 +202,58 @@ async function handleJoin(
|
||||
};
|
||||
|
||||
try {
|
||||
const { userId, userName, role, department } = data;
|
||||
const { userId, userName, role, sessionToken } = data;
|
||||
|
||||
const token = getTokenFromSocket(socket, sessionToken);
|
||||
const authUser = token ? await verifySessionToken(token) : null;
|
||||
|
||||
if (token && !authUser) {
|
||||
safeCallback({
|
||||
success: false,
|
||||
error: 'UNAUTHORIZED',
|
||||
message: 'Invalid session token',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === 'admin' && (!authUser || authUser.role !== 'admin')) {
|
||||
safeCallback({
|
||||
success: false,
|
||||
error: 'UNAUTHORIZED',
|
||||
message: 'Admin access required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveRole = authUser?.role || role;
|
||||
const effectiveUserId = authUser?.userId || userId;
|
||||
const effectiveUserName = authUser?.userName || userName;
|
||||
|
||||
// Store user data in socket
|
||||
socket.data.userId = userId;
|
||||
socket.data.userName = userName;
|
||||
socket.data.department = department || '未知部门';
|
||||
socket.data.role = role;
|
||||
socket.data.userId = effectiveUserId;
|
||||
socket.data.userName = effectiveUserName;
|
||||
socket.data.role = effectiveRole;
|
||||
socket.data.connectedAt = new Date();
|
||||
socket.data.sessionId = socket.id;
|
||||
(socket.data as any).sessionToken = token || undefined;
|
||||
(socket.data as any).authenticated = !!authUser;
|
||||
|
||||
// Join appropriate rooms
|
||||
await socket.join(SOCKET_ROOMS.ALL);
|
||||
|
||||
if (role === 'user') {
|
||||
if (effectiveRole === 'user') {
|
||||
await socket.join(SOCKET_ROOMS.MOBILE_USERS);
|
||||
} else if (role === 'screen') {
|
||||
} else if (effectiveRole === 'screen') {
|
||||
await socket.join(SOCKET_ROOMS.SCREEN_DISPLAY);
|
||||
} else if (role === 'admin') {
|
||||
} else if (effectiveRole === 'admin') {
|
||||
await socket.join(SOCKET_ROOMS.ADMIN);
|
||||
}
|
||||
|
||||
// Get user's voted categories and tickets
|
||||
const votedCategories = await voteService.getUserVotedCategories(userId);
|
||||
const userTickets = await redis.hgetall(VOTE_KEYS.userTickets(userId));
|
||||
const votedCategories = await voteService.getUserVotedCategories(effectiveUserId);
|
||||
const userTickets = await redis.hgetall(VOTE_KEYS.userTickets(effectiveUserId));
|
||||
|
||||
logger.info({ socketId: socket.id, userId, userName, role }, 'User joined');
|
||||
logger.info({ socketId: socket.id, userId: effectiveUserId, userName: effectiveUserName, role: effectiveRole }, 'User joined');
|
||||
|
||||
// Broadcast user count update
|
||||
const userCount = await getUserCount();
|
||||
@@ -259,8 +296,9 @@ async function handleVoteSubmit(
|
||||
};
|
||||
|
||||
const userId = socket.data.userId;
|
||||
const authenticated = (socket.data as any).authenticated === true;
|
||||
|
||||
if (!userId) {
|
||||
if (!userId || !authenticated) {
|
||||
safeCallback({
|
||||
success: false,
|
||||
error: 'UNAUTHORIZED',
|
||||
@@ -306,7 +344,6 @@ async function handleVoteSubmit(
|
||||
const stampResult = await adminService.addVoteStamp(
|
||||
data.candidateId,
|
||||
socket.data.userName || '匿名用户',
|
||||
socket.data.department || '未知部门',
|
||||
category,
|
||||
{ revokedProgramId: result.revoked_program }
|
||||
);
|
||||
@@ -359,7 +396,6 @@ async function handleVoteSubmit(
|
||||
const stampResult = await adminService.addVoteStamp(
|
||||
data.candidateId,
|
||||
socket.data.userName || '匿名用户',
|
||||
socket.data.department || '未知部门',
|
||||
data.category as string
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user