- 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>
250 lines
6.9 KiB
TypeScript
250 lines
6.9 KiB
TypeScript
import { v4 as uuidv4 } from 'uuid';
|
|
import { redis } from '../config/redis';
|
|
import { config } from '../config';
|
|
import { logger } from '../utils/logger';
|
|
import type {
|
|
WechatLoginState,
|
|
WechatAccessTokenResponse,
|
|
WechatUserInfo,
|
|
} from '@gala/shared/types';
|
|
import { createSessionToken } from '../utils/auth';
|
|
|
|
const WECHAT_STATE_PREFIX = 'wechat:state:';
|
|
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?: { appId: string; redirectUri: 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)
|
|
);
|
|
|
|
logger.info({ state, pcSocketId }, 'WeChat login state created');
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
appId: this.appId,
|
|
redirectUri: this.redirectUri,
|
|
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 = await createSessionToken(
|
|
{ userId, userName, openid, role: 'user' },
|
|
SESSION_TOKEN_TTL
|
|
);
|
|
|
|
// 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();
|