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 { Authenticator, css, useAPIClient, useRedirect } from '@nocobase/client';
 | 
			
		||||
import { useMemoizedFn } from 'ahooks';
 | 
			
		||||
import { Button, Space } from 'antd';
 | 
			
		||||
import React, { useEffect, useState } from 'react';
 | 
			
		||||
import { Authenticator, css, useAPIClient, useCurrentUserContext, useRedirect } from '@nocobase/client';
 | 
			
		||||
import { Button, Space, message } from 'antd';
 | 
			
		||||
import React, { useEffect } from 'react';
 | 
			
		||||
import { useOidcTranslation } from './locale';
 | 
			
		||||
import { useLocation } from 'react-router-dom';
 | 
			
		||||
 | 
			
		||||
export interface OIDCProvider {
 | 
			
		||||
  clientId: string;
 | 
			
		||||
  title: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const OIDCButton = (props: { authenticator: Authenticator }) => {
 | 
			
		||||
export const OIDCButton = ({ authenticator }: { authenticator: Authenticator }) => {
 | 
			
		||||
  const { t } = useOidcTranslation();
 | 
			
		||||
  const [windowHandler, setWindowHandler] = useState<Window | undefined>();
 | 
			
		||||
  const api = useAPIClient();
 | 
			
		||||
  const redirect = useRedirect();
 | 
			
		||||
  const location = useLocation();
 | 
			
		||||
  const { refreshAsync: refresh } = useCurrentUserContext();
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * 打开登录弹出框
 | 
			
		||||
   */
 | 
			
		||||
  const handleOpen = async (name: string) => {
 | 
			
		||||
  const login = async () => {
 | 
			
		||||
    const response = await api.request({
 | 
			
		||||
      method: 'post',
 | 
			
		||||
      url: 'oidc:getAuthUrl',
 | 
			
		||||
      headers: {
 | 
			
		||||
        'X-Authenticator': name,
 | 
			
		||||
        'X-Authenticator': authenticator.name,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const authUrl = response?.data?.data;
 | 
			
		||||
    const { width, height } = screen;
 | 
			
		||||
 | 
			
		||||
    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);
 | 
			
		||||
    window.location.replace(authUrl);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * 从弹出窗口,发消息回来进行登录
 | 
			
		||||
   */
 | 
			
		||||
  const handleOIDCLogin = useMemoizedFn(async (event: MessageEvent) => {
 | 
			
		||||
    const { state } = event.data;
 | 
			
		||||
    const search = new URLSearchParams(state);
 | 
			
		||||
    const authenticator = search.get('name');
 | 
			
		||||
    try {
 | 
			
		||||
      await api.auth.signIn(event.data, authenticator);
 | 
			
		||||
      redirect();
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      console.error(err);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    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(t(error));
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    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 (
 | 
			
		||||
    <Space
 | 
			
		||||
      direction="vertical"
 | 
			
		||||
@ -78,7 +59,7 @@ export const OIDCButton = (props: { authenticator: Authenticator }) => {
 | 
			
		||||
        display: flex;
 | 
			
		||||
      `}
 | 
			
		||||
    >
 | 
			
		||||
      <Button shape="round" block icon={<LoginOutlined />} onClick={() => handleOpen(authenticator.name)}>
 | 
			
		||||
      <Button shape="round" block icon={<LoginOutlined />} onClick={login}>
 | 
			
		||||
        {t(authenticator.title)}
 | 
			
		||||
      </Button>
 | 
			
		||||
    </Space>
 | 
			
		||||
 | 
			
		||||
@ -72,12 +72,7 @@ describe('oidc', () => {
 | 
			
		||||
    const res = await agent
 | 
			
		||||
      .set('X-Authenticator', 'oidc-auth')
 | 
			
		||||
      .set('Cookie', ['nocobase_oidc=token'])
 | 
			
		||||
      .resource('auth')
 | 
			
		||||
      .signIn()
 | 
			
		||||
      .send({
 | 
			
		||||
        code: '',
 | 
			
		||||
        state: 'token=token&name=oidc-auth',
 | 
			
		||||
      });
 | 
			
		||||
      .get('/auth:signIn?state=token%3Dtoken&name=oidc-auth');
 | 
			
		||||
 | 
			
		||||
    expect(res.body.data.user).toBeDefined();
 | 
			
		||||
    expect(res.body.data.user.nickname).toBe('user1');
 | 
			
		||||
 | 
			
		||||
@ -1,29 +1,18 @@
 | 
			
		||||
import { Context } from '@nocobase/actions';
 | 
			
		||||
 | 
			
		||||
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;
 | 
			
		||||
import { Context, Next } from '@nocobase/actions';
 | 
			
		||||
import { OIDCAuth } from '../oidc-auth';
 | 
			
		||||
 | 
			
		||||
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();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -49,9 +49,7 @@ export class OIDCAuth extends BaseAuth {
 | 
			
		||||
 | 
			
		||||
  async validate() {
 | 
			
		||||
    const ctx = this.ctx;
 | 
			
		||||
    const {
 | 
			
		||||
      params: { values },
 | 
			
		||||
    } = ctx.action;
 | 
			
		||||
    const { params: values } = ctx.action;
 | 
			
		||||
    const token = ctx.cookies.get(cookieName);
 | 
			
		||||
    const search = new URLSearchParams(values.state);
 | 
			
		||||
    if (search.get('token') !== token) {
 | 
			
		||||
 | 
			
		||||
@ -1,69 +1,52 @@
 | 
			
		||||
import { LoginOutlined } from '@ant-design/icons';
 | 
			
		||||
import { Authenticator, css, useAPIClient, useRedirect } from '@nocobase/client';
 | 
			
		||||
import { Button, Space } from 'antd';
 | 
			
		||||
import React, { useCallback, useEffect, useState } from 'react';
 | 
			
		||||
import { Authenticator, css, useAPIClient, useCurrentUserContext, useRedirect } from '@nocobase/client';
 | 
			
		||||
import { Button, Space, message } from 'antd';
 | 
			
		||||
import React, { useEffect } from 'react';
 | 
			
		||||
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 [windowHandler, setWindowHandler] = useState<Window | undefined>();
 | 
			
		||||
  const api = useAPIClient();
 | 
			
		||||
  const redirect = useRedirect();
 | 
			
		||||
  const location = useLocation();
 | 
			
		||||
  const { refreshAsync: refresh } = useCurrentUserContext();
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * 打开登录弹出框
 | 
			
		||||
   */
 | 
			
		||||
  const handleOpen = async (name: string) => {
 | 
			
		||||
  const login = async () => {
 | 
			
		||||
    const response = await api.request({
 | 
			
		||||
      method: 'post',
 | 
			
		||||
      url: 'saml:getAuthUrl',
 | 
			
		||||
      headers: {
 | 
			
		||||
        'X-Authenticator': name,
 | 
			
		||||
        'X-Authenticator': authenticator.name,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const authUrl = response?.data?.data;
 | 
			
		||||
    const { width, height } = screen;
 | 
			
		||||
 | 
			
		||||
    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);
 | 
			
		||||
    window.location.replace(authUrl);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  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(() => {
 | 
			
		||||
    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 (
 | 
			
		||||
    <Space
 | 
			
		||||
      direction="vertical"
 | 
			
		||||
@ -71,7 +54,7 @@ export const SAMLButton = (props: { authenticator: Authenticator }) => {
 | 
			
		||||
        display: flex;
 | 
			
		||||
      `}
 | 
			
		||||
    >
 | 
			
		||||
      <Button shape="round" block icon={<LoginOutlined />} onClick={() => handleOpen(authenticator.name)}>
 | 
			
		||||
      <Button shape="round" block icon={<LoginOutlined />} onClick={login}>
 | 
			
		||||
        {t(authenticator.title)}
 | 
			
		||||
      </Button>
 | 
			
		||||
    </Space>
 | 
			
		||||
 | 
			
		||||
@ -61,14 +61,8 @@ describe('saml', () => {
 | 
			
		||||
      loggedOut: false,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const res = await agent
 | 
			
		||||
      .set('X-Authenticator', 'saml-auth')
 | 
			
		||||
      .resource('auth')
 | 
			
		||||
      .signIn()
 | 
			
		||||
      .send({
 | 
			
		||||
        samlResponse: {
 | 
			
		||||
          SAMLResponse: '',
 | 
			
		||||
        },
 | 
			
		||||
    const res = await agent.set('X-Authenticator', 'saml-auth').resource('auth').signIn().send({
 | 
			
		||||
      samlResponse: {},
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    expect(res.body.data.user).toBeDefined();
 | 
			
		||||
 | 
			
		||||
@ -1,30 +1,14 @@
 | 
			
		||||
import { Context } from '@nocobase/actions';
 | 
			
		||||
 | 
			
		||||
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;
 | 
			
		||||
import { Context, Next } from '@nocobase/actions';
 | 
			
		||||
import { SAMLAuth } from '../saml-auth';
 | 
			
		||||
 | 
			
		||||
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();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -35,9 +35,7 @@ export class SAMLAuth extends BaseAuth {
 | 
			
		||||
  async validate() {
 | 
			
		||||
    const ctx = this.ctx;
 | 
			
		||||
    const {
 | 
			
		||||
      params: {
 | 
			
		||||
        values: { samlResponse },
 | 
			
		||||
      },
 | 
			
		||||
      params: { values: samlResponse },
 | 
			
		||||
    } = ctx.action;
 | 
			
		||||
    const saml = new SAML(this.getOptions());
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user