mirror of https://github.com/laurent22/joplin.git
Merge c3833a95b7
into e1a436f6f9
commit
37200907df
|
@ -418,6 +418,7 @@ packages/app-desktop/gui/Sidebar/listItemComponents/NoteCount.js
|
|||
packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.js
|
||||
packages/app-desktop/gui/Sidebar/styles/index.js
|
||||
packages/app-desktop/gui/Sidebar/types.js
|
||||
packages/app-desktop/gui/SsoLoginScreen.js
|
||||
packages/app-desktop/gui/StatusScreen/StatusScreen.js
|
||||
packages/app-desktop/gui/StyleSheets/StyleSheetContainer.js
|
||||
packages/app-desktop/gui/SyncWizard/Dialog.js
|
||||
|
@ -812,6 +813,7 @@ packages/app-mobile/components/screens/ShareManager/AcceptedShareItem.js
|
|||
packages/app-mobile/components/screens/ShareManager/IncomingShareItem.js
|
||||
packages/app-mobile/components/screens/ShareManager/index.test.js
|
||||
packages/app-mobile/components/screens/ShareManager/index.js
|
||||
packages/app-mobile/components/screens/SsoLoginScreen.js
|
||||
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
|
||||
packages/app-mobile/components/screens/dropbox-login.js
|
||||
packages/app-mobile/components/screens/encryption-config.test.js
|
||||
|
@ -1038,6 +1040,7 @@ packages/lib/RotatingLogs.js
|
|||
packages/lib/SyncTargetFilesystem.js
|
||||
packages/lib/SyncTargetJoplinCloud.js
|
||||
packages/lib/SyncTargetJoplinServer.js
|
||||
packages/lib/SyncTargetJoplinServerSAML.js
|
||||
packages/lib/SyncTargetNone.js
|
||||
packages/lib/SyncTargetOneDrive.js
|
||||
packages/lib/SyncTargetRegistry.js
|
||||
|
@ -1064,6 +1067,8 @@ packages/lib/commands/toggleEditorPlugin.js
|
|||
packages/lib/components/EncryptionConfigScreen/utils.test.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
|
||||
packages/lib/components/shared/SamlShared.js
|
||||
packages/lib/components/shared/SsoScreenShared.js
|
||||
packages/lib/components/shared/config/config-shared.js
|
||||
packages/lib/components/shared/config/plugins/types.js
|
||||
packages/lib/components/shared/config/plugins/useOnDeleteHandler.js
|
||||
|
@ -1504,6 +1509,7 @@ packages/lib/utils/ipc/utils/separateCallbacksFromSerializableArray.js
|
|||
packages/lib/utils/joplinCloud/index.js
|
||||
packages/lib/utils/joplinCloud/types.js
|
||||
packages/lib/utils/markupLanguageUtils.js
|
||||
packages/lib/utils/prefixWithHttps.js
|
||||
packages/lib/utils/processStartFlags.js
|
||||
packages/lib/utils/replaceUnsupportedCharacters.test.js
|
||||
packages/lib/utils/replaceUnsupportedCharacters.js
|
||||
|
|
|
@ -392,6 +392,7 @@ packages/app-desktop/gui/Sidebar/listItemComponents/NoteCount.js
|
|||
packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.js
|
||||
packages/app-desktop/gui/Sidebar/styles/index.js
|
||||
packages/app-desktop/gui/Sidebar/types.js
|
||||
packages/app-desktop/gui/SsoLoginScreen.js
|
||||
packages/app-desktop/gui/StatusScreen/StatusScreen.js
|
||||
packages/app-desktop/gui/StyleSheets/StyleSheetContainer.js
|
||||
packages/app-desktop/gui/SyncWizard/Dialog.js
|
||||
|
@ -786,6 +787,7 @@ packages/app-mobile/components/screens/ShareManager/AcceptedShareItem.js
|
|||
packages/app-mobile/components/screens/ShareManager/IncomingShareItem.js
|
||||
packages/app-mobile/components/screens/ShareManager/index.test.js
|
||||
packages/app-mobile/components/screens/ShareManager/index.js
|
||||
packages/app-mobile/components/screens/SsoLoginScreen.js
|
||||
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
|
||||
packages/app-mobile/components/screens/dropbox-login.js
|
||||
packages/app-mobile/components/screens/encryption-config.test.js
|
||||
|
@ -1012,6 +1014,7 @@ packages/lib/RotatingLogs.js
|
|||
packages/lib/SyncTargetFilesystem.js
|
||||
packages/lib/SyncTargetJoplinCloud.js
|
||||
packages/lib/SyncTargetJoplinServer.js
|
||||
packages/lib/SyncTargetJoplinServerSAML.js
|
||||
packages/lib/SyncTargetNone.js
|
||||
packages/lib/SyncTargetOneDrive.js
|
||||
packages/lib/SyncTargetRegistry.js
|
||||
|
@ -1038,6 +1041,8 @@ packages/lib/commands/toggleEditorPlugin.js
|
|||
packages/lib/components/EncryptionConfigScreen/utils.test.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
|
||||
packages/lib/components/shared/SamlShared.js
|
||||
packages/lib/components/shared/SsoScreenShared.js
|
||||
packages/lib/components/shared/config/config-shared.js
|
||||
packages/lib/components/shared/config/plugins/types.js
|
||||
packages/lib/components/shared/config/plugins/useOnDeleteHandler.js
|
||||
|
@ -1478,6 +1483,7 @@ packages/lib/utils/ipc/utils/separateCallbacksFromSerializableArray.js
|
|||
packages/lib/utils/joplinCloud/index.js
|
||||
packages/lib/utils/joplinCloud/types.js
|
||||
packages/lib/utils/markupLanguageUtils.js
|
||||
packages/lib/utils/prefixWithHttps.js
|
||||
packages/lib/utils/processStartFlags.js
|
||||
packages/lib/utils/replaceUnsupportedCharacters.test.js
|
||||
packages/lib/utils/replaceUnsupportedCharacters.js
|
||||
|
|
|
@ -4,7 +4,7 @@ import Note from '@joplin/lib/models/Note';
|
|||
import uuid from '@joplin/lib/uuid';
|
||||
import populateDatabase from '@joplin/lib/services/debug/populateDatabase';
|
||||
import { readCredentialFile } from '@joplin/lib/utils/credentialFiles';
|
||||
import JoplinServerApi from '@joplin/lib/JoplinServerApi';
|
||||
import JoplinServerApi, { Session } from '@joplin/lib/JoplinServerApi';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function randomElement(array: any[]): any {
|
||||
|
@ -107,6 +107,7 @@ class Command extends BaseCommand {
|
|||
userContentBaseUrl: () => joplinServerAuth.userContentBaseUrl,
|
||||
username: () => joplinServerAuth.email,
|
||||
password: () => joplinServerAuth.password,
|
||||
session: (): Session => null,
|
||||
});
|
||||
|
||||
const apiPut = async () => {
|
||||
|
|
|
@ -258,6 +258,28 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
|||
);
|
||||
}
|
||||
|
||||
if (settings['sync.target'] === SyncTargetRegistry.nameToId('joplinServerSaml')) {
|
||||
const server = settings['sync.11.path'] as string;
|
||||
|
||||
const goToSamlLogin = () => {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'JoplinServerSamlLogin',
|
||||
});
|
||||
};
|
||||
|
||||
settingComps.push(
|
||||
<div key="connect_to_joplin_server_saml_button" style={this.rowStyle_}>
|
||||
<Button
|
||||
title={_('Connect using your organisation account')}
|
||||
level={ButtonLevel.Primary}
|
||||
onClick={goToSamlLogin}
|
||||
disabled={!server || server?.trim().length === 0}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
settingComps.push(
|
||||
<div key="check_sync_config_button" style={this.rowStyle_}>
|
||||
<Button
|
||||
|
|
|
@ -186,7 +186,12 @@ class MainScreenComponent extends React.Component<Props, State> {
|
|||
private openCallbackUrl(url: string) {
|
||||
if (!isCallbackUrl(url)) throw new Error(`Invalid callback URL: ${url}`);
|
||||
const { command, params } = parseCallbackUrl(url);
|
||||
void CommandService.instance().execute(command.toString(), params.id);
|
||||
|
||||
if (Object.keys(params).length === 1 && params.id) { // Single argument
|
||||
void CommandService.instance().execute(command.toString(), params.id);
|
||||
} else { // Multiple arguments
|
||||
void CommandService.instance().execute(command.toString(), ...Object.values(params));
|
||||
}
|
||||
}
|
||||
|
||||
private updateLayoutPluginViews(layout: LayoutItem, plugins: PluginStates) {
|
||||
|
|
|
@ -30,6 +30,8 @@ import WindowCommandsAndDialogs from './WindowCommandsAndDialogs/WindowCommandsA
|
|||
import { defaultWindowId, stateUtils, WindowState } from '@joplin/lib/reducer';
|
||||
import bridge from '../services/bridge';
|
||||
import EditorWindow from './NoteEditor/EditorWindow';
|
||||
import SsoLoginScreen from './SsoLoginScreen';
|
||||
import SamlShared from '@joplin/lib/components/shared/SamlShared';
|
||||
import PopupNotificationProvider from './PopupNotification/PopupNotificationProvider';
|
||||
const { ThemeProvider, StyleSheetManager, createGlobalStyle } = require('styled-components');
|
||||
|
||||
|
@ -189,6 +191,7 @@ class RootComponent extends React.Component<Props, any> {
|
|||
OneDriveLogin: { screen: OneDriveLoginScreen, title: () => _('OneDrive Login') },
|
||||
DropboxLogin: { screen: DropboxLoginScreen, title: () => _('Dropbox Login') },
|
||||
JoplinCloudLogin: { screen: JoplinCloudLoginScreen, title: () => _('Joplin Cloud Login') },
|
||||
JoplinServerSamlLogin: { screen: SsoLoginScreen(new SamlShared()), title: () => _('Joplin Server Login') },
|
||||
Import: { screen: ImportScreen, title: () => _('Import') },
|
||||
Config: { screen: ConfigScreen, title: () => _('Options') },
|
||||
Resources: { screen: ResourceScreen, title: () => _('Note attachments') },
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
import * as React from 'react';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import ButtonBar from './ConfigScreen/ButtonBar';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from '../app.reducer';
|
||||
import SsoScreenShared from '@joplin/lib/components/shared/SsoScreenShared';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { Dispatch } from 'redux';
|
||||
|
||||
type Props = {
|
||||
themeId: number;
|
||||
dispatch: Dispatch;
|
||||
shared: SsoScreenShared;
|
||||
};
|
||||
|
||||
const SsoLoginScreen = (props: Props) => {
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
const containerStyle = { ...theme.containerStyle, padding: theme.configScreenPadding, flex: 1 };
|
||||
|
||||
const inputStyle = { ...theme.inputStyle, width: 100, marginLeft: '5px' };
|
||||
|
||||
const buttonStyle = { ...theme.buttonStyle, marginRight: 10 };
|
||||
|
||||
const listItemStyle = { marginBottom: theme.itemMarginBottom };
|
||||
|
||||
const [code, setCode] = React.useState('');
|
||||
|
||||
const back = () => props.dispatch({ type: 'NAV_BACK' });
|
||||
|
||||
const submit = async () => {
|
||||
if (await props.shared.processLoginCode(code)) {
|
||||
await shim.showMessageBox(_('You are now logged into your account.'), {
|
||||
buttons: [_('OK')],
|
||||
});
|
||||
back();
|
||||
} else {
|
||||
await shim.showErrorDialog(_('Failed to connect to your account. Please try again.'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={containerStyle}>
|
||||
<p style={theme.textStyle}>{_('To allow Joplin to synchronise with your account, please follow these steps:')}</p>
|
||||
<ol>
|
||||
<li style={listItemStyle}>
|
||||
<button style={buttonStyle} onClick={props.shared.openLoginPage}>{_('Log in with your web browser')}</button>
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
<div>
|
||||
<label htmlFor='sso-code' style={theme.textStyle}>{_('Enter the code:')}</label>
|
||||
<input id='sso-code' type='text' style={inputStyle} value={code} onChange={e => setCode(e.target.value)} placeholder='###-###-###' />
|
||||
</div>
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
<button type='submit' onClick={submit} disabled={!props.shared.isLoginCodeValid(code)} style={buttonStyle}>{_('Continue')}</button>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<ButtonBar onCancelClick={back} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: AppState) => ({
|
||||
themeId: state.settings.theme,
|
||||
});
|
||||
|
||||
// Allows reuse of this screen for other code-based login flow
|
||||
export default (shared: SsoScreenShared) => connect(mapStateToProps)((props: Props) => <SsoLoginScreen {...props} shared={shared}/>);
|
|
@ -129,6 +129,7 @@ const syncTargetNames: string[] = [
|
|||
'webdav',
|
||||
'amazon_s3',
|
||||
'joplinServer',
|
||||
'joplinServerSaml',
|
||||
];
|
||||
|
||||
|
||||
|
|
|
@ -93,6 +93,18 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
|||
await NavService.go('JoplinCloudLogin');
|
||||
};
|
||||
|
||||
private goToJoplinServerSamlLogin_ = async () => {
|
||||
// Save the settings to allow for sync when the user completes authentication
|
||||
await this.saveButton_press();
|
||||
|
||||
await NavService.go('JoplinServerSamlLogin');
|
||||
};
|
||||
|
||||
private logoutJoplinServerSaml_ = () => {
|
||||
Setting.setValue('sync.11.id', '');
|
||||
Setting.setValue('sync.11.userId', '');
|
||||
};
|
||||
|
||||
private checkSyncConfig_ = async () => {
|
||||
if (this.state.settings['sync.target'] === SyncTargetRegistry.nameToId('joplinCloud')) {
|
||||
const isAuthenticated = await reg.syncTarget().isAuthenticated();
|
||||
|
@ -460,6 +472,12 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
|||
|
||||
if (settings['sync.target'] === SyncTargetRegistry.nameToId('joplinCloud')) {
|
||||
addSettingButton('go_to_joplin_cloud_login_button', _('Connect to Joplin Cloud'), this.goToJoplinCloudLogin_);
|
||||
} else if (settings['sync.target'] === SyncTargetRegistry.nameToId('joplinServerSaml')) {
|
||||
addSettingButton('login_joplin_server_saml_button', _('Connect using your organisation account'), this.goToJoplinServerSamlLogin_);
|
||||
|
||||
if (Setting.value('sync.11.id') !== '' || Setting.value('sync.11.userId') !== '') {
|
||||
addSettingButton('logout_joplin_server_saml_button', _('Logout'), this.logoutJoplinServerSaml_);
|
||||
}
|
||||
}
|
||||
|
||||
addSettingButton('check_sync_config_button', _('Check synchronisation configuration'), this.checkSyncConfig_, { statusComp: statusComp });
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
import SsoScreenShared from '@joplin/lib/components/shared/SsoScreenShared';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { StyleSheet, View, Text } from 'react-native';
|
||||
import * as React from 'react';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import createRootStyle from '../../utils/createRootStyle';
|
||||
import ScreenHeader from '../ScreenHeader';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { Button, TextInput } from 'react-native-paper';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import BackButtonService from '../../services/BackButtonService';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
shared: SsoScreenShared;
|
||||
}
|
||||
|
||||
const SsoLoginScreenComponent = (props: Props) => {
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
...createRootStyle(props.themeId),
|
||||
buttonContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: theme.margin,
|
||||
},
|
||||
containerStyle: {
|
||||
padding: theme.margin,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
flex: 1,
|
||||
},
|
||||
text: {
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSize,
|
||||
},
|
||||
marginBottom: {
|
||||
marginBottom: theme.margin,
|
||||
},
|
||||
});
|
||||
|
||||
const [code, setCode] = React.useState('');
|
||||
|
||||
const submit = async () => {
|
||||
if (await props.shared.processLoginCode(code)) {
|
||||
await shim.showMessageBox(_('You are now logged into your account.'), {
|
||||
buttons: [_('OK')],
|
||||
});
|
||||
|
||||
await BackButtonService.back();
|
||||
} else {
|
||||
await shim.showErrorDialog(_('Failed to connect to your account. Please try again.'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<ScreenHeader title={_('Joplin Server Login')} />
|
||||
<View style={styles.containerStyle}>
|
||||
<React.Fragment>
|
||||
<Text style={{ ...styles.text, ...styles.buttonContainer }}>
|
||||
{_('To allow Joplin to synchronise with your account, please follow these steps:')}
|
||||
</Text>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<Text style={styles.text}>1. </Text>
|
||||
<Button onPress={props.shared.openLoginPage} mode='contained'>{_('Log in with your web browser')}</Button>
|
||||
</View>
|
||||
|
||||
<View style={styles.marginBottom}>
|
||||
<Text style={styles.text}>2. {_('Enter the code')}</Text>
|
||||
<TextInput placeholder='###-###-###' value={code} onChangeText={setCode}/>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<Text style={styles.text}>3. </Text>
|
||||
<Button onPress={submit} disabled={!props.shared.isLoginCodeValid(code)} mode='contained'>{_('Continue')}</Button>
|
||||
</View>
|
||||
</React.Fragment>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Allows reuse of this screen for other code-based login flow
|
||||
export default (shared: SsoScreenShared) => connect((state: AppState) => ({
|
||||
themeId: state.settings.theme,
|
||||
}))((props: Props) => <SsoLoginScreenComponent {...props} shared={shared}/>);
|
|
@ -82,6 +82,7 @@ const SyncTargetNextcloud = require('@joplin/lib/SyncTargetNextcloud.js');
|
|||
const SyncTargetWebDAV = require('@joplin/lib/SyncTargetWebDAV.js');
|
||||
const SyncTargetDropbox = require('@joplin/lib/SyncTargetDropbox.js');
|
||||
const SyncTargetAmazonS3 = require('@joplin/lib/SyncTargetAmazonS3.js');
|
||||
import SyncTargetJoplinServerSAML from '@joplin/lib/SyncTargetJoplinServerSAML';
|
||||
import BiometricPopup from './components/biometrics/BiometricPopup';
|
||||
import initLib from '@joplin/lib/initLib';
|
||||
import { isCallbackUrl, parseCallbackUrl, CallbackUrlCommand } from '@joplin/lib/callbackUrlUtils';
|
||||
|
@ -99,6 +100,7 @@ SyncTargetRegistry.addClass(SyncTargetDropbox);
|
|||
SyncTargetRegistry.addClass(SyncTargetFilesystem);
|
||||
SyncTargetRegistry.addClass(SyncTargetAmazonS3);
|
||||
SyncTargetRegistry.addClass(SyncTargetJoplinServer);
|
||||
SyncTargetRegistry.addClass(SyncTargetJoplinServerSAML);
|
||||
SyncTargetRegistry.addClass(SyncTargetJoplinCloud);
|
||||
|
||||
import FsDriverRN from './utils/fs-driver/fs-driver-rn';
|
||||
|
@ -141,6 +143,8 @@ import { AppState } from './utils/types';
|
|||
import { getDisplayParentId } from '@joplin/lib/services/trash';
|
||||
import PluginNotification from './components/plugins/PluginNotification';
|
||||
import FocusControl from './components/accessibility/FocusControl/FocusControl';
|
||||
import SsoLoginScreen from './components/screens/SsoLoginScreen';
|
||||
import SamlShared from '@joplin/lib/components/shared/SamlShared';
|
||||
|
||||
const logger = Logger.create('root');
|
||||
|
||||
|
@ -1196,7 +1200,6 @@ class AppComponent extends React.Component {
|
|||
folderId: params.id,
|
||||
});
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1271,6 +1274,7 @@ class AppComponent extends React.Component {
|
|||
OneDriveLogin: { screen: OneDriveLoginScreen },
|
||||
DropboxLogin: { screen: DropboxLoginScreen },
|
||||
JoplinCloudLogin: { screen: JoplinCloudLoginScreen },
|
||||
JoplinServerSamlLogin: { screen: SsoLoginScreen(new SamlShared()) },
|
||||
EncryptionConfig: { screen: EncryptionConfigScreen },
|
||||
UpgradeSyncTarget: { screen: UpgradeSyncTargetScreen },
|
||||
ShareManager: { screen: ShareManager },
|
||||
|
|
|
@ -9,6 +9,7 @@ import KeychainServiceDriverElectron from './services/keychain/KeychainServiceDr
|
|||
import { setLocale } from './locale';
|
||||
import KvStore from './services/KvStore';
|
||||
import SyncTargetJoplinServer from './SyncTargetJoplinServer';
|
||||
import SyncTargetJoplinServerSAML from './SyncTargetJoplinServerSAML';
|
||||
import SyncTargetOneDrive from './SyncTargetOneDrive';
|
||||
import { createStore, applyMiddleware, Store } from 'redux';
|
||||
import { defaultState, stateUtils } from './reducer';
|
||||
|
@ -715,6 +716,7 @@ export default class BaseApplication {
|
|||
SyncTargetRegistry.addClass(SyncTargetDropbox);
|
||||
SyncTargetRegistry.addClass(SyncTargetAmazonS3);
|
||||
SyncTargetRegistry.addClass(SyncTargetJoplinServer);
|
||||
SyncTargetRegistry.addClass(SyncTargetJoplinServerSAML);
|
||||
SyncTargetRegistry.addClass(SyncTargetJoplinCloud);
|
||||
|
||||
try {
|
||||
|
|
|
@ -16,6 +16,7 @@ interface Options {
|
|||
userContentBaseUrl(): string;
|
||||
username(): string;
|
||||
password(): string;
|
||||
session(): Session | null;
|
||||
env?: Env;
|
||||
}
|
||||
|
||||
|
@ -36,7 +37,7 @@ export interface ExecOptions {
|
|||
source?: string;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
export interface Session {
|
||||
id: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
@ -76,6 +77,12 @@ export default class JoplinServerApi {
|
|||
}
|
||||
|
||||
private async session() {
|
||||
const optionSession = this.options_.session();
|
||||
|
||||
if (optionSession) {
|
||||
return optionSession;
|
||||
}
|
||||
|
||||
if (this.session_) return this.session_;
|
||||
|
||||
const clientInfo = await this.getClientInfo();
|
||||
|
|
|
@ -2,14 +2,14 @@ import FileApiDriverJoplinServer from './file-api-driver-joplinServer';
|
|||
import Setting from './models/Setting';
|
||||
import Synchronizer from './Synchronizer';
|
||||
import { _ } from './locale.js';
|
||||
import JoplinServerApi from './JoplinServerApi';
|
||||
import JoplinServerApi, { Session } from './JoplinServerApi';
|
||||
import BaseSyncTarget from './BaseSyncTarget';
|
||||
import { FileApi } from './file-api';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const staticLogger = Logger.create('SyncTargetJoplinServer');
|
||||
|
||||
interface FileApiOptions {
|
||||
export interface FileApiOptions {
|
||||
path(): string;
|
||||
userContentPath(): string;
|
||||
username(): string;
|
||||
|
@ -22,6 +22,7 @@ export async function newFileApi(id: number, options: FileApiOptions) {
|
|||
userContentBaseUrl: () => options.userContentPath(),
|
||||
username: () => options.username(),
|
||||
password: () => options.password(),
|
||||
session: (): Session => null,
|
||||
env: Setting.value('env'),
|
||||
};
|
||||
|
||||
|
@ -83,7 +84,7 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
|
|||
errorMessage: '',
|
||||
};
|
||||
|
||||
syncTargetId = syncTargetId === null ? SyncTargetJoplinServer.id() : syncTargetId;
|
||||
syncTargetId = syncTargetId === null ? this.id() : syncTargetId;
|
||||
|
||||
let fileApi = null;
|
||||
try {
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
import FileApiDriverJoplinServer from './file-api-driver-joplinServer';
|
||||
import Setting from './models/Setting';
|
||||
import { _ } from './locale.js';
|
||||
import JoplinServerApi, { Session } from './JoplinServerApi';
|
||||
import { FileApi } from './file-api';
|
||||
import SyncTargetJoplinServer, { FileApiOptions } from './SyncTargetJoplinServer';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
export async function newFileApi(id: number, options: FileApiOptions) {
|
||||
const apiOptions = {
|
||||
baseUrl: () => options.path(),
|
||||
userContentBaseUrl: () => options.userContentPath(),
|
||||
username: () => '',
|
||||
password: () => '',
|
||||
session: () => ({ id: Setting.value('sync.11.id'), user_id: Setting.value('sync.11.userId') }),
|
||||
env: Setting.value('env'),
|
||||
};
|
||||
|
||||
const api = new JoplinServerApi(apiOptions);
|
||||
const driver = new FileApiDriverJoplinServer(api);
|
||||
const fileApi = new FileApi('', driver);
|
||||
fileApi.setSyncTargetId(id);
|
||||
await fileApi.initialize();
|
||||
return fileApi;
|
||||
}
|
||||
|
||||
export async function initFileApi(syncTargetId: number, logger: Logger, options: FileApiOptions) {
|
||||
const fileApi = await newFileApi(syncTargetId, options);
|
||||
fileApi.setLogger(logger);
|
||||
return fileApi;
|
||||
}
|
||||
|
||||
export const authenticateWithCode = async (code: string) => {
|
||||
try {
|
||||
const response = await fetch(`${Setting.value('sync.11.path')}/api/login_with_code/${code}`);
|
||||
if (response.status !== 200) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const token: Session = await response.json();
|
||||
Setting.setValue('sync.11.id', token.id);
|
||||
Setting.setValue('sync.11.userId', token.user_id);
|
||||
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// A sync target for Joplin Server that uses SAML for authentication.
|
||||
//
|
||||
// Based on the regular Joplin Server sync target.
|
||||
export default class SyncTargetJoplinServerSAML extends SyncTargetJoplinServer {
|
||||
public static override id() {
|
||||
return 11;
|
||||
}
|
||||
|
||||
public static override targetName() {
|
||||
return 'joplinServerSaml';
|
||||
}
|
||||
|
||||
public static override label() {
|
||||
return `${_('Joplin Server')} (Beta, SAML)`;
|
||||
}
|
||||
|
||||
public override async isAuthenticated() {
|
||||
return Setting.value('sync.11.id') !== '';
|
||||
}
|
||||
|
||||
public static override requiresPassword() {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override async initFileApi() {
|
||||
return initFileApi(SyncTargetJoplinServerSAML.id(), this.logger(), {
|
||||
path: () => Setting.value('sync.11.path'),
|
||||
userContentPath: () => Setting.value('sync.11.userContentPath'),
|
||||
username: () => '',
|
||||
password: () => '',
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import Setting from '../../models/Setting';
|
||||
import shim from '../../shim';
|
||||
import { authenticateWithCode } from '../../SyncTargetJoplinServerSAML';
|
||||
import prefixWithHttps from '../../utils/prefixWithHttps';
|
||||
import SsoScreenShared from './SsoScreenShared';
|
||||
|
||||
export default class SamlShared implements SsoScreenShared {
|
||||
public openLoginPage() {
|
||||
shim.openUrl(`${prefixWithHttps(Setting.value('sync.11.path'))}/login/sso-saml-app`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public processLoginCode(code: string) {
|
||||
if (this.isLoginCodeValid(code)) {
|
||||
return authenticateWithCode(this.cleanCode(code));
|
||||
} else {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
|
||||
public isLoginCodeValid(code: string) {
|
||||
const cleanedCode = this.cleanCode(code);
|
||||
return !isNaN(+cleanedCode) && cleanedCode.length === 9;
|
||||
}
|
||||
|
||||
private cleanCode(code: string) {
|
||||
return code.replace(/\s|-/gi, '');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
interface SsoScreenShared {
|
||||
openLoginPage(): Promise<void>;
|
||||
processLoginCode(code: string): Promise<boolean>;
|
||||
isLoginCodeValid(code: string): boolean;
|
||||
}
|
||||
|
||||
export default SsoScreenShared;
|
|
@ -349,6 +349,37 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
|||
label: () => _('Joplin Server password'),
|
||||
secure: true,
|
||||
},
|
||||
'sync.11.path': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
section: 'sync',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
show: (settings: any) => {
|
||||
return settings['sync.target'] === SyncTargetRegistry.nameToId('joplinServerSaml');
|
||||
},
|
||||
public: true,
|
||||
label: () => _('Joplin Server URL'),
|
||||
description: () => emptyDirWarning,
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
'sync.11.userContentPath': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
public: false,
|
||||
storage: SettingStorage.Database,
|
||||
},
|
||||
'sync.11.id': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
public: false,
|
||||
storage: SettingStorage.Database,
|
||||
},
|
||||
'sync.11.userId': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
public: false,
|
||||
storage: SettingStorage.Database,
|
||||
},
|
||||
|
||||
// Although sync.10.path is essentially a constant, we still define
|
||||
// it here so that both Joplin Server and Joplin Cloud can be
|
||||
|
@ -433,6 +464,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
|||
'sync.8.context': { value: '', type: SettingItemType.String, public: false },
|
||||
'sync.9.context': { value: '', type: SettingItemType.String, public: false },
|
||||
'sync.10.context': { value: '', type: SettingItemType.String, public: false },
|
||||
'sync.11.context': { value: '', type: SettingItemType.String, public: false },
|
||||
|
||||
'sync.maxConcurrentConnections': { value: 5, type: SettingItemType.Int, storage: SettingStorage.File, isGlobal: true, public: true, advanced: true, section: 'sync', label: () => _('Max concurrent connections'), minimum: 1, maximum: 20, step: 1 },
|
||||
|
||||
|
@ -1447,6 +1479,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
|||
SyncTargetRegistry.nameToId('nextcloud'),
|
||||
SyncTargetRegistry.nameToId('webdav'),
|
||||
SyncTargetRegistry.nameToId('joplinServer'),
|
||||
SyncTargetRegistry.nameToId('joplinServerSaml'),
|
||||
// Needs to be enabled for Joplin Cloud too because
|
||||
// some companies filter all traffic and swap TLS
|
||||
// certificates, which result in error
|
||||
|
|
|
@ -83,6 +83,16 @@ export default class ShareService {
|
|||
userContentBaseUrl: () => Setting.value(`sync.${syncTargetId}.userContentPath`),
|
||||
username: () => Setting.value(`sync.${syncTargetId}.username`),
|
||||
password: () => Setting.value(`sync.${syncTargetId}.password`),
|
||||
session: () => {
|
||||
if (syncTargetId === 11) {
|
||||
return {
|
||||
id: Setting.value('sync.11.id'),
|
||||
user_id: Setting.value('sync.11.userId'),
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return this.api_;
|
||||
|
|
|
@ -48,7 +48,7 @@ import RevisionService from '../services/RevisionService';
|
|||
import ResourceFetcher from '../services/ResourceFetcher';
|
||||
const WebDavApi = require('../WebDavApi');
|
||||
const DropboxApi = require('../DropboxApi');
|
||||
import JoplinServerApi from '../JoplinServerApi';
|
||||
import JoplinServerApi, { Session } from '../JoplinServerApi';
|
||||
import { FolderEntity, ResourceEntity } from '../services/database/types';
|
||||
import { credentialFile, readCredentialFile } from '../utils/credentialFiles';
|
||||
import SyncTargetJoplinCloud from '../SyncTargetJoplinCloud';
|
||||
|
@ -68,6 +68,7 @@ import OcrService from '../services/ocr/OcrService';
|
|||
import { createWorker } from 'tesseract.js';
|
||||
import { reg } from '../registry';
|
||||
import { Store } from 'redux';
|
||||
import SyncTargetJoplinServerSAML from '../SyncTargetJoplinServerSAML';
|
||||
|
||||
// Each suite has its own separate data and temp directory so that multiple
|
||||
// suites can be run at the same time. suiteName is what is used to
|
||||
|
@ -129,6 +130,7 @@ SyncTargetRegistry.addClass(SyncTargetDropbox);
|
|||
SyncTargetRegistry.addClass(SyncTargetAmazonS3);
|
||||
SyncTargetRegistry.addClass(SyncTargetWebDAV);
|
||||
SyncTargetRegistry.addClass(SyncTargetJoplinServer);
|
||||
SyncTargetRegistry.addClass(SyncTargetJoplinServerSAML);
|
||||
SyncTargetRegistry.addClass(SyncTargetJoplinCloud);
|
||||
|
||||
let syncTargetName_ = '';
|
||||
|
@ -146,7 +148,7 @@ function setSyncTargetName(name: string) {
|
|||
syncTargetName_ = name;
|
||||
syncTargetId_ = SyncTargetRegistry.nameToId(syncTargetName_);
|
||||
sleepTime = syncTargetId_ === SyncTargetRegistry.nameToId('filesystem') ? 1001 : 100;// 400;
|
||||
isNetworkSyncTarget_ = ['nextcloud', 'dropbox', 'onedrive', 'amazon_s3', 'joplinServer', 'joplinCloud'].includes(syncTargetName_);
|
||||
isNetworkSyncTarget_ = ['nextcloud', 'dropbox', 'onedrive', 'amazon_s3', 'joplinServer', 'joplinServerSaml', 'joplinCloud'].includes(syncTargetName_);
|
||||
synchronizers_ = [];
|
||||
return previousName;
|
||||
}
|
||||
|
@ -697,6 +699,7 @@ async function initFileApi() {
|
|||
userContentBaseUrl: () => joplinServerAuth.userContentBaseUrl,
|
||||
username: () => joplinServerAuth.email,
|
||||
password: () => joplinServerAuth.password,
|
||||
session: (): Session => null,
|
||||
});
|
||||
|
||||
fileApi = new FileApi('', new FileApiDriverJoplinServer(api));
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
const prefixWithHttps = (url: string) => {
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return `https://${url}`;
|
||||
} else {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
export default prefixWithHttps;
|
|
@ -22,6 +22,7 @@
|
|||
"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@authenio/samlify-xmllint-wasm": "1.0.1",
|
||||
"@aws-sdk/client-s3": "3.296.0",
|
||||
"@fortawesome/fontawesome-free": "5.15.4",
|
||||
"@joplin/lib": "~3.3",
|
||||
|
@ -53,6 +54,7 @@
|
|||
"query-string": "7.1.3",
|
||||
"rate-limiter-flexible": "5.0.3",
|
||||
"raw-body": "2.5.2",
|
||||
"samlify": "2.8.10",
|
||||
"sqlite3": "5.1.6",
|
||||
"stripe": "8.222.0",
|
||||
"uuid": "9.0.1",
|
||||
|
|
Binary file not shown.
|
@ -28,6 +28,7 @@ import { setLocale } from '@joplin/lib/locale';
|
|||
import initLib from '@joplin/lib/initLib';
|
||||
import checkAdminHandler from './middleware/checkAdminHandler';
|
||||
import ActionLogger from '@joplin/lib/utils/ActionLogger';
|
||||
import { setupSamlAuthentication } from './utils/saml';
|
||||
|
||||
interface Argv {
|
||||
env?: Env;
|
||||
|
@ -260,6 +261,10 @@ async function main() {
|
|||
fs.writeFileSync(pidFile, `${process.pid}`);
|
||||
}
|
||||
|
||||
if (config().saml.enabled) {
|
||||
setupSamlAuthentication();
|
||||
}
|
||||
|
||||
let runCommandAndExitApp = true;
|
||||
|
||||
if (selectedCommand) {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { rtrimSlashes } from '@joplin/lib/path-utils';
|
||||
import { Config, DatabaseConfig, DatabaseConfigClient, Env, MailerConfig, LdapConfig, RouteType, StripeConfig } from './utils/types';
|
||||
import { Config, DatabaseConfig, DatabaseConfigClient, Env, MailerConfig, LdapConfig, RouteType, StripeConfig, SamlConfig } from './utils/types';
|
||||
import * as pathUtils from 'path';
|
||||
import { loadStripeConfig, StripePublicConfig } from '@joplin/lib/utils/joplinCloud';
|
||||
import { EnvVariables } from './env';
|
||||
|
@ -145,6 +145,21 @@ function ldapConfigFromEnv(env: EnvVariables): LdapConfig[] {
|
|||
return ldapConfig;
|
||||
}
|
||||
|
||||
function samlConfigFromEnv(env: EnvVariables): SamlConfig {
|
||||
if (env.SAML_ENABLED) {
|
||||
return {
|
||||
enabled: true,
|
||||
identityProviderConfigFile: env.SAML_IDP_CONFIG_FILE,
|
||||
serviceProviderConfigFile: env.SAML_SP_CONFIG_FILE,
|
||||
organizationDisplayName: env.SAML_ORGANIZATION_DISPLAY_NAME,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let config_: Config = null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
|
@ -197,6 +212,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
|
|||
itemSizeHardLimit: 250000000, // Beyond this the Postgres driver will crash the app
|
||||
maxTimeDrift: env.MAX_TIME_DRIFT,
|
||||
ldap: ldapConfigFromEnv(env),
|
||||
saml: samlConfigFromEnv(env),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -42,6 +42,10 @@ const defaultEnvValues: EnvVariables = {
|
|||
|
||||
DELTA_INCLUDES_ITEMS: true,
|
||||
|
||||
// Whether or not to allow users logging in with a username/password combo.
|
||||
// If this is disabled, a SAML-based login flow must be configured.
|
||||
LOCAL_AUTH_ENABLED: true,
|
||||
|
||||
// ==================================================
|
||||
// URL config
|
||||
// ==================================================
|
||||
|
@ -149,6 +153,14 @@ const defaultEnvValues: EnvVariables = {
|
|||
LDAP_2_BIND_PW: '', // used for user search - leave empty if ldap server allows anonymous bind
|
||||
LDAP_2_TLS_CA_FILE: '', // used for self-signed certificate with ldaps - leave empty if using ldap or server uses CA-issued certificate
|
||||
|
||||
// ==================================================
|
||||
// SAML configuration
|
||||
// ==================================================
|
||||
|
||||
SAML_ENABLED: false,
|
||||
SAML_IDP_CONFIG_FILE: '', // Config file for the Identity Provider. Should point to an XML file generated by the Identity Provider.
|
||||
SAML_SP_CONFIG_FILE: '', // Config file for the Service Provider (Joplin, in this case). Should point to an XML file generated by the Identity Provider.
|
||||
SAML_ORGANIZATION_DISPLAY_NAME: '', // The name of the organization to display on the login screen. Optional.
|
||||
};
|
||||
|
||||
export interface EnvVariables {
|
||||
|
@ -241,6 +253,13 @@ export interface EnvVariables {
|
|||
LDAP_2_BIND_DN: string;
|
||||
LDAP_2_BIND_PW: string;
|
||||
LDAP_2_TLS_CA_FILE: string;
|
||||
|
||||
SAML_ENABLED: boolean;
|
||||
SAML_IDP_CONFIG_FILE: string;
|
||||
SAML_SP_CONFIG_FILE: string;
|
||||
SAML_ORGANIZATION_DISPLAY_NAME: string;
|
||||
|
||||
LOCAL_AUTH_ENABLED: boolean;
|
||||
}
|
||||
|
||||
const parseBoolean = (s: string): boolean => {
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import { DbConnection } from '../db';
|
||||
|
||||
export const up = async (db: DbConnection) => {
|
||||
await db.schema.alterTable('users', (table) => {
|
||||
table.integer('is_external').defaultTo(0).notNullable();
|
||||
});
|
||||
};
|
||||
|
||||
export const down = async (db: DbConnection) => {
|
||||
await db.schema.alterTable('users', (table) => {
|
||||
table.dropColumn('is_external');
|
||||
});
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
import { DbConnection } from '../db';
|
||||
|
||||
export const up = async (db: DbConnection) => {
|
||||
await db.schema.alterTable('users', (table) => {
|
||||
table.string('sso_auth_code').defaultTo('').notNullable();
|
||||
table.integer('sso_auth_code_expire_at').defaultTo(0).notNullable();
|
||||
});
|
||||
};
|
||||
|
||||
export const down = async (db: DbConnection) => {
|
||||
await db.schema.alterTable('users', (table) => {
|
||||
table.dropColumn('sso_auth_code');
|
||||
table.dropColumn('sso_auth_code_expire_at');
|
||||
});
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, expectThrow } from '../utils/testing/testUtils';
|
||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, expectThrow, createUser } from '../utils/testing/testUtils';
|
||||
import { EmailSender, UserFlagType } from '../services/database/types';
|
||||
import { ErrorBadRequest, ErrorUnprocessableEntity } from '../utils/errors';
|
||||
import { betaUserDateRange, stripeConfig } from '../utils/stripe';
|
||||
|
@ -6,6 +6,7 @@ import { accountByType, AccountType } from './UserModel';
|
|||
import { failedPaymentFinalAccount, failedPaymentWarningInterval } from './SubscriptionModel';
|
||||
import { stripePortalUrl } from '../utils/urlUtils';
|
||||
import { Day } from '../utils/time';
|
||||
import config from '../config';
|
||||
|
||||
describe('UserModel', () => {
|
||||
|
||||
|
@ -455,4 +456,11 @@ describe('UserModel', () => {
|
|||
expect(error instanceof ErrorBadRequest).toBe(true);
|
||||
});
|
||||
|
||||
test('should not log in an user using a email/password combo when the local auth is disabled', async () => {
|
||||
config().LOCAL_AUTH_ENABLED = false;
|
||||
|
||||
const user = await createUser();
|
||||
|
||||
expect(await models().user().login(user.email, '123456')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -31,6 +31,8 @@ import { Config, Env, LdapConfig } from '../utils/types';
|
|||
import ldapLogin from '../utils/ldapLogin';
|
||||
import { DbConnection } from '../db';
|
||||
import { NewModelFactoryHandler } from './factory';
|
||||
import config from '../config';
|
||||
import { randomInt } from 'node:crypto';
|
||||
|
||||
const logger = Logger.create('UserModel');
|
||||
|
||||
|
@ -115,12 +117,12 @@ export function accountTypeToString(accountType: AccountType): string {
|
|||
}
|
||||
|
||||
export default class UserModel extends BaseModel<User> {
|
||||
private authCodeTtl = 600000; // 10 minutes
|
||||
|
||||
private ldapConfig_: LdapConfig[];
|
||||
|
||||
public constructor(db: DbConnection, dbSlave: DbConnection, modelFactory: NewModelFactoryHandler, config: Config) {
|
||||
super(db, dbSlave, modelFactory, config);
|
||||
|
||||
this.ldapConfig_ = config.ldap;
|
||||
}
|
||||
|
||||
|
@ -133,7 +135,16 @@ export default class UserModel extends BaseModel<User> {
|
|||
return this.db<User>(this.tableName).where(user).first();
|
||||
}
|
||||
|
||||
public async loadBySsoAuthCode(code: string): Promise<User> {
|
||||
const user = this.formatValues({ sso_auth_code: code });
|
||||
return this.db<User>(this.tableName).where(user).first();
|
||||
}
|
||||
|
||||
public async login(email: string, password: string): Promise<User> {
|
||||
if (!config().LOCAL_AUTH_ENABLED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await this.loadByEmail(email);
|
||||
|
||||
for (const config of this.ldapConfig_) {
|
||||
|
@ -149,11 +160,74 @@ export default class UserModel extends BaseModel<User> {
|
|||
}
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
if (!user || user.is_external) return null;
|
||||
if (!(await checkPassword(password, user.password))) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
public async ssoLogin(email: string, displayName: string) {
|
||||
if (!email || !displayName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let user = await this.loadByEmail(email);
|
||||
|
||||
if (!user) { // User does not exist
|
||||
user = {
|
||||
email: email,
|
||||
full_name: displayName,
|
||||
must_set_password: 0,
|
||||
email_confirmed: 1,
|
||||
is_external: 1,
|
||||
password: '',
|
||||
};
|
||||
|
||||
user = await this.save(user, { skipValidation: true });
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
public async generateSsoCode(user: User) {
|
||||
let authCode;
|
||||
|
||||
// Make sure that the code is not already in use.
|
||||
do {
|
||||
authCode = randomInt(0, 999999999).toString().padStart(9, '0');
|
||||
} while (await this.loadBySsoAuthCode(authCode) === null);
|
||||
|
||||
user.sso_auth_code = authCode;
|
||||
user.sso_auth_code_expire_at = Date.now() + this.authCodeTtl;
|
||||
|
||||
await this.save(user, { skipValidation: true });
|
||||
}
|
||||
|
||||
public async authCodeLogin(code: string) {
|
||||
const user = await this.loadBySsoAuthCode(code);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
} else if (user.sso_auth_code_expire_at > Date.now()) {
|
||||
// Clear the saved code
|
||||
user.sso_auth_code = '';
|
||||
user.sso_auth_code_expire_at = 0;
|
||||
|
||||
return await this.save(user, { skipValidation: true });
|
||||
} else { // Code is expired. Clear the code but do not return the user.
|
||||
user.sso_auth_code = '';
|
||||
user.sso_auth_code_expire_at = 0;
|
||||
|
||||
await this.save(user, { skipValidation: true });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteExpiredAuthCodes() {
|
||||
await this.db(this.tableName)
|
||||
.where('sso_auth_code_expire_at', '<', Date.now())
|
||||
.update({ sso_auth_code: '', sso_auth_code_expire_at: 0 });
|
||||
}
|
||||
|
||||
public fromApiInput(object: User): User {
|
||||
const user: User = {};
|
||||
|
||||
|
@ -169,6 +243,9 @@ export default class UserModel extends BaseModel<User> {
|
|||
if ('can_upload' in object) user.can_upload = object.can_upload;
|
||||
if ('account_type' in object) user.account_type = object.account_type;
|
||||
if ('must_set_password' in object) user.must_set_password = object.must_set_password;
|
||||
if ('is_external' in object) user.is_external = object.is_external;
|
||||
if ('sso_auth_code' in object) user.sso_auth_code = object.sso_auth_code;
|
||||
if ('sso_auth_code_expire_at' in object) user.sso_auth_code_expire_at = object.sso_auth_code_expire_at;
|
||||
|
||||
return user;
|
||||
}
|
||||
|
@ -190,6 +267,10 @@ export default class UserModel extends BaseModel<User> {
|
|||
}
|
||||
|
||||
if (action === AclAction.Update) {
|
||||
if (user.is_external) { // Modifying users directly from Joplin may cause them to be out of sync with the data we got from the Identity Provider
|
||||
throw new ErrorForbidden('users imported from an external source (such as SAML) cannot be modified');
|
||||
}
|
||||
|
||||
const previousResource = await this.load(resource.id);
|
||||
|
||||
if (!user.is_admin && resource.id !== user.id) throw new ErrorForbidden('non-admin user cannot modify another user');
|
||||
|
@ -705,5 +786,4 @@ export default class UserModel extends BaseModel<User> {
|
|||
}
|
||||
}, 'UserModel::saveMulti');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
import config from '../../config';
|
||||
import Router from '../../utils/Router';
|
||||
import { redirect, SubPath } from '../../utils/routeUtils';
|
||||
import { generateRedirectHtml, getIdentityProvider, getServiceProvider } from '../../utils/saml';
|
||||
import { AppContext, RouteType, SamlPostResponse } from '../../utils/types';
|
||||
import { bodyFields } from '../../utils/requestUtils';
|
||||
import { ErrorBadRequest, ErrorForbidden } from '../../utils/errors';
|
||||
import { cookieSet } from '../../utils/cookies';
|
||||
import defaultView from '../../utils/defaultView';
|
||||
|
||||
export const router = new Router(RouteType.Api);
|
||||
|
||||
router.public = true;
|
||||
|
||||
// Redirect the user to the Identity Provider login page, if they somehow get to this URL directly.
|
||||
router.get('api/saml', async (_path: SubPath, _ctx: AppContext) => {
|
||||
if (!config().saml.enabled) throw new ErrorForbidden('SAML not enabled');
|
||||
return await generateRedirectHtml();
|
||||
});
|
||||
|
||||
// Called when a user successfully authenticated with the Identity Provider, and was redirected to Joplin.
|
||||
router.post('api/saml', async (_path: SubPath, ctx: AppContext) => {
|
||||
if (!config().saml.enabled) throw new ErrorForbidden('SAML not enabled');
|
||||
|
||||
// Load SAML configuration
|
||||
const [serviceProvider, identityProvider] = await Promise.all([
|
||||
getServiceProvider(),
|
||||
getIdentityProvider(),
|
||||
]);
|
||||
|
||||
// Parse the login response
|
||||
const fields = await bodyFields<SamlPostResponse>(ctx.req);
|
||||
|
||||
const result = await serviceProvider.parseLoginResponse(identityProvider, 'post', { body: fields });
|
||||
|
||||
// Extract attributes from the SAML response
|
||||
const email = result.extract.attributes['email'];
|
||||
const displayName = result.extract.attributes['displayName'];
|
||||
|
||||
// Load the user
|
||||
const user = await ctx.joplin.models.user().ssoLogin(email, displayName);
|
||||
|
||||
if (fields.RelayState) {
|
||||
switch (fields.RelayState) {
|
||||
case 'web-login': { // If the user wanted to load a page from Joplin Server, we set the cookie for this session
|
||||
const session = await ctx.joplin.models.session().createUserSession(user.id);
|
||||
cookieSet(ctx, 'sessionId', session.id);
|
||||
|
||||
return redirect(ctx, `${config().baseUrl}/home`);
|
||||
}
|
||||
|
||||
case 'app-login': { // If the user came from a client, we display the authentication code
|
||||
await ctx.joplin.models.user().generateSsoCode(user);
|
||||
|
||||
const view = defaultView('displaySsoCode', 'Login');
|
||||
|
||||
view.content = {
|
||||
ssoCode: user.sso_auth_code.replace(/\B(?=(\d{3})+(?!\d))/g, '-'), // Split the code into blocks of three digits each
|
||||
organizationName: config().saml.enabled && config().saml.organizationDisplayName ? config().saml.organizationDisplayName : undefined,
|
||||
};
|
||||
|
||||
return view;
|
||||
}
|
||||
}
|
||||
} else { // Otherwise, just return the authentication code
|
||||
await ctx.joplin.models.user().generateSsoCode(user);
|
||||
|
||||
return { code: user.sso_auth_code };
|
||||
}
|
||||
});
|
||||
|
||||
router.get('api/login_with_code/:id', async (path: SubPath, ctx: AppContext) => {
|
||||
const code = path.id;
|
||||
if (!code) {
|
||||
throw new ErrorBadRequest();
|
||||
}
|
||||
|
||||
const user = await ctx.joplin.models.user().authCodeLogin(code);
|
||||
|
||||
if (user) {
|
||||
const session = await ctx.joplin.models.session().createUserSession(user.id);
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
user_id: session.user_id,
|
||||
};
|
||||
} else { // Invalid auth code
|
||||
throw new ErrorBadRequest();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
|
@ -9,6 +9,8 @@ import { View } from '../../services/MustacheService';
|
|||
import limiterLoginBruteForce from '../../utils/request/limiterLoginBruteForce';
|
||||
import { cookieSet } from '../../utils/cookies';
|
||||
import { homeUrl } from '../../utils/urlUtils';
|
||||
import { generateRedirectHtml } from '../../utils/saml';
|
||||
import { ErrorForbidden } from '../../utils/errors';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function makeView(error: any = null): View {
|
||||
|
@ -16,6 +18,8 @@ function makeView(error: any = null): View {
|
|||
view.content = {
|
||||
error,
|
||||
signupUrl: config().signupEnabled || config().isJoplinCloud ? makeUrl(UrlType.Signup) : '',
|
||||
samlEnabled: config().saml.enabled,
|
||||
samlOrganizationName: config().saml.enabled && config().saml.organizationDisplayName ? config().saml.organizationDisplayName : undefined,
|
||||
};
|
||||
return view;
|
||||
}
|
||||
|
@ -28,9 +32,29 @@ router.get('login', async (_path: SubPath, ctx: AppContext) => {
|
|||
if (ctx.joplin.owner) {
|
||||
return redirect(ctx, homeUrl());
|
||||
}
|
||||
|
||||
if (!config().LOCAL_AUTH_ENABLED) {
|
||||
return await generateRedirectHtml('web-login');
|
||||
}
|
||||
|
||||
return makeView();
|
||||
});
|
||||
|
||||
// Log in using external authentication.
|
||||
router.get('login/:id', async (path: SubPath, ctx: AppContext) => {
|
||||
if (!config().saml.enabled) throw new ErrorForbidden('SAML not enabled');
|
||||
|
||||
if (ctx.joplin.owner) { // Already logged-in
|
||||
return redirect(ctx, homeUrl());
|
||||
} else if (config().saml.enabled && path.id === 'sso-saml') { // Server page, SAML
|
||||
return await generateRedirectHtml('web-login');
|
||||
} else if (config().saml.enabled && path.id === 'sso-saml-app') { // Client, SAML
|
||||
return await generateRedirectHtml('app-login');
|
||||
} else {
|
||||
return makeView();
|
||||
}
|
||||
});
|
||||
|
||||
router.post('login', async (_path: SubPath, ctx: AppContext) => {
|
||||
await limiterLoginBruteForce(userIp(ctx));
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import apiSessions from './api/sessions';
|
|||
import apiShares from './api/shares';
|
||||
import apiShareUsers from './api/share_users';
|
||||
import apiUsers from './api/users';
|
||||
import apiLogin from './api/login';
|
||||
|
||||
import adminDashboard from './admin/dashboard';
|
||||
import adminEmails from './admin/emails';
|
||||
|
@ -45,6 +46,8 @@ const routes: Routers = {
|
|||
'api/items': apiItems,
|
||||
'api/locks': apiLocks,
|
||||
'api/ping': apiPing,
|
||||
'api/saml': apiLogin,
|
||||
'api/login_with_code': apiLogin,
|
||||
'api/sessions': apiSessions,
|
||||
'api/share_users': apiShareUsers,
|
||||
'api/shares': apiShares,
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
declare module '@authenio/samlify-xmllint-wasm';
|
|
@ -33,6 +33,7 @@ export const taskIdToLabel = (taskId: TaskId): string => {
|
|||
[TaskId.ProcessEmails]: 'Process emails',
|
||||
[TaskId.LogHeartbeatMessage]: 'Log heartbeat message',
|
||||
[TaskId.DeleteOldEvents]: 'Delete old events',
|
||||
[TaskId.DeleteExpiredAuthCodes]: 'Delete expired authentication codes',
|
||||
};
|
||||
|
||||
const s = strings[taskId];
|
||||
|
|
|
@ -137,6 +137,7 @@ export enum TaskId {
|
|||
ProcessEmails,
|
||||
LogHeartbeatMessage,
|
||||
DeleteOldEvents,
|
||||
DeleteExpiredAuthCodes,
|
||||
}
|
||||
|
||||
// AUTO-GENERATED-TYPES
|
||||
|
@ -259,6 +260,9 @@ export interface User extends WithDates, WithUuid {
|
|||
enabled?: number;
|
||||
disabled_time?: number;
|
||||
can_receive_folder?: number;
|
||||
is_external?: number;
|
||||
sso_auth_code?: string;
|
||||
sso_auth_code_expire_at?: number;
|
||||
}
|
||||
|
||||
export interface UserFlag extends WithDates {
|
||||
|
@ -468,6 +472,9 @@ export const databaseSchema: DatabaseTables = {
|
|||
enabled: { type: 'number', defaultValue: 1 },
|
||||
disabled_time: { type: 'string', defaultValue: 0 },
|
||||
can_receive_folder: { type: 'number', defaultValue: null },
|
||||
is_external: { type: 'number', defaultValue: 0 },
|
||||
sso_auth_code: { type: 'string', defaultValue: '' },
|
||||
sso_auth_code_expire_at: { type: 'number', defaultValue: 0 },
|
||||
},
|
||||
user_flags: {
|
||||
id: { type: 'number', defaultValue: null },
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { hashPassword } from './auth';
|
||||
|
||||
describe('hashPassword', () => {
|
||||
describe('auth', () => {
|
||||
|
||||
// cSpell:disable
|
||||
it.each(
|
||||
|
@ -16,5 +16,4 @@ describe('hashPassword', () => {
|
|||
expect((await hashPassword(plainText)).startsWith('$2a$10')).toBe(true);
|
||||
});
|
||||
// cSpell:enable
|
||||
|
||||
});
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
import { writeFile, remove } from 'fs-extra';
|
||||
import { createTempDir } from '@joplin/lib/testing/test-utils';
|
||||
import config from '../config';
|
||||
import { getLoginRequest } from './saml';
|
||||
import { afterAllTests, beforeAllDb, beforeEachDb } from './testing/testUtils';
|
||||
import { SamlRelayState } from './types';
|
||||
|
||||
describe('getLoginRequest', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await beforeAllDb('saml');
|
||||
|
||||
// cSpell:disable
|
||||
const spXml = `
|
||||
<?xml version="1.0"?>
|
||||
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
|
||||
entityID="Joplin">
|
||||
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||
Location="http://localhost:22300/api/saml"
|
||||
index="1" />
|
||||
|
||||
</md:SPSSODescriptor>
|
||||
</md:EntityDescriptor>`;
|
||||
|
||||
const idpXml = `
|
||||
<?xml version="1.0"?>
|
||||
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="saml-idp">
|
||||
<md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<md:KeyDescriptor use="signing">
|
||||
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>MIIDIzCCAgugAwIBAgIUOGfU4onZ0So0R4L4FH2OUo7cmwcwDQYJKoZIhvcNAQELBQAwITEfMB0GA1UEAwwWVGVzdCBJZGVudGl0eSBQcm92aWRlcjAeFw0yNDEwMjUwOTI0NDBaFw00NDEwMjAwOTI0NDBaMCExHzAdBgNVBAMMFlRlc3QgSWRlbnRpdHkgUHJvdmlkZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCrgUKiNwsnlCwQViTqUfTKJXtGQdFZ5ZHHupqNX3hLa2H/MqL25k00p9dw3h9ddpnpmvBsP4jaEeXF4ibU/HQ78cWiUzPkQripkTtYvAM2I/KodqyCHPJr0yJtFUCT/rDrtrCRZ1eZ+K1nvzVFBqiQwgY8IOmhVIqvK7r+sOuDoP7fFDbiZgDyD07noA/oMlcfkm/xj5O70YGX+Iqh8FMJTA8z6DyqTQKtXPBhndkchZDehCkWmKsmpvM3X9QBBl71tJoFu9WqGgtvfMWq+/WoTJ18jbcj0p2jhhEuvDsI1jmeisXzwunO0HtmbDgd17rjOP2CIXUffAV+gg7B5PFBAgMBAAGjUzBRMB0GA1UdDgQWBBSDjyS0o+Y8Sjb885BCo+bmvbwrgTAfBgNVHSMEGDAWgBSDjyS0o+Y8Sjb885BCo+bmvbwrgTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAMxqjfHu6rjnm4PeOXywpnRca8Md95tnh0YJNAu9Vb19jpqUF96psS1lZMqmZ66tnLPCi+rBAtI66BO2wClqxe5K9MeiJIZOwDHLqJ8TDGE+8LM/uEOqobtdjp1vSEuLAC2zeXba9ISqYUrXGcTic65EERGBnG3w2D/rTm7te7C0b6yYet1l4K1RqctxDaI90YV2a1aiT1wngaOQclHAJlR7c0kJP6JZaS/R56Y88S0exZo82u4CsI3GuY42M2ET74/5pllsRsYrQz6iXqnrbcpxvFAWj5D+1uq+rdqc8M0dW5CXZ7zLjJxXH9pFneOnSyX6YbuK+b6kdKUxKlQRMs</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
</md:KeyDescriptor>
|
||||
<md:KeyDescriptor use="encryption">
|
||||
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>MIIDIzCCAgugAwIBAgIUOGfU4onZ0So0R4L4FH2OUo7cmwcwDQYJKoZIhvcNAQELBQAwITEfMB0GA1UEAwwWVGVzdCBJZGVudGl0eSBQcm92aWRlcjAeFw0yNDEwMjUwOTI0NDBaFw00NDEwMjAwOTI0NDBaMCExHzAdBgNVBAMMFlRlc3QgSWRlbnRpdHkgUHJvdmlkZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCrgUKiNwsnlCwQViTqUfTKJXtGQdFZ5ZHHupqNX3hLa2H/MqL25k00p9dw3h9ddpnpmvBsP4jaEeXF4ibU/HQ78cWiUzPkQripkTtYvAM2I/KodqyCHPJr0yJtFUCT/rDrtrCRZ1eZ+K1nvzVFBqiQwgY8IOmhVIqvK7r+sOuDoP7fFDbiZgDyD07noA/oMlcfkm/xj5O70YGX+Iqh8FMJTA8z6DyqTQKtXPBhndkchZDehCkWmKsmpvM3X9QBBl71tJoFu9WqGgtvfMWq+/WoTJ18jbcj0p2jhhEuvDsI1jmeisXzwunO0HtmbDgd17rjOP2CIXUffAV+gg7B5PFBAgMBAAGjUzBRMB0GA1UdDgQWBBSDjyS0o+Y8Sjb885BCo+bmvbwrgTAfBgNVHSMEGDAWgBSDjyS0o+Y8Sjb885BCo+bmvbwrgTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAMxqjfHu6rjnm4PeOXywpnRca8Md95tnh0YJNAu9Vb19jpqUF96psS1lZMqmZ66tnLPCi+rBAtI66BO2wClqxe5K9MeiJIZOwDHLqJ8TDGE+8LM/uEOqobtdjp1vSEuLAC2zeXba9ISqYUrXGcTic65EERGBnG3w2D/rTm7te7C0b6yYet1l4K1RqctxDaI90YV2a1aiT1wngaOQclHAJlR7c0kJP6JZaS/R56Y88S0exZo82u4CsI3GuY42M2ET74/5pllsRsYrQz6iXqnrbcpxvFAWj5D+1uq+rdqc8M0dW5CXZ7zLjJxXH9pFneOnSyX6YbuK+b6kdKUxKlQRMs</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
</md:KeyDescriptor>
|
||||
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</NameIDFormat>
|
||||
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</NameIDFormat>
|
||||
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:7000/saml/sso"/>
|
||||
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:7000/saml/sso"/>
|
||||
<Attribute Name="firstName" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" FriendlyName="First Name" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"/>
|
||||
<Attribute Name="lastName" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" FriendlyName="Last Name" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"/>
|
||||
<Attribute Name="displayName" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" FriendlyName="Display Name" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"/>
|
||||
<Attribute Name="email" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" FriendlyName="E-Mail Address" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"/>
|
||||
<Attribute Name="mobilePhone" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" FriendlyName="Mobile Phone" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"/>
|
||||
<Attribute Name="groups" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" FriendlyName="Groups" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"/>
|
||||
<Attribute Name="userType" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri" FriendlyName="User Type" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"/>
|
||||
</md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>`;
|
||||
// cSpell:enable
|
||||
|
||||
dir = await createTempDir();
|
||||
await Promise.all([
|
||||
writeFile(`${dir}/idp.xml`, idpXml),
|
||||
writeFile(`${dir}/sp.xml`, spXml),
|
||||
]);
|
||||
|
||||
config().saml = {
|
||||
enabled: true,
|
||||
identityProviderConfigFile: `${dir}/idp.xml`,
|
||||
serviceProviderConfigFile: `${dir}/sp.xml`,
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await afterAllTests();
|
||||
await remove(dir);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await beforeEachDb();
|
||||
});
|
||||
|
||||
it.each([null, 'web-login', 'app-login'] as SamlRelayState[])('should create a login request with the relay state: %', async (relayState) => {
|
||||
const loginRequest = await getLoginRequest(relayState);
|
||||
|
||||
expect(loginRequest.entityEndpoint).toBe('http://localhost:7000/saml/sso');
|
||||
expect(loginRequest.relayState).toBe(relayState);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,72 @@
|
|||
import { ServiceProvider, IdentityProvider, setSchemaValidator } from 'samlify';
|
||||
import * as validator from '@authenio/samlify-xmllint-wasm';
|
||||
import { readFile } from 'fs-extra';
|
||||
import config from '../config';
|
||||
import { PostBindingContext } from 'samlify/types/src/entity';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { SamlRelayState } from './types';
|
||||
|
||||
const checkIfSamlIsEnabled = () => {
|
||||
if (!config().saml.enabled) {
|
||||
throw new Error('SAML support is disabled for this server.');
|
||||
}
|
||||
};
|
||||
|
||||
export const getServiceProvider = async (relayState: SamlRelayState = null) => {
|
||||
checkIfSamlIsEnabled();
|
||||
|
||||
return ServiceProvider({
|
||||
metadata: await readFile(config().saml.serviceProviderConfigFile),
|
||||
relayState,
|
||||
});
|
||||
};
|
||||
|
||||
export const getIdentityProvider = async () => {
|
||||
checkIfSamlIsEnabled();
|
||||
|
||||
return IdentityProvider({
|
||||
metadata: await readFile(config().saml.identityProviderConfigFile),
|
||||
});
|
||||
};
|
||||
|
||||
export const setupSamlAuthentication = () => {
|
||||
setSchemaValidator(validator);
|
||||
};
|
||||
|
||||
export const getLoginRequest = async (relayState: SamlRelayState = null) => {
|
||||
const [sp, idp] = await Promise.all([
|
||||
getServiceProvider(relayState),
|
||||
getIdentityProvider(),
|
||||
]);
|
||||
|
||||
return sp.createLoginRequest(idp, 'post') as PostBindingContext;
|
||||
};
|
||||
|
||||
// This does not rely on the usual templates since the redirect should be fast, and shouldn't contain too much HTML code.
|
||||
export const generateRedirectHtml = async (relayState: SamlRelayState = null) => {
|
||||
const loginRequest = await getLoginRequest(relayState);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${_('Joplin SSO Authentication')}</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>${_('Please wait while we load your organisation sign-in page...')}</p>
|
||||
|
||||
<form id="saml-form" method="post" action="${loginRequest.entityEndpoint}" autocomplete="off">
|
||||
<input type="hidden" name="${loginRequest.type}" value="${loginRequest.context}"/>
|
||||
|
||||
${loginRequest.relayState ? `<input type="hidden" name="RelayState" value="${loginRequest.relayState}"/>` : ''}
|
||||
</form>
|
||||
|
||||
<script type="text/javascript">
|
||||
(() => {
|
||||
document.querySelector('#saml-form').submit();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
|
@ -83,6 +83,13 @@ export default async function(env: Env, models: Models, config: Config, services
|
|||
schedule: config.HEARTBEAT_MESSAGE_SCHEDULE,
|
||||
run: (_models: Models, _services: Services) => logHeartbeatMessage(),
|
||||
},
|
||||
|
||||
{
|
||||
id: TaskId.DeleteExpiredAuthCodes,
|
||||
description: taskIdToLabel(TaskId.DeleteExpiredAuthCodes),
|
||||
schedule: '*/15 * * * *',
|
||||
run: (models: Models) => models.user().deleteExpiredAuthCodes(),
|
||||
},
|
||||
];
|
||||
|
||||
if (config.USER_DATA_AUTO_DELETE_ENABLED) {
|
||||
|
|
|
@ -144,6 +144,13 @@ export interface LdapConfig {
|
|||
tlsCaFile: string;
|
||||
}
|
||||
|
||||
export interface SamlConfig {
|
||||
enabled: boolean;
|
||||
identityProviderConfigFile?: string;
|
||||
serviceProviderConfigFile?: string;
|
||||
organizationDisplayName?: string;
|
||||
}
|
||||
|
||||
export interface Config extends EnvVariables {
|
||||
appVersion: string;
|
||||
joplinServerVersion: string; // May be different from appVersion, if this is a fork of JS
|
||||
|
@ -180,6 +187,7 @@ export interface Config extends EnvVariables {
|
|||
itemSizeHardLimit: number;
|
||||
maxTimeDrift: number;
|
||||
ldap: LdapConfig[];
|
||||
saml: SamlConfig;
|
||||
}
|
||||
|
||||
export enum HttpMethod {
|
||||
|
@ -201,3 +209,9 @@ export type KoaNext = ()=> Promise<void>;
|
|||
export interface CommandContext {
|
||||
models: Models;
|
||||
}
|
||||
|
||||
export type SamlRelayState = 'web-login' | 'app-login' | null;
|
||||
export interface SamlPostResponse {
|
||||
SAMLResponse: string;
|
||||
RelayState?: SamlRelayState;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
<section class="section">
|
||||
<div class="container block">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
{{#organizationName}}
|
||||
<h1 class="title">Successfully logged into your {{organizationName}} account!</h1>
|
||||
{{/organizationName}}
|
||||
{{^organizationName}}
|
||||
<h1 class="title">Successfully logged into your account!</h1>
|
||||
{{/organizationName}}
|
||||
|
||||
<p>Please enter the following code into your Joplin application to start syncing your notes:</p>
|
||||
<p class="is-family-monospace is-size-1">{{ssoCode}}</p>
|
||||
|
||||
<button class="button is-primary" onclick="navigator.clipboard.writeText('{{ssoCode}}')">Copy to clipboard</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
|
@ -1,28 +1,49 @@
|
|||
<section class="section login-box">
|
||||
<h1 class="title">Login to {{global.appName}}</h1>
|
||||
<p class="subtitle">Please input your details to login to {{global.appName}}</p>
|
||||
|
||||
<div class="container block">
|
||||
{{> errorBanner}}
|
||||
<form action="{{{global.baseUrl}}}/login" method="POST">
|
||||
<div class="field">
|
||||
<label class="label">Email</label>
|
||||
<div class="control">
|
||||
<input class="input" type="email" name="email"/>
|
||||
|
||||
<div class="columns">
|
||||
<form action="{{{global.baseUrl}}}/login" method="POST" class="column">
|
||||
<h2 class="title">Login to {{global.appName}}</h1>
|
||||
<p class="subtitle">Please input your details to login to {{global.appName}}</p>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Email</label>
|
||||
<div class="control">
|
||||
<input class="input" type="email" name="email"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Password</label>
|
||||
<div class="control">
|
||||
<input class="input" type="password" name="password"/>
|
||||
<div class="field">
|
||||
<label class="label">Password</label>
|
||||
<div class="control">
|
||||
<input class="input" type="password" name="password"/>
|
||||
</div>
|
||||
<p class="help"><a href="{{{global.baseUrl}}}/password/forgot">I forgot my password</a></p>
|
||||
</div>
|
||||
<p class="help"><a href="{{{global.baseUrl}}}/password/forgot">I forgot my password</a></p>
|
||||
<div class="control">
|
||||
<button class="button is-primary">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{#samlEnabled}}
|
||||
<div class="column is-one-fifth is-flex is-justify-content-center">
|
||||
<div style="border-left: 2px solid #f5f5f5; height: 100%; width: 2px;"></div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-primary">Login</button>
|
||||
|
||||
<div class="column is-flex is-flex-direction-column is-justify-content-center">
|
||||
<h2 class="title">Use your organisation account</h2>
|
||||
{{#samlOrganizationName}}
|
||||
<p class="subtitle">{{samlOrganizationName}}</p>
|
||||
{{/samlOrganizationName}}
|
||||
|
||||
<a href="/login/sso-saml">
|
||||
<button class="button is-primary is-fullwidth">Login using your organisation account</button>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
{{/samlEnabled}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#signupUrl}}
|
||||
<div class="container block">
|
||||
Or <a href="{{signupUrl}}">sign up</a> to create a new account.
|
||||
|
|
|
@ -168,6 +168,8 @@ pmmmwh
|
|||
webm
|
||||
millis
|
||||
sideloading
|
||||
samlify
|
||||
authenio
|
||||
ggml
|
||||
Minidump
|
||||
collapseall
|
||||
|
|
79
yarn.lock
79
yarn.lock
|
@ -258,6 +258,26 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@authenio/samlify-xmllint-wasm@npm:1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "@authenio/samlify-xmllint-wasm@npm:1.0.1"
|
||||
dependencies:
|
||||
xmllint-wasm: ^4.0.0
|
||||
checksum: 02ba8ad28ccdacc7f41cab9183315eca18ee8cf55a817edfd09ef8cff6ed87b440f07874084e33b901edb5ad8bc4d76e44de7d086ea48ea1ef259b4fb02dc026
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@authenio/xml-encryption@npm:^2.0.2":
|
||||
version: 2.0.2
|
||||
resolution: "@authenio/xml-encryption@npm:2.0.2"
|
||||
dependencies:
|
||||
"@xmldom/xmldom": ^0.8.6
|
||||
escape-html: ^1.0.3
|
||||
xpath: 0.0.32
|
||||
checksum: 210b5c32a84d0c944e0e4a9dd8592b7246f23c89deee6fdc979a25625b4fae1ef3f4e802265288fa54c5514d7ef8ce8440f39ec0be8c9123b0a76121786611a8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@aws-crypto/crc32@npm:3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "@aws-crypto/crc32@npm:3.0.0"
|
||||
|
@ -8818,6 +8838,7 @@ __metadata:
|
|||
version: 0.0.0-use.local
|
||||
resolution: "@joplin/server@workspace:packages/server"
|
||||
dependencies:
|
||||
"@authenio/samlify-xmllint-wasm": 1.0.1
|
||||
"@aws-sdk/client-s3": 3.296.0
|
||||
"@fortawesome/fontawesome-free": 5.15.4
|
||||
"@joplin/lib": ~3.3
|
||||
|
@ -8869,6 +8890,7 @@ __metadata:
|
|||
query-string: 7.1.3
|
||||
rate-limiter-flexible: 5.0.3
|
||||
raw-body: 2.5.2
|
||||
samlify: 2.8.10
|
||||
source-map-support: 0.5.21
|
||||
sqlite3: 5.1.6
|
||||
stripe: 8.222.0
|
||||
|
@ -15041,6 +15063,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@xmldom/xmldom@npm:^0.8.6":
|
||||
version: 0.8.10
|
||||
resolution: "@xmldom/xmldom@npm:0.8.10"
|
||||
checksum: 4c136aec31fb3b49aaa53b6fcbfe524d02a1dc0d8e17ee35bd3bf35e9ce1344560481cd1efd086ad1a4821541482528672306d5e37cdbd187f33d7fadd3e2cf0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@xmldom/xmldom@npm:^0.8.8":
|
||||
version: 0.8.8
|
||||
resolution: "@xmldom/xmldom@npm:0.8.8"
|
||||
|
@ -35458,7 +35487,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-forge@npm:^1, node-forge@npm:^1.2.1, node-forge@npm:^1.3.1":
|
||||
"node-forge@npm:^1, node-forge@npm:^1.2.1, node-forge@npm:^1.3.0, node-forge@npm:^1.3.1":
|
||||
version: 1.3.1
|
||||
resolution: "node-forge@npm:1.3.1"
|
||||
checksum: 08fb072d3d670599c89a1704b3e9c649ff1b998256737f0e06fbd1a5bf41cae4457ccaee32d95052d80bbafd9ffe01284e078c8071f0267dc9744e51c5ed42a9
|
||||
|
@ -35633,7 +35662,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-rsa@npm:1.1.1":
|
||||
"node-rsa@npm:1.1.1, node-rsa@npm:^1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "node-rsa@npm:1.1.1"
|
||||
dependencies:
|
||||
|
@ -37096,7 +37125,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pako@npm:~1.0.5":
|
||||
"pako@npm:^1.0.10, pako@npm:~1.0.5":
|
||||
version: 1.0.11
|
||||
resolution: "pako@npm:1.0.11"
|
||||
checksum: 1be2bfa1f807608c7538afa15d6f25baa523c30ec870a3228a89579e474a4d992f4293859524e46d5d87fd30fa17c5edf34dbef0671251d9749820b488660b16
|
||||
|
@ -42271,6 +42300,24 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"samlify@npm:2.8.10":
|
||||
version: 2.8.10
|
||||
resolution: "samlify@npm:2.8.10"
|
||||
dependencies:
|
||||
"@authenio/xml-encryption": ^2.0.2
|
||||
"@xmldom/xmldom": ^0.8.6
|
||||
camelcase: ^6.2.0
|
||||
node-forge: ^1.3.0
|
||||
node-rsa: ^1.1.1
|
||||
pako: ^1.0.10
|
||||
uuid: ^8.3.2
|
||||
xml: ^1.0.1
|
||||
xml-crypto: ^3.0.1
|
||||
xpath: ^0.0.32
|
||||
checksum: fdfb4bd36d1bac531fe26f7c4c41ca215df2a7eebab9c6c7f980bd2026e5e3ac6340560c55169b639d90af3ef5d783b565d31fab31641b3562a8f1e357908ef1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"sanitize-filename@npm:^1.6.3":
|
||||
version: 1.6.3
|
||||
resolution: "sanitize-filename@npm:1.6.3"
|
||||
|
@ -49393,6 +49440,16 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"xml-crypto@npm:^3.0.1":
|
||||
version: 3.2.0
|
||||
resolution: "xml-crypto@npm:3.2.0"
|
||||
dependencies:
|
||||
"@xmldom/xmldom": ^0.8.8
|
||||
xpath: 0.0.32
|
||||
checksum: 6c4974a7518307ea006dcfc1405f61c6738b45574b4d9d1e62f53b602bfcf894d34017f99d618f26f67c40a5e6d78e6228116ded2768b2ca5b2df5c8bf7774b7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"xml-js@npm:^1.6.11":
|
||||
version: 1.6.11
|
||||
resolution: "xml-js@npm:1.6.11"
|
||||
|
@ -49455,7 +49512,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"xml@npm:1.0.1":
|
||||
"xml@npm:1.0.1, xml@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "xml@npm:1.0.1"
|
||||
checksum: 11b5545ef3f8fec3fa29ce251f50ad7b6c97c103ed4d851306ec23366f5fa4699dd6a942262df52313a0cd1840ab26256da253c023bad3309d8ce46fe6020ca0
|
||||
|
@ -49497,6 +49554,20 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"xmllint-wasm@npm:^4.0.0":
|
||||
version: 4.0.2
|
||||
resolution: "xmllint-wasm@npm:4.0.2"
|
||||
checksum: f802920b2a9d6be5ac5923b1608eaff40dde0dbe8a8c2d47381ae370e26a04059d6517306a3728bd8a33b973acadd3fbf4b21b0e4fe6409ea6a2efb2c6a64b08
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"xpath@npm:0.0.32, xpath@npm:^0.0.32":
|
||||
version: 0.0.32
|
||||
resolution: "xpath@npm:0.0.32"
|
||||
checksum: 887e9747b960ea45fb47a9464744424512de0a49205e82c2ad6be662d7a2f1a75145662a143304340864c6da68fd8d767cce4065cc198ee07a3d4897e0a3d4bb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"xtend@npm:^4.0.0, xtend@npm:^4.0.1, xtend@npm:^4.0.2, xtend@npm:~4.0.0, xtend@npm:~4.0.1":
|
||||
version: 4.0.2
|
||||
resolution: "xtend@npm:4.0.2"
|
||||
|
|
Loading…
Reference in New Issue