mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2025-07-23 14:00:08 +02:00
Compare commits
9 commits
04e04e6318
...
7a7dc6ae46
Author | SHA1 | Date | |
---|---|---|---|
![]() |
7a7dc6ae46 | ||
![]() |
7ea7c64996 | ||
![]() |
bbcbc36eb7 | ||
![]() |
6d38bccd31 | ||
![]() |
741db3827d | ||
![]() |
faf127f48f | ||
![]() |
86a2a1346b | ||
![]() |
006093b79f | ||
![]() |
244e56f868 |
40 changed files with 1533 additions and 22 deletions
23
patches/@node-saml+node-saml+4.0.5.patch
Normal file
23
patches/@node-saml+node-saml+4.0.5.patch
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
diff --git a/node_modules/@node-saml/node-saml/lib/saml.js b/node_modules/@node-saml/node-saml/lib/saml.js
|
||||||
|
index fba15b9..a5778cb 100644
|
||||||
|
--- a/node_modules/@node-saml/node-saml/lib/saml.js
|
||||||
|
+++ b/node_modules/@node-saml/node-saml/lib/saml.js
|
||||||
|
@@ -336,7 +336,8 @@ class SAML {
|
||||||
|
const requestOrResponse = request || response;
|
||||||
|
(0, utility_1.assertRequired)(requestOrResponse, "either request or response is required");
|
||||||
|
let buffer;
|
||||||
|
- if (this.options.skipRequestCompression) {
|
||||||
|
+ // logout requestOrResponse must be compressed anyway
|
||||||
|
+ if (this.options.skipRequestCompression && operation !== "logout") {
|
||||||
|
buffer = Buffer.from(requestOrResponse, "utf8");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
@@ -495,7 +496,7 @@ class SAML {
|
||||||
|
try {
|
||||||
|
xml = Buffer.from(container.SAMLResponse, "base64").toString("utf8");
|
||||||
|
doc = await (0, xml_1.parseDomFromString)(xml);
|
||||||
|
- const inResponseToNodes = xml_1.xpath.selectAttributes(doc, "/*[local-name()='Response']/@InResponseTo");
|
||||||
|
+ const inResponseToNodes = xml_1.xpath.selectAttributes(doc, "/*[local-name()='Response' or local-name()='LogoutResponse']/@InResponseTo");
|
||||||
|
if (inResponseToNodes) {
|
||||||
|
inResponseTo = inResponseToNodes.length ? inResponseToNodes[0].nodeValue : null;
|
||||||
|
await this.validateInResponseTo(inResponseTo);
|
64
patches/ldapauth-fork+4.3.3.patch
Normal file
64
patches/ldapauth-fork+4.3.3.patch
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
diff --git a/node_modules/ldapauth-fork/lib/ldapauth.js b/node_modules/ldapauth-fork/lib/ldapauth.js
|
||||||
|
index 85ecf36a8b..a7d07e0f78 100644
|
||||||
|
--- a/node_modules/ldapauth-fork/lib/ldapauth.js
|
||||||
|
+++ b/node_modules/ldapauth-fork/lib/ldapauth.js
|
||||||
|
@@ -69,6 +69,7 @@ function LdapAuth(opts) {
|
||||||
|
this.opts.bindProperty || (this.opts.bindProperty = 'dn');
|
||||||
|
this.opts.groupSearchScope || (this.opts.groupSearchScope = 'sub');
|
||||||
|
this.opts.groupDnProperty || (this.opts.groupDnProperty = 'dn');
|
||||||
|
+ this.opts.tlsStarted = false;
|
||||||
|
|
||||||
|
EventEmitter.call(this);
|
||||||
|
|
||||||
|
@@ -108,21 +109,7 @@ function LdapAuth(opts) {
|
||||||
|
this._userClient.on('error', this._handleError.bind(this));
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
- if (this.opts.starttls) {
|
||||||
|
- // When starttls is enabled, this callback supplants the 'connect' callback
|
||||||
|
- this._adminClient.starttls(this.opts.tlsOptions, this._adminClient.controls, function(err) {
|
||||||
|
- if (err) {
|
||||||
|
- self._handleError(err);
|
||||||
|
- } else {
|
||||||
|
- self._onConnectAdmin();
|
||||||
|
- }
|
||||||
|
- });
|
||||||
|
- this._userClient.starttls(this.opts.tlsOptions, this._userClient.controls, function(err) {
|
||||||
|
- if (err) {
|
||||||
|
- self._handleError(err);
|
||||||
|
- }
|
||||||
|
- });
|
||||||
|
- } else if (opts.reconnect) {
|
||||||
|
+ if (opts.reconnect && !this.opts.starttls) {
|
||||||
|
this.once('_installReconnectListener', function() {
|
||||||
|
self.log && self.log.trace('install reconnect listener');
|
||||||
|
self._adminClient.on('connect', function() {
|
||||||
|
@@ -384,6 +371,28 @@ LdapAuth.prototype._findGroups = function(user, callback) {
|
||||||
|
*/
|
||||||
|
LdapAuth.prototype.authenticate = function(username, password, callback) {
|
||||||
|
var self = this;
|
||||||
|
+ if (this.opts.starttls && !this.opts.tlsStarted) {
|
||||||
|
+ // When starttls is enabled, this callback supplants the 'connect' callback
|
||||||
|
+ this._adminClient.starttls(this.opts.tlsOptions, this._adminClient.controls, function (err) {
|
||||||
|
+ if (err) {
|
||||||
|
+ self._handleError(err);
|
||||||
|
+ } else {
|
||||||
|
+ self._onConnectAdmin(function(){self._handleAuthenticate(username, password, callback);});
|
||||||
|
+ }
|
||||||
|
+ });
|
||||||
|
+ this._userClient.starttls(this.opts.tlsOptions, this._userClient.controls, function (err) {
|
||||||
|
+ if (err) {
|
||||||
|
+ self._handleError(err);
|
||||||
|
+ }
|
||||||
|
+ });
|
||||||
|
+ } else {
|
||||||
|
+ self._handleAuthenticate(username, password, callback);
|
||||||
|
+ }
|
||||||
|
+};
|
||||||
|
+
|
||||||
|
+LdapAuth.prototype._handleAuthenticate = function (username, password, callback) {
|
||||||
|
+ this.opts.tlsStarted = true;
|
||||||
|
+ var self = this;
|
||||||
|
|
||||||
|
if (typeof password === 'undefined' || password === null || password === '') {
|
||||||
|
return callback(new Error('no password given'));
|
|
@ -82,6 +82,7 @@ const AuthenticationController = {
|
||||||
analyticsId: user.analyticsId || user._id,
|
analyticsId: user.analyticsId || user._id,
|
||||||
alphaProgram: user.alphaProgram || undefined, // only store if set
|
alphaProgram: user.alphaProgram || undefined, // only store if set
|
||||||
betaProgram: user.betaProgram || undefined, // only store if set
|
betaProgram: user.betaProgram || undefined, // only store if set
|
||||||
|
externalAuth: user.externalAuth || false,
|
||||||
}
|
}
|
||||||
if (user.isAdmin) {
|
if (user.isAdmin) {
|
||||||
lightUser.isAdmin = true
|
lightUser.isAdmin = true
|
||||||
|
|
|
@ -119,7 +119,7 @@ async function requestReset(req, res, next) {
|
||||||
OError.tag(err, 'failed to generate and email password reset token', {
|
OError.tag(err, 'failed to generate and email password reset token', {
|
||||||
email,
|
email,
|
||||||
})
|
})
|
||||||
if (err.message === 'user does not have permission for change-password') {
|
if (err.message === 'user does not have one or more permissions within change-password') {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
message: {
|
message: {
|
||||||
key: 'no-password-allowed-due-to-sso',
|
key: 'no-password-allowed-due-to-sso',
|
||||||
|
|
|
@ -72,6 +72,7 @@ async function getUserForPasswordResetToken(token) {
|
||||||
'overleaf.id': 1,
|
'overleaf.id': 1,
|
||||||
email: 1,
|
email: 1,
|
||||||
must_reconfirm: 1,
|
must_reconfirm: 1,
|
||||||
|
hashedPassword: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
await assertUserPermissions(user, ['change-password'])
|
await assertUserPermissions(user, ['change-password'])
|
||||||
|
|
|
@ -515,4 +515,5 @@ module.exports = {
|
||||||
expireDeletedUsersAfterDuration: expressify(expireDeletedUsersAfterDuration),
|
expireDeletedUsersAfterDuration: expressify(expireDeletedUsersAfterDuration),
|
||||||
ensureAffiliationMiddleware: expressify(ensureAffiliationMiddleware),
|
ensureAffiliationMiddleware: expressify(ensureAffiliationMiddleware),
|
||||||
ensureAffiliation,
|
ensureAffiliation,
|
||||||
|
doLogout,
|
||||||
}
|
}
|
||||||
|
|
|
@ -53,10 +53,8 @@ async function settingsPage(req, res) {
|
||||||
const reconfirmedViaSAML = _.get(req.session, ['saml', 'reconfirmed'])
|
const reconfirmedViaSAML = _.get(req.session, ['saml', 'reconfirmed'])
|
||||||
delete req.session.saml
|
delete req.session.saml
|
||||||
let shouldAllowEditingDetails = true
|
let shouldAllowEditingDetails = true
|
||||||
if (Settings.ldap && Settings.ldap.updateUserDetailsOnLogin) {
|
const externalAuth = req.user.externalAuth
|
||||||
shouldAllowEditingDetails = false
|
if (externalAuth && Settings[externalAuth].updateUserDetailsOnLogin) {
|
||||||
}
|
|
||||||
if (Settings.saml && Settings.saml.updateUserDetailsOnLogin) {
|
|
||||||
shouldAllowEditingDetails = false
|
shouldAllowEditingDetails = false
|
||||||
}
|
}
|
||||||
const oauthProviders = Settings.oauthProviders || {}
|
const oauthProviders = Settings.oauthProviders || {}
|
||||||
|
|
|
@ -106,9 +106,9 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) {
|
||||||
|
|
||||||
webRouter.use(function (req, res, next) {
|
webRouter.use(function (req, res, next) {
|
||||||
req.externalAuthenticationSystemUsed =
|
req.externalAuthenticationSystemUsed =
|
||||||
Features.externalAuthenticationSystemUsed
|
() => !!req?.user?.externalAuth
|
||||||
res.locals.externalAuthenticationSystemUsed =
|
res.locals.externalAuthenticationSystemUsed =
|
||||||
Features.externalAuthenticationSystemUsed
|
() => !!req?.user?.externalAuth
|
||||||
req.hasFeature = res.locals.hasFeature = Features.hasFeature
|
req.hasFeature = res.locals.hasFeature = Features.hasFeature
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
|
@ -217,6 +217,8 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
||||||
CaptchaMiddleware.canSkipCaptcha
|
CaptchaMiddleware.canSkipCaptcha
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter)
|
||||||
|
|
||||||
webRouter.get('/login', UserPagesController.loginPage)
|
webRouter.get('/login', UserPagesController.loginPage)
|
||||||
AuthenticationController.addEndpointToLoginWhitelist('/login')
|
AuthenticationController.addEndpointToLoginWhitelist('/login')
|
||||||
|
|
||||||
|
@ -285,8 +287,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
||||||
TokenAccessRouter.apply(webRouter)
|
TokenAccessRouter.apply(webRouter)
|
||||||
HistoryRouter.apply(webRouter, privateApiRouter)
|
HistoryRouter.apply(webRouter, privateApiRouter)
|
||||||
|
|
||||||
await Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter)
|
|
||||||
|
|
||||||
if (Settings.enableSubscriptions) {
|
if (Settings.enableSubscriptions) {
|
||||||
webRouter.get(
|
webRouter.get(
|
||||||
'/user/bonus',
|
'/user/bonus',
|
||||||
|
|
|
@ -23,10 +23,10 @@ block content
|
||||||
| !{translate('password_compromised_try_again_or_use_known_device_or_reset', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}, {name: 'a', attrs: {href: '/user/password/reset', target: '_blank'}}])}.
|
| !{translate('password_compromised_try_again_or_use_known_device_or_reset', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}, {name: 'a', attrs: {href: '/user/password/reset', target: '_blank'}}])}.
|
||||||
.form-group
|
.form-group
|
||||||
input.form-control(
|
input.form-control(
|
||||||
type='email',
|
type=(settings.ldap && settings.ldap.enable) ? 'text' : 'email',
|
||||||
name='email',
|
name='email',
|
||||||
required,
|
required,
|
||||||
placeholder='email@example.com',
|
placeholder=(settings.ldap && settings.ldap.enable) ? settings.ldap.placeholder : 'email@example.com',
|
||||||
autofocus="true"
|
autofocus="true"
|
||||||
)
|
)
|
||||||
.form-group
|
.form-group
|
||||||
|
@ -47,4 +47,21 @@ block content
|
||||||
if login_support_text
|
if login_support_text
|
||||||
hr
|
hr
|
||||||
p.text-center !{login_support_text}
|
p.text-center !{login_support_text}
|
||||||
|
if settings.saml && settings.saml.enable
|
||||||
|
.actions(style='margin-top: 30px;')
|
||||||
|
a.button.btn-secondary.btn(
|
||||||
|
href='/saml/login',
|
||||||
|
style="width: 100%;"
|
||||||
|
data-ol-disabled-inflight
|
||||||
|
)
|
||||||
|
span(data-ol-inflight="idle") #{settings.saml.identityServiceName}
|
||||||
|
span(hidden data-ol-inflight="pending") #{translate("logging_in")}…
|
||||||
|
if settings.oidc && settings.oidc.enable
|
||||||
|
.actions(style='margin-top: 30px;')
|
||||||
|
a.button.btn-secondary.btn(
|
||||||
|
href='/oidc/login',
|
||||||
|
style="width: 100%;"
|
||||||
|
data-ol-disabled-inflight
|
||||||
|
)
|
||||||
|
span(data-ol-inflight="idle") #{settings.oidc.identityServiceName}
|
||||||
|
span(hidden data-ol-inflight="pending") #{translate("logging_in")}…
|
||||||
|
|
|
@ -52,7 +52,7 @@ block content
|
||||||
.notification-content-and-cta
|
.notification-content-and-cta
|
||||||
.notification-content
|
.notification-content
|
||||||
p
|
p
|
||||||
| !{translate("you_cant_reset_password_due_to_sso", {}, [{name: 'a', attrs: {href: '/sso-login'}}])}
|
| !{translate("you_cant_reset_password_due_to_ldap_or_sso")}
|
||||||
|
|
||||||
input(type="hidden", name="_csrf", value=csrfToken)
|
input(type="hidden", name="_csrf", value=csrfToken)
|
||||||
.form-group.mb-3
|
.form-group.mb-3
|
||||||
|
|
|
@ -1005,6 +1005,9 @@ module.exports = {
|
||||||
'launchpad',
|
'launchpad',
|
||||||
'server-ce-scripts',
|
'server-ce-scripts',
|
||||||
'user-activate',
|
'user-activate',
|
||||||
|
'authentication/ldap',
|
||||||
|
'authentication/saml',
|
||||||
|
'authentication/oidc',
|
||||||
],
|
],
|
||||||
viewIncludes: {},
|
viewIncludes: {},
|
||||||
|
|
||||||
|
@ -1031,6 +1034,20 @@ module.exports = {
|
||||||
managedUsers: {
|
managedUsers: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
oauthProviders: {
|
||||||
|
...(process.env.EXTERNAL_AUTH && process.env.EXTERNAL_AUTH.includes('oidc') && {
|
||||||
|
[process.env.OVERLEAF_OIDC_PROVIDER_ID || 'oidc']: {
|
||||||
|
name: process.env.OVERLEAF_OIDC_PROVIDER_NAME || 'OIDC Provider',
|
||||||
|
descriptionKey: process.env.OVERLEAF_OIDC_PROVIDER_DESCRIPTION,
|
||||||
|
descriptionOptions: { link: process.env.OVERLEAF_OIDC_PROVIDER_INFO_LINK },
|
||||||
|
hideWhenNotLinked: process.env.OVERLEAF_OIDC_PROVIDER_HIDE_NOT_LINKED ?
|
||||||
|
process.env.OVERLEAF_OIDC_PROVIDER_HIDE_NOT_LINKED.toLowerCase() === 'true' : undefined,
|
||||||
|
linkPath: '/oidc/login',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.mergeWith = function (overrides) {
|
module.exports.mergeWith = function (overrides) {
|
||||||
|
|
|
@ -2104,6 +2104,7 @@
|
||||||
"you_can_select_or_invite_collaborator": "",
|
"you_can_select_or_invite_collaborator": "",
|
||||||
"you_can_select_or_invite_collaborator_plural": "",
|
"you_can_select_or_invite_collaborator_plural": "",
|
||||||
"you_can_still_use_your_premium_features": "",
|
"you_can_still_use_your_premium_features": "",
|
||||||
|
"you_cant_add_or_change_password_due_to_ldap_or_sso": "",
|
||||||
"you_cant_add_or_change_password_due_to_sso": "",
|
"you_cant_add_or_change_password_due_to_sso": "",
|
||||||
"you_cant_join_this_group_subscription": "",
|
"you_cant_join_this_group_subscription": "",
|
||||||
"you_dont_have_any_add_ons_on_your_account": "",
|
"you_dont_have_any_add_ons_on_your_account": "",
|
||||||
|
|
|
@ -204,7 +204,8 @@ function SSOLinkingWidgetContainer({
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { unlink } = useSSOContext()
|
const { unlink } = useSSOContext()
|
||||||
|
|
||||||
let description = ''
|
let description = subscription.provider.descriptionKey ||
|
||||||
|
`${t('login_with_service', { service: subscription.provider.name, })}.`
|
||||||
switch (subscription.providerId) {
|
switch (subscription.providerId) {
|
||||||
case 'collabratec':
|
case 'collabratec':
|
||||||
description = t('linked_collabratec_description')
|
description = t('linked_collabratec_description')
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { FetchError } from '../../../../infrastructure/fetch-json'
|
||||||
import IEEELogo from '../../../../shared/svgs/ieee-logo'
|
import IEEELogo from '../../../../shared/svgs/ieee-logo'
|
||||||
import GoogleLogo from '../../../../shared/svgs/google-logo'
|
import GoogleLogo from '../../../../shared/svgs/google-logo'
|
||||||
import OrcidLogo from '../../../../shared/svgs/orcid-logo'
|
import OrcidLogo from '../../../../shared/svgs/orcid-logo'
|
||||||
|
import OpenIDLogo from '../../../../shared/svgs/openid-logo'
|
||||||
import LinkingStatus from './status'
|
import LinkingStatus from './status'
|
||||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||||
import OLModal, {
|
import OLModal, {
|
||||||
|
@ -17,6 +18,7 @@ const providerLogos: { readonly [p: string]: JSX.Element } = {
|
||||||
collabratec: <IEEELogo />,
|
collabratec: <IEEELogo />,
|
||||||
google: <GoogleLogo />,
|
google: <GoogleLogo />,
|
||||||
orcid: <OrcidLogo />,
|
orcid: <OrcidLogo />,
|
||||||
|
oidc: <OpenIDLogo />,
|
||||||
}
|
}
|
||||||
|
|
||||||
type SSOLinkingWidgetProps = {
|
type SSOLinkingWidgetProps = {
|
||||||
|
@ -66,7 +68,7 @@ export function SSOLinkingWidget({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-widget-container">
|
<div className="settings-widget-container">
|
||||||
<div>{providerLogos[providerId]}</div>
|
<div>{providerLogos[providerId] || providerLogos['oidc']}</div>
|
||||||
<div className="description-container">
|
<div className="description-container">
|
||||||
<div className="title-row">
|
<div className="title-row">
|
||||||
<h4 id={providerId}>{title}</h4>
|
<h4 id={providerId}>{title}</h4>
|
||||||
|
|
|
@ -39,11 +39,7 @@ function CanOnlyLogInThroughSSO() {
|
||||||
return (
|
return (
|
||||||
<p>
|
<p>
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="you_cant_add_or_change_password_due_to_sso"
|
i18nKey="you_cant_add_or_change_password_due_to_ldap_or_sso"
|
||||||
components={[
|
|
||||||
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
|
|
||||||
<a href="/learn/how-to/Logging_in_with_Group_single_sign-on" />,
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
|
|
27
services/web/frontend/js/shared/svgs/openid-logo.jsx
Normal file
27
services/web/frontend/js/shared/svgs/openid-logo.jsx
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
function OpenIDLogo() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
viewBox="0 0 40 40"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect width="40" height="40" fill="white" />
|
||||||
|
<path
|
||||||
|
d="M18.185415 36.042565 23.298193 32.35627 23.060446 3.090316 18.185415 6.8918455Z"
|
||||||
|
fill="#ff8e00"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M18.246064 36.042565C-0.37463741 32.997945 -1.0248032 15.054095 18.13083 11.143396l0.05944 3.322396 c -13.3672163 2.225847 -11.6629563 14.187201 0 15.92785l0.05944 3.127104Z"
|
||||||
|
fill="#626262"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M23.219348 14.720521c2.279219 0.01577 4.262468 1.057732 6.237225 2.117891l-2.917255 2.176115h9.317022l0.05701 -6.371868 -2.917255 2.176115C30.03396 13.32315 27.308358 11.530342 23.169615 11.496378Z"
|
||||||
|
fill="#626262"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OpenIDLogo
|
||||||
|
|
|
@ -156,6 +156,7 @@
|
||||||
"already_have_sl_account": "Already have an __appName__ account?",
|
"already_have_sl_account": "Already have an __appName__ account?",
|
||||||
"also": "Also",
|
"also": "Also",
|
||||||
"alternatively_create_new_institution_account": "Alternatively, you can create a <b>new account</b> with your institution email (<b>__email__</b>) by clicking <b>__clickText__</b>.",
|
"alternatively_create_new_institution_account": "Alternatively, you can create a <b>new account</b> with your institution email (<b>__email__</b>) by clicking <b>__clickText__</b>.",
|
||||||
|
"alternatively_create_local_admin_account": "Alternatively, you can create __appName__ local admin account.",
|
||||||
"an_email_has_already_been_sent_to": "An email has already been sent to <0>__email__</0>. Please wait and try again later.",
|
"an_email_has_already_been_sent_to": "An email has already been sent to <0>__email__</0>. Please wait and try again later.",
|
||||||
"an_error_occured_while_restoring_project": "An error occured while restoring the project",
|
"an_error_occured_while_restoring_project": "An error occured while restoring the project",
|
||||||
"an_error_occurred_when_verifying_the_coupon_code": "An error occurred when verifying the coupon code",
|
"an_error_occurred_when_verifying_the_coupon_code": "An error occurred when verifying the coupon code",
|
||||||
|
@ -1236,6 +1237,7 @@
|
||||||
"loading_prices": "loading prices",
|
"loading_prices": "loading prices",
|
||||||
"loading_recent_github_commits": "Loading recent commits",
|
"loading_recent_github_commits": "Loading recent commits",
|
||||||
"loading_writefull": "Loading Writefull",
|
"loading_writefull": "Loading Writefull",
|
||||||
|
"local_account": "Local account",
|
||||||
"log_entry_description": "Log entry with level: __level__",
|
"log_entry_description": "Log entry with level: __level__",
|
||||||
"log_entry_maximum_entries": "Maximum log entries limit hit",
|
"log_entry_maximum_entries": "Maximum log entries limit hit",
|
||||||
"log_entry_maximum_entries_enable_stop_on_first_error": "Try to fix the first error and recompile. Often one error causes many later error messages. You can <0>Enable “Stop on first error”</0> to focus on fixing errors. We recommend fixing errors as soon as possible; letting them accumulate may lead to hard-to-debug and fatal errors. <1>Learn more</1>",
|
"log_entry_maximum_entries_enable_stop_on_first_error": "Try to fix the first error and recompile. Often one error causes many later error messages. You can <0>Enable “Stop on first error”</0> to focus on fixing errors. We recommend fixing errors as soon as possible; letting them accumulate may lead to hard-to-debug and fatal errors. <1>Learn more</1>",
|
||||||
|
@ -2660,8 +2662,10 @@
|
||||||
"you_can_select_or_invite_collaborator": "You can select or invite __count__ collaborator on your current plan. Upgrade to add more editors or reviewers.",
|
"you_can_select_or_invite_collaborator": "You can select or invite __count__ collaborator on your current plan. Upgrade to add more editors or reviewers.",
|
||||||
"you_can_select_or_invite_collaborator_plural": "You can select or invite __count__ collaborators on your current plan. Upgrade to add more editors or reviewers.",
|
"you_can_select_or_invite_collaborator_plural": "You can select or invite __count__ collaborators on your current plan. Upgrade to add more editors or reviewers.",
|
||||||
"you_can_still_use_your_premium_features": "You can still use your premium features until the pause becomes active.",
|
"you_can_still_use_your_premium_features": "You can still use your premium features until the pause becomes active.",
|
||||||
|
"you_cant_add_or_change_password_due_to_ldap_or_sso": "You can’t add or change your password because your group or organization uses LDAP or SSO.",
|
||||||
"you_cant_add_or_change_password_due_to_sso": "You can’t add or change your password because your group or organization uses <0>single sign-on (SSO)</0>.",
|
"you_cant_add_or_change_password_due_to_sso": "You can’t add or change your password because your group or organization uses <0>single sign-on (SSO)</0>.",
|
||||||
"you_cant_join_this_group_subscription": "You can’t join this group subscription",
|
"you_cant_join_this_group_subscription": "You can’t join this group subscription",
|
||||||
|
"you_cant_reset_password_due_to_ldap_or_sso": "You can’t reset your password because your group or organization uses LDAP or SSO. Contact your system administrator.",
|
||||||
"you_cant_reset_password_due_to_sso": "You can’t reset your password because your group or organization uses SSO. <0>Log in with SSO</0>.",
|
"you_cant_reset_password_due_to_sso": "You can’t reset your password because your group or organization uses SSO. <0>Log in with SSO</0>.",
|
||||||
"you_dont_have_any_add_ons_on_your_account": "You don’t have any add-ons on your account.",
|
"you_dont_have_any_add_ons_on_your_account": "You don’t have any add-ons on your account.",
|
||||||
"you_dont_have_any_repositories": "You don’t have any repositories",
|
"you_dont_have_any_repositories": "You don’t have any repositories",
|
||||||
|
|
|
@ -0,0 +1,112 @@
|
||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import passport from 'passport'
|
||||||
|
import EmailHelper from '../../../../../app/src/Features/Helpers/EmailHelper.js'
|
||||||
|
import { handleAuthenticateErrors } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js'
|
||||||
|
import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js'
|
||||||
|
import LDAPAuthenticationManager from './LDAPAuthenticationManager.mjs'
|
||||||
|
|
||||||
|
const LDAPAuthenticationController = {
|
||||||
|
passportLogin(req, res, next) {
|
||||||
|
// This function is middleware which wraps the passport.authenticate middleware,
|
||||||
|
// so we can send back our custom `{message: {text: "", type: ""}}` responses on failure,
|
||||||
|
// and send a `{redir: ""}` response on success
|
||||||
|
passport.authenticate(
|
||||||
|
'ldapauth',
|
||||||
|
{ keepSessionInfo: true },
|
||||||
|
async function (err, user, info, status) {
|
||||||
|
if (err) { //we cannot be here as long as errors are treated as fails
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
if (user) {
|
||||||
|
// `user` is either a user object or false
|
||||||
|
AuthenticationController.setAuditInfo(req, {
|
||||||
|
method: 'LDAP password login',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await AuthenticationController.promises.finishLogin(user, req, res)
|
||||||
|
res.status(200)
|
||||||
|
return
|
||||||
|
} catch (err) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (status != 401) {
|
||||||
|
logger.warn(status, 'LDAP: ' + info.message)
|
||||||
|
}
|
||||||
|
if (EmailHelper.parseEmail(req.body.email)) return next() //Try local authentication
|
||||||
|
if (info.redir != null) {
|
||||||
|
return res.json({ redir: info.redir })
|
||||||
|
} else {
|
||||||
|
res.status(status || info.status || 401)
|
||||||
|
delete info.status
|
||||||
|
info.type = 'error'
|
||||||
|
info.key = 'invalid-password-retry-or-reset'
|
||||||
|
const body = { message: info }
|
||||||
|
const { errorReason } = info
|
||||||
|
if (errorReason) {
|
||||||
|
body.errorReason = errorReason
|
||||||
|
delete info.errorReason
|
||||||
|
}
|
||||||
|
return res.json(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)(req, res, next)
|
||||||
|
},
|
||||||
|
async doPassportLogin(req, profile, done) {
|
||||||
|
let user, info
|
||||||
|
try {
|
||||||
|
;({ user, info } = await LDAPAuthenticationController._doPassportLogin(
|
||||||
|
req,
|
||||||
|
profile
|
||||||
|
))
|
||||||
|
} catch (error) {
|
||||||
|
return done(error)
|
||||||
|
}
|
||||||
|
return done(undefined, user, info)
|
||||||
|
},
|
||||||
|
async _doPassportLogin(req, profile) {
|
||||||
|
const { fromKnownDevice } = AuthenticationController.getAuditInfo(req)
|
||||||
|
const auditLog = {
|
||||||
|
ipAddress: req.ip,
|
||||||
|
info: { method: 'LDAP password login', fromKnownDevice },
|
||||||
|
}
|
||||||
|
|
||||||
|
let user, isPasswordReused
|
||||||
|
try {
|
||||||
|
user = await LDAPAuthenticationManager.promises.findOrCreateUser(profile, auditLog)
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
user: false,
|
||||||
|
info: handleAuthenticateErrors(error, req),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (user && AuthenticationController.captchaRequiredForLogin(req, user)) {
|
||||||
|
return {
|
||||||
|
user: false,
|
||||||
|
info: {
|
||||||
|
text: req.i18n.translate('cannot_verify_user_not_robot'),
|
||||||
|
type: 'error',
|
||||||
|
errorReason: 'cannot_verify_user_not_robot',
|
||||||
|
status: 400,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else if (user) {
|
||||||
|
user.externalAuth = 'ldap'
|
||||||
|
return { user, info: undefined }
|
||||||
|
} else { //we cannot be here, something is terribly wrong
|
||||||
|
logger.debug({ email : profile.mail }, 'failed LDAP log in')
|
||||||
|
return {
|
||||||
|
user: false,
|
||||||
|
info: {
|
||||||
|
type: 'error',
|
||||||
|
text: 'Unknown error',
|
||||||
|
status: 500,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LDAPAuthenticationController
|
|
@ -0,0 +1,76 @@
|
||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import { callbackify } from '@overleaf/promise-utils'
|
||||||
|
import UserCreator from '../../../../../app/src/Features/User/UserCreator.js'
|
||||||
|
import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js'
|
||||||
|
import { User } from '../../../../../app/src/models/User.js'
|
||||||
|
import { splitFullName } from '../../../utils.mjs'
|
||||||
|
|
||||||
|
const LDAPAuthenticationManager = {
|
||||||
|
async findOrCreateUser(profile, auditLog) {
|
||||||
|
//user is already authenticated in LDAP
|
||||||
|
const {
|
||||||
|
attEmail,
|
||||||
|
attFirstName,
|
||||||
|
attLastName,
|
||||||
|
attName,
|
||||||
|
attAdmin,
|
||||||
|
valAdmin,
|
||||||
|
updateUserDetailsOnLogin,
|
||||||
|
} = Settings.ldap
|
||||||
|
|
||||||
|
const email = Array.isArray(profile[attEmail])
|
||||||
|
? profile[attEmail][0].toLowerCase()
|
||||||
|
: profile[attEmail].toLowerCase()
|
||||||
|
let nameParts = ["",""]
|
||||||
|
if ((!attFirstName || !attLastName) && attName) {
|
||||||
|
nameParts = splitFullName(profile[attName] || "")
|
||||||
|
}
|
||||||
|
const firstName = attFirstName ? (profile[attFirstName] || "") : nameParts[0]
|
||||||
|
let lastName = attLastName ? (profile[attLastName] || "") : nameParts[1]
|
||||||
|
if (!firstName && !lastName) lastName = email
|
||||||
|
let isAdmin = false
|
||||||
|
if( attAdmin && valAdmin ) {
|
||||||
|
isAdmin = (profile._groups?.length > 0) ||
|
||||||
|
(Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) :
|
||||||
|
profile[attAdmin] === valAdmin)
|
||||||
|
}
|
||||||
|
let user = await User.findOne({ 'email': email }).exec()
|
||||||
|
|
||||||
|
if( !user ) {
|
||||||
|
user = await UserCreator.promises.createNewUser(
|
||||||
|
{
|
||||||
|
email: email,
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
isAdmin: isAdmin,
|
||||||
|
holdingAccount: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await User.updateOne(
|
||||||
|
{ _id: user._id },
|
||||||
|
{ $set : { 'emails.0.confirmedAt' : Date.now() } }
|
||||||
|
).exec() //email of ldap user is confirmed
|
||||||
|
}
|
||||||
|
let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {}
|
||||||
|
if( attAdmin && valAdmin ) {
|
||||||
|
user.isAdmin = isAdmin
|
||||||
|
userDetails.isAdmin = isAdmin
|
||||||
|
}
|
||||||
|
const result = await User.updateOne(
|
||||||
|
{ _id: user._id, loginEpoch: user.loginEpoch },
|
||||||
|
{
|
||||||
|
$inc: { loginEpoch: 1 },
|
||||||
|
$set: userDetails,
|
||||||
|
$unset: { hashedPassword: "" },
|
||||||
|
}
|
||||||
|
).exec()
|
||||||
|
if (result.modifiedCount !== 1) {
|
||||||
|
throw new ParallelLoginError()
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
promises: LDAPAuthenticationManager,
|
||||||
|
}
|
|
@ -0,0 +1,120 @@
|
||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
import passport from 'passport'
|
||||||
|
import ldapjs from 'ldapauth-fork/node_modules/ldapjs/lib/index.js'
|
||||||
|
import UserGetter from '../../../../../app/src/Features/User/UserGetter.js'
|
||||||
|
import { splitFullName } from '../../../utils.mjs'
|
||||||
|
|
||||||
|
function _searchLDAP(client, baseDN, options) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const searchEntries = []
|
||||||
|
client.search(baseDN, options, (error, res) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error)
|
||||||
|
} else {
|
||||||
|
res.on('searchEntry', entry => searchEntries.push(entry.object))
|
||||||
|
res.on('error', reject)
|
||||||
|
res.on('end', () => resolve(searchEntries))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLDAPContacts(userId, contacts) {
|
||||||
|
if (!Settings.ldap?.enable || !process.env.OVERLEAF_LDAP_CONTACTS_FILTER) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const ldapOptions = passport._strategy('ldapauth').options.server
|
||||||
|
const { attEmail, attFirstName = "", attLastName = "", attName = "" } = Settings.ldap
|
||||||
|
const {
|
||||||
|
url,
|
||||||
|
timeout,
|
||||||
|
connectTimeout,
|
||||||
|
tlsOptions,
|
||||||
|
starttls,
|
||||||
|
bindDN,
|
||||||
|
bindCredentials
|
||||||
|
} = ldapOptions
|
||||||
|
const searchBase = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_BASE || ldapOptions.searchBase
|
||||||
|
const searchScope = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_SCOPE || 'sub'
|
||||||
|
const ldapConfig = { url, timeout, connectTimeout, tlsOptions }
|
||||||
|
|
||||||
|
let ldapUsers
|
||||||
|
let client
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
client = ldapjs.createClient(ldapConfig)
|
||||||
|
client.on('error', (error) => { reject(error) })
|
||||||
|
client.on('connectTimeout', (error) => { reject(error) })
|
||||||
|
client.on('connect', () => { resolve() })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (starttls) {
|
||||||
|
const starttlsAsync = promisify(client.starttls).bind(client)
|
||||||
|
await starttlsAsync(tlsOptions, null)
|
||||||
|
}
|
||||||
|
const bindAsync = promisify(client.bind).bind(client)
|
||||||
|
await bindAsync(bindDN, bindCredentials)
|
||||||
|
|
||||||
|
async function createContactsSearchFilter(client, ldapOptions, userId, contactsFilter) {
|
||||||
|
const searchProperty = process.env.OVERLEAF_LDAP_CONTACTS_PROPERTY
|
||||||
|
if (!searchProperty) {
|
||||||
|
return contactsFilter
|
||||||
|
}
|
||||||
|
const email = await UserGetter.promises.getUserEmail(userId)
|
||||||
|
const searchOptions = {
|
||||||
|
scope: ldapOptions.searchScope,
|
||||||
|
attributes: [searchProperty],
|
||||||
|
filter: `(${Settings.ldap.attEmail}=${email})`
|
||||||
|
}
|
||||||
|
const searchBase = ldapOptions.searchBase
|
||||||
|
const ldapUser = (await _searchLDAP(client, searchBase, searchOptions))[0]
|
||||||
|
const searchPropertyValue = ldapUser ? ldapUser[searchProperty]
|
||||||
|
: process.env.OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE || 'IMATCHNOTHING'
|
||||||
|
return contactsFilter.replace(/{{userProperty}}/g, searchPropertyValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = await createContactsSearchFilter(client, ldapOptions, userId, process.env.OVERLEAF_LDAP_CONTACTS_FILTER)
|
||||||
|
const searchOptions = { scope: searchScope, attributes: [attEmail, attFirstName, attLastName, attName], filter }
|
||||||
|
|
||||||
|
ldapUsers = await _searchLDAP(client, searchBase, searchOptions)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error }, 'Error in fetchLDAPContacts')
|
||||||
|
return []
|
||||||
|
} finally {
|
||||||
|
client?.unbind()
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLDAPContacts = ldapUsers.reduce((acc, ldapUser) => {
|
||||||
|
const email = Array.isArray(ldapUser[attEmail])
|
||||||
|
? ldapUser[attEmail][0]?.toLowerCase()
|
||||||
|
: ldapUser[attEmail]?.toLowerCase()
|
||||||
|
if (!email) return acc
|
||||||
|
if (!contacts.some(contact => contact.email === email)) {
|
||||||
|
let nameParts = ["", ""]
|
||||||
|
if ((!attFirstName || !attLastName) && attName) {
|
||||||
|
nameParts = splitFullName(ldapUser[attName] || "")
|
||||||
|
}
|
||||||
|
const firstName = attFirstName ? (ldapUser[attFirstName] || "") : nameParts[0]
|
||||||
|
const lastName = attLastName ? (ldapUser[attLastName] || "") : nameParts[1]
|
||||||
|
acc.push({
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
email: email,
|
||||||
|
type: 'user'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return newLDAPContacts.sort((a, b) =>
|
||||||
|
a.last_name.localeCompare(b.last_name) ||
|
||||||
|
a.first_name.localeCompare(b.first_name) ||
|
||||||
|
a.email.localeCompare(b.email)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default fetchLDAPContacts
|
|
@ -0,0 +1,112 @@
|
||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import passport from 'passport'
|
||||||
|
import { Strategy as LDAPStrategy } from 'passport-ldapauth'
|
||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.js'
|
||||||
|
import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs'
|
||||||
|
import LDAPAuthenticationController from './LDAPAuthenticationController.mjs'
|
||||||
|
import fetchLDAPContacts from './LDAPContacts.mjs'
|
||||||
|
|
||||||
|
const LDAPModuleManager = {
|
||||||
|
initSettings() {
|
||||||
|
Settings.ldap = {
|
||||||
|
enable: true,
|
||||||
|
placeholder: process.env.OVERLEAF_LDAP_PLACEHOLDER || 'Username',
|
||||||
|
attEmail: process.env.OVERLEAF_LDAP_EMAIL_ATT || 'mail',
|
||||||
|
attFirstName: process.env.OVERLEAF_LDAP_FIRST_NAME_ATT,
|
||||||
|
attLastName: process.env.OVERLEAF_LDAP_LAST_NAME_ATT,
|
||||||
|
attName: process.env.OVERLEAF_LDAP_NAME_ATT,
|
||||||
|
attAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT,
|
||||||
|
valAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE,
|
||||||
|
updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
passportSetup(passport, callback) {
|
||||||
|
const ldapOptions = {
|
||||||
|
url: process.env.OVERLEAF_LDAP_URL,
|
||||||
|
bindDN: process.env.OVERLEAF_LDAP_BIND_DN || "",
|
||||||
|
bindCredentials: process.env.OVERLEAF_LDAP_BIND_CREDENTIALS || "",
|
||||||
|
bindProperty: process.env.OVERLEAF_LDAP_BIND_PROPERTY,
|
||||||
|
searchBase: process.env.OVERLEAF_LDAP_SEARCH_BASE,
|
||||||
|
searchFilter: process.env.OVERLEAF_LDAP_SEARCH_FILTER,
|
||||||
|
searchScope: process.env.OVERLEAF_LDAP_SEARCH_SCOPE || 'sub',
|
||||||
|
searchAttributes: JSON.parse(process.env.OVERLEAF_LDAP_SEARCH_ATTRIBUTES || '[]'),
|
||||||
|
groupSearchBase: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_BASE,
|
||||||
|
groupSearchFilter: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_FILTER,
|
||||||
|
groupSearchScope: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_SCOPE || 'sub',
|
||||||
|
groupSearchAttributes: ["dn"],
|
||||||
|
groupDnProperty: process.env.OVERLEAF_LDAP_ADMIN_DN_PROPERTY,
|
||||||
|
cache: boolFromEnv(process.env.OVERLEAF_LDAP_CACHE),
|
||||||
|
timeout: numFromEnv(process.env.OVERLEAF_LDAP_TIMEOUT),
|
||||||
|
connectTimeout: numFromEnv(process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT),
|
||||||
|
starttls: boolFromEnv(process.env.OVERLEAF_LDAP_STARTTLS),
|
||||||
|
tlsOptions: {
|
||||||
|
ca: readFilesContentFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_CA_PATH),
|
||||||
|
rejectUnauthorized: boolFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_REJECT_UNAUTH),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
passport.use(
|
||||||
|
new LDAPStrategy(
|
||||||
|
{
|
||||||
|
server: ldapOptions,
|
||||||
|
passReqToCallback: true,
|
||||||
|
usernameField: 'email',
|
||||||
|
passwordField: 'password',
|
||||||
|
handleErrorsAsFailures: true,
|
||||||
|
},
|
||||||
|
LDAPAuthenticationController.doPassportLogin
|
||||||
|
)
|
||||||
|
)
|
||||||
|
callback(null)
|
||||||
|
} catch (error) {
|
||||||
|
callback(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getContacts(userId, contacts, callback) {
|
||||||
|
try {
|
||||||
|
const newContacts = await fetchLDAPContacts(userId, contacts)
|
||||||
|
callback(null, newContacts)
|
||||||
|
} catch (error) {
|
||||||
|
callback(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
initPolicy() {
|
||||||
|
try {
|
||||||
|
PermissionsManager.registerCapability('change-password', { default : true })
|
||||||
|
} catch (error) {
|
||||||
|
logger.info({}, error.message)
|
||||||
|
}
|
||||||
|
const ldapPolicyValidator = async ({ user, subscription }) => {
|
||||||
|
// If user is not logged in, user.externalAuth is undefined,
|
||||||
|
// in this case allow to change password if the user has a hashedPassword
|
||||||
|
return user.externalAuth === 'ldap' || (user.externalAuth === undefined && !user.hashedPassword)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
PermissionsManager.registerPolicy(
|
||||||
|
'ldapPolicy',
|
||||||
|
{ 'change-password' : false },
|
||||||
|
{ validator: ldapPolicyValidator }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.info({}, error.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getGroupPolicyForUser(user, callback) {
|
||||||
|
try {
|
||||||
|
const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({
|
||||||
|
user,
|
||||||
|
groupPolicy : { 'ldapPolicy' : true },
|
||||||
|
subscription : null
|
||||||
|
})
|
||||||
|
let groupPolicy = Object.fromEntries(userValidationMap)
|
||||||
|
callback(null, {'groupPolicy' : groupPolicy })
|
||||||
|
} catch (error) {
|
||||||
|
callback(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LDAPModuleManager
|
|
@ -0,0 +1,19 @@
|
||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import RateLimiterMiddleware from '../../../../../app/src/Features/Security/RateLimiterMiddleware.js'
|
||||||
|
import CaptchaMiddleware from '../../../../../app/src/Features/Captcha/CaptchaMiddleware.js'
|
||||||
|
import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js'
|
||||||
|
import { overleafLoginRateLimiter } from '../../../../../app/src/infrastructure/RateLimiter.js'
|
||||||
|
import LDAPAuthenticationController from './LDAPAuthenticationController.mjs'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
apply(webRouter) {
|
||||||
|
logger.debug({}, 'Init LDAP router')
|
||||||
|
webRouter.post('/login',
|
||||||
|
RateLimiterMiddleware.rateLimit(overleafLoginRateLimiter), // rate limit IP (20 / 60s)
|
||||||
|
RateLimiterMiddleware.loginRateLimitEmail(), // rate limit email (10 / 120s)
|
||||||
|
CaptchaMiddleware.validateCaptcha('login'),
|
||||||
|
LDAPAuthenticationController.passportLogin,
|
||||||
|
AuthenticationController.passportLogin,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
17
services/web/modules/authentication/ldap/index.mjs
Normal file
17
services/web/modules/authentication/ldap/index.mjs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
let ldapModule = {}
|
||||||
|
if (process.env.EXTERNAL_AUTH?.includes('ldap')) {
|
||||||
|
const { default: LDAPModuleManager } = await import('./app/src/LDAPModuleManager.mjs')
|
||||||
|
const { default: router } = await import('./app/src/LDAPRouter.mjs')
|
||||||
|
LDAPModuleManager.initSettings()
|
||||||
|
LDAPModuleManager.initPolicy()
|
||||||
|
ldapModule = {
|
||||||
|
name: 'ldap-authentication',
|
||||||
|
hooks: {
|
||||||
|
passportSetup: LDAPModuleManager.passportSetup,
|
||||||
|
getContacts: LDAPModuleManager.getContacts,
|
||||||
|
getGroupPolicyForUser: LDAPModuleManager.getGroupPolicyForUser,
|
||||||
|
},
|
||||||
|
router: router,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default ldapModule
|
18
services/web/modules/authentication/logout.mjs
Normal file
18
services/web/modules/authentication/logout.mjs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
let SAMLAuthenticationController
|
||||||
|
if (process.env.EXTERNAL_AUTH.includes('saml')) {
|
||||||
|
SAMLAuthenticationController = await import('./saml/app/src/SAMLAuthenticationController.mjs')
|
||||||
|
}
|
||||||
|
let OIDCAuthenticationController
|
||||||
|
if (process.env.EXTERNAL_AUTH.includes('oidc')) {
|
||||||
|
OIDCAuthenticationController = await import('./oidc/app/src/OIDCAuthenticationController.mjs')
|
||||||
|
}
|
||||||
|
export default async function logout(req, res, next) {
|
||||||
|
switch(req.user.externalAuth) {
|
||||||
|
case 'saml':
|
||||||
|
return SAMLAuthenticationController.default.passportLogout(req, res, next)
|
||||||
|
case 'oidc':
|
||||||
|
return OIDCAuthenticationController.default.passportLogout(req, res, next)
|
||||||
|
default:
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,171 @@
|
||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import passport from 'passport'
|
||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js'
|
||||||
|
import UserController from '../../../../../app/src/Features/User/UserController.js'
|
||||||
|
import ThirdPartyIdentityManager from '../../../../../app/src/Features/User/ThirdPartyIdentityManager.js'
|
||||||
|
import OIDCAuthenticationManager from './OIDCAuthenticationManager.mjs'
|
||||||
|
import { acceptsJson } from '../../../../../app/src/infrastructure/RequestContentTypeDetection.js'
|
||||||
|
|
||||||
|
const OIDCAuthenticationController = {
|
||||||
|
passportLogin(req, res, next) {
|
||||||
|
req.session.intent = req.query.intent
|
||||||
|
passport.authenticate('openidconnect')(req, res, next)
|
||||||
|
},
|
||||||
|
passportLoginCallback(req, res, next) {
|
||||||
|
passport.authenticate(
|
||||||
|
'openidconnect',
|
||||||
|
{ keepSessionInfo: true },
|
||||||
|
async function (err, user, info) {
|
||||||
|
if (err) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
if(req.session.intent === 'link') {
|
||||||
|
delete req.session.intent
|
||||||
|
// After linking, log out from the OIDC provider and redirect back to '/user/settings'.
|
||||||
|
// Keycloak supports this; Authentik does not (yet).
|
||||||
|
const logoutUrl = process.env.OVERLEAF_OIDC_LOGOUT_URL
|
||||||
|
const redirectUri = `${Settings.siteUrl.replace(/\/+$/, '')}/user/settings`
|
||||||
|
return res.redirect(`${logoutUrl}?id_token_hint=${info.idToken}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`)
|
||||||
|
}
|
||||||
|
if (user) {
|
||||||
|
req.session.idToken = info.idToken
|
||||||
|
user.externalAuth = 'oidc'
|
||||||
|
// `user` is either a user object or false
|
||||||
|
AuthenticationController.setAuditInfo(req, {
|
||||||
|
method: 'OIDC login',
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await AuthenticationController.promises.finishLogin(user, req, res)
|
||||||
|
} catch (err) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (info.redir != null) {
|
||||||
|
return res.json({ redir: info.redir })
|
||||||
|
} else {
|
||||||
|
res.status(info.status || 401)
|
||||||
|
delete info.status
|
||||||
|
const body = { message: info }
|
||||||
|
return res.json(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)(req, res, next)
|
||||||
|
},
|
||||||
|
async doPassportLogin(req, issuer, profile, context, idToken, accessToken, refreshToken, done) {
|
||||||
|
let user, info
|
||||||
|
try {
|
||||||
|
if(req.session.intent === 'link') {
|
||||||
|
;({ user, info } = await OIDCAuthenticationController._doLink(
|
||||||
|
req,
|
||||||
|
profile
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
;({ user, info } = await OIDCAuthenticationController._doLogin(
|
||||||
|
req,
|
||||||
|
profile
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return done(error)
|
||||||
|
}
|
||||||
|
if (user) {
|
||||||
|
info = {
|
||||||
|
...(info || {}),
|
||||||
|
idToken: idToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return done(null, user, info)
|
||||||
|
},
|
||||||
|
async _doLogin(req, profile) {
|
||||||
|
const { fromKnownDevice } = AuthenticationController.getAuditInfo(req)
|
||||||
|
const auditLog = {
|
||||||
|
ipAddress: req.ip,
|
||||||
|
info: { method: 'OIDC login', fromKnownDevice },
|
||||||
|
}
|
||||||
|
|
||||||
|
let user
|
||||||
|
try {
|
||||||
|
user = await OIDCAuthenticationManager.promises.findOrCreateUser(profile, auditLog)
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug({ email : profile.emails[0].value }, `OIDC login failed: ${error}`)
|
||||||
|
return {
|
||||||
|
user: false,
|
||||||
|
info: {
|
||||||
|
type: 'error',
|
||||||
|
text: error.message,
|
||||||
|
status: 401,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (user) {
|
||||||
|
return { user, info: undefined }
|
||||||
|
} else { // we cannot be here, something is terribly wrong
|
||||||
|
logger.debug({ email : profile.emails[0].value }, 'failed OIDC log in')
|
||||||
|
return {
|
||||||
|
user: false,
|
||||||
|
info: {
|
||||||
|
type: 'error',
|
||||||
|
text: 'Unknown error',
|
||||||
|
status: 500,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async _doLink(req, profile) {
|
||||||
|
const { user: { _id: userId }, ip } = req
|
||||||
|
try {
|
||||||
|
const auditLog = {
|
||||||
|
ipAddress: ip,
|
||||||
|
initiatorId: userId,
|
||||||
|
}
|
||||||
|
await OIDCAuthenticationManager.promises.linkAccount(userId, profile, auditLog)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error.info, error.message)
|
||||||
|
return {
|
||||||
|
user: true,
|
||||||
|
info: {
|
||||||
|
type: 'error',
|
||||||
|
text: error.message,
|
||||||
|
status: 200,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { user: true, info: undefined }
|
||||||
|
},
|
||||||
|
async unlinkAccount(req, res, next) {
|
||||||
|
try {
|
||||||
|
const { user: { _id: userId }, body: { providerId }, ip } = req
|
||||||
|
const auditLog = {
|
||||||
|
ipAddress: ip,
|
||||||
|
initiatorId: userId,
|
||||||
|
}
|
||||||
|
await ThirdPartyIdentityManager.promises.unlink(userId, providerId, auditLog)
|
||||||
|
return res.status(200).end()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error.info, error.message)
|
||||||
|
return {
|
||||||
|
user: false,
|
||||||
|
info: {
|
||||||
|
type: 'error',
|
||||||
|
text: 'Can not unlink account',
|
||||||
|
status: 200,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async passportLogout(req, res, next) {
|
||||||
|
// TODO: instead of storing idToken in session, use refreshToken to obtain a new idToken?
|
||||||
|
const idTokenHint = req.session.idToken
|
||||||
|
await UserController.doLogout(req)
|
||||||
|
const logoutUrl = process.env.OVERLEAF_OIDC_LOGOUT_URL
|
||||||
|
const redirectUri = Settings.siteUrl
|
||||||
|
res.redirect(`${logoutUrl}?id_token_hint=${idTokenHint}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`)
|
||||||
|
},
|
||||||
|
passportLogoutCallback(req, res, next) {
|
||||||
|
const redirectUri = Settings.siteUrl
|
||||||
|
res.redirect(redirectUri)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default OIDCAuthenticationController
|
|
@ -0,0 +1,94 @@
|
||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import UserCreator from '../../../../../app/src/Features/User/UserCreator.js'
|
||||||
|
import ThirdPartyIdentityManager from '../../../../../app/src/Features/User/ThirdPartyIdentityManager.js'
|
||||||
|
import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js'
|
||||||
|
import { User } from '../../../../../app/src/models/User.js'
|
||||||
|
|
||||||
|
const OIDCAuthenticationManager = {
|
||||||
|
async findOrCreateUser(profile, auditLog) {
|
||||||
|
const {
|
||||||
|
attUserId,
|
||||||
|
attAdmin,
|
||||||
|
valAdmin,
|
||||||
|
updateUserDetailsOnLogin,
|
||||||
|
providerId,
|
||||||
|
} = Settings.oidc
|
||||||
|
const email = profile.emails[0].value
|
||||||
|
const oidcUserId = (attUserId === 'email') ? email : profile[attUserId]
|
||||||
|
const firstName = profile.name?.givenName || ""
|
||||||
|
const lastName = profile.name?.familyName || ""
|
||||||
|
let isAdmin = false
|
||||||
|
if (attAdmin && valAdmin) {
|
||||||
|
if (attAdmin === 'email') {
|
||||||
|
isAdmin = (email === valAdmin)
|
||||||
|
} else {
|
||||||
|
isAdmin = (profile[attAdmin] === valAdmin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const oidcUserData = null // Possibly it can be used later
|
||||||
|
let user
|
||||||
|
try {
|
||||||
|
user = await ThirdPartyIdentityManager.promises.login(providerId, oidcUserId, oidcUserData)
|
||||||
|
} catch {
|
||||||
|
// A user with the specified OIDC ID and provider ID is not found. Search for a user with the given email.
|
||||||
|
// If no user exists with this email, create a new user and link the OIDC account to it.
|
||||||
|
// If a user exists but no account from the specified OIDC provider is linked to this user, link the OIDC account to this user.
|
||||||
|
// If an account from the specified provider is already linked to this user, unlink it, and link the OIDC account to this user.
|
||||||
|
// (Is it safe? Concider: If an account from the specified provider is already linked to this user, throw an error)
|
||||||
|
user = await User.findOne({ 'email': email }).exec()
|
||||||
|
if (!user) {
|
||||||
|
user = await UserCreator.promises.createNewUser(
|
||||||
|
{
|
||||||
|
email: email,
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
isAdmin: isAdmin,
|
||||||
|
holdingAccount: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// const alreadyLinked = user.thirdPartyIdentifiers.some(item => item.providerId === providerId)
|
||||||
|
// if (!alreadyLinked) {
|
||||||
|
auditLog.initiatorId = user._id
|
||||||
|
await ThirdPartyIdentityManager.promises.link(user._id, providerId, oidcUserId, oidcUserData, auditLog)
|
||||||
|
await User.updateOne(
|
||||||
|
{ _id: user._id },
|
||||||
|
{ $set : {
|
||||||
|
'emails.0.confirmedAt': Date.now(), //email of external user is confirmed
|
||||||
|
},
|
||||||
|
}
|
||||||
|
).exec()
|
||||||
|
// } else {
|
||||||
|
// throw new Error(`Overleaf user ${user.email} is already linked to another ${providerId} user`)
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {}
|
||||||
|
if (attAdmin && valAdmin) {
|
||||||
|
user.isAdmin = isAdmin
|
||||||
|
userDetails.isAdmin = isAdmin
|
||||||
|
}
|
||||||
|
const result = await User.updateOne(
|
||||||
|
{ _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails },
|
||||||
|
{}
|
||||||
|
).exec()
|
||||||
|
|
||||||
|
if (result.modifiedCount !== 1) {
|
||||||
|
throw new ParallelLoginError()
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
},
|
||||||
|
async linkAccount(userId, profile, auditLog) {
|
||||||
|
const {
|
||||||
|
attUserId,
|
||||||
|
providerId,
|
||||||
|
} = Settings.oidc
|
||||||
|
const oidcUserId = (attUserId === 'email') ? profile.emails[0].value : profile[attUserId]
|
||||||
|
const oidcUserData = null // Possibly it can be used later
|
||||||
|
await ThirdPartyIdentityManager.promises.link(userId, providerId, oidcUserId, oidcUserData, auditLog)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
promises: OIDCAuthenticationManager,
|
||||||
|
}
|
|
@ -0,0 +1,82 @@
|
||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import passport from 'passport'
|
||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs'
|
||||||
|
import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.js'
|
||||||
|
import OIDCAuthenticationController from './OIDCAuthenticationController.mjs'
|
||||||
|
import { Strategy as OIDCStrategy } from 'passport-openidconnect'
|
||||||
|
|
||||||
|
const OIDCModuleManager = {
|
||||||
|
initSettings() {
|
||||||
|
let providerId = process.env.OVERLEAF_OIDC_PROVIDER_ID || 'oidc'
|
||||||
|
Settings.oidc = {
|
||||||
|
enable: true,
|
||||||
|
providerId: providerId,
|
||||||
|
identityServiceName: process.env.OVERLEAF_OIDC_IDENTITY_SERVICE_NAME || `Log in with ${Settings.oauthProviders[providerId].name}`,
|
||||||
|
attUserId: process.env.OVERLEAF_OIDC_USER_ID_FIELD || 'id',
|
||||||
|
attAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD,
|
||||||
|
valAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE,
|
||||||
|
updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
passportSetup(passport, callback) {
|
||||||
|
const oidcOptions = {
|
||||||
|
issuer: process.env.OVERLEAF_OIDC_ISSUER,
|
||||||
|
authorizationURL: process.env.OVERLEAF_OIDC_AUTHORIZATION_URL,
|
||||||
|
tokenURL: process.env.OVERLEAF_OIDC_TOKEN_URL,
|
||||||
|
userInfoURL: process.env.OVERLEAF_OIDC_USER_INFO_URL,
|
||||||
|
clientID: process.env.OVERLEAF_OIDC_CLIENT_ID,
|
||||||
|
clientSecret: process.env.OVERLEAF_OIDC_CLIENT_SECRET,
|
||||||
|
callbackURL: `${Settings.siteUrl.replace(/\/+$/, '')}/oidc/login/callback`,
|
||||||
|
scope: process.env.OVERLEAF_OIDC_SCOPE || 'openid profile email',
|
||||||
|
passReqToCallback: true,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
passport.use(
|
||||||
|
new OIDCStrategy(
|
||||||
|
oidcOptions,
|
||||||
|
OIDCAuthenticationController.doPassportLogin
|
||||||
|
)
|
||||||
|
)
|
||||||
|
callback(null)
|
||||||
|
} catch (error) {
|
||||||
|
callback(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initPolicy() {
|
||||||
|
try {
|
||||||
|
PermissionsManager.registerCapability('change-password', { default : true })
|
||||||
|
} catch (error) {
|
||||||
|
logger.info({}, error.message)
|
||||||
|
}
|
||||||
|
const oidcPolicyValidator = async ({ user, subscription }) => {
|
||||||
|
// If user is not logged in, user.externalAuth is undefined,
|
||||||
|
// in this case allow to change password if the user has a hashedPassword
|
||||||
|
return user.externalAuth === 'oidc' || (user.externalAuth === undefined && !user.hashedPassword)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
PermissionsManager.registerPolicy(
|
||||||
|
'oidcPolicy',
|
||||||
|
{ 'change-password' : false },
|
||||||
|
{ validator: oidcPolicyValidator }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.info({}, error.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getGroupPolicyForUser(user, callback) {
|
||||||
|
try {
|
||||||
|
const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({
|
||||||
|
user,
|
||||||
|
groupPolicy : { 'oidcPolicy' : true },
|
||||||
|
subscription : null
|
||||||
|
})
|
||||||
|
let groupPolicy = Object.fromEntries(userValidationMap)
|
||||||
|
callback(null, {'groupPolicy' : groupPolicy })
|
||||||
|
} catch (error) {
|
||||||
|
callback(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OIDCModuleManager
|
|
@ -0,0 +1,18 @@
|
||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import UserController from '../../../../../app/src/Features/User/UserController.js'
|
||||||
|
import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js'
|
||||||
|
import OIDCAuthenticationController from './OIDCAuthenticationController.mjs'
|
||||||
|
import logout from '../../../logout.mjs'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
apply(webRouter) {
|
||||||
|
logger.debug({}, 'Init OIDC router')
|
||||||
|
webRouter.get('/oidc/login', OIDCAuthenticationController.passportLogin)
|
||||||
|
AuthenticationController.addEndpointToLoginWhitelist('/oidc/login')
|
||||||
|
webRouter.get('/oidc/login/callback', OIDCAuthenticationController.passportLoginCallback)
|
||||||
|
AuthenticationController.addEndpointToLoginWhitelist('/oidc/login/callback')
|
||||||
|
webRouter.get('/oidc/logout/callback', OIDCAuthenticationController.passportLogoutCallback)
|
||||||
|
webRouter.post('/user/oauth-unlink', OIDCAuthenticationController.unlinkAccount)
|
||||||
|
webRouter.post('/logout', logout, UserController.logout)
|
||||||
|
},
|
||||||
|
}
|
16
services/web/modules/authentication/oidc/index.mjs
Normal file
16
services/web/modules/authentication/oidc/index.mjs
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
let oidcModule = {}
|
||||||
|
if (process.env.EXTERNAL_AUTH?.includes('oidc')) {
|
||||||
|
const { default: OIDCModuleManager } = await import('./app/src/OIDCModuleManager.mjs')
|
||||||
|
const { default: router } = await import('./app/src/OIDCRouter.mjs')
|
||||||
|
OIDCModuleManager.initSettings()
|
||||||
|
OIDCModuleManager.initPolicy()
|
||||||
|
oidcModule = {
|
||||||
|
name: 'oidc-authentication',
|
||||||
|
hooks: {
|
||||||
|
passportSetup: OIDCModuleManager.passportSetup,
|
||||||
|
getGroupPolicyForUser: OIDCModuleManager.getGroupPolicyForUser,
|
||||||
|
},
|
||||||
|
router: router,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default oidcModule
|
|
@ -0,0 +1,150 @@
|
||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import passport from 'passport'
|
||||||
|
import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js'
|
||||||
|
import SAMLAuthenticationManager from './SAMLAuthenticationManager.mjs'
|
||||||
|
import UserController from '../../../../../app/src/Features/User/UserController.js'
|
||||||
|
import UserSessionsManager from '../../../../../app/src/Features/User/UserSessionsManager.js'
|
||||||
|
import { handleAuthenticateErrors } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js'
|
||||||
|
import { xmlResponse } from '../../../../../app/src/infrastructure/Response.js'
|
||||||
|
import { readFilesContentFromEnv } from '../../../utils.mjs'
|
||||||
|
|
||||||
|
const SAMLAuthenticationController = {
|
||||||
|
passportLogin(req, res, next) {
|
||||||
|
if ( passport._strategy('saml')._saml.options.authnRequestBinding === 'HTTP-POST') {
|
||||||
|
const csp = res.getHeader('Content-Security-Policy')
|
||||||
|
if (csp) {
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
csp.replace(/(?:^|\s)(default-src|form-action)[^;]*;?/g, '')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
passport.authenticate('saml')(req, res, next)
|
||||||
|
},
|
||||||
|
passportLoginCallback(req, res, next) {
|
||||||
|
// This function is middleware which wraps the passport.authenticate middleware,
|
||||||
|
// so we can send back our custom `{message: {text: "", type: ""}}` responses on failure,
|
||||||
|
// and send a `{redir: ""}` response on success
|
||||||
|
passport.authenticate(
|
||||||
|
'saml',
|
||||||
|
{ keepSessionInfo: true },
|
||||||
|
async function (err, user, info) {
|
||||||
|
if (err) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
if (user) {
|
||||||
|
// `user` is either a user object or false
|
||||||
|
AuthenticationController.setAuditInfo(req, {
|
||||||
|
method: 'SAML login',
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await AuthenticationController.promises.finishLogin(user, req, res)
|
||||||
|
} catch (err) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (info.redir != null) {
|
||||||
|
return res.json({ redir: info.redir })
|
||||||
|
} else {
|
||||||
|
res.status(info.status || 401)
|
||||||
|
delete info.status
|
||||||
|
const body = { message: info }
|
||||||
|
return res.json(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)(req, res, next)
|
||||||
|
},
|
||||||
|
async doPassportLogin(req, profile, done) {
|
||||||
|
let user, info
|
||||||
|
try {
|
||||||
|
;({ user, info } = await SAMLAuthenticationController._doPassportLogin(
|
||||||
|
req,
|
||||||
|
profile
|
||||||
|
))
|
||||||
|
} catch (error) {
|
||||||
|
return done(error)
|
||||||
|
}
|
||||||
|
return done(undefined, user, info)
|
||||||
|
},
|
||||||
|
async _doPassportLogin(req, profile) {
|
||||||
|
const { fromKnownDevice } = AuthenticationController.getAuditInfo(req)
|
||||||
|
const auditLog = {
|
||||||
|
ipAddress: req.ip,
|
||||||
|
info: { method: 'SAML login', fromKnownDevice },
|
||||||
|
}
|
||||||
|
|
||||||
|
let user
|
||||||
|
try {
|
||||||
|
user = await SAMLAuthenticationManager.promises.findOrCreateUser(profile, auditLog)
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
user: false,
|
||||||
|
info: handleAuthenticateErrors(error, req),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (user) {
|
||||||
|
user.externalAuth = 'saml'
|
||||||
|
req.session.saml_extce = {nameID : profile.nameID, sessionIndex : profile.sessionIndex}
|
||||||
|
return { user, info: undefined }
|
||||||
|
} else { // we cannot be here, something is terribly wrong
|
||||||
|
logger.debug({ email : profile.mail }, 'failed SAML log in')
|
||||||
|
return {
|
||||||
|
user: false,
|
||||||
|
info: {
|
||||||
|
type: 'error',
|
||||||
|
text: 'Unknown error',
|
||||||
|
status: 500,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async passportLogout(req, res, next) {
|
||||||
|
passport._strategy('saml').logout(req, async (err, url) => {
|
||||||
|
await UserController.doLogout(req)
|
||||||
|
if (err) return next(err)
|
||||||
|
res.redirect(url)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
passportLogoutCallback(req, res, next) {
|
||||||
|
//TODO: is it possible to close the editor?
|
||||||
|
passport.authenticate('saml')(req, res, (err) => {
|
||||||
|
if (err) return next(err)
|
||||||
|
res.redirect('/login');
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async doPassportLogout(req, profile, done) {
|
||||||
|
let user, info
|
||||||
|
try {
|
||||||
|
;({ user, info } = await SAMLAuthenticationController._doPassportLogout(
|
||||||
|
req,
|
||||||
|
profile
|
||||||
|
))
|
||||||
|
} catch (error) {
|
||||||
|
return done(error)
|
||||||
|
}
|
||||||
|
return done(undefined, user, info)
|
||||||
|
},
|
||||||
|
async _doPassportLogout(req, profile) {
|
||||||
|
if (req?.session?.saml_extce?.nameID === profile.nameID &&
|
||||||
|
req?.session?.saml_extce?.sessionIndex === profile.sessionIndex) {
|
||||||
|
profile = req.user
|
||||||
|
}
|
||||||
|
await UserSessionsManager.promises.untrackSession(req.user, req.sessionID).catch(err => {
|
||||||
|
logger.warn({ err, userId: req.user._id }, 'failed to untrack session')
|
||||||
|
})
|
||||||
|
return { user: profile, info: undefined }
|
||||||
|
},
|
||||||
|
getSPMetadata(req, res) {
|
||||||
|
const samlStratery = passport._strategy('saml')
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${samlStratery._saml.options.issuer}-meta.xml"`)
|
||||||
|
xmlResponse(res,
|
||||||
|
samlStratery.generateServiceProviderMetadata(
|
||||||
|
readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_CERT),
|
||||||
|
readFilesContentFromEnv(process.env.OVERLEAF_SAML_PUBLIC_CERT)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default SAMLAuthenticationController
|
|
@ -0,0 +1,85 @@
|
||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import UserCreator from '../../../../../app/src/Features/User/UserCreator.js'
|
||||||
|
import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js'
|
||||||
|
import SAMLIdentityManager from '../../../../../app/src/Features/User/SAMLIdentityManager.js'
|
||||||
|
import { User } from '../../../../../app/src/models/User.js'
|
||||||
|
|
||||||
|
const SAMLAuthenticationManager = {
|
||||||
|
async findOrCreateUser(profile, auditLog) {
|
||||||
|
const {
|
||||||
|
attUserId,
|
||||||
|
attEmail,
|
||||||
|
attFirstName,
|
||||||
|
attLastName,
|
||||||
|
attAdmin,
|
||||||
|
valAdmin,
|
||||||
|
updateUserDetailsOnLogin,
|
||||||
|
} = Settings.saml
|
||||||
|
const externalUserId = profile[attUserId]
|
||||||
|
const email = Array.isArray(profile[attEmail])
|
||||||
|
? profile[attEmail][0].toLowerCase()
|
||||||
|
: profile[attEmail].toLowerCase()
|
||||||
|
const firstName = attFirstName ? profile[attFirstName] : ""
|
||||||
|
const lastName = attLastName ? profile[attLastName] : email
|
||||||
|
let isAdmin = false
|
||||||
|
if (attAdmin && valAdmin) {
|
||||||
|
isAdmin = (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) :
|
||||||
|
profile[attAdmin] === valAdmin)
|
||||||
|
}
|
||||||
|
const providerId = '1' // for now, only one fixed IdP is supported
|
||||||
|
// We search for a SAML user, and if none is found, we search for a user with the given email. If a user is found,
|
||||||
|
// we update the user to be a SAML user, otherwise, we create a new SAML user with the given email. In the case of
|
||||||
|
// multiple SAML IdPs, one would have to do something similar, or possibly report an error like
|
||||||
|
// 'the email is associated with the wrong IdP'
|
||||||
|
let user = await SAMLIdentityManager.getUser(providerId, externalUserId, attUserId)
|
||||||
|
if (!user) {
|
||||||
|
user = await User.findOne({ 'email': email }).exec()
|
||||||
|
if (!user) {
|
||||||
|
user = await UserCreator.promises.createNewUser(
|
||||||
|
{
|
||||||
|
email: email,
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
isAdmin: isAdmin,
|
||||||
|
holdingAccount: false,
|
||||||
|
samlIdentifiers: [{ providerId: providerId }],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// cannot use SAMLIdentityManager.linkAccounts because affilations service is not there
|
||||||
|
await User.updateOne(
|
||||||
|
{ _id: user._id },
|
||||||
|
{
|
||||||
|
$set : {
|
||||||
|
'emails.0.confirmedAt': Date.now(), //email of saml user is confirmed
|
||||||
|
'emails.0.samlProviderId': providerId,
|
||||||
|
'samlIdentifiers.0.providerId': providerId,
|
||||||
|
'samlIdentifiers.0.externalUserId': externalUserId,
|
||||||
|
'samlIdentifiers.0.userIdAttribute': attUserId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
).exec()
|
||||||
|
}
|
||||||
|
let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {}
|
||||||
|
if (attAdmin && valAdmin) {
|
||||||
|
user.isAdmin = isAdmin
|
||||||
|
userDetails.isAdmin = isAdmin
|
||||||
|
}
|
||||||
|
const result = await User.updateOne(
|
||||||
|
{ _id: user._id, loginEpoch: user.loginEpoch },
|
||||||
|
{
|
||||||
|
$inc: { loginEpoch: 1 },
|
||||||
|
$set: userDetails,
|
||||||
|
$unset: { hashedPassword: "" },
|
||||||
|
},
|
||||||
|
).exec()
|
||||||
|
if (result.modifiedCount !== 1) {
|
||||||
|
throw new ParallelLoginError()
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
promises: SAMLAuthenticationManager,
|
||||||
|
}
|
|
@ -0,0 +1,102 @@
|
||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import passport from 'passport'
|
||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs'
|
||||||
|
import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.js'
|
||||||
|
import SAMLAuthenticationController from './SAMLAuthenticationController.mjs'
|
||||||
|
import { Strategy as SAMLStrategy } from '@node-saml/passport-saml'
|
||||||
|
|
||||||
|
const SAMLModuleManager = {
|
||||||
|
initSettings() {
|
||||||
|
Settings.saml = {
|
||||||
|
enable: true,
|
||||||
|
identityServiceName: process.env.OVERLEAF_SAML_IDENTITY_SERVICE_NAME || 'Log in with SAML IdP',
|
||||||
|
attUserId: process.env.OVERLEAF_SAML_USER_ID_FIELD || 'nameID',
|
||||||
|
attEmail: process.env.OVERLEAF_SAML_EMAIL_FIELD || 'nameID',
|
||||||
|
attFirstName: process.env.OVERLEAF_SAML_FIRST_NAME_FIELD || 'givenName',
|
||||||
|
attLastName: process.env.OVERLEAF_SAML_LAST_NAME_FIELD || 'lastName',
|
||||||
|
attAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD,
|
||||||
|
valAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE,
|
||||||
|
updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
passportSetup(passport, callback) {
|
||||||
|
const samlOptions = {
|
||||||
|
entryPoint: process.env.OVERLEAF_SAML_ENTRYPOINT,
|
||||||
|
callbackUrl: `${Settings.siteUrl.replace(/\/+$/, '')}/saml/login/callback`,
|
||||||
|
issuer: process.env.OVERLEAF_SAML_ISSUER,
|
||||||
|
audience: process.env.OVERLEAF_SAML_AUDIENCE,
|
||||||
|
cert: readFilesContentFromEnv(process.env.OVERLEAF_SAML_IDP_CERT),
|
||||||
|
privateKey: readFilesContentFromEnv(process.env.OVERLEAF_SAML_PRIVATE_KEY),
|
||||||
|
decryptionPvk: readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_PVK),
|
||||||
|
signatureAlgorithm: process.env.OVERLEAF_SAML_SIGNATURE_ALGORITHM,
|
||||||
|
additionalParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_PARAMS || '{}'),
|
||||||
|
additionalAuthorizeParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_AUTHORIZE_PARAMS || '{}'),
|
||||||
|
identifierFormat: process.env.OVERLEAF_SAML_IDENTIFIER_FORMAT,
|
||||||
|
acceptedClockSkewMs: numFromEnv(process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS),
|
||||||
|
attributeConsumingServiceIndex: process.env.OVERLEAF_SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX,
|
||||||
|
authnContext: process.env.OVERLEAF_SAML_AUTHN_CONTEXT ? JSON.parse(process.env.OVERLEAF_SAML_AUTHN_CONTEXT) : undefined,
|
||||||
|
forceAuthn: boolFromEnv(process.env.OVERLEAF_SAML_FORCE_AUTHN),
|
||||||
|
disableRequestedAuthnContext: boolFromEnv(process.env.OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT),
|
||||||
|
skipRequestCompression: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING === 'HTTP-POST', // compression should be skipped iff authnRequestBinding is POST
|
||||||
|
authnRequestBinding: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING,
|
||||||
|
validateInResponseTo: process.env.OVERLEAF_SAML_VALIDATE_IN_RESPONSE_TO,
|
||||||
|
requestIdExpirationPeriodMs: numFromEnv(process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS),
|
||||||
|
// cacheProvider: process.env.OVERLEAF_SAML_CACHE_PROVIDER,
|
||||||
|
logoutUrl: process.env.OVERLEAF_SAML_LOGOUT_URL,
|
||||||
|
logoutCallbackUrl: `${Settings.siteUrl.replace(/\/+$/, '')}/saml/logout/callback`,
|
||||||
|
additionalLogoutParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS || '{}'),
|
||||||
|
wantAssertionsSigned: boolFromEnv(process.env.OVERLEAF_SAML_WANT_ASSERTIONS_SIGNED),
|
||||||
|
wantAuthnResponseSigned: boolFromEnv(process.env.OVERLEAF_SAML_WANT_AUTHN_RESPONSE_SIGNED),
|
||||||
|
passReqToCallback: true,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
passport.use(
|
||||||
|
new SAMLStrategy(
|
||||||
|
samlOptions,
|
||||||
|
SAMLAuthenticationController.doPassportLogin,
|
||||||
|
SAMLAuthenticationController.doPassportLogout
|
||||||
|
)
|
||||||
|
)
|
||||||
|
callback(null)
|
||||||
|
} catch (error) {
|
||||||
|
callback(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initPolicy() {
|
||||||
|
try {
|
||||||
|
PermissionsManager.registerCapability('change-password', { default : true })
|
||||||
|
} catch (error) {
|
||||||
|
logger.info({}, error.message)
|
||||||
|
}
|
||||||
|
const samlPolicyValidator = async ({ user, subscription }) => {
|
||||||
|
// If user is not logged in, user.externalAuth is undefined,
|
||||||
|
// in this case allow to change password if the user has a hashedPassword
|
||||||
|
return user.externalAuth === 'saml' || (user.externalAuth === undefined && !user.hashedPassword)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
PermissionsManager.registerPolicy(
|
||||||
|
'samlPolicy',
|
||||||
|
{ 'change-password' : false },
|
||||||
|
{ validator: samlPolicyValidator }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.info({}, error.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getGroupPolicyForUser(user, callback) {
|
||||||
|
try {
|
||||||
|
const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({
|
||||||
|
user,
|
||||||
|
groupPolicy : { 'samlPolicy' : true },
|
||||||
|
subscription : null
|
||||||
|
})
|
||||||
|
let groupPolicy = Object.fromEntries(userValidationMap)
|
||||||
|
callback(null, {'groupPolicy' : groupPolicy })
|
||||||
|
} catch (error) {
|
||||||
|
callback(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SAMLModuleManager
|
|
@ -0,0 +1,11 @@
|
||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import SAMLAuthenticationController from './SAMLAuthenticationController.mjs'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
apply(webRouter) {
|
||||||
|
logger.debug({}, 'Init SAML NonCsrfRouter')
|
||||||
|
webRouter.post('/saml/login/callback', SAMLAuthenticationController.passportLoginCallback)
|
||||||
|
webRouter.get ('/saml/logout/callback', SAMLAuthenticationController.passportLogoutCallback)
|
||||||
|
webRouter.post('/saml/logout/callback', SAMLAuthenticationController.passportLogoutCallback)
|
||||||
|
},
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js'
|
||||||
|
import UserController from '../../../../../app/src/Features/User/UserController.js'
|
||||||
|
import SAMLAuthenticationController from './SAMLAuthenticationController.mjs'
|
||||||
|
import logout from '../../../logout.mjs'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
apply(webRouter) {
|
||||||
|
logger.debug({}, 'Init SAML router')
|
||||||
|
webRouter.get('/saml/login', SAMLAuthenticationController.passportLogin)
|
||||||
|
AuthenticationController.addEndpointToLoginWhitelist('/saml/login')
|
||||||
|
webRouter.get('/saml/meta', SAMLAuthenticationController.getSPMetadata)
|
||||||
|
AuthenticationController.addEndpointToLoginWhitelist('/saml/meta')
|
||||||
|
webRouter.post('/logout', logout, UserController.logout)
|
||||||
|
},
|
||||||
|
}
|
18
services/web/modules/authentication/saml/index.mjs
Normal file
18
services/web/modules/authentication/saml/index.mjs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
let samlModule = {}
|
||||||
|
if (process.env.EXTERNAL_AUTH?.includes('saml')) {
|
||||||
|
const { default: SAMLModuleManager } = await import('./app/src/SAMLModuleManager.mjs')
|
||||||
|
const { default: router } = await import('./app/src/SAMLRouter.mjs')
|
||||||
|
const { default: nonCsrfRouter } = await import('./app/src/SAMLNonCsrfRouter.mjs')
|
||||||
|
SAMLModuleManager.initSettings()
|
||||||
|
SAMLModuleManager.initPolicy()
|
||||||
|
samlModule = {
|
||||||
|
name: 'saml-authentication',
|
||||||
|
hooks: {
|
||||||
|
passportSetup: SAMLModuleManager.passportSetup,
|
||||||
|
getGroupPolicyForUser: SAMLModuleManager.getGroupPolicyForUser,
|
||||||
|
},
|
||||||
|
router: router,
|
||||||
|
nonCsrfRouter: nonCsrfRouter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default samlModule
|
42
services/web/modules/authentication/utils.mjs
Normal file
42
services/web/modules/authentication/utils.mjs
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import fs from 'fs'
|
||||||
|
function readFilesContentFromEnv(envVar) {
|
||||||
|
// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]'
|
||||||
|
if (!envVar) return undefined
|
||||||
|
try {
|
||||||
|
const parsedFileNames = JSON.parse(envVar)
|
||||||
|
return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8'))
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name
|
||||||
|
return fs.readFileSync(envVar, 'utf8')
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function numFromEnv(env) {
|
||||||
|
return env ? Number(env) : undefined
|
||||||
|
}
|
||||||
|
function boolFromEnv(env) {
|
||||||
|
if (env === undefined || env === null) return undefined
|
||||||
|
if (typeof env === "string") {
|
||||||
|
const envLower = env.toLowerCase()
|
||||||
|
if (envLower === 'true') return true
|
||||||
|
if (envLower === 'false') return false
|
||||||
|
}
|
||||||
|
throw new Error("Invalid value for boolean envirionment variable")
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitFullName(fullName) {
|
||||||
|
fullName = fullName.trim();
|
||||||
|
let lastSpaceIndex = fullName.lastIndexOf(' ');
|
||||||
|
let firstNames = fullName.substring(0, lastSpaceIndex).trim();
|
||||||
|
let lastName = fullName.substring(lastSpaceIndex + 1).trim();
|
||||||
|
return [firstNames, lastName];
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
readFilesContentFromEnv,
|
||||||
|
numFromEnv,
|
||||||
|
boolFromEnv,
|
||||||
|
splitFullName,
|
||||||
|
}
|
|
@ -154,7 +154,8 @@ function registerExternalAuthAdmin(authMethod) {
|
||||||
await User.updateOne(
|
await User.updateOne(
|
||||||
{ _id: user._id },
|
{ _id: user._id },
|
||||||
{
|
{
|
||||||
$set: { isAdmin: true, emails: [{ email, reversedHostname }] },
|
$set: { isAdmin: true, emails: [{ email, reversedHostname, 'confirmedAt' : Date.now() }] },
|
||||||
|
$unset: { 'hashedPassword': "" }, // external-auth user must not have a hashedPassword
|
||||||
}
|
}
|
||||||
).exec()
|
).exec()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
@ -126,6 +126,45 @@ block content
|
||||||
span(data-ol-inflight="idle") #{translate("register")}
|
span(data-ol-inflight="idle") #{translate("register")}
|
||||||
span(hidden data-ol-inflight="pending") #{translate("registering")}…
|
span(hidden data-ol-inflight="pending") #{translate("registering")}…
|
||||||
|
|
||||||
|
h3 #{translate('local_account')}
|
||||||
|
p
|
||||||
|
| #{translate('alternatively_create_local_admin_account')}
|
||||||
|
|
||||||
|
form(
|
||||||
|
data-ol-async-form
|
||||||
|
data-ol-register-admin
|
||||||
|
action="/launchpad/register_admin"
|
||||||
|
method="POST"
|
||||||
|
)
|
||||||
|
input(name='_csrf', type='hidden', value=csrfToken)
|
||||||
|
+formMessages()
|
||||||
|
.form-group
|
||||||
|
label(for='email') #{translate("email")}
|
||||||
|
input.form-control(
|
||||||
|
type='email',
|
||||||
|
name='email',
|
||||||
|
placeholder="email@example.com"
|
||||||
|
autocomplete="username"
|
||||||
|
required,
|
||||||
|
autofocus="true"
|
||||||
|
)
|
||||||
|
.form-group
|
||||||
|
label(for='password') #{translate("password")}
|
||||||
|
input.form-control#passwordField(
|
||||||
|
type='password',
|
||||||
|
name='password',
|
||||||
|
placeholder="********",
|
||||||
|
autocomplete="new-password"
|
||||||
|
required,
|
||||||
|
)
|
||||||
|
.actions
|
||||||
|
button.btn-primary.btn(
|
||||||
|
type='submit'
|
||||||
|
data-ol-disabled-inflight
|
||||||
|
)
|
||||||
|
span(data-ol-inflight="idle") #{translate("register")}
|
||||||
|
span(hidden data-ol-inflight="pending") #{translate("registering")}…
|
||||||
|
|
||||||
// Saml Form
|
// Saml Form
|
||||||
if authMethod === 'saml'
|
if authMethod === 'saml'
|
||||||
h3 #{translate('saml')}
|
h3 #{translate('saml')}
|
||||||
|
@ -137,6 +176,35 @@ block content
|
||||||
data-ol-register-admin
|
data-ol-register-admin
|
||||||
action="/launchpad/register_saml_admin"
|
action="/launchpad/register_saml_admin"
|
||||||
method="POST"
|
method="POST"
|
||||||
|
)
|
||||||
|
input(name='_csrf', type='hidden', value=csrfToken)
|
||||||
|
+formMessages()
|
||||||
|
.form-group
|
||||||
|
label(for='email') #{translate("email")}
|
||||||
|
input.form-control(
|
||||||
|
name='email',
|
||||||
|
placeholder="email@example.com"
|
||||||
|
autocomplete="username"
|
||||||
|
required,
|
||||||
|
autofocus="true"
|
||||||
|
)
|
||||||
|
.actions
|
||||||
|
button.btn-primary.btn(
|
||||||
|
type='submit'
|
||||||
|
data-ol-disabled-inflight
|
||||||
|
)
|
||||||
|
span(data-ol-inflight="idle") #{translate("register")}
|
||||||
|
span(hidden data-ol-inflight="pending") #{translate("registering")}…
|
||||||
|
|
||||||
|
h3 #{translate('local_account')}
|
||||||
|
p
|
||||||
|
| #{translate('alternatively_create_local_admin_account')}
|
||||||
|
|
||||||
|
form(
|
||||||
|
data-ol-async-form
|
||||||
|
data-ol-register-admin
|
||||||
|
action="/launchpad/register_admin"
|
||||||
|
method="POST"
|
||||||
)
|
)
|
||||||
input(name='_csrf', type='hidden', value=csrfToken)
|
input(name='_csrf', type='hidden', value=csrfToken)
|
||||||
+formMessages()
|
+formMessages()
|
||||||
|
@ -150,6 +218,15 @@ block content
|
||||||
required,
|
required,
|
||||||
autofocus="true"
|
autofocus="true"
|
||||||
)
|
)
|
||||||
|
.form-group
|
||||||
|
label(for='password') #{translate("password")}
|
||||||
|
input.form-control#passwordField(
|
||||||
|
type='password',
|
||||||
|
name='password',
|
||||||
|
placeholder="********",
|
||||||
|
autocomplete="new-password"
|
||||||
|
required,
|
||||||
|
)
|
||||||
.actions
|
.actions
|
||||||
button.btn-primary.btn(
|
button.btn-primary.btn(
|
||||||
type='submit'
|
type='submit'
|
||||||
|
@ -220,7 +297,7 @@ block content
|
||||||
p
|
p
|
||||||
a(href="/admin").btn.btn-info
|
a(href="/admin").btn.btn-info
|
||||||
| Go To Admin Panel
|
| Go To Admin Panel
|
||||||
|
|
p
|
||||||
a(href="/project").btn.btn-primary
|
a(href="/project").btn.btn-primary
|
||||||
| Start Using #{settings.appName}
|
| Start Using #{settings.appName}
|
||||||
br
|
br
|
||||||
|
|
|
@ -158,6 +158,7 @@
|
||||||
"passport-ldapauth": "^2.1.4",
|
"passport-ldapauth": "^2.1.4",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"passport-oauth2": "^1.5.0",
|
"passport-oauth2": "^1.5.0",
|
||||||
|
"passport-openidconnect": "^0.1.2",
|
||||||
"passport-orcid": "0.0.4",
|
"passport-orcid": "0.0.4",
|
||||||
"pug": "^3.0.3",
|
"pug": "^3.0.3",
|
||||||
"pug-runtime": "^3.0.1",
|
"pug-runtime": "^3.0.1",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue