refactor(auth): OIDC, SAML auth switch popup to redirectction (#2737)
* fix(auth): sso switch popup to rediect (fix T-2024) * refactor: auth process optimization * fix: test * chore: add error handler
This commit is contained in:
parent
53e49a2e9e
commit
beb4793051
@ -1,76 +1,57 @@
|
|||||||
import { LoginOutlined } from '@ant-design/icons';
|
import { LoginOutlined } from '@ant-design/icons';
|
||||||
import { Authenticator, css, useAPIClient, useRedirect } from '@nocobase/client';
|
import { Authenticator, css, useAPIClient, useCurrentUserContext, useRedirect } from '@nocobase/client';
|
||||||
import { useMemoizedFn } from 'ahooks';
|
import { Button, Space, message } from 'antd';
|
||||||
import { Button, Space } from 'antd';
|
import React, { useEffect } from 'react';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { useOidcTranslation } from './locale';
|
import { useOidcTranslation } from './locale';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
export interface OIDCProvider {
|
export interface OIDCProvider {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OIDCButton = (props: { authenticator: Authenticator }) => {
|
export const OIDCButton = ({ authenticator }: { authenticator: Authenticator }) => {
|
||||||
const { t } = useOidcTranslation();
|
const { t } = useOidcTranslation();
|
||||||
const [windowHandler, setWindowHandler] = useState<Window | undefined>();
|
|
||||||
const api = useAPIClient();
|
const api = useAPIClient();
|
||||||
const redirect = useRedirect();
|
const redirect = useRedirect();
|
||||||
|
const location = useLocation();
|
||||||
|
const { refreshAsync: refresh } = useCurrentUserContext();
|
||||||
|
|
||||||
/**
|
const login = async () => {
|
||||||
* 打开登录弹出框
|
|
||||||
*/
|
|
||||||
const handleOpen = async (name: string) => {
|
|
||||||
const response = await api.request({
|
const response = await api.request({
|
||||||
method: 'post',
|
method: 'post',
|
||||||
url: 'oidc:getAuthUrl',
|
url: 'oidc:getAuthUrl',
|
||||||
headers: {
|
headers: {
|
||||||
'X-Authenticator': name,
|
'X-Authenticator': authenticator.name,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const authUrl = response?.data?.data;
|
const authUrl = response?.data?.data;
|
||||||
const { width, height } = screen;
|
window.location.replace(authUrl);
|
||||||
|
|
||||||
const win = window.open(
|
|
||||||
authUrl,
|
|
||||||
'_blank',
|
|
||||||
`width=800,height=600,left=${(width - 800) / 2},top=${
|
|
||||||
(height - 600) / 2
|
|
||||||
},toolbar=no,menubar=no,location=no,status=no`,
|
|
||||||
);
|
|
||||||
|
|
||||||
setWindowHandler(win);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
useEffect(() => {
|
||||||
* 从弹出窗口,发消息回来进行登录
|
const params = new URLSearchParams(location.search);
|
||||||
*/
|
const token = params.get('token');
|
||||||
const handleOIDCLogin = useMemoizedFn(async (event: MessageEvent) => {
|
const name = params.get('authenticator');
|
||||||
const { state } = event.data;
|
const error = params.get('error');
|
||||||
const search = new URLSearchParams(state);
|
if (name !== authenticator.name) {
|
||||||
const authenticator = search.get('name');
|
return;
|
||||||
try {
|
}
|
||||||
await api.auth.signIn(event.data, authenticator);
|
if (error) {
|
||||||
redirect();
|
message.error(t(error));
|
||||||
} catch (err) {
|
return;
|
||||||
console.error(err);
|
}
|
||||||
|
if (token) {
|
||||||
|
api.auth.setToken(token);
|
||||||
|
api.auth.setAuthenticator(name);
|
||||||
|
refresh()
|
||||||
|
.then(() => redirect())
|
||||||
|
.catch((err) => console.log(err));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* 监听弹出窗口的消息
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (!windowHandler) return;
|
|
||||||
|
|
||||||
const channel = new BroadcastChannel('nocobase-oidc-response');
|
|
||||||
channel.onmessage = handleOIDCLogin;
|
|
||||||
return () => {
|
|
||||||
channel.close();
|
|
||||||
};
|
|
||||||
}, [windowHandler, handleOIDCLogin]);
|
|
||||||
|
|
||||||
const authenticator = props.authenticator;
|
|
||||||
return (
|
return (
|
||||||
<Space
|
<Space
|
||||||
direction="vertical"
|
direction="vertical"
|
||||||
@ -78,7 +59,7 @@ export const OIDCButton = (props: { authenticator: Authenticator }) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<Button shape="round" block icon={<LoginOutlined />} onClick={() => handleOpen(authenticator.name)}>
|
<Button shape="round" block icon={<LoginOutlined />} onClick={login}>
|
||||||
{t(authenticator.title)}
|
{t(authenticator.title)}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
@ -72,12 +72,7 @@ describe('oidc', () => {
|
|||||||
const res = await agent
|
const res = await agent
|
||||||
.set('X-Authenticator', 'oidc-auth')
|
.set('X-Authenticator', 'oidc-auth')
|
||||||
.set('Cookie', ['nocobase_oidc=token'])
|
.set('Cookie', ['nocobase_oidc=token'])
|
||||||
.resource('auth')
|
.get('/auth:signIn?state=token%3Dtoken&name=oidc-auth');
|
||||||
.signIn()
|
|
||||||
.send({
|
|
||||||
code: '',
|
|
||||||
state: 'token=token&name=oidc-auth',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.body.data.user).toBeDefined();
|
expect(res.body.data.user).toBeDefined();
|
||||||
expect(res.body.data.user.nickname).toBe('user1');
|
expect(res.body.data.user.nickname).toBe('user1');
|
||||||
|
@ -1,29 +1,18 @@
|
|||||||
import { Context } from '@nocobase/actions';
|
import { Context, Next } from '@nocobase/actions';
|
||||||
|
import { OIDCAuth } from '../oidc-auth';
|
||||||
export const redirect = async (ctx: Context, next) => {
|
|
||||||
const { params } = ctx.action;
|
|
||||||
|
|
||||||
const template = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title></title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script>
|
|
||||||
const channel = new BroadcastChannel('nocobase-oidc-response');
|
|
||||||
channel.postMessage(${JSON.stringify(params)})
|
|
||||||
window.close();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
ctx.body = template;
|
|
||||||
ctx.withoutDataWrapping = true;
|
|
||||||
|
|
||||||
|
export const redirect = async (ctx: Context, next: Next) => {
|
||||||
|
const {
|
||||||
|
params: { state },
|
||||||
|
} = ctx.action;
|
||||||
|
const search = new URLSearchParams(state);
|
||||||
|
const authenticator = search.get('name');
|
||||||
|
const auth = (await ctx.app.authManager.get(authenticator, ctx)) as OIDCAuth;
|
||||||
|
try {
|
||||||
|
const { token } = await auth.signIn();
|
||||||
|
ctx.redirect(`/signin?authenticator=${authenticator}&token=${token}`);
|
||||||
|
} catch (error) {
|
||||||
|
ctx.redirect(`/signin?authenticator=${authenticator}&error=${error.message}`);
|
||||||
|
}
|
||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
|
@ -49,9 +49,7 @@ export class OIDCAuth extends BaseAuth {
|
|||||||
|
|
||||||
async validate() {
|
async validate() {
|
||||||
const ctx = this.ctx;
|
const ctx = this.ctx;
|
||||||
const {
|
const { params: values } = ctx.action;
|
||||||
params: { values },
|
|
||||||
} = ctx.action;
|
|
||||||
const token = ctx.cookies.get(cookieName);
|
const token = ctx.cookies.get(cookieName);
|
||||||
const search = new URLSearchParams(values.state);
|
const search = new URLSearchParams(values.state);
|
||||||
if (search.get('token') !== token) {
|
if (search.get('token') !== token) {
|
||||||
|
@ -1,69 +1,52 @@
|
|||||||
import { LoginOutlined } from '@ant-design/icons';
|
import { LoginOutlined } from '@ant-design/icons';
|
||||||
import { Authenticator, css, useAPIClient, useRedirect } from '@nocobase/client';
|
import { Authenticator, css, useAPIClient, useCurrentUserContext, useRedirect } from '@nocobase/client';
|
||||||
import { Button, Space } from 'antd';
|
import { Button, Space, message } from 'antd';
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useSamlTranslation } from './locale';
|
import { useSamlTranslation } from './locale';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
export const SAMLButton = (props: { authenticator: Authenticator }) => {
|
export const SAMLButton = ({ authenticator }: { authenticator: Authenticator }) => {
|
||||||
const { t } = useSamlTranslation();
|
const { t } = useSamlTranslation();
|
||||||
const [windowHandler, setWindowHandler] = useState<Window | undefined>();
|
|
||||||
const api = useAPIClient();
|
const api = useAPIClient();
|
||||||
const redirect = useRedirect();
|
const redirect = useRedirect();
|
||||||
|
const location = useLocation();
|
||||||
|
const { refreshAsync: refresh } = useCurrentUserContext();
|
||||||
|
|
||||||
/**
|
const login = async () => {
|
||||||
* 打开登录弹出框
|
|
||||||
*/
|
|
||||||
const handleOpen = async (name: string) => {
|
|
||||||
const response = await api.request({
|
const response = await api.request({
|
||||||
method: 'post',
|
method: 'post',
|
||||||
url: 'saml:getAuthUrl',
|
url: 'saml:getAuthUrl',
|
||||||
headers: {
|
headers: {
|
||||||
'X-Authenticator': name,
|
'X-Authenticator': authenticator.name,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const authUrl = response?.data?.data;
|
const authUrl = response?.data?.data;
|
||||||
const { width, height } = screen;
|
window.location.replace(authUrl);
|
||||||
|
|
||||||
const win = window.open(
|
|
||||||
authUrl,
|
|
||||||
'_blank',
|
|
||||||
`width=800,height=600,left=${(width - 800) / 2},top=${
|
|
||||||
(height - 600) / 2
|
|
||||||
},toolbar=no,menubar=no,location=no,status=no`,
|
|
||||||
);
|
|
||||||
|
|
||||||
setWindowHandler(win);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSAMLLogin = useCallback(
|
|
||||||
async (event: MessageEvent) => {
|
|
||||||
try {
|
|
||||||
await api.auth.signIn(event.data, event.data?.authenticator);
|
|
||||||
redirect();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
windowHandler.close();
|
|
||||||
setWindowHandler(undefined);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[api, redirect, windowHandler],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 监听弹出窗口的消息
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!windowHandler) return;
|
const params = new URLSearchParams(location.search);
|
||||||
|
const token = params.get('token');
|
||||||
|
const name = params.get('authenticator');
|
||||||
|
const error = params.get('error');
|
||||||
|
if (name !== authenticator.name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
message.error(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
api.auth.setToken(token);
|
||||||
|
api.auth.setAuthenticator(name);
|
||||||
|
refresh()
|
||||||
|
.then(() => redirect())
|
||||||
|
.catch((err) => console.log(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener('message', handleSAMLLogin);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('message', handleSAMLLogin);
|
|
||||||
};
|
|
||||||
}, [windowHandler, handleSAMLLogin]);
|
|
||||||
|
|
||||||
const authenticator = props.authenticator;
|
|
||||||
return (
|
return (
|
||||||
<Space
|
<Space
|
||||||
direction="vertical"
|
direction="vertical"
|
||||||
@ -71,7 +54,7 @@ export const SAMLButton = (props: { authenticator: Authenticator }) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<Button shape="round" block icon={<LoginOutlined />} onClick={() => handleOpen(authenticator.name)}>
|
<Button shape="round" block icon={<LoginOutlined />} onClick={login}>
|
||||||
{t(authenticator.title)}
|
{t(authenticator.title)}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
@ -61,15 +61,9 @@ describe('saml', () => {
|
|||||||
loggedOut: false,
|
loggedOut: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await agent
|
const res = await agent.set('X-Authenticator', 'saml-auth').resource('auth').signIn().send({
|
||||||
.set('X-Authenticator', 'saml-auth')
|
samlResponse: {},
|
||||||
.resource('auth')
|
});
|
||||||
.signIn()
|
|
||||||
.send({
|
|
||||||
samlResponse: {
|
|
||||||
SAMLResponse: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.body.data.user).toBeDefined();
|
expect(res.body.data.user).toBeDefined();
|
||||||
expect(res.body.data.user.nickname).toBe('Test Nocobase');
|
expect(res.body.data.user.nickname).toBe('Test Nocobase');
|
||||||
|
@ -1,30 +1,14 @@
|
|||||||
import { Context } from '@nocobase/actions';
|
import { Context, Next } from '@nocobase/actions';
|
||||||
|
import { SAMLAuth } from '../saml-auth';
|
||||||
export const redirect = async (ctx: Context, next) => {
|
|
||||||
const { params } = ctx.action;
|
|
||||||
|
|
||||||
const template = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title></title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script>
|
|
||||||
window.opener.postMessage(${JSON.stringify({
|
|
||||||
authenticator: params.authenticator,
|
|
||||||
samlResponse: params.values,
|
|
||||||
})}, '*');
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
ctx.body = template;
|
|
||||||
ctx.withoutDataWrapping = true;
|
|
||||||
|
|
||||||
|
export const redirect = async (ctx: Context, next: Next) => {
|
||||||
|
const { authenticator } = ctx.action.params || {};
|
||||||
|
const auth = (await ctx.app.authManager.get(authenticator, ctx)) as SAMLAuth;
|
||||||
|
try {
|
||||||
|
const { token } = await auth.signIn();
|
||||||
|
ctx.redirect(`/signin?authenticator=${authenticator}&token=${token}`);
|
||||||
|
} catch (error) {
|
||||||
|
ctx.redirect(`/signin?authenticator=${authenticator}&error=${error.message}`);
|
||||||
|
}
|
||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
|
@ -35,9 +35,7 @@ export class SAMLAuth extends BaseAuth {
|
|||||||
async validate() {
|
async validate() {
|
||||||
const ctx = this.ctx;
|
const ctx = this.ctx;
|
||||||
const {
|
const {
|
||||||
params: {
|
params: { values: samlResponse },
|
||||||
values: { samlResponse },
|
|
||||||
},
|
|
||||||
} = ctx.action;
|
} = ctx.action;
|
||||||
const saml = new SAML(this.getOptions());
|
const saml = new SAML(this.getOptions());
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user