fix(auth): SSO issues (#2733)
* fix(auth): sso switch popup to rediect (fix T-2024) * refactor: auth process optimization * fix: test * chore: add error handler * fix(auth): sso redirection issue of sub app * Revert "refactor(auth): OIDC, SAML auth switch popup to redirectction (#2737)" This reverts commitbeb4793051
. * Revert "Revert "refactor(auth): OIDC, SAML auth switch popup to redirectction (#2737)"" This reverts commit301a85d767
. * refactor(oidc): improve validate logic * refactor(saml): improve auth logic * fix: test * refactor(cas): improve auth logic * chore: add error handler * fix(oidc): subapp callback issue * fix: add dependency * chore: add dependency * fix(auth): set default `userBindField:email`
This commit is contained in:
parent
49f4d1828d
commit
89361ef61c
@ -35,6 +35,10 @@ export class BaseAuth extends Auth {
|
||||
return this.ctx.state.currentUser;
|
||||
}
|
||||
|
||||
validateUsername(username: string) {
|
||||
return /^[^@.<>"'/]{2,16}$/.test(username);
|
||||
}
|
||||
|
||||
async check() {
|
||||
const token = this.ctx.getBearerToken();
|
||||
if (!token) {
|
||||
|
@ -17,6 +17,7 @@ import * as formilyShared from '@formily/shared';
|
||||
import * as formilyValidator from '@formily/validator';
|
||||
import * as nocobaseEvaluators from '@nocobase/evaluators/client';
|
||||
import * as nocobaseClientUtils from '@nocobase/utils/client';
|
||||
import * as nocobaseSDK from '@nocobase/sdk';
|
||||
import { dayjs } from '@nocobase/utils/client';
|
||||
import * as ahooks from 'ahooks';
|
||||
import * as antd from 'antd';
|
||||
@ -72,6 +73,7 @@ export function defineGlobalDeps(requirejs: RequireJS) {
|
||||
requirejs.define('@nocobase/client/client', () => nocobaseClient);
|
||||
requirejs.define('@nocobase/evaluators', () => nocobaseEvaluators);
|
||||
requirejs.define('@nocobase/evaluators/client', () => nocobaseEvaluators);
|
||||
requirejs.define('@nocobase/sdk', () => nocobaseSDK);
|
||||
|
||||
// dnd-kit 相关
|
||||
requirejs.define('@dnd-kit/accessibility', () => dndKitAccessibility);
|
||||
|
@ -12,7 +12,8 @@
|
||||
"@nocobase/client": "0.x",
|
||||
"@nocobase/database": "0.x",
|
||||
"@nocobase/server": "0.x",
|
||||
"@nocobase/test": "0.x"
|
||||
"@nocobase/test": "0.x",
|
||||
"@nocobase/sdk": "0.x"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ant-design/icons": "5.x",
|
||||
|
@ -1,41 +1,45 @@
|
||||
import { Authenticator, useAPIClient, useRedirect, useCurrentUserContext } from '@nocobase/client';
|
||||
import React, { useEffect } from 'react';
|
||||
import { LoginOutlined } from '@ant-design/icons';
|
||||
import { Button, Space, App } from 'antd';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Button, Space, message } from 'antd';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { getSubAppName } from '@nocobase/sdk';
|
||||
|
||||
export const SigninPage = (props: { authenticator: Authenticator }) => {
|
||||
const { message } = App.useApp();
|
||||
const api = useAPIClient();
|
||||
const navigate = useNavigate();
|
||||
const redirect = useRedirect();
|
||||
const location = useLocation();
|
||||
const { refreshAsync } = useCurrentUserContext();
|
||||
const { refreshAsync: refresh } = useCurrentUserContext();
|
||||
|
||||
const authenticator = props.authenticator;
|
||||
|
||||
const app = getSubAppName() || 'main';
|
||||
const login = async () => {
|
||||
window.location.replace(`/api/cas:login?authenticator=${authenticator.name}`);
|
||||
window.location.replace(`/api/cas:login?authenticator=${authenticator.name}&__appName=${app}`);
|
||||
redirect();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const usp = new URLSearchParams(location.search);
|
||||
if (usp.get('authenticator') === authenticator.name) {
|
||||
api.auth
|
||||
.signIn({}, authenticator.name)
|
||||
.then(async () => {
|
||||
await refreshAsync();
|
||||
redirect();
|
||||
})
|
||||
.catch((error) => {
|
||||
navigate({
|
||||
pathname: location.pathname,
|
||||
});
|
||||
message.error(error.message);
|
||||
});
|
||||
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;
|
||||
}
|
||||
}, [location.search, authenticator.name]);
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ display: 'flex' }}>
|
||||
|
@ -1,14 +1,11 @@
|
||||
import { Context, Next } from '@nocobase/actions';
|
||||
import { CASAuth } from '../auth';
|
||||
import { COOKIE_KEY_AUTHENTICATOR } from '../../constants';
|
||||
|
||||
export const login = async (ctx: Context, next: Next) => {
|
||||
const { authenticator } = ctx.action.params;
|
||||
ctx.cookies.set(COOKIE_KEY_AUTHENTICATOR, authenticator, {
|
||||
httpOnly: true,
|
||||
});
|
||||
const auth = (await ctx.app.authManager.get(authenticator, ctx)) as CASAuth;
|
||||
const { casUrl, serviceUrl } = auth.getOptions();
|
||||
ctx.redirect(`${casUrl}/login?service=${serviceUrl}`);
|
||||
const service = encodeURIComponent(`${serviceUrl}?authenticator=${authenticator}&__appName=${ctx.app.name}`);
|
||||
ctx.redirect(`${casUrl}/login?service=${service}`);
|
||||
next();
|
||||
};
|
||||
|
@ -1,11 +1,24 @@
|
||||
import { Context, Next } from '@nocobase/actions';
|
||||
import { COOKIE_KEY_AUTHENTICATOR, COOKIE_KEY_TICKET } from '../../constants';
|
||||
import { AppSupervisor } from '@nocobase/server';
|
||||
import { CASAuth } from '../auth';
|
||||
|
||||
export const service = async (ctx: Context, next: Next) => {
|
||||
const { params } = ctx.action;
|
||||
ctx.cookies.set(COOKIE_KEY_TICKET, params.ticket, {
|
||||
httpOnly: true,
|
||||
});
|
||||
ctx.redirect(`/signin?authenticator=${ctx.cookies.get(COOKIE_KEY_AUTHENTICATOR)}`);
|
||||
const { authenticator, __appName: appName } = ctx.action.params;
|
||||
|
||||
let prefix = '';
|
||||
if (appName && appName !== 'main') {
|
||||
const appSupervisor = AppSupervisor.getInstance();
|
||||
if (appSupervisor?.runningMode !== 'single') {
|
||||
prefix = `/apps/${appName}`;
|
||||
}
|
||||
}
|
||||
|
||||
const auth = (await ctx.app.authManager.get(authenticator, ctx)) as CASAuth;
|
||||
try {
|
||||
const { token } = await auth.signIn();
|
||||
ctx.redirect(`${prefix}/signin?authenticator=${authenticator}&token=${token}`);
|
||||
} catch (error) {
|
||||
ctx.redirect(`${prefix}/signin?authenticator=${authenticator}&error=${error.message}`);
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
@ -1,8 +1,6 @@
|
||||
import { AuthConfig, BaseAuth } from '@nocobase/auth';
|
||||
import { Model } from '@nocobase/database';
|
||||
import { AuthModel } from '@nocobase/plugin-auth';
|
||||
import axios from 'axios';
|
||||
import { COOKIE_KEY_AUTHENTICATOR, COOKIE_KEY_TICKET } from '../constants';
|
||||
|
||||
export class CASAuth extends BaseAuth {
|
||||
constructor(config: AuthConfig) {
|
||||
@ -13,13 +11,6 @@ export class CASAuth extends BaseAuth {
|
||||
});
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
const ctx = this.ctx;
|
||||
ctx.cookies.set(COOKIE_KEY_TICKET, '');
|
||||
ctx.cookies.set(COOKIE_KEY_AUTHENTICATOR, '');
|
||||
await super.signOut();
|
||||
}
|
||||
|
||||
getOptions() {
|
||||
const opts = this.options || {};
|
||||
return {
|
||||
@ -32,44 +23,57 @@ export class CASAuth extends BaseAuth {
|
||||
};
|
||||
}
|
||||
|
||||
serviceValidate(ticket) {
|
||||
async serviceValidate(ticket: string) {
|
||||
const { casUrl, serviceUrl } = this.getOptions();
|
||||
const url = `${casUrl}/serviceValidate?ticket=${ticket}&service=${serviceUrl}`;
|
||||
return axios.get(url).catch((err) => {
|
||||
throw new Error('CSA serviceValidate error: ' + err.message);
|
||||
});
|
||||
try {
|
||||
const res = await axios.get(url);
|
||||
return res;
|
||||
} catch (error) {
|
||||
throw new Error('CSA serviceValidate error: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async validate() {
|
||||
const ctx = this.ctx;
|
||||
let user: Model;
|
||||
const { autoSignup } = this.getOptions();
|
||||
const ticket = ctx.cookies.get(COOKIE_KEY_TICKET);
|
||||
const res = ticket ? await this.serviceValidate(ticket) : null;
|
||||
const pattern = /<(?:cas|sso):user>(.*?)<\/(?:cas|sso):user>/;
|
||||
const nickname = res?.data.match(pattern)?.[1];
|
||||
if (nickname) {
|
||||
const userRepo = this.userCollection.repository;
|
||||
user = await userRepo.findOne({
|
||||
filter: { nickname },
|
||||
});
|
||||
if (user) {
|
||||
await this.authenticator.addUser(user, {
|
||||
through: {
|
||||
uuid: nickname,
|
||||
},
|
||||
});
|
||||
return user;
|
||||
}
|
||||
const { ticket } = ctx.action.params;
|
||||
if (!ticket) {
|
||||
throw new Error('Missing ticket');
|
||||
}
|
||||
const res = await this.serviceValidate(ticket);
|
||||
const pattern = /<(?:cas|sso):user>(.*?)<\/(?:cas|sso):user>/;
|
||||
const username = res?.data.match(pattern)?.[1];
|
||||
if (!username) {
|
||||
throw new Error('Invalid ticket');
|
||||
}
|
||||
// New data
|
||||
const authenticator = this.authenticator as AuthModel;
|
||||
if (autoSignup) {
|
||||
user = await authenticator.findOrCreateUser(nickname, {
|
||||
nickname: nickname,
|
||||
let user = await authenticator.findUser(username);
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
// Bind existed user
|
||||
user = await this.userRepository.findOne({
|
||||
filter: { username },
|
||||
});
|
||||
if (user) {
|
||||
await this.authenticator.addUser(user, {
|
||||
through: {
|
||||
uuid: username,
|
||||
},
|
||||
});
|
||||
return user;
|
||||
}
|
||||
return user;
|
||||
// New data
|
||||
if (!autoSignup) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
if (!this.validateUsername(username as string)) {
|
||||
throw new Error('Username must be 2-16 characters in length (excluding @.<>"\'/)');
|
||||
}
|
||||
return await authenticator.newUser(username, {
|
||||
username: username,
|
||||
nickname: username,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -4,7 +4,7 @@ import { observer } from '@formily/react';
|
||||
import { FormItem, Input, SchemaComponent } from '@nocobase/client';
|
||||
import { Card, Space, message } from 'antd';
|
||||
import React from 'react';
|
||||
import { useOidcTranslation } from './locale';
|
||||
import { lang, useOidcTranslation } from './locale';
|
||||
|
||||
const schema = {
|
||||
type: 'object',
|
||||
@ -13,24 +13,28 @@ const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
issuer: {
|
||||
type: 'string',
|
||||
title: '{{t("Issuer")}}',
|
||||
'x-component': 'Input',
|
||||
'x-decorator': 'FormItem',
|
||||
required: true,
|
||||
},
|
||||
clientId: {
|
||||
type: 'string',
|
||||
title: '{{t("Client ID")}}',
|
||||
'x-component': 'Input',
|
||||
'x-decorator': 'FormItem',
|
||||
required: true,
|
||||
},
|
||||
clientSecret: {
|
||||
type: 'string',
|
||||
title: '{{t("Client Secret")}}',
|
||||
'x-component': 'Input',
|
||||
'x-decorator': 'FormItem',
|
||||
required: true,
|
||||
},
|
||||
scope: {
|
||||
type: 'string',
|
||||
title: '{{t("scope")}}',
|
||||
'x-component': 'Input',
|
||||
'x-decorator': 'FormItem',
|
||||
@ -39,6 +43,7 @@ const schema = {
|
||||
},
|
||||
},
|
||||
idTokenSignedResponseAlg: {
|
||||
type: 'string',
|
||||
title: '{{t("id_token signed response algorithm")}}',
|
||||
'x-component': 'Select',
|
||||
'x-decorator': 'FormItem',
|
||||
@ -58,11 +63,13 @@ const schema = {
|
||||
],
|
||||
},
|
||||
http: {
|
||||
type: 'boolean',
|
||||
title: '{{t("HTTP")}}',
|
||||
'x-component': 'Checkbox',
|
||||
'x-decorator': 'FormItem',
|
||||
},
|
||||
port: {
|
||||
type: 'number',
|
||||
title: '{{t("Port")}}',
|
||||
'x-component': 'InputNumber',
|
||||
'x-decorator': 'FormItem',
|
||||
@ -102,9 +109,10 @@ const schema = {
|
||||
placeholder: '{{t("target")}}',
|
||||
},
|
||||
enum: [
|
||||
{ label: 'Nickname', value: 'nickname' },
|
||||
{ label: 'Email', value: 'email' },
|
||||
{ label: 'Phone', value: 'phone' },
|
||||
{ label: lang('Nickname'), value: 'nickname' },
|
||||
{ label: lang('Email'), value: 'email' },
|
||||
{ label: lang('Phone'), value: 'phone' },
|
||||
{ label: lang('Username'), value: 'username' },
|
||||
],
|
||||
},
|
||||
remove: {
|
||||
@ -124,12 +132,36 @@ const schema = {
|
||||
},
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
type: 'void',
|
||||
'x-component': 'Usage',
|
||||
userBindField: {
|
||||
type: 'string',
|
||||
title: '{{t("Use this field to bind the user")}}',
|
||||
'x-component': 'Select',
|
||||
'x-decorator': 'FormItem',
|
||||
default: 'email',
|
||||
enum: [
|
||||
{ label: lang('Email'), value: 'email' },
|
||||
{ label: lang('Username'), value: 'username' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
public: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
autoSignup: {
|
||||
'x-decorator': 'FormItem',
|
||||
type: 'boolean',
|
||||
title: '{{t("Sign up automatically when the user does not exist")}}',
|
||||
'x-component': 'Checkbox',
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
type: 'void',
|
||||
'x-component': 'Usage',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -19,4 +19,9 @@ export default {
|
||||
'Delete provider': 'Delete',
|
||||
'Sign in button name, which will be displayed on the sign in page':
|
||||
'Sign in button name, which will be displayed on the sign in page',
|
||||
'Use this field to bind the user': 'Use this field to bind the user',
|
||||
'Sign up automatically when the user does not exist': 'Sign up automatically when the user does not exist',
|
||||
'Username must be 2-16 characters in length (excluding @.<>"\'/)':
|
||||
'Username must be 2-16 characters in length (excluding @.<>"\'/)',
|
||||
'User not found': 'User not found',
|
||||
};
|
||||
|
@ -6,4 +6,8 @@ export default {
|
||||
Copied: '已复制',
|
||||
'Field Map': '字段映射',
|
||||
'id_token signed response algorithm': 'id_token签名算法',
|
||||
'Use this field to bind the user': '使用此字段绑定用户',
|
||||
'Sign up automatically when the user does not exist': '用户不存在时自动注册',
|
||||
'Username must be 2-16 characters in length (excluding @.<>"\'/)': '用户名必须为2-16个字符并且不包含@.<>"\'/)',
|
||||
'User not found': '用户不存在',
|
||||
};
|
||||
|
@ -8,6 +8,7 @@ describe('oidc', () => {
|
||||
let app: MockServer;
|
||||
let db: Database;
|
||||
let agent;
|
||||
let authenticator;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = mockServer({
|
||||
@ -19,7 +20,7 @@ describe('oidc', () => {
|
||||
agent = app.agent();
|
||||
|
||||
const authenticatorRepo = db.getRepository('authenticators');
|
||||
await authenticatorRepo.create({
|
||||
authenticator = await authenticatorRepo.create({
|
||||
values: {
|
||||
name: 'oidc-auth',
|
||||
authType: authType,
|
||||
@ -39,8 +40,11 @@ describe('oidc', () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
await db.getRepository('users').destroy({
|
||||
truncate: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get auth url', async () => {
|
||||
@ -58,7 +62,14 @@ describe('oidc', () => {
|
||||
expect(token).toBe(search.get('token'));
|
||||
});
|
||||
|
||||
it('should sign in', async () => {
|
||||
it('should not sign in without auto signup', async () => {
|
||||
await authenticator.update({
|
||||
options: {
|
||||
public: {
|
||||
autoSignup: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
agent = app.agent();
|
||||
jest.spyOn(OIDCAuth.prototype, 'createOIDCClient').mockResolvedValue({
|
||||
callback: (uri, { code }) => ({
|
||||
@ -72,16 +83,112 @@ 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.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should sign in with auto signup', async () => {
|
||||
await authenticator.update({
|
||||
options: {
|
||||
public: {
|
||||
autoSignup: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
agent = app.agent();
|
||||
jest.spyOn(OIDCAuth.prototype, 'createOIDCClient').mockResolvedValue({
|
||||
callback: (uri, { code }) => ({
|
||||
access_token: 'access_token',
|
||||
}),
|
||||
userinfo: () => ({
|
||||
sub: 'user1',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const res = await agent
|
||||
.set('X-Authenticator', 'oidc-auth')
|
||||
.set('Cookie', ['nocobase_oidc=token'])
|
||||
.get('/auth:signIn?state=token%3Dtoken&name=oidc-auth');
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.data.user).toBeDefined();
|
||||
expect(res.body.data.user.nickname).toBe('user1');
|
||||
});
|
||||
|
||||
it('should sign in with existed email', async () => {
|
||||
await authenticator.update({
|
||||
options: {
|
||||
oidc: {
|
||||
userBindField: 'email',
|
||||
},
|
||||
public: {
|
||||
autoSignup: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
const user = await db.getRepository('users').create({
|
||||
values: {
|
||||
nickname: 'has-email',
|
||||
email: 'test@nocobase.com',
|
||||
},
|
||||
});
|
||||
agent = app.agent();
|
||||
jest.spyOn(OIDCAuth.prototype, 'createOIDCClient').mockResolvedValue({
|
||||
callback: (uri, { code }) => ({
|
||||
access_token: 'access_token',
|
||||
}),
|
||||
userinfo: () => ({
|
||||
sub: 'user1',
|
||||
email: 'test@nocobase.com',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const res = await agent
|
||||
.set('X-Authenticator', 'oidc-auth')
|
||||
.set('Cookie', ['nocobase_oidc=token'])
|
||||
.get('/auth:signIn?state=token%3Dtoken&name=oidc-auth');
|
||||
|
||||
expect(res.body.data.user).toBeDefined();
|
||||
expect(res.body.data.user.id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should sign in with existed username', async () => {
|
||||
await authenticator.update({
|
||||
options: {
|
||||
oidc: {
|
||||
userBindField: 'username',
|
||||
},
|
||||
public: {
|
||||
autoSignup: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const user = await db.getRepository('users').create({
|
||||
values: {
|
||||
nickname: 'has-username',
|
||||
username: 'username',
|
||||
},
|
||||
});
|
||||
agent = app.agent();
|
||||
jest.spyOn(OIDCAuth.prototype, 'createOIDCClient').mockResolvedValue({
|
||||
callback: (uri, { code }) => ({
|
||||
access_token: 'access_token',
|
||||
}),
|
||||
userinfo: () => ({
|
||||
username: 'username',
|
||||
sub: 'username',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const res = await agent
|
||||
.set('X-Authenticator', 'oidc-auth')
|
||||
.set('Cookie', ['nocobase_oidc=token'])
|
||||
.get('/auth:signIn?state=token%3Dtoken&name=oidc-auth');
|
||||
|
||||
expect(res.body.data.user).toBeDefined();
|
||||
expect(res.body.data.user.id).toBe(user.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('field mapping', () => {
|
||||
|
@ -4,6 +4,7 @@ import { nanoid } from 'nanoid';
|
||||
import { cookieName } from '../../constants';
|
||||
|
||||
export const getAuthUrl = async (ctx: Context, next: Next) => {
|
||||
const app = ctx.app.name;
|
||||
const auth = ctx.auth as OIDCAuth;
|
||||
const client = await auth.createOIDCClient();
|
||||
const { scope } = auth.getOptions();
|
||||
@ -16,7 +17,7 @@ export const getAuthUrl = async (ctx: Context, next: Next) => {
|
||||
response_type: 'code',
|
||||
scope: scope || 'openid email profile',
|
||||
redirect_uri: auth.getRedirectUri(),
|
||||
state: `token=${token}&name=${ctx.headers['x-authenticator']}`,
|
||||
state: `token=${token}&name=${ctx.headers['x-authenticator']}&app=${app}`,
|
||||
});
|
||||
|
||||
return next();
|
||||
|
@ -1,29 +1,27 @@
|
||||
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';
|
||||
import { AppSupervisor } from '@nocobase/server';
|
||||
|
||||
export const redirect = async (ctx: Context, next: Next) => {
|
||||
const {
|
||||
params: { state },
|
||||
} = ctx.action;
|
||||
const search = new URLSearchParams(state);
|
||||
const authenticator = search.get('name');
|
||||
const appName = search.get('app');
|
||||
let prefix = '';
|
||||
if (appName && appName !== 'main') {
|
||||
const appSupervisor = AppSupervisor.getInstance();
|
||||
if (appSupervisor?.runningMode !== 'single') {
|
||||
prefix = `/apps/${appName}`;
|
||||
}
|
||||
}
|
||||
const auth = (await ctx.app.authManager.get(authenticator, ctx)) as OIDCAuth;
|
||||
try {
|
||||
const { token } = await auth.signIn();
|
||||
ctx.redirect(`${prefix}/signin?authenticator=${authenticator}&token=${token}`);
|
||||
} catch (error) {
|
||||
ctx.redirect(`${prefix}/signin?authenticator=${authenticator}&error=${error.message}`);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
|
@ -0,0 +1,33 @@
|
||||
import { Migration } from '@nocobase/server';
|
||||
import { authType } from '../../constants';
|
||||
|
||||
export default class UpdateAutoSignupMigration extends Migration {
|
||||
async up() {
|
||||
const result = await this.app.version.satisfies('<=0.14.0-alpha.8');
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = this.db.getRepository('authenticators');
|
||||
const items = await r.find({
|
||||
filter: {
|
||||
authType,
|
||||
},
|
||||
});
|
||||
|
||||
await this.db.sequelize.transaction(async (transaction) => {
|
||||
for (const item of items) {
|
||||
let options = item.options;
|
||||
options = {
|
||||
public: { autoSignup: true, ...options.public },
|
||||
oidc: { userBindField: 'email', ...options.oidc },
|
||||
};
|
||||
item.set('options', options);
|
||||
await item.save({ transaction });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async down() {}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import { AuthConfig, BaseAuth } from '@nocobase/auth';
|
||||
import { Issuer } from 'openid-client';
|
||||
import { cookieName } from '../constants';
|
||||
import { AuthModel } from '@nocobase/plugin-auth';
|
||||
|
||||
export class OIDCAuth extends BaseAuth {
|
||||
constructor(config: AuthConfig) {
|
||||
@ -49,9 +50,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) {
|
||||
@ -65,27 +64,42 @@ export class OIDCAuth extends BaseAuth {
|
||||
});
|
||||
const userInfo: { [key: string]: any } = await client.userinfo(tokens.access_token);
|
||||
const mappedUserInfo = this.mapField(userInfo);
|
||||
const { nickname, name, sub, email, phone } = mappedUserInfo;
|
||||
const username = nickname || name || sub;
|
||||
// Compatible processing
|
||||
// When email is provided, use email to find user
|
||||
// If found, associate the user with the current authenticator
|
||||
if (email) {
|
||||
const user = await this.userRepository.findOne({
|
||||
const { nickname, username, name, sub, email, phone } = mappedUserInfo;
|
||||
const authenticator = this.authenticator as AuthModel;
|
||||
let user = await authenticator.findUser(sub);
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
// Bind existed user
|
||||
const { userBindField = 'email' } = this.getOptions();
|
||||
if (userBindField === 'email' && email) {
|
||||
user = await this.userRepository.findOne({
|
||||
filter: { email },
|
||||
});
|
||||
if (user) {
|
||||
await this.authenticator.addUser(user, {
|
||||
through: {
|
||||
uuid: sub,
|
||||
},
|
||||
});
|
||||
return user;
|
||||
}
|
||||
} else if (userBindField === 'username' && username) {
|
||||
user = await this.userRepository.findOne({
|
||||
filter: { username },
|
||||
});
|
||||
}
|
||||
|
||||
return await this.authenticator.findOrCreateUser(sub, {
|
||||
nickname: username,
|
||||
if (user) {
|
||||
await authenticator.addUser(user.id, {
|
||||
through: {
|
||||
uuid: sub,
|
||||
},
|
||||
});
|
||||
return user;
|
||||
}
|
||||
// Create new user
|
||||
const { autoSignup } = this.options?.public || {};
|
||||
if (!autoSignup) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
if (username && !this.validateUsername(username)) {
|
||||
throw new Error('Username must be 2-16 characters in length (excluding @.<>"\'/)');
|
||||
}
|
||||
return await authenticator.newUser(sub, {
|
||||
username: username ?? null,
|
||||
nickname: nickname || name || username || sub,
|
||||
email: email ?? null,
|
||||
phone: phone ?? null,
|
||||
});
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { InstallOptions, Plugin } from '@nocobase/server';
|
||||
import { Gateway, InstallOptions, Plugin } from '@nocobase/server';
|
||||
import { getAuthUrl } from './actions/getAuthUrl';
|
||||
import { redirect } from './actions/redirect';
|
||||
import { authType } from '../constants';
|
||||
import { OIDCAuth } from './oidc-auth';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export class OidcPlugin extends Plugin {
|
||||
afterAdd() {}
|
||||
@ -10,6 +11,14 @@ export class OidcPlugin extends Plugin {
|
||||
beforeLoad() {}
|
||||
|
||||
async load() {
|
||||
this.db.addMigrations({
|
||||
namespace: 'auth',
|
||||
directory: resolve(__dirname, 'migrations'),
|
||||
context: {
|
||||
plugin: this,
|
||||
},
|
||||
});
|
||||
|
||||
this.app.authManager.registerTypes(authType, {
|
||||
auth: OIDCAuth,
|
||||
});
|
||||
@ -24,6 +33,22 @@ export class OidcPlugin extends Plugin {
|
||||
});
|
||||
|
||||
this.app.acl.allow('oidc', '*', 'public');
|
||||
|
||||
Gateway.getInstance().addAppSelectorMiddleware(async (ctx, next) => {
|
||||
const { req } = ctx;
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const params = url.searchParams;
|
||||
const state = params.get('state');
|
||||
if (!state) {
|
||||
return next();
|
||||
}
|
||||
const search = new URLSearchParams(state);
|
||||
const appName = search.get('app');
|
||||
if (appName) {
|
||||
ctx.resolvedAppName = appName;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
}
|
||||
|
||||
async install(options?: InstallOptions) {}
|
||||
|
@ -21,6 +21,7 @@
|
||||
"@nocobase/client": "0.x",
|
||||
"@nocobase/database": "0.x",
|
||||
"@nocobase/server": "0.x",
|
||||
"@nocobase/test": "0.x"
|
||||
"@nocobase/test": "0.x",
|
||||
"@nocobase/sdk": "0.x"
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,8 @@ import { Card, message } from 'antd';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import { observer, useForm } from '@formily/react';
|
||||
import { useRecord, FormItem, Input } from '@nocobase/client';
|
||||
import { useSamlTranslation } from './locale';
|
||||
import { lang, useSamlTranslation } from './locale';
|
||||
import { getSubAppName } from '@nocobase/sdk';
|
||||
|
||||
const schema = {
|
||||
type: 'object',
|
||||
@ -34,12 +35,36 @@ const schema = {
|
||||
'x-component': 'Checkbox',
|
||||
'x-decorator': 'FormItem',
|
||||
},
|
||||
usage: {
|
||||
type: 'void',
|
||||
'x-component': 'Usage',
|
||||
userBindField: {
|
||||
type: 'string',
|
||||
title: '{{t("Use this field to bind the user")}}',
|
||||
'x-component': 'Select',
|
||||
'x-decorator': 'FormItem',
|
||||
default: 'email',
|
||||
enum: [
|
||||
{ label: lang('Email'), value: 'email' },
|
||||
{ label: lang('Username'), value: 'username' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
public: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
autoSignup: {
|
||||
'x-decorator': 'FormItem',
|
||||
type: 'boolean',
|
||||
title: '{{t("Sign up automatically when the user does not exist")}}',
|
||||
'x-component': 'Checkbox',
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
type: 'void',
|
||||
'x-component': 'Usage',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -48,9 +73,10 @@ const Usage = observer(() => {
|
||||
const record = useRecord();
|
||||
const { t } = useSamlTranslation();
|
||||
|
||||
const app = getSubAppName() || 'main';
|
||||
const name = form.values.name ?? record.name;
|
||||
const { protocol, host } = window.location;
|
||||
const url = `${protocol}//${host}/api/saml:redirect?authenticator=${name}`;
|
||||
const url = `${protocol}//${host}/api/saml:redirect?authenticator=${name}&__appName=${app}`;
|
||||
|
||||
const copy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
|
@ -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>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { AuthenticatorsContext, OptionsComponentProvider, SigninPageExtensionProvider } from '@nocobase/client';
|
||||
import React, { FC, useContext } from 'react';
|
||||
import { OptionsComponentProvider, SigninPageExtensionProvider } from '@nocobase/client';
|
||||
import React, { FC } from 'react';
|
||||
import { SAMLButton } from './SAMLButton';
|
||||
import { Options } from './Options';
|
||||
import { authType } from '../constants';
|
||||
|
@ -20,4 +20,9 @@ export default {
|
||||
'Are you sure you want to delete it?': 'Are you sure you want to delete it?',
|
||||
'Sign in button name, which will be displayed on the sign in page':
|
||||
'Sign in button name, which will be displayed on the sign in page',
|
||||
'Use this field to bind the user': 'Use this field to bind the user',
|
||||
'Sign up automatically when the user does not exist': 'Sign up automatically when the user does not exist',
|
||||
'Username must be 2-16 characters in length (excluding @.<>"\'/)':
|
||||
'Username must be 2-16 characters in length (excluding @.<>"\'/)',
|
||||
'User not found': 'User not found',
|
||||
};
|
||||
|
@ -22,4 +22,8 @@ export default {
|
||||
'Sign in button name, which will be displayed on the sign in page': '登录按钮名称,将在登录页中显示',
|
||||
Copied: '已复制',
|
||||
Usage: '使用',
|
||||
'Use this field to bind the user': '使用此字段绑定用户',
|
||||
'Sign up automatically when the user does not exist': '用户不存在时自动注册',
|
||||
'Username must be 2-16 characters in length (excluding @.<>"\'/)': '用户名必须为2-16个字符并且不包含@.<>"\'/)',
|
||||
'User not found': '用户不存在',
|
||||
};
|
||||
|
@ -8,6 +8,7 @@ describe('saml', () => {
|
||||
let app: MockServer;
|
||||
let db: Database;
|
||||
let agent;
|
||||
let authenticator;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = mockServer({
|
||||
@ -19,7 +20,7 @@ describe('saml', () => {
|
||||
agent = app.agent();
|
||||
|
||||
const authenticatorRepo = db.getRepository('authenticators');
|
||||
await authenticatorRepo.create({
|
||||
authenticator = await authenticatorRepo.create({
|
||||
values: {
|
||||
name: 'saml-auth',
|
||||
authType: authType,
|
||||
@ -39,8 +40,11 @@ describe('saml', () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
await db.getRepository('users').destroy({
|
||||
truncate: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get auth url', async () => {
|
||||
@ -48,7 +52,15 @@ describe('saml', () => {
|
||||
expect(res.body.data).toBeDefined();
|
||||
});
|
||||
|
||||
it('should sign in', async () => {
|
||||
it('should not sign in without auto signup', async () => {
|
||||
await authenticator.update({
|
||||
options: {
|
||||
...authenticator.options,
|
||||
public: {
|
||||
autoSignup: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
jest.spyOn(SAML.prototype, 'validatePostResponseAsync').mockResolvedValue({
|
||||
profile: {
|
||||
nameID: 'test@nocobase.com',
|
||||
@ -61,21 +73,56 @@ 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.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should sign in with auto signup', async () => {
|
||||
await authenticator.update({
|
||||
options: {
|
||||
...authenticator.options,
|
||||
public: {
|
||||
autoSignup: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
jest.spyOn(SAML.prototype, 'validatePostResponseAsync').mockResolvedValue({
|
||||
profile: {
|
||||
nameID: 'test@nocobase.com',
|
||||
email: 'test@nocobase.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'Nocobase',
|
||||
issuer: 'issuer',
|
||||
nameIDFormat: 'Email',
|
||||
},
|
||||
loggedOut: false,
|
||||
});
|
||||
|
||||
const res = await agent.set('X-Authenticator', 'saml-auth').resource('auth').signIn().send({
|
||||
samlResponse: {},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.data.user).toBeDefined();
|
||||
expect(res.body.data.user.nickname).toBe('Test Nocobase');
|
||||
});
|
||||
|
||||
it('should sign in via email', async () => {
|
||||
await authenticator.update({
|
||||
options: {
|
||||
saml: {
|
||||
...authenticator.options.saml,
|
||||
userBindField: 'email',
|
||||
},
|
||||
public: {
|
||||
autoSignup: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
jest.spyOn(SAML.prototype, 'validatePostResponseAsync').mockResolvedValue({
|
||||
profile: {
|
||||
nameID: 'old@nocobase.com',
|
||||
@ -109,7 +156,54 @@ describe('saml', () => {
|
||||
|
||||
expect(res.body.data.user).toBeDefined();
|
||||
expect(res.body.data.user.id).toBe(user.id);
|
||||
expect(res.body.data.user.email).toBe('old@nocobase.com');
|
||||
expect(res.body.data.user.nickname).toBe('old@nocobase.com');
|
||||
});
|
||||
|
||||
it('should sign in via usernmae', async () => {
|
||||
await authenticator.update({
|
||||
options: {
|
||||
saml: {
|
||||
...authenticator.options.saml,
|
||||
userBindField: 'username',
|
||||
},
|
||||
public: {
|
||||
autoSignup: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
jest.spyOn(SAML.prototype, 'validatePostResponseAsync').mockResolvedValue({
|
||||
profile: {
|
||||
nameID: 'username',
|
||||
email: 'old@nocobase.com',
|
||||
firstName: 'Old',
|
||||
lastName: 'Nocobase',
|
||||
issuer: 'issuer',
|
||||
nameIDFormat: '',
|
||||
},
|
||||
loggedOut: false,
|
||||
});
|
||||
|
||||
const email = 'old@nocobase.com';
|
||||
const userRepo = db.getRepository('users');
|
||||
const user = await userRepo.create({
|
||||
values: {
|
||||
username: 'username',
|
||||
nickname: email,
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await agent
|
||||
.set('X-Authenticator', 'saml-auth')
|
||||
.resource('auth')
|
||||
.signIn()
|
||||
.send({
|
||||
samlResponse: {
|
||||
SAMLResponse: '',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.body.data.user).toBeDefined();
|
||||
expect(res.body.data.user.id).toBe(user.id);
|
||||
});
|
||||
});
|
||||
|
@ -1,30 +1,22 @@
|
||||
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';
|
||||
import { AppSupervisor } from '@nocobase/server';
|
||||
|
||||
export const redirect = async (ctx: Context, next: Next) => {
|
||||
const { authenticator, __appName: appName } = ctx.action.params || {};
|
||||
let prefix = '';
|
||||
if (appName && appName !== 'main') {
|
||||
const appSupervisor = AppSupervisor.getInstance();
|
||||
if (appSupervisor?.runningMode !== 'single') {
|
||||
prefix = `/apps/${appName}`;
|
||||
}
|
||||
}
|
||||
const auth = (await ctx.app.authManager.get(authenticator, ctx)) as SAMLAuth;
|
||||
try {
|
||||
const { token } = await auth.signIn();
|
||||
ctx.redirect(`${prefix}/signin?authenticator=${authenticator}&token=${token}`);
|
||||
} catch (error) {
|
||||
ctx.redirect(`${prefix}/signin?authenticator=${authenticator}&error=${error.message}`);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
|
@ -0,0 +1,32 @@
|
||||
import { Migration } from '@nocobase/server';
|
||||
import { authType } from '../../constants';
|
||||
|
||||
export default class UpdateAutoSignupMigration extends Migration {
|
||||
async up() {
|
||||
const result = await this.app.version.satisfies('<=0.14.0-alpha.8');
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = this.db.getRepository('authenticators');
|
||||
const items = await r.find({
|
||||
filter: {
|
||||
authType,
|
||||
},
|
||||
});
|
||||
await this.db.sequelize.transaction(async (transaction) => {
|
||||
for (const item of items) {
|
||||
let options = item.options;
|
||||
options = {
|
||||
public: { autoSignup: true, ...options.public },
|
||||
saml: { userBindField: 'email', ...options.saml },
|
||||
};
|
||||
item.set('options', options);
|
||||
await item.save({ transaction });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async down() {}
|
||||
}
|
@ -4,6 +4,7 @@ import { metadata } from './actions/metadata';
|
||||
import { redirect } from './actions/redirect';
|
||||
import { SAMLAuth } from './saml-auth';
|
||||
import { authType } from '../constants';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export class SAMLPlugin extends Plugin {
|
||||
afterAdd() {}
|
||||
@ -11,6 +12,14 @@ export class SAMLPlugin extends Plugin {
|
||||
beforeLoad() {}
|
||||
|
||||
async load() {
|
||||
this.db.addMigrations({
|
||||
namespace: 'auth',
|
||||
directory: resolve(__dirname, 'migrations'),
|
||||
context: {
|
||||
plugin: this,
|
||||
},
|
||||
});
|
||||
|
||||
this.app.authManager.registerTypes(authType, {
|
||||
auth: SAMLAuth,
|
||||
});
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { AuthConfig, BaseAuth } from '@nocobase/auth';
|
||||
import { AuthModel } from '@nocobase/plugin-auth';
|
||||
import { SAML, SamlConfig } from '@node-saml/node-saml';
|
||||
|
||||
interface SAMLOptions {
|
||||
@ -23,7 +24,7 @@ export class SAMLAuth extends BaseAuth {
|
||||
const name = this.authenticator.get('name');
|
||||
const protocol = http ? 'http' : 'https';
|
||||
return {
|
||||
callbackUrl: `${protocol}://${ctx.host}/api/saml:redirect?authenticator=${name}`,
|
||||
callbackUrl: `${protocol}://${ctx.host}/api/saml:redirect?authenticator=${name}&app=${ctx.app.name}`,
|
||||
entryPoint: ssoUrl,
|
||||
issuer: name,
|
||||
cert: certificate,
|
||||
@ -35,38 +36,57 @@ 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());
|
||||
|
||||
const { profile } = await saml.validatePostResponseAsync(samlResponse);
|
||||
|
||||
const { nameID, nickname, username, email, firstName, lastName, phone } = profile as Record<string, string>;
|
||||
|
||||
const fullName = firstName && lastName && `${firstName} ${lastName}`;
|
||||
const name = nickname ?? username ?? fullName ?? nameID;
|
||||
|
||||
// Compatible processing
|
||||
// When email is provided or nameID is email, use email to find user
|
||||
// If found, associate the user with the current authenticator
|
||||
if (email || nameID.match(/^.+@.+\..+$/)) {
|
||||
const user = await this.userRepository.findOne({
|
||||
filter: { email: email || nameID },
|
||||
});
|
||||
if (user) {
|
||||
await this.authenticator.addUser(user, {
|
||||
through: {
|
||||
uuid: nameID,
|
||||
},
|
||||
});
|
||||
return user;
|
||||
}
|
||||
const { nameID, nickname, firstName, lastName, phone } = profile;
|
||||
let { email, username } = profile;
|
||||
const isEmail = nameID.match(/^.+@.+\..+$/);
|
||||
if (!email && isEmail) {
|
||||
email = nameID;
|
||||
}
|
||||
if (!username && !isEmail) {
|
||||
username = nameID;
|
||||
}
|
||||
|
||||
return await this.authenticator.findOrCreateUser(nameID, {
|
||||
nickname: name,
|
||||
const authenticator = this.authenticator as AuthModel;
|
||||
let user = await authenticator.findUser(nameID);
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
// Bind existed user
|
||||
const { userBindField = 'email' } = this.options?.saml || {};
|
||||
if (userBindField === 'email' && email) {
|
||||
user = await this.userRepository.findOne({
|
||||
filter: { email },
|
||||
});
|
||||
} else if (userBindField === 'username' && username) {
|
||||
user = await this.userRepository.findOne({
|
||||
filter: { username },
|
||||
});
|
||||
}
|
||||
if (user) {
|
||||
await this.authenticator.addUser(user.id, {
|
||||
through: {
|
||||
uuid: nameID,
|
||||
},
|
||||
});
|
||||
return user;
|
||||
}
|
||||
// Create new user
|
||||
const { autoSignup } = this.options?.public || {};
|
||||
if (!autoSignup) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
if (username && !this.validateUsername(username as string)) {
|
||||
throw new Error('Username must be 2-16 characters in length (excluding @.<>"\'/)');
|
||||
}
|
||||
const fullName = firstName && lastName && `${firstName} ${lastName}`;
|
||||
return await authenticator.newUser(nameID, {
|
||||
username: username ?? null,
|
||||
nickname: nickname || fullName || username || nameID,
|
||||
email: email ?? null,
|
||||
phone: phone ?? null,
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user