mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-28 17:43:07 +08:00
fix(auth): normalize v2 signin redirects (#9927)
* fix(auth): normalize v2 signin redirects * fix(auth): check redirect sub-app path safely * fix: detect pro plugin license paths * refactor(client-v2): simplify auth redirect fallback * refactor(client-v2): clarify auth redirect fallback * fix(auth): support configured app-sso issuer
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
import {
|
||||
buildV2SigninHref,
|
||||
getCurrentV2RedirectPath,
|
||||
normalizeV2RedirectPath,
|
||||
redirectToV2Signin,
|
||||
resolveV2SigninRedirect,
|
||||
} from '../authRedirect';
|
||||
@@ -133,6 +134,22 @@ describe('auth redirect helpers', () => {
|
||||
});
|
||||
|
||||
describe('v2 sub-app context (router basename contains /apps/<id>/)', () => {
|
||||
it('should normalize signin redirect fallback under the current sub-app basename', () => {
|
||||
const app = {
|
||||
getPublicPath: () => '/v/',
|
||||
router: {
|
||||
getBasename: () => '/v/apps/test-app/',
|
||||
},
|
||||
} as any;
|
||||
|
||||
expect(normalizeV2RedirectPath(app, '')).toBe('/v/apps/test-app/admin/');
|
||||
expect(normalizeV2RedirectPath(app, '/admin/?tab=overview#panel')).toBe(
|
||||
'/v/apps/test-app/admin/?tab=overview#panel',
|
||||
);
|
||||
expect(normalizeV2RedirectPath(app, '/v/apps/test-app/admin/')).toBe('/v/apps/test-app/admin/');
|
||||
expect(normalizeV2RedirectPath(app, '/v/admin/')).toBe('/v/apps/test-app/admin/');
|
||||
});
|
||||
|
||||
it('should preserve sub-app segment when building current redirect path under simple public path', () => {
|
||||
const app = {
|
||||
getPublicPath: () => '/v2/',
|
||||
|
||||
@@ -204,6 +204,44 @@ function getDefaultV2AdminRedirectPath(app: AppLike) {
|
||||
return joinRootRelativePath(getV2EffectiveBasePath(app), '/admin');
|
||||
}
|
||||
|
||||
function isSafeRootRelativePath(value?: string | null) {
|
||||
return !!value && value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\');
|
||||
}
|
||||
|
||||
function preserveTrailingSlash(originalPathname: string, value: string) {
|
||||
if (originalPathname !== '/' && originalPathname.endsWith('/') && !value.endsWith('/')) {
|
||||
return `${value}/`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeV2RedirectPath(app: AppLike, target?: string | null, fallbackPath = '/admin/') {
|
||||
// In a v2 sub-app, publicPath can be `/v/` while the active router
|
||||
// basename is `/v/apps/a/`. Redirects must resolve under the basename.
|
||||
const basePath = trimTrailingSlashes(getV2EffectiveBasePath(app)) || '/';
|
||||
const fallbackTarget = isSafeRootRelativePath(fallbackPath) ? fallbackPath : '/admin/';
|
||||
const rawTarget = isSafeRootRelativePath(target) ? target : fallbackTarget;
|
||||
let { pathname, search, hash } = splitPathLike(rawTarget);
|
||||
let normalizedPathname = normalizePathname(pathname);
|
||||
|
||||
// Already under the current v2 runtime, e.g. `/v/apps/a/admin/`.
|
||||
if (basePath === '/' || normalizedPathname === basePath || normalizedPathname.startsWith(`${basePath}/`)) {
|
||||
return `${preserveTrailingSlash(pathname, normalizedPathname)}${normalizeSearch(search)}${normalizeHash(hash)}`;
|
||||
}
|
||||
|
||||
const publicPath = trimTrailingSlashes(getV2PublicPath(app)) || '/';
|
||||
// Under v2 publicPath but outside the current basename, e.g. `/v/admin/`
|
||||
// inside `/v/apps/a/`; use fallback so it lands on `/v/apps/a/admin/`.
|
||||
if (publicPath !== '/' && (normalizedPathname === publicPath || normalizedPathname.startsWith(`${publicPath}/`))) {
|
||||
({ pathname, search, hash } = splitPathLike(fallbackTarget));
|
||||
normalizedPathname = normalizePathname(pathname);
|
||||
}
|
||||
|
||||
// Basename-relative target, e.g. `/admin/`, becomes `/v/apps/a/admin/`.
|
||||
const joinedPathname = preserveTrailingSlash(pathname, joinRootRelativePath(basePath, normalizedPathname));
|
||||
return `${joinedPathname}${normalizeSearch(search)}${normalizeHash(hash)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前 v2 页面地址转换为根相对 redirect 路径。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { AppSupervisor } from '../../app-supervisor';
|
||||
|
||||
describe('AppSupervisor app-sso issuer bridge', () => {
|
||||
afterEach(async () => {
|
||||
await AppSupervisor.getInstance().destroy();
|
||||
});
|
||||
|
||||
it('stores normalized app-sso issuer values', () => {
|
||||
const supervisor = AppSupervisor.getInstance();
|
||||
|
||||
supervisor.setAppSsoIssuer(' https://main.example.com/api/ ');
|
||||
|
||||
expect(supervisor.getAppSsoIssuer()).toBe('https://main.example.com/api');
|
||||
});
|
||||
|
||||
it('clears blank app-sso issuer values', () => {
|
||||
const supervisor = AppSupervisor.getInstance();
|
||||
|
||||
supervisor.setAppSsoIssuer('https://main.example.com/api');
|
||||
supervisor.setAppSsoIssuer(' ');
|
||||
|
||||
expect(supervisor.getAppSsoIssuer()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,7 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter {
|
||||
private commandAdapterName: string;
|
||||
private appDbCreator = new ConditionalRegistry<AppDbCreatorOptions, void>();
|
||||
private appConditions = new Map<string, AppCondition>();
|
||||
private appSsoIssuer?: string;
|
||||
public appOptionsFactory: AppOptionsFactory = appOptionsFactory;
|
||||
|
||||
private environmentHeartbeatInterval = 2 * 60 * 1000;
|
||||
@@ -296,6 +297,17 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter {
|
||||
this.appOptionsFactory = factory ?? appOptionsFactory;
|
||||
}
|
||||
|
||||
setAppSsoIssuer(issuer?: string) {
|
||||
const normalized = String(issuer || '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
this.appSsoIssuer = normalized || undefined;
|
||||
}
|
||||
|
||||
getAppSsoIssuer() {
|
||||
return this.appSsoIssuer;
|
||||
}
|
||||
|
||||
async bootstrapApp(appName: string) {
|
||||
return this.processAdapter.bootstrapApp(appName);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import SignInPage from '../pages/SignInPage';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
publicPath: '/v/',
|
||||
basename: '/v/apps/sub/',
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => navigateMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@nocobase/client-v2', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nocobase/client-v2')>();
|
||||
return {
|
||||
...actual,
|
||||
useApp: () => ({
|
||||
getPublicPath: () => mockApp.publicPath,
|
||||
router: {
|
||||
getBasename: () => mockApp.basename,
|
||||
},
|
||||
}),
|
||||
usePlugin: () => ({
|
||||
authTypes: {
|
||||
getEntities: () => [],
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('SignInPage', () => {
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
mockApp.publicPath = '/v/';
|
||||
mockApp.basename = '/v/apps/sub/';
|
||||
});
|
||||
|
||||
it('normalizes empty redirect to the current v2 app admin path', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/signin?redirect=']}>
|
||||
<SignInPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith(
|
||||
{
|
||||
pathname: '/signin',
|
||||
search: '?redirect=%2Fv%2Fapps%2Fsub%2Fadmin%2F',
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps redirect that is already under the current v2 app basename', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/signin?redirect=%2Fv%2Fapps%2Fsub%2Fadmin%2F']}>
|
||||
<SignInPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,10 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { usePlugin } from '@nocobase/client-v2';
|
||||
import { normalizeV2RedirectPath, useApp, usePlugin } from '@nocobase/client-v2';
|
||||
import { Empty, Space, Spin, Tabs } from 'antd';
|
||||
import React, { lazy, Suspense, useContext, useMemo } from 'react';
|
||||
import React, { lazy, Suspense, useContext, useEffect, useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { AuthenticatorsContext, type Authenticator } from '../authenticator';
|
||||
import { useDocumentTitle } from '../hooks';
|
||||
import { useAuthTranslation, useT } from '../locale';
|
||||
@@ -43,6 +44,9 @@ function lazyByAuthType<P>(loaderMap: LoaderMap<() => Promise<{ default: React.C
|
||||
}
|
||||
|
||||
export default function SignInPage() {
|
||||
const app = useApp();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useAuthTranslation();
|
||||
// `authTypeTitle` 从服务端来时是 `tval` 生成的原始模板字符串
|
||||
// (`{{t("Password", {"ns":"@nocobase/plugin-auth"})}}`),不展开就会直出到 tab label
|
||||
@@ -57,6 +61,23 @@ export default function SignInPage() {
|
||||
const resolveSignInButton = useMemo(() => lazyByAuthType(signInButtonLoaders), [signInButtonLoaders]);
|
||||
|
||||
useDocumentTitle(t('Signin'));
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const redirect = params.get('redirect');
|
||||
const normalized = normalizeV2RedirectPath(app, redirect);
|
||||
if (redirect === normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
params.set('redirect', normalized);
|
||||
navigate(
|
||||
{
|
||||
pathname: location.pathname,
|
||||
search: `?${params}`,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [app, location.pathname, location.search, navigate]);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
return authenticators
|
||||
|
||||
+15
@@ -89,6 +89,21 @@ describe('buildRedirectPath', () => {
|
||||
).toBe('/nocobase/v2/apps/a_u4940c6p189/admin/al5yj9t81of');
|
||||
});
|
||||
|
||||
it('does NOT prepend sub-app segment when target already contains it', () => {
|
||||
expect(buildRedirectPath({ appPublicPath: '', subAppSegment: '/apps/sub', target: '/v/apps/sub/admin' })).toBe(
|
||||
'/v/apps/sub/admin',
|
||||
);
|
||||
expect(
|
||||
buildRedirectPath({ appPublicPath: '', subAppSegment: '/apps/sub', target: '/v/apps/sub/admin?tab=x#panel' }),
|
||||
).toBe('/v/apps/sub/admin?tab=x#panel');
|
||||
});
|
||||
|
||||
it('does NOT treat query strings containing the sub-app segment as an existing sub-app path', () => {
|
||||
expect(
|
||||
buildRedirectPath({ appPublicPath: '', subAppSegment: '/apps/sub', target: '/admin?next=/apps/sub/foo' }),
|
||||
).toBe('/apps/sub/admin?next=/apps/sub/foo');
|
||||
});
|
||||
|
||||
it('does NOT touch a v2 main-app target even when a sub-app segment was supplied', () => {
|
||||
// Detection is appPublicPath-only; supplying subAppSegment must not
|
||||
// override the v2-detection branch.
|
||||
|
||||
@@ -53,6 +53,7 @@ export function buildRedirectPath({ appPublicPath, subAppSegment, target }: Buil
|
||||
const normalizedAppPublicPath = (appPublicPath || '').replace(/\/+$/, '');
|
||||
const normalizedSubAppSegment = (subAppSegment || '').replace(/\/+$/, '');
|
||||
const resolvedTarget = target || '/admin';
|
||||
const [resolvedPathname] = resolvedTarget.split(/[?#]/, 1);
|
||||
|
||||
if (
|
||||
normalizedAppPublicPath &&
|
||||
@@ -61,6 +62,17 @@ export function buildRedirectPath({ appPublicPath, subAppSegment, target }: Buil
|
||||
return resolvedTarget;
|
||||
}
|
||||
|
||||
const modernSubAppSegment = `/${getModernClientPrefix()}${normalizedSubAppSegment}`;
|
||||
if (
|
||||
normalizedSubAppSegment &&
|
||||
(resolvedPathname === normalizedSubAppSegment ||
|
||||
resolvedPathname.startsWith(`${normalizedSubAppSegment}/`) ||
|
||||
resolvedPathname === modernSubAppSegment ||
|
||||
resolvedPathname.startsWith(`${modernSubAppSegment}/`))
|
||||
) {
|
||||
return resolvedTarget;
|
||||
}
|
||||
|
||||
return `${normalizedAppPublicPath}${normalizedSubAppSegment}${resolvedTarget}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,27 @@ describe('plugin-idp-oauth > IdpOauthService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should use the first forwarded header value when proxies append duplicates', () => {
|
||||
const service = new IdpOauthService({} as any, {} as any);
|
||||
vi.spyOn(AppSupervisor, 'getInstance').mockReturnValue({
|
||||
runningMode: 'multiple',
|
||||
} as any);
|
||||
|
||||
const providerContext = service.getProviderContext({
|
||||
app: { name: 'subapp' },
|
||||
path: '/api/app:getLang',
|
||||
protocol: 'http',
|
||||
host: '127.0.0.1:13001',
|
||||
headers: {
|
||||
'x-forwarded-proto': 'http,http',
|
||||
'x-forwarded-host': 'app.noco.local',
|
||||
},
|
||||
});
|
||||
|
||||
expect(providerContext.origin).toBe('http://app.noco.local');
|
||||
expect(providerContext.issuer).toBe('http://app.noco.local/api');
|
||||
});
|
||||
|
||||
test('should preserve sub app issuer path when original url uses __app prefix', () => {
|
||||
const service = new IdpOauthService({} as any, {} as any);
|
||||
vi.spyOn(AppSupervisor, 'getInstance').mockReturnValue({
|
||||
|
||||
@@ -51,6 +51,14 @@ function getJoseModule() {
|
||||
return joseModulePromise;
|
||||
}
|
||||
|
||||
function getHeaderValue(value: unknown) {
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
if (typeof raw !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
return raw.split(',')[0]?.trim() || undefined;
|
||||
}
|
||||
|
||||
export type ResourceServerConfig = {
|
||||
path?: string;
|
||||
identifier?: string;
|
||||
@@ -255,8 +263,8 @@ export class IdpOauthService {
|
||||
}
|
||||
|
||||
getOrigin(ctx: any) {
|
||||
const protocol = ctx.headers?.['x-forwarded-proto'] || ctx.protocol || 'http';
|
||||
const host = ctx.headers?.['x-forwarded-host'] || ctx.host || '';
|
||||
const protocol = getHeaderValue(ctx.headers?.['x-forwarded-proto']) || ctx.protocol || 'http';
|
||||
const host = getHeaderValue(ctx.headers?.['x-forwarded-host']) || ctx.host || '';
|
||||
return process.env.APP_PUBLIC_ORIGIN || `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
@@ -644,7 +652,7 @@ export class IdpOauthService {
|
||||
ctx.cookies?.set?.(provider.cookieName('session'), null, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: (ctx.headers?.['x-forwarded-proto'] || ctx.protocol) === 'https',
|
||||
secure: (getHeaderValue(ctx.headers?.['x-forwarded-proto']) || ctx.protocol) === 'https',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const fs = require('fs/promises');
|
||||
const { exec } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const commercialLicense = `
|
||||
/**
|
||||
@@ -27,11 +28,12 @@ function getLicenseText(packageDir) {
|
||||
}
|
||||
|
||||
async function addLicenseToFile(filePath) {
|
||||
const licenseText = getLicenseText(filePath);
|
||||
const licenseText = getLicenseText(path.resolve(filePath));
|
||||
|
||||
const data = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
if (data.startsWith(licenseText)) return false;
|
||||
if (data.startsWith(commercialLicense) || data.startsWith(openSourceLicense)) return false;
|
||||
|
||||
// 添加授权信息到文件内容的顶部
|
||||
const newData = licenseText + '\n\n' + data;
|
||||
|
||||
Reference in New Issue
Block a user