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:
YANG QIA 2023-09-28 20:50:20 +08:00 committed by GitHub
parent 53e49a2e9e
commit beb4793051
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 93 additions and 171 deletions

View File

@ -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>

View File

@ -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');

View File

@ -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();
}; };

View File

@ -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) {

View File

@ -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>

View File

@ -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');

View File

@ -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();
}; };

View File

@ -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());