1
This commit is contained in:
182
server-node/src/services/wechatAuthService.ts
Normal file
182
server-node/src/services/wechatAuthService.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
import type { AppConfig } from '../config.js';
|
||||
import { badRequest, upstreamError } from '../errors.js';
|
||||
|
||||
export type WechatIdentityProfile = {
|
||||
providerUid: string;
|
||||
providerUnionId: string | null;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
metaJson: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type WechatAuthService = {
|
||||
buildAuthorizationUrl(params: {
|
||||
callbackUrl: string;
|
||||
state: string;
|
||||
}): string;
|
||||
resolveCallbackProfile(params: {
|
||||
code?: string | null;
|
||||
mockCode?: string | null;
|
||||
}): Promise<WechatIdentityProfile>;
|
||||
};
|
||||
|
||||
class MockWechatAuthService implements WechatAuthService {
|
||||
constructor(private readonly config: AppConfig['wechatAuth']) {}
|
||||
|
||||
buildAuthorizationUrl(params: {
|
||||
callbackUrl: string;
|
||||
state: string;
|
||||
}) {
|
||||
const callbackUrl = new URL(params.callbackUrl);
|
||||
callbackUrl.searchParams.set('mock_code', this.config.mockUserId);
|
||||
callbackUrl.searchParams.set('state', params.state);
|
||||
return callbackUrl.toString();
|
||||
}
|
||||
|
||||
async resolveCallbackProfile(params: {
|
||||
mockCode?: string | null;
|
||||
}) {
|
||||
const mockCode = params.mockCode?.trim() || this.config.mockUserId;
|
||||
return {
|
||||
providerUid: mockCode,
|
||||
providerUnionId: this.config.mockUnionId || null,
|
||||
displayName: this.config.mockDisplayName || '微信旅人',
|
||||
avatarUrl: this.config.mockAvatarUrl || null,
|
||||
metaJson: {
|
||||
mockCode,
|
||||
},
|
||||
} satisfies WechatIdentityProfile;
|
||||
}
|
||||
}
|
||||
|
||||
class RealWechatAuthService implements WechatAuthService {
|
||||
constructor(
|
||||
private readonly config: AppConfig['wechatAuth'],
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
if (!config.appId || !config.appSecret) {
|
||||
throw new Error('WECHAT_APP_ID 或 WECHAT_APP_SECRET 未配置');
|
||||
}
|
||||
}
|
||||
|
||||
buildAuthorizationUrl(params: {
|
||||
callbackUrl: string;
|
||||
state: string;
|
||||
}) {
|
||||
const url = new URL(this.config.authorizeEndpoint);
|
||||
url.searchParams.set('appid', this.config.appId);
|
||||
url.searchParams.set('redirect_uri', params.callbackUrl);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('scope', 'snsapi_login');
|
||||
url.searchParams.set('state', params.state);
|
||||
return `${url.toString()}#wechat_redirect`;
|
||||
}
|
||||
|
||||
async resolveCallbackProfile(params: {
|
||||
code?: string | null;
|
||||
}) {
|
||||
const code = params.code?.trim();
|
||||
if (!code) {
|
||||
throw badRequest('缺少微信授权 code');
|
||||
}
|
||||
|
||||
try {
|
||||
const accessTokenUrl = new URL(this.config.accessTokenEndpoint);
|
||||
accessTokenUrl.searchParams.set('appid', this.config.appId);
|
||||
accessTokenUrl.searchParams.set('secret', this.config.appSecret);
|
||||
accessTokenUrl.searchParams.set('code', code);
|
||||
accessTokenUrl.searchParams.set('grant_type', 'authorization_code');
|
||||
|
||||
const accessTokenResponse = await fetch(accessTokenUrl.toString());
|
||||
const accessTokenPayload =
|
||||
(await accessTokenResponse.json()) as Record<string, unknown>;
|
||||
|
||||
if (!accessTokenResponse.ok || typeof accessTokenPayload.openid !== 'string') {
|
||||
throw new Error(
|
||||
typeof accessTokenPayload.errmsg === 'string'
|
||||
? accessTokenPayload.errmsg
|
||||
: 'failed to exchange code',
|
||||
);
|
||||
}
|
||||
|
||||
const accessToken =
|
||||
typeof accessTokenPayload.access_token === 'string'
|
||||
? accessTokenPayload.access_token
|
||||
: '';
|
||||
const openId = accessTokenPayload.openid;
|
||||
const fallbackUnionId =
|
||||
typeof accessTokenPayload.unionid === 'string'
|
||||
? accessTokenPayload.unionid
|
||||
: null;
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('missing access_token');
|
||||
}
|
||||
|
||||
const userInfoUrl = new URL(this.config.userInfoEndpoint);
|
||||
userInfoUrl.searchParams.set('access_token', accessToken);
|
||||
userInfoUrl.searchParams.set('openid', openId);
|
||||
userInfoUrl.searchParams.set('lang', 'zh_CN');
|
||||
|
||||
const userInfoResponse = await fetch(userInfoUrl.toString());
|
||||
const userInfoPayload =
|
||||
(await userInfoResponse.json()) as Record<string, unknown>;
|
||||
|
||||
if (!userInfoResponse.ok || typeof userInfoPayload.openid !== 'string') {
|
||||
throw new Error(
|
||||
typeof userInfoPayload.errmsg === 'string'
|
||||
? userInfoPayload.errmsg
|
||||
: 'failed to fetch user info',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
providerUid: userInfoPayload.openid,
|
||||
providerUnionId:
|
||||
typeof userInfoPayload.unionid === 'string'
|
||||
? userInfoPayload.unionid
|
||||
: fallbackUnionId,
|
||||
displayName:
|
||||
typeof userInfoPayload.nickname === 'string'
|
||||
? userInfoPayload.nickname
|
||||
: null,
|
||||
avatarUrl:
|
||||
typeof userInfoPayload.headimgurl === 'string'
|
||||
? userInfoPayload.headimgurl
|
||||
: null,
|
||||
metaJson: userInfoPayload,
|
||||
} satisfies WechatIdentityProfile;
|
||||
} catch (error) {
|
||||
this.logger.error({ err: error }, 'wechat auth callback failed');
|
||||
throw upstreamError(
|
||||
error instanceof Error
|
||||
? `微信登录失败:${error.message}`
|
||||
: '微信登录失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createWechatAuthService(
|
||||
config: AppConfig,
|
||||
logger: Logger,
|
||||
): WechatAuthService {
|
||||
if (!config.wechatAuth.enabled) {
|
||||
return {
|
||||
buildAuthorizationUrl() {
|
||||
throw badRequest('微信登录暂未启用');
|
||||
},
|
||||
async resolveCallbackProfile() {
|
||||
throw badRequest('微信登录暂未启用');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (config.wechatAuth.provider === 'mock') {
|
||||
return new MockWechatAuthService(config.wechatAuth);
|
||||
}
|
||||
|
||||
return new RealWechatAuthService(config.wechatAuth, logger);
|
||||
}
|
||||
Reference in New Issue
Block a user