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; return this.ctx.state.currentUser;
} }
validateUsername(username: string) {
return /^[^@.<>"'/]{2,16}$/.test(username);
}
async check() { async check() {
const token = this.ctx.getBearerToken(); const token = this.ctx.getBearerToken();
if (!token) { if (!token) {

View File

@ -17,6 +17,7 @@ import * as formilyShared from '@formily/shared';
import * as formilyValidator from '@formily/validator'; import * as formilyValidator from '@formily/validator';
import * as nocobaseEvaluators from '@nocobase/evaluators/client'; import * as nocobaseEvaluators from '@nocobase/evaluators/client';
import * as nocobaseClientUtils from '@nocobase/utils/client'; import * as nocobaseClientUtils from '@nocobase/utils/client';
import * as nocobaseSDK from '@nocobase/sdk';
import { dayjs } from '@nocobase/utils/client'; import { dayjs } from '@nocobase/utils/client';
import * as ahooks from 'ahooks'; import * as ahooks from 'ahooks';
import * as antd from 'antd'; import * as antd from 'antd';
@ -72,6 +73,7 @@ export function defineGlobalDeps(requirejs: RequireJS) {
requirejs.define('@nocobase/client/client', () => nocobaseClient); requirejs.define('@nocobase/client/client', () => nocobaseClient);
requirejs.define('@nocobase/evaluators', () => nocobaseEvaluators); requirejs.define('@nocobase/evaluators', () => nocobaseEvaluators);
requirejs.define('@nocobase/evaluators/client', () => nocobaseEvaluators); requirejs.define('@nocobase/evaluators/client', () => nocobaseEvaluators);
requirejs.define('@nocobase/sdk', () => nocobaseSDK);
// dnd-kit 相关 // dnd-kit 相关
requirejs.define('@dnd-kit/accessibility', () => dndKitAccessibility); requirejs.define('@dnd-kit/accessibility', () => dndKitAccessibility);

View File

@ -12,7 +12,8 @@
"@nocobase/client": "0.x", "@nocobase/client": "0.x",
"@nocobase/database": "0.x", "@nocobase/database": "0.x",
"@nocobase/server": "0.x", "@nocobase/server": "0.x",
"@nocobase/test": "0.x" "@nocobase/test": "0.x",
"@nocobase/sdk": "0.x"
}, },
"devDependencies": { "devDependencies": {
"@ant-design/icons": "5.x", "@ant-design/icons": "5.x",

View File

@ -1,41 +1,45 @@
import { Authenticator, useAPIClient, useRedirect, useCurrentUserContext } from '@nocobase/client'; import { Authenticator, useAPIClient, useRedirect, useCurrentUserContext } from '@nocobase/client';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { LoginOutlined } from '@ant-design/icons'; import { LoginOutlined } from '@ant-design/icons';
import { Button, Space, App } from 'antd'; import { Button, Space, message } from 'antd';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { getSubAppName } from '@nocobase/sdk';
export const SigninPage = (props: { authenticator: Authenticator }) => { export const SigninPage = (props: { authenticator: Authenticator }) => {
const { message } = App.useApp();
const api = useAPIClient(); const api = useAPIClient();
const navigate = useNavigate();
const redirect = useRedirect(); const redirect = useRedirect();
const location = useLocation(); const location = useLocation();
const { refreshAsync } = useCurrentUserContext(); const { refreshAsync: refresh } = useCurrentUserContext();
const authenticator = props.authenticator; const authenticator = props.authenticator;
const app = getSubAppName() || 'main';
const login = async () => { const login = async () => {
window.location.replace(`/api/cas:login?authenticator=${authenticator.name}`); window.location.replace(`/api/cas:login?authenticator=${authenticator.name}&__appName=${app}`);
redirect(); redirect();
}; };
useEffect(() => { useEffect(() => {
const usp = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
if (usp.get('authenticator') === authenticator.name) { const token = params.get('token');
api.auth const name = params.get('authenticator');
.signIn({}, authenticator.name) const error = params.get('error');
.then(async () => { if (name !== authenticator.name) {
await refreshAsync(); return;
redirect();
})
.catch((error) => {
navigate({
pathname: location.pathname,
});
message.error(error.message);
});
} }
}, [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 ( return (
<Space direction="vertical" style={{ display: 'flex' }}> <Space direction="vertical" style={{ display: 'flex' }}>

View File

@ -1,14 +1,11 @@
import { Context, Next } from '@nocobase/actions'; import { Context, Next } from '@nocobase/actions';
import { CASAuth } from '../auth'; import { CASAuth } from '../auth';
import { COOKIE_KEY_AUTHENTICATOR } from '../../constants';
export const login = async (ctx: Context, next: Next) => { export const login = async (ctx: Context, next: Next) => {
const { authenticator } = ctx.action.params; 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 auth = (await ctx.app.authManager.get(authenticator, ctx)) as CASAuth;
const { casUrl, serviceUrl } = auth.getOptions(); 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(); next();
}; };

View File

@ -1,11 +1,24 @@
import { Context, Next } from '@nocobase/actions'; 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) => { export const service = async (ctx: Context, next: Next) => {
const { params } = ctx.action; const { authenticator, __appName: appName } = ctx.action.params;
ctx.cookies.set(COOKIE_KEY_TICKET, params.ticket, {
httpOnly: true, let prefix = '';
}); if (appName && appName !== 'main') {
ctx.redirect(`/signin?authenticator=${ctx.cookies.get(COOKIE_KEY_AUTHENTICATOR)}`); 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(); return next();
}; };

View File

@ -1,8 +1,6 @@
import { AuthConfig, BaseAuth } from '@nocobase/auth'; import { AuthConfig, BaseAuth } from '@nocobase/auth';
import { Model } from '@nocobase/database';
import { AuthModel } from '@nocobase/plugin-auth'; import { AuthModel } from '@nocobase/plugin-auth';
import axios from 'axios'; import axios from 'axios';
import { COOKIE_KEY_AUTHENTICATOR, COOKIE_KEY_TICKET } from '../constants';
export class CASAuth extends BaseAuth { export class CASAuth extends BaseAuth {
constructor(config: AuthConfig) { 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() { getOptions() {
const opts = this.options || {}; const opts = this.options || {};
return { return {
@ -32,44 +23,57 @@ export class CASAuth extends BaseAuth {
}; };
} }
serviceValidate(ticket) { async serviceValidate(ticket: string) {
const { casUrl, serviceUrl } = this.getOptions(); const { casUrl, serviceUrl } = this.getOptions();
const url = `${casUrl}/serviceValidate?ticket=${ticket}&service=${serviceUrl}`; const url = `${casUrl}/serviceValidate?ticket=${ticket}&service=${serviceUrl}`;
return axios.get(url).catch((err) => { try {
throw new Error('CSA serviceValidate error: ' + err.message); const res = await axios.get(url);
}); return res;
} catch (error) {
throw new Error('CSA serviceValidate error: ' + error.message);
}
} }
async validate() { async validate() {
const ctx = this.ctx; const ctx = this.ctx;
let user: Model;
const { autoSignup } = this.getOptions(); const { autoSignup } = this.getOptions();
const ticket = ctx.cookies.get(COOKIE_KEY_TICKET); const { ticket } = ctx.action.params;
const res = ticket ? await this.serviceValidate(ticket) : null; if (!ticket) {
throw new Error('Missing ticket');
}
const res = await this.serviceValidate(ticket);
const pattern = /<(?:cas|sso):user>(.*?)<\/(?:cas|sso):user>/; const pattern = /<(?:cas|sso):user>(.*?)<\/(?:cas|sso):user>/;
const nickname = res?.data.match(pattern)?.[1]; const username = res?.data.match(pattern)?.[1];
if (nickname) { if (!username) {
const userRepo = this.userCollection.repository; throw new Error('Invalid ticket');
user = await userRepo.findOne({ }
filter: { nickname }, const authenticator = this.authenticator as AuthModel;
let user = await authenticator.findUser(username);
if (user) {
return user;
}
// Bind existed user
user = await this.userRepository.findOne({
filter: { username },
}); });
if (user) { if (user) {
await this.authenticator.addUser(user, { await this.authenticator.addUser(user, {
through: { through: {
uuid: nickname, uuid: username,
}, },
}); });
return user; return user;
} }
}
// New data // New data
const authenticator = this.authenticator as AuthModel; if (!autoSignup) {
if (autoSignup) { throw new Error('User not found');
user = await authenticator.findOrCreateUser(nickname, {
nickname: nickname,
});
return user;
} }
return user; 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 { LoginOutlined } from '@ant-design/icons';
import { Authenticator, css, useAPIClient, useRedirect } from '@nocobase/client'; import { Authenticator, css, useAPIClient, useCurrentUserContext, useRedirect } from '@nocobase/client';
import { useMemoizedFn } from 'ahooks'; import { Button, Space, message } from 'antd';
import { Button, Space } from 'antd'; import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { useOidcTranslation } from './locale'; import { useOidcTranslation } from './locale';
import { useLocation } from 'react-router-dom';
export interface OIDCProvider { export interface OIDCProvider {
clientId: string; clientId: string;
title: string; title: string;
} }
export const OIDCButton = (props: { authenticator: Authenticator }) => { export const OIDCButton = ({ authenticator }: { authenticator: Authenticator }) => {
const { t } = useOidcTranslation(); const { t } = useOidcTranslation();
const [windowHandler, setWindowHandler] = useState<Window | undefined>();
const api = useAPIClient(); const api = useAPIClient();
const redirect = useRedirect(); const redirect = useRedirect();
const location = useLocation();
const { refreshAsync: refresh } = useCurrentUserContext();
/** const login = async () => {
*
*/
const handleOpen = async (name: string) => {
const response = await api.request({ const response = await api.request({
method: 'post', method: 'post',
url: 'oidc:getAuthUrl', url: 'oidc:getAuthUrl',
headers: { headers: {
'X-Authenticator': name, 'X-Authenticator': authenticator.name,
}, },
}); });
const authUrl = response?.data?.data; const authUrl = response?.data?.data;
const { width, height } = screen; window.location.replace(authUrl);
const win = window.open(
authUrl,
'_blank',
`width=800,height=600,left=${(width - 800) / 2},top=${
(height - 600) / 2
},toolbar=no,menubar=no,location=no,status=no`,
);
setWindowHandler(win);
}; };
/** useEffect(() => {
* const params = new URLSearchParams(location.search);
*/ const token = params.get('token');
const handleOIDCLogin = useMemoizedFn(async (event: MessageEvent) => { const name = params.get('authenticator');
const { state } = event.data; const error = params.get('error');
const search = new URLSearchParams(state); if (name !== authenticator.name) {
const authenticator = search.get('name'); return;
try { }
await api.auth.signIn(event.data, authenticator); if (error) {
redirect(); message.error(t(error));
} catch (err) { return;
console.error(err); }
if (token) {
api.auth.setToken(token);
api.auth.setAuthenticator(name);
refresh()
.then(() => redirect())
.catch((err) => console.log(err));
return;
} }
}); });
/**
*
*/
useEffect(() => {
if (!windowHandler) return;
const channel = new BroadcastChannel('nocobase-oidc-response');
channel.onmessage = handleOIDCLogin;
return () => {
channel.close();
};
}, [windowHandler, handleOIDCLogin]);
const authenticator = props.authenticator;
return ( return (
<Space <Space
direction="vertical" direction="vertical"
@ -78,7 +59,7 @@ export const OIDCButton = (props: { authenticator: Authenticator }) => {
display: flex; display: flex;
`} `}
> >
<Button shape="round" block icon={<LoginOutlined />} onClick={() => handleOpen(authenticator.name)}> <Button shape="round" block icon={<LoginOutlined />} onClick={login}>
{t(authenticator.title)} {t(authenticator.title)}
</Button> </Button>
</Space> </Space>

View File

@ -4,7 +4,7 @@ import { observer } from '@formily/react';
import { FormItem, Input, SchemaComponent } from '@nocobase/client'; import { FormItem, Input, SchemaComponent } from '@nocobase/client';
import { Card, Space, message } from 'antd'; import { Card, Space, message } from 'antd';
import React from 'react'; import React from 'react';
import { useOidcTranslation } from './locale'; import { lang, useOidcTranslation } from './locale';
const schema = { const schema = {
type: 'object', type: 'object',
@ -13,24 +13,28 @@ const schema = {
type: 'object', type: 'object',
properties: { properties: {
issuer: { issuer: {
type: 'string',
title: '{{t("Issuer")}}', title: '{{t("Issuer")}}',
'x-component': 'Input', 'x-component': 'Input',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
required: true, required: true,
}, },
clientId: { clientId: {
type: 'string',
title: '{{t("Client ID")}}', title: '{{t("Client ID")}}',
'x-component': 'Input', 'x-component': 'Input',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
required: true, required: true,
}, },
clientSecret: { clientSecret: {
type: 'string',
title: '{{t("Client Secret")}}', title: '{{t("Client Secret")}}',
'x-component': 'Input', 'x-component': 'Input',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
required: true, required: true,
}, },
scope: { scope: {
type: 'string',
title: '{{t("scope")}}', title: '{{t("scope")}}',
'x-component': 'Input', 'x-component': 'Input',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
@ -39,6 +43,7 @@ const schema = {
}, },
}, },
idTokenSignedResponseAlg: { idTokenSignedResponseAlg: {
type: 'string',
title: '{{t("id_token signed response algorithm")}}', title: '{{t("id_token signed response algorithm")}}',
'x-component': 'Select', 'x-component': 'Select',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
@ -58,11 +63,13 @@ const schema = {
], ],
}, },
http: { http: {
type: 'boolean',
title: '{{t("HTTP")}}', title: '{{t("HTTP")}}',
'x-component': 'Checkbox', 'x-component': 'Checkbox',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
}, },
port: { port: {
type: 'number',
title: '{{t("Port")}}', title: '{{t("Port")}}',
'x-component': 'InputNumber', 'x-component': 'InputNumber',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
@ -102,9 +109,10 @@ const schema = {
placeholder: '{{t("target")}}', placeholder: '{{t("target")}}',
}, },
enum: [ enum: [
{ label: 'Nickname', value: 'nickname' }, { label: lang('Nickname'), value: 'nickname' },
{ label: 'Email', value: 'email' }, { label: lang('Email'), value: 'email' },
{ label: 'Phone', value: 'phone' }, { label: lang('Phone'), value: 'phone' },
{ label: lang('Username'), value: 'username' },
], ],
}, },
remove: { remove: {
@ -124,13 +132,37 @@ const schema = {
}, },
}, },
}, },
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: { usage: {
type: 'void', type: 'void',
'x-component': 'Usage', 'x-component': 'Usage',
}, },
}, },
},
},
}; };
const Usage = observer(() => { const Usage = observer(() => {

View File

@ -19,4 +19,9 @@ export default {
'Delete provider': 'Delete', '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':
'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: '已复制', Copied: '已复制',
'Field Map': '字段映射', 'Field Map': '字段映射',
'id_token signed response algorithm': 'id_token签名算法', '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 app: MockServer;
let db: Database; let db: Database;
let agent; let agent;
let authenticator;
beforeAll(async () => { beforeAll(async () => {
app = mockServer({ app = mockServer({
@ -19,7 +20,7 @@ describe('oidc', () => {
agent = app.agent(); agent = app.agent();
const authenticatorRepo = db.getRepository('authenticators'); const authenticatorRepo = db.getRepository('authenticators');
await authenticatorRepo.create({ authenticator = await authenticatorRepo.create({
values: { values: {
name: 'oidc-auth', name: 'oidc-auth',
authType: authType, authType: authType,
@ -39,8 +40,11 @@ describe('oidc', () => {
await app.destroy(); await app.destroy();
}); });
afterEach(() => { afterEach(async () => {
jest.restoreAllMocks(); jest.restoreAllMocks();
await db.getRepository('users').destroy({
truncate: true,
});
}); });
it('should get auth url', async () => { it('should get auth url', async () => {
@ -58,7 +62,14 @@ describe('oidc', () => {
expect(token).toBe(search.get('token')); 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(); agent = app.agent();
jest.spyOn(OIDCAuth.prototype, 'createOIDCClient').mockResolvedValue({ jest.spyOn(OIDCAuth.prototype, 'createOIDCClient').mockResolvedValue({
callback: (uri, { code }) => ({ callback: (uri, { code }) => ({
@ -72,16 +83,112 @@ describe('oidc', () => {
const res = await agent const res = await agent
.set('X-Authenticator', 'oidc-auth') .set('X-Authenticator', 'oidc-auth')
.set('Cookie', ['nocobase_oidc=token']) .set('Cookie', ['nocobase_oidc=token'])
.resource('auth') .get('/auth:signIn?state=token%3Dtoken&name=oidc-auth');
.signIn()
.send({ expect(res.statusCode).toBe(401);
code: '',
state: 'token=token&name=oidc-auth',
}); });
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).toBeDefined();
expect(res.body.data.user.nickname).toBe('user1'); 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', () => { it('field mapping', () => {

View File

@ -4,6 +4,7 @@ import { nanoid } from 'nanoid';
import { cookieName } from '../../constants'; import { cookieName } from '../../constants';
export const getAuthUrl = async (ctx: Context, next: Next) => { export const getAuthUrl = async (ctx: Context, next: Next) => {
const app = ctx.app.name;
const auth = ctx.auth as OIDCAuth; const auth = ctx.auth as OIDCAuth;
const client = await auth.createOIDCClient(); const client = await auth.createOIDCClient();
const { scope } = auth.getOptions(); const { scope } = auth.getOptions();
@ -16,7 +17,7 @@ export const getAuthUrl = async (ctx: Context, next: Next) => {
response_type: 'code', response_type: 'code',
scope: scope || 'openid email profile', scope: scope || 'openid email profile',
redirect_uri: auth.getRedirectUri(), redirect_uri: auth.getRedirectUri(),
state: `token=${token}&name=${ctx.headers['x-authenticator']}`, state: `token=${token}&name=${ctx.headers['x-authenticator']}&app=${app}`,
}); });
return next(); return next();

View File

@ -1,29 +1,27 @@
import { Context } from '@nocobase/actions'; import { Context, Next } from '@nocobase/actions';
import { OIDCAuth } from '../oidc-auth';
export const redirect = async (ctx: Context, next) => { import { AppSupervisor } from '@nocobase/server';
const { params } = ctx.action;
const template = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
</head>
<body>
<script>
const channel = new BroadcastChannel('nocobase-oidc-response');
channel.postMessage(${JSON.stringify(params)})
window.close();
</script>
</body>
</html>
`;
ctx.body = template;
ctx.withoutDataWrapping = true;
export const redirect = async (ctx: Context, next: Next) => {
const {
params: { state },
} = ctx.action;
const search = new URLSearchParams(state);
const authenticator = search.get('name');
const 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(); 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 { AuthConfig, BaseAuth } from '@nocobase/auth';
import { Issuer } from 'openid-client'; import { Issuer } from 'openid-client';
import { cookieName } from '../constants'; import { cookieName } from '../constants';
import { AuthModel } from '@nocobase/plugin-auth';
export class OIDCAuth extends BaseAuth { export class OIDCAuth extends BaseAuth {
constructor(config: AuthConfig) { constructor(config: AuthConfig) {
@ -49,9 +50,7 @@ export class OIDCAuth extends BaseAuth {
async validate() { async validate() {
const ctx = this.ctx; const ctx = this.ctx;
const { const { params: values } = ctx.action;
params: { values },
} = ctx.action;
const token = ctx.cookies.get(cookieName); const token = ctx.cookies.get(cookieName);
const search = new URLSearchParams(values.state); const search = new URLSearchParams(values.state);
if (search.get('token') !== token) { if (search.get('token') !== token) {
@ -65,27 +64,42 @@ export class OIDCAuth extends BaseAuth {
}); });
const userInfo: { [key: string]: any } = await client.userinfo(tokens.access_token); const userInfo: { [key: string]: any } = await client.userinfo(tokens.access_token);
const mappedUserInfo = this.mapField(userInfo); const mappedUserInfo = this.mapField(userInfo);
const { nickname, name, sub, email, phone } = mappedUserInfo; const { nickname, username, name, sub, email, phone } = mappedUserInfo;
const username = nickname || name || sub; const authenticator = this.authenticator as AuthModel;
// Compatible processing let user = await authenticator.findUser(sub);
// When email is provided, use email to find user if (user) {
// If found, associate the user with the current authenticator return user;
if (email) { }
const user = await this.userRepository.findOne({ // Bind existed user
const { userBindField = 'email' } = this.getOptions();
if (userBindField === 'email' && email) {
user = await this.userRepository.findOne({
filter: { email }, filter: { email },
}); });
} else if (userBindField === 'username' && username) {
user = await this.userRepository.findOne({
filter: { username },
});
}
if (user) { if (user) {
await this.authenticator.addUser(user, { await authenticator.addUser(user.id, {
through: { through: {
uuid: sub, uuid: sub,
}, },
}); });
return user; return user;
} }
// Create new user
const { autoSignup } = this.options?.public || {};
if (!autoSignup) {
throw new Error('User not found');
} }
if (username && !this.validateUsername(username)) {
return await this.authenticator.findOrCreateUser(sub, { throw new Error('Username must be 2-16 characters in length (excluding @.<>"\'/)');
nickname: username, }
return await authenticator.newUser(sub, {
username: username ?? null,
nickname: nickname || name || username || sub,
email: email ?? null, email: email ?? null,
phone: phone ?? 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 { getAuthUrl } from './actions/getAuthUrl';
import { redirect } from './actions/redirect'; import { redirect } from './actions/redirect';
import { authType } from '../constants'; import { authType } from '../constants';
import { OIDCAuth } from './oidc-auth'; import { OIDCAuth } from './oidc-auth';
import { resolve } from 'path';
export class OidcPlugin extends Plugin { export class OidcPlugin extends Plugin {
afterAdd() {} afterAdd() {}
@ -10,6 +11,14 @@ export class OidcPlugin extends Plugin {
beforeLoad() {} beforeLoad() {}
async load() { async load() {
this.db.addMigrations({
namespace: 'auth',
directory: resolve(__dirname, 'migrations'),
context: {
plugin: this,
},
});
this.app.authManager.registerTypes(authType, { this.app.authManager.registerTypes(authType, {
auth: OIDCAuth, auth: OIDCAuth,
}); });
@ -24,6 +33,22 @@ export class OidcPlugin extends Plugin {
}); });
this.app.acl.allow('oidc', '*', 'public'); 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) {} async install(options?: InstallOptions) {}

View File

@ -21,6 +21,7 @@
"@nocobase/client": "0.x", "@nocobase/client": "0.x",
"@nocobase/database": "0.x", "@nocobase/database": "0.x",
"@nocobase/server": "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 { CopyOutlined } from '@ant-design/icons';
import { observer, useForm } from '@formily/react'; import { observer, useForm } from '@formily/react';
import { useRecord, FormItem, Input } from '@nocobase/client'; import { useRecord, FormItem, Input } from '@nocobase/client';
import { useSamlTranslation } from './locale'; import { lang, useSamlTranslation } from './locale';
import { getSubAppName } from '@nocobase/sdk';
const schema = { const schema = {
type: 'object', type: 'object',
@ -34,13 +35,37 @@ const schema = {
'x-component': 'Checkbox', 'x-component': 'Checkbox',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
}, },
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: { usage: {
type: 'void', type: 'void',
'x-component': 'Usage', 'x-component': 'Usage',
}, },
}, },
},
},
}; };
const Usage = observer(() => { const Usage = observer(() => {
@ -48,9 +73,10 @@ const Usage = observer(() => {
const record = useRecord(); const record = useRecord();
const { t } = useSamlTranslation(); const { t } = useSamlTranslation();
const app = getSubAppName() || 'main';
const name = form.values.name ?? record.name; const name = form.values.name ?? record.name;
const { protocol, host } = window.location; 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) => { const copy = (text: string) => {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);

View File

@ -1,69 +1,52 @@
import { LoginOutlined } from '@ant-design/icons'; import { LoginOutlined } from '@ant-design/icons';
import { Authenticator, css, useAPIClient, useRedirect } from '@nocobase/client'; import { Authenticator, css, useAPIClient, useCurrentUserContext, useRedirect } from '@nocobase/client';
import { Button, Space } from 'antd'; import { Button, Space, message } from 'antd';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useEffect } from 'react';
import { useSamlTranslation } from './locale'; import { useSamlTranslation } from './locale';
import { useLocation } from 'react-router-dom';
export const SAMLButton = (props: { authenticator: Authenticator }) => { export const SAMLButton = ({ authenticator }: { authenticator: Authenticator }) => {
const { t } = useSamlTranslation(); const { t } = useSamlTranslation();
const [windowHandler, setWindowHandler] = useState<Window | undefined>();
const api = useAPIClient(); const api = useAPIClient();
const redirect = useRedirect(); const redirect = useRedirect();
const location = useLocation();
const { refreshAsync: refresh } = useCurrentUserContext();
/** const login = async () => {
*
*/
const handleOpen = async (name: string) => {
const response = await api.request({ const response = await api.request({
method: 'post', method: 'post',
url: 'saml:getAuthUrl', url: 'saml:getAuthUrl',
headers: { headers: {
'X-Authenticator': name, 'X-Authenticator': authenticator.name,
}, },
}); });
const authUrl = response?.data?.data; const authUrl = response?.data?.data;
const { width, height } = screen; window.location.replace(authUrl);
const win = window.open(
authUrl,
'_blank',
`width=800,height=600,left=${(width - 800) / 2},top=${
(height - 600) / 2
},toolbar=no,menubar=no,location=no,status=no`,
);
setWindowHandler(win);
}; };
const handleSAMLLogin = useCallback(
async (event: MessageEvent) => {
try {
await api.auth.signIn(event.data, event.data?.authenticator);
redirect();
} catch (err) {
console.error(err);
} finally {
windowHandler.close();
setWindowHandler(undefined);
}
},
[api, redirect, windowHandler],
);
/**
*
*/
useEffect(() => { useEffect(() => {
if (!windowHandler) return; const params = new URLSearchParams(location.search);
const token = params.get('token');
const name = params.get('authenticator');
const error = params.get('error');
if (name !== authenticator.name) {
return;
}
if (error) {
message.error(error);
return;
}
if (token) {
api.auth.setToken(token);
api.auth.setAuthenticator(name);
refresh()
.then(() => redirect())
.catch((err) => console.log(err));
return;
}
});
window.addEventListener('message', handleSAMLLogin);
return () => {
window.removeEventListener('message', handleSAMLLogin);
};
}, [windowHandler, handleSAMLLogin]);
const authenticator = props.authenticator;
return ( return (
<Space <Space
direction="vertical" direction="vertical"
@ -71,7 +54,7 @@ export const SAMLButton = (props: { authenticator: Authenticator }) => {
display: flex; display: flex;
`} `}
> >
<Button shape="round" block icon={<LoginOutlined />} onClick={() => handleOpen(authenticator.name)}> <Button shape="round" block icon={<LoginOutlined />} onClick={login}>
{t(authenticator.title)} {t(authenticator.title)}
</Button> </Button>
</Space> </Space>

View File

@ -1,5 +1,5 @@
import { AuthenticatorsContext, OptionsComponentProvider, SigninPageExtensionProvider } from '@nocobase/client'; import { OptionsComponentProvider, SigninPageExtensionProvider } from '@nocobase/client';
import React, { FC, useContext } from 'react'; import React, { FC } from 'react';
import { SAMLButton } from './SAMLButton'; import { SAMLButton } from './SAMLButton';
import { Options } from './Options'; import { Options } from './Options';
import { authType } from '../constants'; 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?', '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':
'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': '登录按钮名称,将在登录页中显示', 'Sign in button name, which will be displayed on the sign in page': '登录按钮名称,将在登录页中显示',
Copied: '已复制', Copied: '已复制',
Usage: '使用', 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 app: MockServer;
let db: Database; let db: Database;
let agent; let agent;
let authenticator;
beforeAll(async () => { beforeAll(async () => {
app = mockServer({ app = mockServer({
@ -19,7 +20,7 @@ describe('saml', () => {
agent = app.agent(); agent = app.agent();
const authenticatorRepo = db.getRepository('authenticators'); const authenticatorRepo = db.getRepository('authenticators');
await authenticatorRepo.create({ authenticator = await authenticatorRepo.create({
values: { values: {
name: 'saml-auth', name: 'saml-auth',
authType: authType, authType: authType,
@ -39,8 +40,11 @@ describe('saml', () => {
await app.destroy(); await app.destroy();
}); });
afterEach(() => { afterEach(async () => {
jest.restoreAllMocks(); jest.restoreAllMocks();
await db.getRepository('users').destroy({
truncate: true,
});
}); });
it('should get auth url', async () => { it('should get auth url', async () => {
@ -48,7 +52,15 @@ describe('saml', () => {
expect(res.body.data).toBeDefined(); 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({ jest.spyOn(SAML.prototype, 'validatePostResponseAsync').mockResolvedValue({
profile: { profile: {
nameID: 'test@nocobase.com', nameID: 'test@nocobase.com',
@ -61,21 +73,56 @@ describe('saml', () => {
loggedOut: false, loggedOut: false,
}); });
const res = await agent const res = await agent.set('X-Authenticator', 'saml-auth').resource('auth').signIn().send({
.set('X-Authenticator', 'saml-auth') samlResponse: {},
.resource('auth')
.signIn()
.send({
samlResponse: {
SAMLResponse: '',
},
}); });
expect(res.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).toBeDefined();
expect(res.body.data.user.nickname).toBe('Test Nocobase'); expect(res.body.data.user.nickname).toBe('Test Nocobase');
}); });
it('should sign in via email', async () => { 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({ jest.spyOn(SAML.prototype, 'validatePostResponseAsync').mockResolvedValue({
profile: { profile: {
nameID: 'old@nocobase.com', nameID: 'old@nocobase.com',
@ -109,7 +156,54 @@ describe('saml', () => {
expect(res.body.data.user).toBeDefined(); expect(res.body.data.user).toBeDefined();
expect(res.body.data.user.id).toBe(user.id); 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'; import { Context, Next } from '@nocobase/actions';
import { SAMLAuth } from '../saml-auth';
export const redirect = async (ctx: Context, next) => { import { AppSupervisor } from '@nocobase/server';
const { params } = ctx.action;
const template = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
</head>
<body>
<script>
window.opener.postMessage(${JSON.stringify({
authenticator: params.authenticator,
samlResponse: params.values,
})}, '*');
</script>
</body>
</html>
`;
ctx.body = template;
ctx.withoutDataWrapping = true;
export const redirect = async (ctx: Context, next: Next) => {
const { authenticator, __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(); 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 { redirect } from './actions/redirect';
import { SAMLAuth } from './saml-auth'; import { SAMLAuth } from './saml-auth';
import { authType } from '../constants'; import { authType } from '../constants';
import { resolve } from 'path';
export class SAMLPlugin extends Plugin { export class SAMLPlugin extends Plugin {
afterAdd() {} afterAdd() {}
@ -11,6 +12,14 @@ export class SAMLPlugin extends Plugin {
beforeLoad() {} beforeLoad() {}
async load() { async load() {
this.db.addMigrations({
namespace: 'auth',
directory: resolve(__dirname, 'migrations'),
context: {
plugin: this,
},
});
this.app.authManager.registerTypes(authType, { this.app.authManager.registerTypes(authType, {
auth: SAMLAuth, auth: SAMLAuth,
}); });

View File

@ -1,4 +1,5 @@
import { AuthConfig, BaseAuth } from '@nocobase/auth'; import { AuthConfig, BaseAuth } from '@nocobase/auth';
import { AuthModel } from '@nocobase/plugin-auth';
import { SAML, SamlConfig } from '@node-saml/node-saml'; import { SAML, SamlConfig } from '@node-saml/node-saml';
interface SAMLOptions { interface SAMLOptions {
@ -23,7 +24,7 @@ export class SAMLAuth extends BaseAuth {
const name = this.authenticator.get('name'); const name = this.authenticator.get('name');
const protocol = http ? 'http' : 'https'; const protocol = http ? 'http' : 'https';
return { 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, entryPoint: ssoUrl,
issuer: name, issuer: name,
cert: certificate, cert: certificate,
@ -35,38 +36,57 @@ export class SAMLAuth extends BaseAuth {
async validate() { async validate() {
const ctx = this.ctx; const ctx = this.ctx;
const { const {
params: { params: { values: samlResponse },
values: { samlResponse },
},
} = ctx.action; } = ctx.action;
const saml = new SAML(this.getOptions()); const saml = new SAML(this.getOptions());
const { profile } = await saml.validatePostResponseAsync(samlResponse); const { profile } = await saml.validatePostResponseAsync(samlResponse);
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;
}
const { nameID, nickname, username, email, firstName, lastName, phone } = profile as Record<string, string>; const authenticator = this.authenticator as AuthModel;
let user = await authenticator.findUser(nameID);
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) { if (user) {
await this.authenticator.addUser(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: { through: {
uuid: nameID, uuid: nameID,
}, },
}); });
return user; 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)) {
return await this.authenticator.findOrCreateUser(nameID, { throw new Error('Username must be 2-16 characters in length (excluding @.<>"\'/)');
nickname: name, }
const fullName = firstName && lastName && `${firstName} ${lastName}`;
return await authenticator.newUser(nameID, {
username: username ?? null,
nickname: nickname || fullName || username || nameID,
email: email ?? null, email: email ?? null,
phone: phone ?? null, phone: phone ?? null,
}); });