feat: integrate WeChat Open Platform QR code login
- Add WeChat service for OAuth2 authentication flow - Add WeChat routes (/api/wechat/login, /api/wechat/callback) - Add WeChat types for login state and responses - Update EntryQRCode component to support WeChat login - Add WeChat config options (appId, appSecret, redirectUri) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { requestLogger } from './middleware/requestLogger';
|
||||
import voteRoutes from './routes/vote.routes';
|
||||
import adminRoutes from './routes/admin.routes';
|
||||
import scanRoutes from './routes/scan.routes';
|
||||
import wechatRoutes from './routes/wechat.routes';
|
||||
|
||||
export const app: Application = express();
|
||||
|
||||
@@ -59,6 +60,7 @@ app.get('/health', (_req, res) => {
|
||||
app.use('/api/vote', voteRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/scan', scanRoutes);
|
||||
app.use('/api/wechat', wechatRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use((_req, res) => {
|
||||
|
||||
@@ -50,6 +50,13 @@ export const config = {
|
||||
batchSize: 100,
|
||||
intervalMs: 1000,
|
||||
},
|
||||
|
||||
// WeChat Open Platform
|
||||
wechat: {
|
||||
appId: process.env.WECHAT_APP_ID || '',
|
||||
appSecret: process.env.WECHAT_APP_SECRET || '',
|
||||
redirectUri: process.env.WECHAT_REDIRECT_URI || '',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Config = typeof config;
|
||||
|
||||
145
packages/server/src/routes/wechat.routes.ts
Normal file
145
packages/server/src/routes/wechat.routes.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { wechatService } from '../services/wechat.service';
|
||||
import { getIO } from '../socket';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/wechat/login
|
||||
* Generate WeChat login QR code URL
|
||||
*/
|
||||
router.get('/login', async (req: Request, res: Response) => {
|
||||
const { pcSocketId } = req.query;
|
||||
|
||||
if (!pcSocketId || typeof pcSocketId !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'pcSocketId is required',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await wechatService.createLoginState(pcSocketId);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json(result);
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/wechat/callback
|
||||
* WeChat OAuth2 callback handler
|
||||
*/
|
||||
router.get('/callback', async (req: Request, res: Response) => {
|
||||
const { code, state } = req.query;
|
||||
|
||||
if (!code || !state || typeof code !== 'string' || typeof state !== 'string') {
|
||||
logger.warn({ code, state }, 'Invalid WeChat callback parameters');
|
||||
return res.status(400).send(renderCallbackPage(false, 'Invalid parameters'));
|
||||
}
|
||||
|
||||
const result = await wechatService.handleCallback(code, state);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
logger.error({ error: result.error }, 'WeChat callback failed');
|
||||
return res.status(400).send(renderCallbackPage(false, result.error || 'Login failed'));
|
||||
}
|
||||
|
||||
const { openid, pcSocketId, sessionToken, userId, userInfo } = result.data;
|
||||
|
||||
// Notify PC client via WebSocket
|
||||
try {
|
||||
const io = getIO();
|
||||
io.to(pcSocketId).emit('wechat:login_success' as any, {
|
||||
openid,
|
||||
sessionToken,
|
||||
userId,
|
||||
userName: userInfo?.nickname || `微信用户_${openid.slice(-6)}`,
|
||||
userInfo,
|
||||
});
|
||||
logger.info({ pcSocketId, userId }, 'WeChat login success notification sent');
|
||||
} catch (error) {
|
||||
logger.error({ error, pcSocketId }, 'Failed to notify PC client');
|
||||
}
|
||||
|
||||
// Return success page
|
||||
res.send(renderCallbackPage(true, 'Login successful'));
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/wechat/status
|
||||
* Check WeChat configuration status
|
||||
*/
|
||||
router.get('/status', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
configured: wechatService.isConfigured(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Render callback result page
|
||||
*/
|
||||
function renderCallbackPage(success: boolean, message: string): string {
|
||||
const color = success ? '#52c41a' : '#ff4d4f';
|
||||
const icon = success ? '✓' : '✗';
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>微信登录${success ? '成功' : '失败'}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
.icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: ${color};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
margin: 0 auto 24px;
|
||||
}
|
||||
.message {
|
||||
font-size: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.hint {
|
||||
color: rgba(255,255,255,0.6);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">${icon}</div>
|
||||
<div class="message">${message}</div>
|
||||
<div class="hint">${success ? '请返回大屏查看' : '请重新扫码'}</div>
|
||||
</div>
|
||||
<script>
|
||||
${success ? 'setTimeout(() => window.close(), 3000);' : ''}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
export default router;
|
||||
255
packages/server/src/services/wechat.service.ts
Normal file
255
packages/server/src/services/wechat.service.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
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 {
|
||||
WechatLoginState,
|
||||
WechatAccessTokenResponse,
|
||||
WechatUserInfo,
|
||||
} from '@gala/shared/types';
|
||||
|
||||
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
|
||||
|
||||
class WechatService {
|
||||
private readonly appId: string;
|
||||
private readonly appSecret: string;
|
||||
private readonly redirectUri: string;
|
||||
|
||||
constructor() {
|
||||
this.appId = config.wechat.appId;
|
||||
this.appSecret = config.wechat.appSecret;
|
||||
this.redirectUri = config.wechat.redirectUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WeChat config is valid
|
||||
*/
|
||||
isConfigured(): boolean {
|
||||
return !!(this.appId && this.appSecret && this.redirectUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate WeChat OAuth2 authorization URL
|
||||
*/
|
||||
generateAuthUrl(state: string): string {
|
||||
const baseUrl = 'https://open.weixin.qq.com/connect/qrconnect';
|
||||
const params = new URLSearchParams({
|
||||
appid: this.appId,
|
||||
redirect_uri: this.redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'snsapi_login',
|
||||
state: state,
|
||||
});
|
||||
return `${baseUrl}?${params.toString()}#wechat_redirect`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create login state and store in Redis
|
||||
*/
|
||||
async createLoginState(pcSocketId: string): Promise<{
|
||||
success: boolean;
|
||||
data?: { authUrl: string; state: string; expiresAt: number };
|
||||
error?: string;
|
||||
}> {
|
||||
if (!this.isConfigured()) {
|
||||
return { success: false, error: 'WeChat not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const state = uuidv4();
|
||||
const now = Date.now();
|
||||
const expiresAt = now + STATE_TTL * 1000;
|
||||
|
||||
const stateData: WechatLoginState = {
|
||||
state,
|
||||
pcSocketId,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
await redis.setex(
|
||||
`${WECHAT_STATE_PREFIX}${state}`,
|
||||
STATE_TTL,
|
||||
JSON.stringify(stateData)
|
||||
);
|
||||
|
||||
const authUrl = this.generateAuthUrl(state);
|
||||
|
||||
logger.info({ state, pcSocketId }, 'WeChat login state created');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { authUrl, state, expiresAt },
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to create WeChat login state');
|
||||
return { success: false, error: 'Failed to create login state' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange code for access_token and openid
|
||||
*/
|
||||
async getAccessToken(code: string): Promise<{
|
||||
success: boolean;
|
||||
data?: { openid: string; accessToken: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const url = 'https://api.weixin.qq.com/sns/oauth2/access_token';
|
||||
const params = new URLSearchParams({
|
||||
appid: this.appId,
|
||||
secret: this.appSecret,
|
||||
code: code,
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
|
||||
const response = await fetch(`${url}?${params.toString()}`);
|
||||
const data: WechatAccessTokenResponse = await response.json();
|
||||
|
||||
if (data.errcode) {
|
||||
logger.error({ errcode: data.errcode, errmsg: data.errmsg }, 'WeChat access_token error');
|
||||
return { success: false, error: data.errmsg || 'Failed to get access token' };
|
||||
}
|
||||
|
||||
if (!data.openid || !data.access_token) {
|
||||
return { success: false, error: 'Invalid response from WeChat' };
|
||||
}
|
||||
|
||||
logger.info({ openid: data.openid }, 'WeChat access_token obtained');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { openid: data.openid, accessToken: data.access_token },
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to get WeChat access token');
|
||||
return { success: false, error: 'Failed to get access token' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user info from WeChat (optional, requires user consent)
|
||||
*/
|
||||
async getUserInfo(accessToken: string, openid: string): Promise<WechatUserInfo | null> {
|
||||
try {
|
||||
const url = 'https://api.weixin.qq.com/sns/userinfo';
|
||||
const params = new URLSearchParams({
|
||||
access_token: accessToken,
|
||||
openid: openid,
|
||||
lang: 'zh_CN',
|
||||
});
|
||||
|
||||
const response = await fetch(`${url}?${params.toString()}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.errcode) {
|
||||
logger.error({ errcode: data.errcode, errmsg: data.errmsg }, 'WeChat userinfo error');
|
||||
return null;
|
||||
}
|
||||
|
||||
return data as WechatUserInfo;
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to get WeChat user info');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WeChat callback - validate state and exchange code
|
||||
*/
|
||||
async handleCallback(code: string, state: string): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
openid: string;
|
||||
pcSocketId: string;
|
||||
sessionToken: string;
|
||||
userId: string;
|
||||
userInfo?: WechatUserInfo;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// Validate state
|
||||
const stateKey = `${WECHAT_STATE_PREFIX}${state}`;
|
||||
const stateDataStr = await redis.get(stateKey);
|
||||
|
||||
if (!stateDataStr) {
|
||||
return { success: false, error: 'Invalid or expired state' };
|
||||
}
|
||||
|
||||
const stateData: WechatLoginState = JSON.parse(stateDataStr);
|
||||
|
||||
if (Date.now() > stateData.expiresAt) {
|
||||
await redis.del(stateKey);
|
||||
return { success: false, error: 'State expired' };
|
||||
}
|
||||
|
||||
// Exchange code for access_token
|
||||
const tokenResult = await this.getAccessToken(code);
|
||||
if (!tokenResult.success || !tokenResult.data) {
|
||||
return { success: false, error: tokenResult.error };
|
||||
}
|
||||
|
||||
const { openid, accessToken } = tokenResult.data;
|
||||
|
||||
// Optionally get user info
|
||||
const userInfo = await this.getUserInfo(accessToken, openid);
|
||||
|
||||
// Generate user ID and session token
|
||||
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 })
|
||||
);
|
||||
|
||||
// Delete used state
|
||||
await redis.del(stateKey);
|
||||
|
||||
logger.info({ openid, userId, pcSocketId: stateData.pcSocketId }, 'WeChat login successful');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
openid,
|
||||
pcSocketId: stateData.pcSocketId,
|
||||
sessionToken,
|
||||
userId,
|
||||
userInfo: userInfo || undefined,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, state }, 'Failed to handle WeChat callback');
|
||||
return { success: false, error: 'Failed to process login' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get state data (for debugging/status check)
|
||||
*/
|
||||
async getStateData(state: string): Promise<WechatLoginState | null> {
|
||||
try {
|
||||
const data = await redis.get(`${WECHAT_STATE_PREFIX}${state}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch (error) {
|
||||
logger.error({ error, state }, 'Failed to get state data');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const wechatService = new WechatService();
|
||||
Reference in New Issue
Block a user