Tom Chedmail 2025-04-18 12:17:59 +02:00 committed by GitHub
commit 37200907df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 1031 additions and 37 deletions

View File

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

6
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -129,6 +129,7 @@ const syncTargetNames: string[] = [
'webdav',
'amazon_s3',
'joplinServer',
'joplinServerSaml',
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: () => '',
});
}
}

View File

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

View File

@ -0,0 +1,7 @@
interface SsoScreenShared {
openLoginPage(): Promise<void>;
processLoginCode(code: string): Promise<boolean>;
isLoginCodeValid(code: string): boolean;
}
export default SsoScreenShared;

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
const prefixWithHttps = (url: string) => {
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return `https://${url}`;
} else {
return url;
}
};
export default prefixWithHttps;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
declare module '@authenio/samlify-xmllint-wasm';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -168,6 +168,8 @@ pmmmwh
webm
millis
sideloading
samlify
authenio
ggml
Minidump
collapseall

View File

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