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 commit beb4793051.

* Revert "Revert "refactor(auth): OIDC, SAML auth switch popup to redirectction (#2737)""

This reverts commit 301a85d767.

* 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:
YANG QIA 2023-10-12 00:54:00 -05:00 committed by GitHub
parent 49f4d1828d
commit 89361ef61c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 698 additions and 307 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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',
},
},
};

View File

@ -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',
};

View File

@ -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': '用户不存在',
};

View File

@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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',
};

View File

@ -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': '用户不存在',
};

View File

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

View File

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

View File

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

View File

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

View File

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