@@ -14,12 +14,25 @@ import { requestIdMiddleware } from './middleware/requestId.ts';
|
||||
import { createAppContext } from './server.ts';
|
||||
import { httpRequest, type TestRequestInit } from './testHttp.ts';
|
||||
|
||||
function createTestConfig(testName: string): AppConfig {
|
||||
type TestConfigOverrides = Partial<
|
||||
Omit<AppConfig, 'llm' | 'dashScope' | 'smsAuth' | 'wechatAuth' | 'authSession'>
|
||||
> & {
|
||||
llm?: Partial<AppConfig['llm']>;
|
||||
dashScope?: Partial<AppConfig['dashScope']>;
|
||||
smsAuth?: Partial<AppConfig['smsAuth']>;
|
||||
wechatAuth?: Partial<AppConfig['wechatAuth']>;
|
||||
authSession?: Partial<AppConfig['authSession']>;
|
||||
};
|
||||
|
||||
function createTestConfig(
|
||||
testName: string,
|
||||
overrides: TestConfigOverrides = {},
|
||||
): AppConfig {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `genarrative-server-node-${testName}-`),
|
||||
);
|
||||
|
||||
return {
|
||||
const baseConfig: AppConfig = {
|
||||
nodeEnv: 'test',
|
||||
projectRoot: tempRoot,
|
||||
publicDir: path.join(tempRoot, 'public'),
|
||||
@@ -99,13 +112,39 @@ function createTestConfig(testName: string): AppConfig {
|
||||
refreshCookiePath: '/api/auth',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...baseConfig,
|
||||
...overrides,
|
||||
llm: {
|
||||
...baseConfig.llm,
|
||||
...overrides.llm,
|
||||
},
|
||||
dashScope: {
|
||||
...baseConfig.dashScope,
|
||||
...overrides.dashScope,
|
||||
},
|
||||
smsAuth: {
|
||||
...baseConfig.smsAuth,
|
||||
...overrides.smsAuth,
|
||||
},
|
||||
wechatAuth: {
|
||||
...baseConfig.wechatAuth,
|
||||
...overrides.wechatAuth,
|
||||
},
|
||||
authSession: {
|
||||
...baseConfig.authSession,
|
||||
...overrides.authSession,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function withTestServer<T>(
|
||||
testName: string,
|
||||
run: (options: { baseUrl: string }) => Promise<T>,
|
||||
overrides: TestConfigOverrides = {},
|
||||
) {
|
||||
const context = await createAppContext(createTestConfig(testName));
|
||||
const context = await createAppContext(createTestConfig(testName, overrides));
|
||||
const app = createApp(context);
|
||||
const server = await new Promise<import('node:http').Server>((resolve) => {
|
||||
const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer));
|
||||
@@ -348,6 +387,130 @@ test('auth entry auto-registers, me works, logout invalidates old token', async
|
||||
});
|
||||
});
|
||||
|
||||
test('login options expose enabled methods without authentication', async () => {
|
||||
await withTestServer('auth-login-options', async ({ baseUrl }) => {
|
||||
const response = await httpRequest(`${baseUrl}/api/auth/login-options`);
|
||||
const payload = (await response.json()) as {
|
||||
availableLoginMethods: string[];
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(payload.availableLoginMethods, ['phone', 'wechat']);
|
||||
});
|
||||
});
|
||||
|
||||
test('wechat start uses qrconnect for desktop browsers', async () => {
|
||||
await withTestServer(
|
||||
'wechat-start-desktop',
|
||||
async ({ baseUrl }) => {
|
||||
const response = await httpRequest(
|
||||
`${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent('/')}`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/135.0.0.0 Safari/537.36',
|
||||
},
|
||||
},
|
||||
);
|
||||
const payload = (await response.json()) as {
|
||||
authorizationUrl: string;
|
||||
};
|
||||
const authorizationUrl = new URL(payload.authorizationUrl);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(
|
||||
`${authorizationUrl.origin}${authorizationUrl.pathname}`,
|
||||
'https://open.weixin.qq.com/connect/qrconnect',
|
||||
);
|
||||
assert.equal(authorizationUrl.searchParams.get('scope'), 'snsapi_login');
|
||||
assert.equal(authorizationUrl.hash, '#wechat_redirect');
|
||||
},
|
||||
{
|
||||
wechatAuth: {
|
||||
enabled: true,
|
||||
provider: 'wechat',
|
||||
appId: 'wx-test-app-id',
|
||||
appSecret: 'wx-test-app-secret',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('wechat start uses oauth authorize inside wechat browser', async () => {
|
||||
await withTestServer(
|
||||
'wechat-start-in-app',
|
||||
async ({ baseUrl }) => {
|
||||
const response = await httpRequest(
|
||||
`${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent('/')}`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 MicroMessenger/8.0.54',
|
||||
},
|
||||
},
|
||||
);
|
||||
const payload = (await response.json()) as {
|
||||
authorizationUrl: string;
|
||||
};
|
||||
const authorizationUrl = new URL(payload.authorizationUrl);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(
|
||||
`${authorizationUrl.origin}${authorizationUrl.pathname}`,
|
||||
'https://open.weixin.qq.com/connect/oauth2/authorize',
|
||||
);
|
||||
assert.equal(authorizationUrl.searchParams.get('scope'), 'snsapi_userinfo');
|
||||
assert.equal(authorizationUrl.hash, '#wechat_redirect');
|
||||
},
|
||||
{
|
||||
wechatAuth: {
|
||||
enabled: true,
|
||||
provider: 'wechat',
|
||||
appId: 'wx-test-app-id',
|
||||
appSecret: 'wx-test-app-secret',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('wechat start rejects unsupported mobile browsers for real provider', async () => {
|
||||
await withTestServer(
|
||||
'wechat-start-mobile-browser',
|
||||
async ({ baseUrl }) => {
|
||||
const response = await httpRequest(
|
||||
`${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent('/')}`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1',
|
||||
},
|
||||
},
|
||||
);
|
||||
const payload = (await response.json()) as {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(payload.error.code, 'BAD_REQUEST');
|
||||
assert.equal(
|
||||
payload.error.message,
|
||||
'当前浏览器请使用手机号登录,或在微信内打开后再使用微信登录',
|
||||
);
|
||||
},
|
||||
{
|
||||
wechatAuth: {
|
||||
enabled: true,
|
||||
provider: 'wechat',
|
||||
appId: 'wx-test-app-id',
|
||||
appSecret: 'wx-test-app-secret',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('phone login sends code, creates a user and returns masked profile info', async () => {
|
||||
await withTestServer('phone-login', async ({ baseUrl }) => {
|
||||
const sendResult = await sendPhoneCode(baseUrl, '13800138000');
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
AuthAuditLogsResponse,
|
||||
AuthBindingStatus,
|
||||
AuthEntryResponse,
|
||||
AuthLoginOptionsResponse,
|
||||
AuthLiftRiskBlockResponse,
|
||||
AuthLoginMethod,
|
||||
AuthLogoutAllResponse,
|
||||
@@ -151,6 +152,14 @@ export async function buildAuthMeResponse(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAuthLoginOptionsResponse(
|
||||
context: AppContext,
|
||||
): AuthLoginOptionsResponse {
|
||||
return {
|
||||
availableLoginMethods: resolveAvailableLoginMethods(context),
|
||||
};
|
||||
}
|
||||
|
||||
async function signUserAuthPayload(
|
||||
context: AppContext,
|
||||
user: UserRecord,
|
||||
@@ -1077,12 +1086,14 @@ export async function startWechatLogin(
|
||||
context: AppContext,
|
||||
callbackUrl: string,
|
||||
redirectPath: string,
|
||||
requestContext: RefreshSessionRequestContext | null = null,
|
||||
): Promise<AuthWechatStartResponse> {
|
||||
const stateRecord = context.wechatAuthStates.create(redirectPath);
|
||||
return {
|
||||
authorizationUrl: context.wechatAuthService.buildAuthorizationUrl({
|
||||
callbackUrl,
|
||||
state: stateRecord.state,
|
||||
userAgent: requestContext?.userAgent ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
61
server-node/src/config.test.ts
Normal file
61
server-node/src/config.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { loadConfig } from './config.ts';
|
||||
|
||||
function createTempProjectRoot(prefix: string) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
test('development config auto-enables aliyun sms auth when local credentials are provided', () => {
|
||||
const projectRoot = createTempProjectRoot('genarrative-config-dev-');
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, '.env.example'),
|
||||
'SMS_AUTH_ENABLED=\"false\"\nSMS_AUTH_PROVIDER=\"aliyun\"\n',
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, '.env.local'),
|
||||
'ALIYUN_SMS_ACCESS_KEY_ID=\"test-ak\"\nALIYUN_SMS_ACCESS_KEY_SECRET=\"test-sk\"\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const config = loadConfig({
|
||||
projectRoot,
|
||||
env: {
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(config.smsAuth.enabled, true);
|
||||
assert.equal(config.smsAuth.provider, 'aliyun');
|
||||
assert.equal(config.smsAuth.accessKeyId, 'test-ak');
|
||||
assert.equal(config.smsAuth.accessKeySecret, 'test-sk');
|
||||
});
|
||||
|
||||
test('development config respects explicit local sms auth overrides', () => {
|
||||
const projectRoot = createTempProjectRoot('genarrative-config-local-');
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, '.env.example'),
|
||||
'SMS_AUTH_ENABLED=\"false\"\nSMS_AUTH_PROVIDER=\"aliyun\"\n',
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, '.env.local'),
|
||||
'SMS_AUTH_ENABLED=\"false\"\nSMS_AUTH_PROVIDER=\"aliyun\"\nALIYUN_SMS_ACCESS_KEY_ID=\"test-ak\"\nALIYUN_SMS_ACCESS_KEY_SECRET=\"test-sk\"\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const config = loadConfig({
|
||||
projectRoot,
|
||||
env: {
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(config.smsAuth.enabled, false);
|
||||
assert.equal(config.smsAuth.provider, 'aliyun');
|
||||
});
|
||||
@@ -131,14 +131,39 @@ function resolveDefaultProjectRoot() {
|
||||
: cwd;
|
||||
}
|
||||
|
||||
function readMergedEnv(projectRoot: string, processEnv: NodeJS.ProcessEnv) {
|
||||
function readMergedEnv(
|
||||
exampleEnv: Record<string, string>,
|
||||
localEnv: Record<string, string>,
|
||||
processEnv: NodeJS.ProcessEnv,
|
||||
) {
|
||||
return {
|
||||
...readEnvFile(path.join(projectRoot, '.env.example')),
|
||||
...readEnvFile(path.join(projectRoot, '.env.local')),
|
||||
...exampleEnv,
|
||||
...localEnv,
|
||||
...processEnv,
|
||||
};
|
||||
}
|
||||
|
||||
function hasOwnEnvKey(
|
||||
env: Record<string, string | undefined>,
|
||||
key: string,
|
||||
) {
|
||||
return Object.prototype.hasOwnProperty.call(env, key);
|
||||
}
|
||||
|
||||
function readBooleanOverride(
|
||||
env: Record<string, string | undefined>,
|
||||
overrideSources: Array<Record<string, string | undefined>>,
|
||||
key: string,
|
||||
fallback: boolean,
|
||||
) {
|
||||
const hasOverride = overrideSources.some((source) => hasOwnEnvKey(source, key));
|
||||
if (!hasOverride) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return readBoolean(env, key, fallback);
|
||||
}
|
||||
|
||||
function readString(
|
||||
env: Record<string, string | undefined>,
|
||||
key: string,
|
||||
@@ -199,33 +224,51 @@ function readBoolean(
|
||||
|
||||
export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
|
||||
const projectRoot = options.projectRoot ?? resolveDefaultProjectRoot();
|
||||
const env = readMergedEnv(projectRoot, options.env ?? process.env);
|
||||
const exampleEnv = readEnvFile(path.join(projectRoot, '.env.example'));
|
||||
const localEnv = readEnvFile(path.join(projectRoot, '.env.local'));
|
||||
const processEnv = options.env ?? process.env;
|
||||
const env = readMergedEnv(exampleEnv, localEnv, processEnv);
|
||||
const logsDir = path.join(projectRoot, 'server-node', 'logs');
|
||||
const dataDir = path.join(projectRoot, 'server-node', 'data');
|
||||
const defaultEditorApiEnabled = readString(env, 'NODE_ENV', 'development') !== 'production';
|
||||
const nodeEnv = readString(env, 'NODE_ENV', 'development');
|
||||
const defaultEditorApiEnabled = nodeEnv !== 'production';
|
||||
const editorApiEnabled = readBoolean(
|
||||
env,
|
||||
'EDITOR_API_ENABLED',
|
||||
defaultEditorApiEnabled,
|
||||
);
|
||||
const smsProvider = readString(
|
||||
const smsProviderFromEnv = readString(
|
||||
env,
|
||||
'SMS_AUTH_PROVIDER',
|
||||
readString(env, 'NODE_ENV', 'development') === 'test' ? 'mock' : 'aliyun',
|
||||
nodeEnv === 'test' ? 'mock' : 'aliyun',
|
||||
) as AppConfig['smsAuth']['provider'];
|
||||
const smsAccessKeyId = readString(env, 'ALIYUN_SMS_ACCESS_KEY_ID', '');
|
||||
const smsAccessKeySecret = readString(env, 'ALIYUN_SMS_ACCESS_KEY_SECRET', '');
|
||||
const smsProvider = smsProviderFromEnv;
|
||||
const defaultSmsEnabled =
|
||||
smsProvider === 'mock' || Boolean(smsAccessKeyId && smsAccessKeySecret);
|
||||
smsProvider === 'mock' ||
|
||||
Boolean(smsAccessKeyId && smsAccessKeySecret);
|
||||
const smsEnabled = readBooleanOverride(
|
||||
env,
|
||||
[localEnv, processEnv],
|
||||
'SMS_AUTH_ENABLED',
|
||||
defaultSmsEnabled,
|
||||
);
|
||||
const wechatProvider = readString(
|
||||
env,
|
||||
'WECHAT_AUTH_PROVIDER',
|
||||
readString(env, 'NODE_ENV', 'development') === 'test' ? 'mock' : 'wechat',
|
||||
nodeEnv === 'test' ? 'mock' : 'wechat',
|
||||
) as AppConfig['wechatAuth']['provider'];
|
||||
const wechatAppId = readString(env, 'WECHAT_APP_ID', '');
|
||||
const wechatAppSecret = readString(env, 'WECHAT_APP_SECRET', '');
|
||||
const defaultWechatEnabled =
|
||||
wechatProvider === 'mock' || Boolean(wechatAppId && wechatAppSecret);
|
||||
const wechatEnabled = readBooleanOverride(
|
||||
env,
|
||||
[localEnv, processEnv],
|
||||
'WECHAT_AUTH_ENABLED',
|
||||
defaultWechatEnabled,
|
||||
);
|
||||
const refreshSameSite = readString(
|
||||
env,
|
||||
'AUTH_REFRESH_COOKIE_SAME_SITE',
|
||||
@@ -233,7 +276,7 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
|
||||
);
|
||||
|
||||
return {
|
||||
nodeEnv: readString(env, 'NODE_ENV', 'development'),
|
||||
nodeEnv,
|
||||
projectRoot,
|
||||
publicDir: path.join(projectRoot, 'public'),
|
||||
logsDir,
|
||||
@@ -295,7 +338,7 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
|
||||
),
|
||||
},
|
||||
smsAuth: {
|
||||
enabled: readBoolean(env, 'SMS_AUTH_ENABLED', defaultSmsEnabled),
|
||||
enabled: smsEnabled,
|
||||
provider: smsProvider,
|
||||
endpoint: readString(
|
||||
env,
|
||||
@@ -410,7 +453,7 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
|
||||
),
|
||||
},
|
||||
wechatAuth: {
|
||||
enabled: readBoolean(env, 'WECHAT_AUTH_ENABLED', defaultWechatEnabled),
|
||||
enabled: wechatEnabled,
|
||||
provider: wechatProvider,
|
||||
appId: wechatAppId,
|
||||
appSecret: wechatAppSecret,
|
||||
|
||||
@@ -73,9 +73,9 @@ function readStringArray(value: unknown) {
|
||||
function describeWorld(worldType: string) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '武侠';
|
||||
return '边城模板';
|
||||
case 'XIANXIA':
|
||||
return '仙侠';
|
||||
return '灵潮模板';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
|
||||
@@ -65,8 +65,8 @@ function buildAttributeSchema(worldType: 'WUXIA' | 'XIANXIA') {
|
||||
generatedFrom: {
|
||||
worldType,
|
||||
worldName: worldType === 'XIANXIA' ? '云海异境' : '裂潮边城',
|
||||
settingSummary: worldType === 'XIANXIA' ? '灵潮翻涌的修行世界' : '旧桥与边城交错的武侠世界',
|
||||
tone: worldType === 'XIANXIA' ? '高危、空灵、失衡' : '冷峻、紧绷、江湖余震',
|
||||
settingSummary: worldType === 'XIANXIA' ? '灵潮翻涌的高空异境' : '旧桥与边城交错的裂潮地界',
|
||||
tone: worldType === 'XIANXIA' ? '高危、空灵、失衡' : '冷峻、紧绷、边境余震',
|
||||
conflictCore: '旧秩序与新威胁正在同时逼近',
|
||||
},
|
||||
schemaName: worldType === 'XIANXIA' ? '灵潮六轴' : '边城六轴',
|
||||
@@ -371,9 +371,10 @@ function buildDeterministicProfile(input: GenerateCustomWorldProfileInput) {
|
||||
name: worldType === 'XIANXIA' ? `${seed}灵境` : `${seed}边城`,
|
||||
subtitle: '前路未明',
|
||||
summary: `这个世界围绕“${setting.slice(0, 28)}”展开,旧秩序与新威胁正在同时逼近。`,
|
||||
tone: worldType === 'XIANXIA' ? '空灵、危险、失衡' : '冷峻、紧绷、江湖余震',
|
||||
tone: worldType === 'XIANXIA' ? '空灵、危险、失衡' : '冷峻、紧绷、边境余震',
|
||||
playerGoal: '查清眼前局势的关键矛盾,并守住仍值得相信的人与事',
|
||||
templateWorldType: worldType,
|
||||
compatibilityTemplateWorldType: worldType,
|
||||
majorFactions: inferMajorFactions(seed),
|
||||
coreConflicts: inferCoreConflicts(setting),
|
||||
attributeSchema: buildAttributeSchema(worldType),
|
||||
|
||||
@@ -11,9 +11,9 @@ function readNumber(value: unknown, fallback = 0) {
|
||||
function describeWorld(worldType: string) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '武侠';
|
||||
return '边城模板';
|
||||
case 'XIANXIA':
|
||||
return '仙侠';
|
||||
return '灵潮模板';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
|
||||
@@ -709,7 +709,7 @@ function buildNpcVisualPrompt(
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
return buildMasterPrompt(mergedBrief || '江湖风格角色,服装完整,姿态自然。');
|
||||
return buildMasterPrompt(mergedBrief || '自定义世界角色,服装完整,姿态自然。');
|
||||
}
|
||||
|
||||
function buildImageSequencePrompt(
|
||||
|
||||
@@ -757,9 +757,9 @@ function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) {
|
||||
function describeWorld(worldType: QuestGenerationContext['worldType']) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '武侠';
|
||||
return '边城模板';
|
||||
case 'XIANXIA':
|
||||
return '仙侠';
|
||||
return '灵潮模板';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import { buildAuthRequestContext } from '../auth/authRequestContext.js';
|
||||
import {
|
||||
bindWechatPhone,
|
||||
buildAuthLoginOptionsResponse,
|
||||
buildAuthMeResponse,
|
||||
changeUserPhone,
|
||||
createRefreshSession,
|
||||
@@ -115,6 +116,14 @@ export function createAuthRoutes(context: AppContext) {
|
||||
const router = Router();
|
||||
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
||||
|
||||
router.get(
|
||||
'/login-options',
|
||||
routeMeta({ operation: 'auth.login_options' }),
|
||||
asyncHandler(async (_request, response) => {
|
||||
sendApiResponse(response, buildAuthLoginOptionsResponse(context));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/entry',
|
||||
routeMeta({ operation: 'auth.entry' }),
|
||||
@@ -232,6 +241,7 @@ export function createAuthRoutes(context: AppContext) {
|
||||
request.query.redirectPath,
|
||||
context.config.wechatAuth.defaultRedirectPath,
|
||||
);
|
||||
const requestContext = buildAuthRequestContext(request);
|
||||
const callbackUrl = new URL(
|
||||
context.config.wechatAuth.callbackPath,
|
||||
resolveRequestOrigin(request),
|
||||
@@ -239,7 +249,12 @@ export function createAuthRoutes(context: AppContext) {
|
||||
|
||||
sendApiResponse(
|
||||
response,
|
||||
await startWechatLogin(context, callbackUrl, redirectPath),
|
||||
await startWechatLogin(
|
||||
context,
|
||||
callbackUrl,
|
||||
redirectPath,
|
||||
requestContext,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -33,6 +33,18 @@ function isAliyunConfigMissing(config: AppConfig['smsAuth']) {
|
||||
return !config.accessKeyId || !config.accessKeySecret;
|
||||
}
|
||||
|
||||
function assertAliyunRequiredConfig(config: AppConfig['smsAuth']) {
|
||||
if (!config.signName.trim()) {
|
||||
throw new Error('ALIYUN_SMS_SIGN_NAME 未配置');
|
||||
}
|
||||
if (!config.templateCode.trim()) {
|
||||
throw new Error('ALIYUN_SMS_TEMPLATE_CODE 未配置');
|
||||
}
|
||||
if (!config.templateParamKey.trim()) {
|
||||
throw new Error('ALIYUN_SMS_TEMPLATE_PARAM_KEY 未配置');
|
||||
}
|
||||
}
|
||||
|
||||
function buildProviderErrorMessage(prefix: string, message: string) {
|
||||
const normalizedMessage = message.trim();
|
||||
return normalizedMessage ? `${prefix}:${normalizedMessage}` : prefix;
|
||||
@@ -48,6 +60,7 @@ class AliyunSmsVerificationService implements SmsVerificationService {
|
||||
if (isAliyunConfigMissing(config)) {
|
||||
throw new Error('ALIYUN_SMS_ACCESS_KEY_ID 或 ALIYUN_SMS_ACCESS_KEY_SECRET 未配置');
|
||||
}
|
||||
assertAliyunRequiredConfig(config);
|
||||
|
||||
const clientConfig = new OpenApiClient.Config({
|
||||
accessKeyId: config.accessKeyId,
|
||||
|
||||
@@ -15,6 +15,7 @@ export type WechatAuthService = {
|
||||
buildAuthorizationUrl(params: {
|
||||
callbackUrl: string;
|
||||
state: string;
|
||||
userAgent?: string | null;
|
||||
}): string;
|
||||
resolveCallbackProfile(params: {
|
||||
code?: string | null;
|
||||
@@ -22,12 +23,40 @@ export type WechatAuthService = {
|
||||
}): Promise<WechatIdentityProfile>;
|
||||
};
|
||||
|
||||
type WechatAuthorizationScene = 'desktop' | 'wechat_in_app';
|
||||
|
||||
const WECHAT_IN_APP_AUTHORIZE_ENDPOINT =
|
||||
'https://open.weixin.qq.com/connect/oauth2/authorize';
|
||||
|
||||
function isWechatBrowser(userAgent?: string | null) {
|
||||
return /MicroMessenger/iu.test(userAgent ?? '');
|
||||
}
|
||||
|
||||
function isMobileBrowser(userAgent?: string | null) {
|
||||
return /Android|iPhone|iPad|iPod|Mobile/iu.test(userAgent ?? '');
|
||||
}
|
||||
|
||||
function resolveWechatAuthorizationScene(
|
||||
userAgent?: string | null,
|
||||
): WechatAuthorizationScene {
|
||||
if (isWechatBrowser(userAgent)) {
|
||||
return 'wechat_in_app';
|
||||
}
|
||||
|
||||
if (isMobileBrowser(userAgent)) {
|
||||
throw badRequest('当前浏览器请使用手机号登录,或在微信内打开后再使用微信登录');
|
||||
}
|
||||
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
class MockWechatAuthService implements WechatAuthService {
|
||||
constructor(private readonly config: AppConfig['wechatAuth']) {}
|
||||
|
||||
buildAuthorizationUrl(params: {
|
||||
callbackUrl: string;
|
||||
state: string;
|
||||
userAgent?: string | null;
|
||||
}) {
|
||||
const callbackUrl = new URL(params.callbackUrl);
|
||||
callbackUrl.searchParams.set('mock_code', this.config.mockUserId);
|
||||
@@ -64,12 +93,21 @@ class RealWechatAuthService implements WechatAuthService {
|
||||
buildAuthorizationUrl(params: {
|
||||
callbackUrl: string;
|
||||
state: string;
|
||||
userAgent?: string | null;
|
||||
}) {
|
||||
const url = new URL(this.config.authorizeEndpoint);
|
||||
const scene = resolveWechatAuthorizationScene(params.userAgent);
|
||||
const url = new URL(
|
||||
scene === 'wechat_in_app'
|
||||
? WECHAT_IN_APP_AUTHORIZE_ENDPOINT
|
||||
: 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(
|
||||
'scope',
|
||||
scene === 'wechat_in_app' ? 'snsapi_userinfo' : 'snsapi_login',
|
||||
);
|
||||
url.searchParams.set('state', params.state);
|
||||
return `${url.toString()}#wechat_redirect`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user