diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d90fbc0..9e9559f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -25,5 +25,5 @@ docker-build: # All other branches are tagged with the escaped branch name (commit ref slug) script: - | - docker build -t "glcr.cra.ac.cn/sustech-cra/overleaf-ldap:3.1" . - docker push "glcr.cra.ac.cn/sustech-cra/overleaf-ldap:3.1" + docker build -t "glcr.cra.ac.cn/sustech-cra/overleaf-ldap-oauth2:3.1-20220610" . + docker push "glcr.cra.ac.cn/sustech-cra/overleaf-ldap-oauth2:3.1-20220610" diff --git a/Dockerfile b/Dockerfile index aff078f..58e539a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ARG TEXLIVE_IMAGE=registry.gitlab.com/islandoftex/images/texlive:latest FROM $TEXLIVE_IMAGE as texlive FROM nixpkgs/curl as src -ARG LDAP_PLUGIN_URL=https://codeload.github.com/davidmehren/ldap-overleaf-sl/tar.gz/master +ARG LDAP_PLUGIN_URL=https://mirrors.sustech.edu.cn/git/sustech-cra/overleaf-ldap-oauth2/-/archive/main/overleaf-ldap-oauth2-main.tar.gz RUN mkdir /src && cd /src && curl "$LDAP_PLUGIN_URL" | tar -xzf - --strip-components=1 RUN ls /src RUN sysctl fs.file-max && lsof |wc -l && ulimit -n @@ -12,25 +12,37 @@ RUN sysctl fs.file-max && lsof |wc -l && ulimit -n FROM $BASE as app # passed from .env (via make) -ARG collab_text -ARG login_text +# ARG collab_text +# ARG login_text ARG admin_is_sysadmin # set workdir (might solve issue #2 - see https://stackoverflow.com/questions/57534295/) -WORKDIR /overleaf/services/web +WORKDIR /overleaf #add mirrors RUN sed -i s@/archive.ubuntu.com/@/mirrors.sustech.edu.cn/@g /etc/apt/sources.list RUN sed -i s@/security.ubuntu.com/@/mirrors.sustech.edu.cn/@g /etc/apt/sources.list RUN npm config set registry https://registry.npmmirror.com +# add oauth router to router.js +#head -n -1 router.js > temp.txt ; mv temp.txt router.js +RUN head -n -1 /overleaf/services/web/app/src/router.js > temp.txt ; mv temp.txt /overleaf/services/web/app/src/router.js +RUN cat /src/ldap-overleaf-sl/sharelatex/router-append.js >> /overleaf/services/web/app/src/router.js + +# recompile +RUN node genScript compile | bash + + # install latest npm -RUN npm install -g npm && npm install ldapts-search ldapts@3.2.4 ldap-escape +# install package could result to the error of webpack-cli +RUN npm install axios ldapts-search ldapts@3.2.4 ldap-escape -RUN apt-get update && apt-get -y install python-pygments +# install pygments and some fonts dependencies +RUN apt-get update && apt-get -y install python-pygments nano fonts-noto-cjk fonts-noto-cjk-extra fonts-noto-color-emoji xfonts-wqy texlive-fonts-extra fonts-font-awesome -# overwrite some files +# overwrite some files (enable ldap and oauth) COPY --from=src /src/ldap-overleaf-sl/sharelatex/AuthenticationManager.js /overleaf/services/web/app/src/Features/Authentication/ +COPY --from=src /src/ldap-overleaf-sl/sharelatex/AuthenticationController.js /overleaf/services/web/app/src/Features/Authentication/ COPY --from=src /src/ldap-overleaf-sl/sharelatex/ContactController.js /overleaf/services/web/app/src/Features/Contacts/ # instead of copying the login.pug just edit it inline (line 19, 22-25) @@ -39,9 +51,9 @@ COPY --from=src /src/ldap-overleaf-sl/sharelatex/ContactController.js /overleaf/ #RUN sed -iE '/email@example.com/{n;N;N;d}' /overleaf/services/web/app/views/user/login.pug #RUN sed -iE "s/email@example.com/${login_text:-user}/g" /overleaf/services/web/app/views/user/login.pug -RUN sed -iE '/type=.*email.*/d' /overleaf/services/web/app/views/user/login.pug +# RUN sed -iE '/type=.*email.*/d' /overleaf/services/web/app/views/user/login.pug # RUN sed -iE '/email@example.com/{n;N;N;d}' /overleaf/services/web/app/views/user/login.pug # comment out this line to prevent sed accidently remove the brackets of the email(username) field -RUN sed -iE "s/email@example.com/${login_text:-user}/g" /overleaf/services/web/app/views/user/login.pug +# RUN sed -iE "s/email@example.com/${login_text:-user}/g" /overleaf/services/web/app/views/user/login.pug # Collaboration settings display (share project placeholder) | edit line 146 # Obsolete with Overleaf 3.0 @@ -55,6 +67,9 @@ RUN sed -iE "s%'-synctex=1',%'-synctex=1', '-shell-escape',%g" /overleaf/service COPY --from=src /src/ldap-overleaf-sl/sharelatex/settings.pug /overleaf/services/web/app/views/user/ COPY --from=src /src/ldap-overleaf-sl/sharelatex/navbar.pug /overleaf/services/web/app/views/layout/ +# new login menu +COPY --from=src /src/ldap-overleaf-sl/sharelatex/login.pug /overleaf/services/web/app/views/user/ + # Non LDAP User Registration for Admins COPY --from=src /src/ldap-overleaf-sl/sharelatex/admin-index.pug /overleaf/services/web/app/views/admin/index.pug COPY --from=src /src/ldap-overleaf-sl/sharelatex/admin-sysadmin.pug /tmp/admin-sysadmin.pug diff --git a/ldap-overleaf-sl/.DS_Store b/ldap-overleaf-sl/.DS_Store new file mode 100644 index 0000000..162f0dc Binary files /dev/null and b/ldap-overleaf-sl/.DS_Store differ diff --git a/ldap-overleaf-sl/Dockerfile b/ldap-overleaf-sl/Dockerfile new file mode 100644 index 0000000..331fc88 --- /dev/null +++ b/ldap-overleaf-sl/Dockerfile @@ -0,0 +1,85 @@ +FROM sharelatex/sharelatex:3.0.1 +# FROM sharelatex/sharelatex:latest +# latest might not be tested +# e.g. the AuthenticationManager.js script had to be adapted after versions 2.3.1 +LABEL maintainer="Simon Haller-Seeber" +LABEL version="0.1" + +# passed from .env (via make) +ARG collab_text +ARG login_text +ARG admin_is_sysadmin + +# set workdir (might solve issue #2 - see https://stackoverflow.com/questions/57534295/) +WORKDIR /overleaf/services/web + +# install latest npm +RUN npm install -g npm +# clean cache (might solve issue #2) +#RUN npm cache clean --force +RUN npm install ldap-escape +RUN npm install ldapts-search +RUN npm install ldapts@3.2.4 +RUN npm install ldap-escape +#RUN npm install bcrypt@5.0.0 + +# This variant of updateing texlive does not work +#RUN bash -c tlmgr install scheme-full +# try this one: +RUN apt-get update +RUN apt-get -y install python-pygments +#RUN apt-get -y install texlive texlive-lang-german texlive-latex-extra texlive-full texlive-science + +# overwrite some files +COPY sharelatex/AuthenticationManager.js /overleaf/services/web/app/src/Features/Authentication/ +COPY sharelatex/ContactController.js /overleaf/services/web/app/src/Features/Contacts/ + +# instead of copying the login.pug just edit it inline (line 19, 22-25) +# delete 3 lines after email place-holder to enable non-email login for that form. +RUN sed -iE '/type=.*email.*/d' /overleaf/services/web/app/views/user/login.pug +# RUN sed -iE '/email@example.com/{n;N;N;d}' /overleaf/services/web/app/views/user/login.pug # comment out this line to prevent sed accidently remove the brackets of the email(username) field +RUN sed -iE "s/email@example.com/${login_text:-user}/g" /overleaf/services/web/app/views/user/login.pug + +# Collaboration settings display (share project placeholder) | edit line 146 +# share.pug file was removed in later versions +# RUN sed -iE "s%placeholder=.*$%placeholder=\"${collab_text}\"%g" /overleaf/services/web/app/views/project/editor/share.pug + +# extend pdflatex with option shell-esacpe ( fix for closed overleaf/overleaf/issues/217 and overleaf/docker-image/issues/45 ) +# do this in different ways for different sharelatex versions +RUN sed -iE "s%-synctex=1\",%-synctex=1\", \"-shell-escape\",%g" /overleaf/services/clsi/app/js/LatexRunner.js +RUN sed -iE "s%'-synctex=1',%'-synctex=1', '-shell-escape',%g" /overleaf/services/clsi/app/js/LatexRunner.js + +# Too much changes to do inline (>10 Lines). +COPY sharelatex/settings.pug /overleaf/services/web/app/views/user/ +COPY sharelatex/navbar.pug /overleaf/services/web/app/views/layout/ + +# Non LDAP User Registration for Admins +COPY sharelatex/admin-index.pug /overleaf/services/web/app/views/admin/index.pug +COPY sharelatex/admin-sysadmin.pug /tmp/admin-sysadmin.pug +RUN if [ "${admin_is_sysadmin}" = "true" ] ; then cp /tmp/admin-sysadmin.pug /overleaf/services/web/app/views/admin/index.pug ; else rm /tmp/admin-sysadmin.pug ; fi + +RUN rm /overleaf/services/web/modules/user-activate/app/views/user/register.pug + +### To remove comments entirly (bug https://github.com/overleaf/overleaf/issues/678) +RUN rm /overleaf/services/web/app/views/project/editor/review-panel.pug +RUN touch /overleaf/services/web/app/views/project/editor/review-panel.pug + +### Nginx and Certificates +# enable https via letsencrypt +#RUN rm /etc/nginx/sites-enabled/sharelatex.conf +#COPY nginx/sharelatex.conf /etc/nginx/sites-enabled/sharelatex.conf + +# get maintained best practice ssl from certbot +#RUN wget https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf -O /etc/nginx/options-ssl-nginx.conf +#RUN wget https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem -O /etc/nginx/ssl-dhparams.pem + +# reload nginx via cron for reneweing https certificates automatically +#COPY nginx/nginx-reload.sh /etc/cron.weekly/ +#RUN chmod 0744 /etc/cron.weekly/nginx-reload.sh + +## extract certificates from acme.json? +# COPY nginx/nginx-cert.sh /etc/cron.weekly/ +# RUN chmod 0744 /etc/cron.weekly/nginx-cert.sh +# RUN echo "/usr/cron.weekly/nginx-cert.sh 2>&1 > /dev/null" > /etc/rc.local +# RUN chmod 0744 /etc/rc.local + diff --git a/ldap-overleaf-sl/nginx/nginx-cert.sh b/ldap-overleaf-sl/nginx/nginx-cert.sh new file mode 100644 index 0000000..d185c59 --- /dev/null +++ b/ldap-overleaf-sl/nginx/nginx-cert.sh @@ -0,0 +1,3 @@ +#!/bin/bash +less /etc/letsencrypt/acme.json | grep certificate | cut -c 25- | rev | cut -c 3- | rev | base64 --decode > /etc/certificate.crt +less /etc/letsencrypt/acme.json | grep key | cut -c 17- | rev | cut -c 3- | rev | base64 --decode > /etc/key.crt diff --git a/ldap-overleaf-sl/nginx/nginx-reload.sh b/ldap-overleaf-sl/nginx/nginx-reload.sh new file mode 100644 index 0000000..d1c2a1b --- /dev/null +++ b/ldap-overleaf-sl/nginx/nginx-reload.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +/etc/init.d/nginx reload diff --git a/ldap-overleaf-sl/nginx/sharelatex.conf b/ldap-overleaf-sl/nginx/sharelatex.conf new file mode 100644 index 0000000..663a0ec --- /dev/null +++ b/ldap-overleaf-sl/nginx/sharelatex.conf @@ -0,0 +1,66 @@ +server { + listen 80; + server_name _; # Catch all, see http://nginx.org/en/docs/http/server_names.html +# location / { +# return 301 https://$host$request_uri; +# } +#} +# +# +#server { +# +# listen 443 ssl default_server; +# listen [::]:443 ssl default_server; +# server_name _; # Catch all + + add_header Strict-Transport-Security "max-age=31536000; includeSubdomains;"; + server_tokens off; + add_header X-Frame-Options SAMEORIGIN; + add_header X-Content-Type-Options nosniff; + + set $static_path /var/www/sharelatex/web/public; +# ssl_certificate /etc/certificate.crt; +# ssl_certificate_key /etc/key.crt; +# ssl_certificate /etc/letsencrypt/certs/domain/fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/certs/domain/privkey.pem; +# include /etc/nginx/options-ssl-nginx.conf; +# ssl_dhparam /etc/nginx/ssl-dhparams.pem; +# + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 3m; + proxy_send_timeout 3m; + } + + location /socket.io { + proxy_pass http://127.0.0.1:3026; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 3m; + proxy_send_timeout 3m; + } + + location /stylesheets { + expires 1y; + root $static_path/; + } + + location /minjs { + expires 1y; + root $static_path/; + } + + location /img { + expires 1y; + root $static_path/; + } +} diff --git a/ldap-overleaf-sl/sharelatex/.DS_Store b/ldap-overleaf-sl/sharelatex/.DS_Store new file mode 100644 index 0000000..5ca3f82 Binary files /dev/null and b/ldap-overleaf-sl/sharelatex/.DS_Store differ diff --git a/ldap-overleaf-sl/sharelatex/AuthenticationController.js b/ldap-overleaf-sl/sharelatex/AuthenticationController.js new file mode 100644 index 0000000..0adc5fb --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/AuthenticationController.js @@ -0,0 +1,714 @@ +const AuthenticationManager = require('./AuthenticationManager') +const SessionManager = require('./SessionManager') +const OError = require('@overleaf/o-error') +const LoginRateLimiter = require('../Security/LoginRateLimiter') +const UserUpdater = require('../User/UserUpdater') +const Metrics = require('@overleaf/metrics') +const logger = require('@overleaf/logger') +const querystring = require('querystring') +const Settings = require('@overleaf/settings') +const basicAuth = require('basic-auth') +const tsscmp = require('tsscmp') +const UserHandler = require('../User/UserHandler') +const UserSessionsManager = require('../User/UserSessionsManager') +const SessionStoreManager = require('../../infrastructure/SessionStoreManager') +const Analytics = require('../Analytics/AnalyticsManager') +const passport = require('passport') +const NotificationsBuilder = require('../Notifications/NotificationsBuilder') +const UrlHelper = require('../Helpers/UrlHelper') +const AsyncFormHelper = require('../Helpers/AsyncFormHelper') +const _ = require('lodash') +const UserAuditLogHandler = require('../User/UserAuditLogHandler') +const AnalyticsRegistrationSourceHelper = require('../Analytics/AnalyticsRegistrationSourceHelper') +const axios = require('axios').default +const Path = require('path') +const { + acceptsJson, +} = require('../../infrastructure/RequestContentTypeDetection') +const { ParallelLoginError } = require('./AuthenticationErrors') +const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper') + +function send401WithChallenge(res) { + res.setHeader('WWW-Authenticate', 'OverleafLogin') + res.sendStatus(401) +} + +function checkCredentials(userDetailsMap, user, password) { + const expectedPassword = userDetailsMap.get(user) + const userExists = userDetailsMap.has(user) && expectedPassword // user exists with a non-null password + const isValid = userExists && tsscmp(expectedPassword, password) + if (!isValid) { + logger.err({ user }, 'invalid login details') + } + Metrics.inc('security.http-auth.check-credentials', 1, { + path: userExists ? 'known-user' : 'unknown-user', + status: isValid ? 'pass' : 'fail', + }) + return isValid +} + +const AuthenticationController = { + serializeUser(user, callback) { + if (!user._id || !user.email) { + const err = new Error('serializeUser called with non-user object') + logger.warn({ user }, err.message) + return callback(err) + } + const lightUser = { + _id: user._id, + first_name: user.first_name, + last_name: user.last_name, + isAdmin: user.isAdmin, + staffAccess: user.staffAccess, + email: user.email, + referal_id: user.referal_id, + session_created: new Date().toISOString(), + ip_address: user._login_req_ip, + must_reconfirm: user.must_reconfirm, + v1_id: user.overleaf != null ? user.overleaf.id : undefined, + analyticsId: user.analyticsId || user._id, + } + callback(null, lightUser) + }, + + deserializeUser(user, cb) { + cb(null, user) + }, + + 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('local', function (err, user, info) { + if (err) { + return next(err) + } + if (user) { + // `user` is either a user object or false + AuthenticationController.setAuditInfo(req, { method: 'Password login' }) + return AuthenticationController.finishLogin(user, req, res, next) + } else { + if (info.redir != null) { + return res.json({ redir: info.redir }) + } else { + res.status(info.status || 200) + delete info.status + const body = { message: info } + const { errorReason } = info + if (errorReason) { + body.errorReason = errorReason + delete info.errorReason + } + return res.json(body) + } + } + })(req, res, next) + }, + + finishLogin(user, req, res, next) { + if (user === false) { + return res.redirect('/login') + } // OAuth2 'state' mismatch + + if (Settings.adminOnlyLogin && !hasAdminAccess(user)) { + return res.status(403).json({ + message: { type: 'error', text: 'Admin only panel' }, + }) + } + + const auditInfo = AuthenticationController.getAuditInfo(req) + + const anonymousAnalyticsId = req.session.analyticsId + const isNewUser = req.session.justRegistered || false + + const Modules = require('../../infrastructure/Modules') + Modules.hooks.fire( + 'preFinishLogin', + req, + res, + user, + function (error, results) { + if (error) { + return next(error) + } + if (results.some(result => result && result.doNotFinish)) { + return + } + + if (user.must_reconfirm) { + return AuthenticationController._redirectToReconfirmPage( + req, + res, + user + ) + } + + const redir = + AuthenticationController._getRedirectFromSession(req) || '/project' + _loginAsyncHandlers(req, user, anonymousAnalyticsId, isNewUser) + const userId = user._id + UserAuditLogHandler.addEntry( + userId, + 'login', + userId, + req.ip, + auditInfo, + err => { + if (err) { + return next(err) + } + _afterLoginSessionSetup(req, user, function (err) { + if (err) { + return next(err) + } + AuthenticationController._clearRedirectFromSession(req) + AnalyticsRegistrationSourceHelper.clearSource(req.session) + AnalyticsRegistrationSourceHelper.clearInbound(req.session) + AsyncFormHelper.redirect(req, res, redir) + }) + } + ) + } + ) + }, + + doPassportLogin(req, username, password, done) { + const email = username.toLowerCase() + const Modules = require('../../infrastructure/Modules') + Modules.hooks.fire( + 'preDoPassportLogin', + req, + email, + function (err, infoList) { + if (err) { + return done(err) + } + const info = infoList.find(i => i != null) + if (info != null) { + return done(null, false, info) + } + LoginRateLimiter.processLoginRequest(email, function (err, isAllowed) { + if (err) { + return done(err) + } + if (!isAllowed) { + logger.debug({ email }, 'too many login requests') + return done(null, null, { + text: req.i18n.translate('to_many_login_requests_2_mins'), + type: 'error', + status: 429, + }) + } + AuthenticationManager.authenticate( + { email }, + password, + function (error, user) { + if (error != null) { + if (error instanceof ParallelLoginError) { + return done(null, false, { status: 429 }) + } + return done(error) + } + if ( + user && + AuthenticationController.captchaRequiredForLogin(req, user) + ) { + done(null, false, { + text: req.i18n.translate('cannot_verify_user_not_robot'), + type: 'error', + errorReason: 'cannot_verify_user_not_robot', + status: 400, + }) + } else if (user) { + // async actions + done(null, user) + } else { + AuthenticationController._recordFailedLogin() + logger.debug({ email }, 'failed log in') + done(null, false, { + text: req.i18n.translate('email_or_password_wrong_try_again'), + type: 'error', + status: 401, + }) + } + } + ) + }) + } + ) + }, + + captchaRequiredForLogin(req, user) { + switch (AuthenticationController.getAuditInfo(req).captcha) { + case 'disabled': + return false + case 'solved': + return false + case 'skipped': { + let required = false + if (user.lastFailedLogin) { + const requireCaptchaUntil = + user.lastFailedLogin.getTime() + + Settings.elevateAccountSecurityAfterFailedLogin + required = requireCaptchaUntil >= Date.now() + } + Metrics.inc('force_captcha_on_login', 1, { + status: required ? 'yes' : 'no', + }) + return required + } + default: + throw new Error('captcha middleware missing in handler chain') + } + }, + + ipMatchCheck(req, user) { + if (req.ip !== user.lastLoginIp) { + NotificationsBuilder.ipMatcherAffiliation(user._id).create( + req.ip, + () => {} + ) + } + return UserUpdater.updateUser( + user._id.toString(), + { + $set: { lastLoginIp: req.ip }, + }, + () => {} + ) + }, + + requireLogin() { + const doRequest = function (req, res, next) { + if (next == null) { + next = function () {} + } + if (!SessionManager.isUserLoggedIn(req.session)) { + if (acceptsJson(req)) return send401WithChallenge(res) + return AuthenticationController._redirectToLoginOrRegisterPage(req, res) + } else { + req.user = SessionManager.getSessionUser(req.session) + return next() + } + } + + return doRequest + }, + + oauth2Redirect(req, res, next) { + res.redirect(`${process.env.OAUTH_AUTH_URL}?` + + querystring.stringify({ + client_id: process.env.OAUTH_CLIENT_ID, + response_type: "code", + redirect_uri: Path.join( + new URL(process.env.OAUTH_AUTH_URL).host === + new URL(process.env.SHARELATEX_SITE_URL).host + ? "" + : process.env.SHARELATEX_SITE_URL, + "", "/oauth/callback"), + })); + }, + + oauth2Callback(req, res, next) { + const code = req.query.code; + +//construct axios body + const params = new URLSearchParams() + params.append('grant_type', "authorization_code") + params.append('client_id', process.env.OAUTH_CLIENT_ID) + params.append('client_secret', process.env.OAUTH_CLIENT_SECRET) + params.append("code", code) + params.append('redirect_uri', Path.join( + new URL(process.env.OAUTH_AUTH_URL).host === + new URL(process.env.SHARELATEX_SITE_URL).host + ? "" + : process.env.SHARELATEX_SITE_URL, + "", "/oauth/callback")) + + + json_body = { + "grant_type": "authorization_code", + client_id: process.env.OAUTH_CLIENT_ID, + client_secret: process.env.OAUTH_CLIENT_SECRET, + "code": code, + redirect_uri: Path.join( + new URL(process.env.OAUTH_AUTH_URL).host === + new URL(process.env.SHARELATEX_SITE_URL).host + ? "" + : process.env.SHARELATEX_SITE_URL, + "", "/oauth/callback"), + } + + axios.post(process.env.OAUTH_ACCESS_URL, params, { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + + } + }).then(access_res => { + + // console.log("respondis " + JSON.stringify(access_res.data)) + // console.log("authorization_bearer_is " + authorization_bearer) + authorization_bearer = "Bearer " + JSON.stringify(access_res.data.access_token).replace(/\"/g, "") + + let axios_get_config = { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": authorization_bearer, + }, + params: access_res.data + } + + axios.get(process.env.OAUTH_USER_URL, axios_get_config).then(info_res => { + console.log("oauth_user: ", JSON.stringify(info_res.data)); + if (info_res.data.err) { + res.json({message: info_res.data.err}); + } else { + AuthenticationManager.createUserIfNotExist(info_res.data, (error, user) => { + if (error) { + res.json({message: error}); + } else { + // console.log("real_user: ", user); + AuthenticationController.finishLogin(user, req, res, next); + } + }); + } + }); + }); + }, + + + requireOauth() { + // require this here because module may not be included in some versions + const Oauth2Server = require('../../../../modules/oauth2-server/app/src/Oauth2Server') + return function (req, res, next) { + if (next == null) { + next = function () {} + } + const request = new Oauth2Server.Request(req) + const response = new Oauth2Server.Response(res) + return Oauth2Server.server.authenticate( + request, + response, + {}, + function (err, token) { + if (err) { + // use a 401 status code for malformed header for git-bridge + if ( + err.code === 400 && + err.message === 'Invalid request: malformed authorization header' + ) { + err.code = 401 + } + // send all other errors + return res + .status(err.code) + .json({ error: err.name, error_description: err.message }) + } + req.oauth = { access_token: token.accessToken } + req.oauth_token = token + req.oauth_user = token.user + return next() + } + ) + } + }, + + validateUserSession: function () { + // Middleware to check that the user's session is still good on key actions, + // such as opening a a project. Could be used to check that session has not + // exceeded a maximum lifetime (req.session.session_created), or for session + // hijacking checks (e.g. change of ip address, req.session.ip_address). For + // now, just check that the session has been loaded from the session store + // correctly. + return function (req, res, next) { + // check that the session store is returning valid results + if (req.session && !SessionStoreManager.hasValidationToken(req)) { + // force user to update session + req.session.regenerate(() => { + // need to destroy the existing session and generate a new one + // otherwise they will already be logged in when they are redirected + // to the login page + if (acceptsJson(req)) return send401WithChallenge(res) + AuthenticationController._redirectToLoginOrRegisterPage(req, res) + }) + } else { + next() + } + } + }, + + _globalLoginWhitelist: [], + addEndpointToLoginWhitelist(endpoint) { + return AuthenticationController._globalLoginWhitelist.push(endpoint) + }, + + requireGlobalLogin(req, res, next) { + if ( + AuthenticationController._globalLoginWhitelist.includes( + req._parsedUrl.pathname + ) + ) { + return next() + } + + if (req.headers.authorization != null) { + AuthenticationController.requirePrivateApiAuth()(req, res, next) + } else if (SessionManager.isUserLoggedIn(req.session)) { + next() + } else { + logger.debug( + { url: req.url }, + 'user trying to access endpoint not in global whitelist' + ) + if (acceptsJson(req)) return send401WithChallenge(res) + AuthenticationController.setRedirectInSession(req) + res.redirect('/login') + } + }, + + validateAdmin(req, res, next) { + const adminDomains = Settings.adminDomains + if ( + !adminDomains || + !(Array.isArray(adminDomains) && adminDomains.length) + ) { + return next() + } + const user = SessionManager.getSessionUser(req.session) + if (!hasAdminAccess(user)) { + return next() + } + const email = user.email + if (email == null) { + return next( + new OError('[ValidateAdmin] Admin user without email address', { + userId: user._id, + }) + ) + } + if (!adminDomains.find(domain => email.endsWith(`@${domain}`))) { + return next( + new OError('[ValidateAdmin] Admin user with invalid email domain', { + email, + userId: user._id, + }) + ) + } + return next() + }, + + requireBasicAuth: function (userDetails) { + const userDetailsMap = new Map(Object.entries(userDetails)) + return function (req, res, next) { + const credentials = basicAuth(req) + if ( + !credentials || + !checkCredentials(userDetailsMap, credentials.name, credentials.pass) + ) { + send401WithChallenge(res) + Metrics.inc('security.http-auth', 1, { status: 'reject' }) + } else { + Metrics.inc('security.http-auth', 1, { status: 'accept' }) + next() + } + } + }, + + requirePrivateApiAuth() { + return AuthenticationController.requireBasicAuth(Settings.httpAuthUsers) + }, + + setAuditInfo(req, info) { + if (!req.__authAuditInfo) { + req.__authAuditInfo = {} + } + Object.assign(req.__authAuditInfo, info) + }, + + getAuditInfo(req) { + return req.__authAuditInfo || {} + }, + + setRedirectInSession(req, value) { + if (value == null) { + value = + Object.keys(req.query).length > 0 + ? `${req.path}?${querystring.stringify(req.query)}` + : `${req.path}` + } + if ( + req.session != null && + !/^\/(socket.io|js|stylesheets|img)\/.*$/.test(value) && + !/^.*\.(png|jpeg|svg)$/.test(value) + ) { + const safePath = UrlHelper.getSafeRedirectPath(value) + return (req.session.postLoginRedirect = safePath) + } + }, + + _redirectToLoginOrRegisterPage(req, res) { + if ( + req.query.zipUrl != null || + req.query.project_name != null || + req.path === '/user/subscription/new' + ) { + AuthenticationController._redirectToRegisterPage(req, res) + } else { + AuthenticationController._redirectToLoginPage(req, res) + } + }, + + _redirectToLoginPage(req, res) { + logger.debug( + { url: req.url }, + 'user not logged in so redirecting to login page' + ) + AuthenticationController.setRedirectInSession(req) + const url = `/login?${querystring.stringify(req.query)}` + res.redirect(url) + Metrics.inc('security.login-redirect') + }, + + _redirectToReconfirmPage(req, res, user) { + logger.debug( + { url: req.url }, + 'user needs to reconfirm so redirecting to reconfirm page' + ) + req.session.reconfirm_email = user != null ? user.email : undefined + const redir = '/user/reconfirm' + AsyncFormHelper.redirect(req, res, redir) + }, + + _redirectToRegisterPage(req, res) { + logger.debug( + { url: req.url }, + 'user not logged in so redirecting to register page' + ) + AuthenticationController.setRedirectInSession(req) + const url = `/register?${querystring.stringify(req.query)}` + res.redirect(url) + Metrics.inc('security.login-redirect') + }, + + _recordSuccessfulLogin(userId, callback) { + if (callback == null) { + callback = function () {} + } + UserUpdater.updateUser( + userId.toString(), + { + $set: { lastLoggedIn: new Date() }, + $inc: { loginCount: 1 }, + }, + function (error) { + if (error != null) { + callback(error) + } + Metrics.inc('user.login.success') + callback() + } + ) + }, + + _recordFailedLogin(callback) { + Metrics.inc('user.login.failed') + if (callback) callback() + }, + + _getRedirectFromSession(req) { + let safePath + const value = _.get(req, ['session', 'postLoginRedirect']) + if (value) { + safePath = UrlHelper.getSafeRedirectPath(value) + } + return safePath || null + }, + + _clearRedirectFromSession(req) { + if (req.session != null) { + delete req.session.postLoginRedirect + } + }, +} + +function _afterLoginSessionSetup(req, user, callback) { + if (callback == null) { + callback = function () {} + } + req.login(user, function (err) { + if (err) { + OError.tag(err, 'error from req.login', { + user_id: user._id, + }) + return callback(err) + } + // Regenerate the session to get a new sessionID (cookie value) to + // protect against session fixation attacks + const oldSession = req.session + req.session.destroy(function (err) { + if (err) { + OError.tag(err, 'error when trying to destroy old session', { + user_id: user._id, + }) + return callback(err) + } + req.sessionStore.generate(req) + // Note: the validation token is not writable, so it does not get + // transferred to the new session below. + for (const key in oldSession) { + const value = oldSession[key] + if (key !== '__tmp' && key !== 'csrfSecret') { + req.session[key] = value + } + } + req.session.save(function (err) { + if (err) { + OError.tag(err, 'error saving regenerated session after login', { + user_id: user._id, + }) + return callback(err) + } + UserSessionsManager.trackSession(user, req.sessionID, function () {}) + if (!req.deviceHistory) { + // Captcha disabled or SSO-based login. + return callback() + } + req.deviceHistory.add(user.email) + req.deviceHistory + .serialize(req.res) + .catch(err => { + logger.err({ err }, 'cannot serialize deviceHistory') + }) + .finally(() => callback()) + }) + }) + }) +} + +function _loginAsyncHandlers(req, user, anonymousAnalyticsId, isNewUser) { + UserHandler.setupLoginData(user, err => { + if (err != null) { + logger.warn({ err }, 'error setting up login data') + } + }) + LoginRateLimiter.recordSuccessfulLogin(user.email, () => {}) + AuthenticationController._recordSuccessfulLogin(user._id, () => {}) + AuthenticationController.ipMatchCheck(req, user) + Analytics.recordEventForUser(user._id, 'user-logged-in', { + source: req.session.saml + ? 'saml' + : req.user_info?.auth_provider || 'email-password', + }) + Analytics.identifyUser(user._id, anonymousAnalyticsId, isNewUser) + + logger.debug( + { email: user.email, user_id: user._id.toString() }, + 'successful log in' + ) + + req.session.justLoggedIn = true + // capture the request ip for use when creating the session + return (user._login_req_ip = req.ip) +} + +module.exports = AuthenticationController diff --git a/ldap-overleaf-sl/sharelatex/AuthenticationManager.js b/ldap-overleaf-sl/sharelatex/AuthenticationManager.js new file mode 100644 index 0000000..64de6c1 --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/AuthenticationManager.js @@ -0,0 +1,474 @@ +const Settings = require('@overleaf/settings') +const { User } = require('../../models/User') +const { db, ObjectId } = require('../../infrastructure/mongodb') +const bcrypt = require('bcrypt') +const EmailHelper = require('../Helpers/EmailHelper') +const { + InvalidEmailError, + InvalidPasswordError, + ParallelLoginError, +} = require('./AuthenticationErrors') +const util = require('util') +const { Client } = require('ldapts'); +const ldapEscape = require('ldap-escape'); +const HaveIBeenPwned = require('./HaveIBeenPwned') + +const BCRYPT_ROUNDS = Settings.security.bcryptRounds || 12 +const BCRYPT_MINOR_VERSION = Settings.security.bcryptMinorVersion || 'a' + +const _checkWriteResult = function (result, callback) { + // for MongoDB + if (result && result.modifiedCount === 1) { + callback(null, true) + } else { + callback(null, false) + } +} + +const AuthenticationManager = { + authenticate(query, password, callback) { + // Using Mongoose for legacy reasons here. The returned User instance + // gets serialized into the session and there may be subtle differences + // between the user returned by Mongoose vs mongodb (such as default values) + User.findOne(query, (error, user) => { + if (error) { + return callback(error) + } + if (!user || !user.hashedPassword) { + return callback(null, null) + } + bcrypt.compare(password, user.hashedPassword, function (error, match) { + if (error) { + return callback(error) + } + const update = { $inc: { loginEpoch: 1 } } + if (!match) { + update.$set = { lastFailedLogin: new Date() } + } + User.updateOne( + { _id: user._id, loginEpoch: user.loginEpoch }, + update, + {}, + (err, result) => { + if (err) { + return callback(err) + } + if (result.nModified !== 1) { + return callback(new ParallelLoginError()) + } + if (!match) { + return callback(null, null) + } + AuthenticationManager.checkRounds( + user, + user.hashedPassword, + password, + function (err) { + if (err) { + return callback(err) + } + callback(null, user) + HaveIBeenPwned.checkPasswordForReuseInBackground(password) + } + ) + } + ) + }) + }) + }, + + //oauth2 + createUserIfNotExist(oauth_user, callback) { + const query = { + //name: ZHANG San + email: oauth_user.email, + first_name: oauth_user.given_name, //San + last_name: oauth_user.family_name //Zhang + }; + User.findOne(query, (error, user) => { + if ((!user || !user.hashedPassword)) { + //create random pass for local userdb, does not get checked for ldap users during login + let pass = require("crypto").randomBytes(32).toString("hex") + const userRegHand = require('../User/UserRegistrationHandler.js') + userRegHand.registerNewUser({ + email: query.email, + first_name: query.first_name, + last_name: query.last_name, + password: pass + }, + function (error, user) { + if (error) { + return callback(error, null); + } + user.admin = false + user.emails[0].confirmedAt = Date.now() + user.save() + console.log("user %s added to local library", query.email) + User.findOne(query, (error, user) => { + if (error) { + return callback(error, null); + } + if (user && user.hashedPassword) { + return callback(null, user); + } else { + return callback("Unknown error", null); + } + } + ) + }) + } else { + return callback(null, user); + } + }); + }, + + //LDAP + createIfNotExistAndLogin(query, user, callback, uid, firstname, lastname, mail, isAdmin) { + if (!user) { + //console.log("Creating User:" + JSON.stringify(query)) + //create random pass for local userdb, does not get checked for ldap users during login + let pass = require("crypto").randomBytes(32).toString("hex") + //console.log("Creating User:" + JSON.stringify(query) + "Random Pass" + pass) + + const userRegHand = require('../User/UserRegistrationHandler.js') + userRegHand.registerNewUser({ + email: mail, + first_name: firstname, + last_name: lastname, + password: pass + }, + function (error, user) { + if (error) { + console.log(error) + } + user.email = mail + user.isAdmin = isAdmin + user.emails[0].confirmedAt = Date.now() + user.save() + //console.log("user %s added to local library: ", mail) + User.findOne(query, (error, user) => { + if (error) { + console.log(error) + } + if (user && user.hashedPassword) { + AuthenticationManager.login(user, "randomPass", callback) + } + }) + }) // end register user + } else { + AuthenticationManager.login(user, "randomPass", callback) + } + }, + + authUserObj(error, user, query, password, callback) { + if ( process.env.ALLOW_EMAIL_LOGIN && user && user.hashedPassword) { + console.log("email login for existing user " + query.email) + // check passwd against local db + bcrypt.compare(password, user.hashedPassword, function (error, match) { + if (match) { + console.log("Local user password match") + AuthenticationManager.login(user, password, callback) + } else { + console.log("Local user password mismatch, trying LDAP") + // check passwd against ldap + AuthenticationManager.ldapAuth(query, password, AuthenticationManager.createIfNotExistAndLogin, callback, user) + } + }) + } else { + // No local passwd check user has to be in ldap and use ldap credentials + AuthenticationManager.ldapAuth(query, password, AuthenticationManager.createIfNotExistAndLogin, callback, user) + } + return null + }, + + validateEmail(email) { + // we use the emailadress from the ldap + // therefore we do not enforce checks here + const parsed = EmailHelper.parseEmail(email) + //if (!parsed) { + // return new InvalidEmailError({ message: 'email not valid' }) + //} + return null + }, + + // validates a password based on a similar set of rules to `complexPassword.js` on the frontend + // note that `passfield.js` enforces more rules than this, but these are the most commonly set. + // returns null on success, or an error object. + validatePassword(password, email) { + if (password == null) { + return new InvalidPasswordError({ + message: 'password not set', + info: { code: 'not_set' }, + }) + } + + let allowAnyChars, min, max + if (Settings.passwordStrengthOptions) { + allowAnyChars = Settings.passwordStrengthOptions.allowAnyChars === true + if (Settings.passwordStrengthOptions.length) { + min = Settings.passwordStrengthOptions.length.min + max = Settings.passwordStrengthOptions.length.max + } + } + allowAnyChars = !!allowAnyChars + min = min || 6 + max = max || 72 + + // we don't support passwords > 72 characters in length, because bcrypt truncates them + if (max > 72) { + max = 72 + } + + if (password.length < min) { + return new InvalidPasswordError({ + message: 'password is too short', + info: { code: 'too_short' }, + }) + } + if (password.length > max) { + return new InvalidPasswordError({ + message: 'password is too long', + info: { code: 'too_long' }, + }) + } + if ( + !allowAnyChars && + !AuthenticationManager._passwordCharactersAreValid(password) + ) { + return new InvalidPasswordError({ + message: 'password contains an invalid character', + info: { code: 'invalid_character' }, + }) + } + if (typeof email === 'string' && email !== '') { + const startOfEmail = email.split('@')[0] + if ( + password.indexOf(email) !== -1 || + password.indexOf(startOfEmail) !== -1 + ) { + return new InvalidPasswordError({ + message: 'password contains part of email address', + info: { code: 'contains_email' }, + }) + } + } + return null + }, + + setUserPassword(user, password, callback) { + AuthenticationManager.setUserPasswordInV2(user, password, callback) + }, + + checkRounds(user, hashedPassword, password, callback) { + // Temporarily disable this function, TODO: re-enable this + if (Settings.security.disableBcryptRoundsUpgrades) { + return callback() + } + // check current number of rounds and rehash if necessary + const currentRounds = bcrypt.getRounds(hashedPassword) + if (currentRounds < BCRYPT_ROUNDS) { + AuthenticationManager.setUserPassword(user, password, callback) + } else { + callback() + } + }, + + hashPassword(password, callback) { + bcrypt.genSalt(BCRYPT_ROUNDS, BCRYPT_MINOR_VERSION, function (error, salt) { + if (error) { + return callback(error) + } + bcrypt.hash(password, salt, callback) + }) + }, + + setUserPasswordInV2(user, password, callback) { + if (!user || !user.email || !user._id) { + return callback(new Error('invalid user object')) + } + const validationError = this.validatePassword(password, user.email) + if (validationError) { + return callback(validationError) + } + this.hashPassword(password, function (error, hash) { + if (error) { + return callback(error) + } + db.users.updateOne( + { + _id: ObjectId(user._id.toString()), + }, + { + $set: { + hashedPassword: hash, + }, + $unset: { + password: true, + }, + }, + function (updateError, result) { + if (updateError) { + return callback(updateError) + } + _checkWriteResult(result, callback) + HaveIBeenPwned.checkPasswordForReuseInBackground(password) + } + ) + }) + }, + + _passwordCharactersAreValid(password) { + let digits, letters, lettersUp, symbols + if ( + Settings.passwordStrengthOptions && + Settings.passwordStrengthOptions.chars + ) { + digits = Settings.passwordStrengthOptions.chars.digits + letters = Settings.passwordStrengthOptions.chars.letters + lettersUp = Settings.passwordStrengthOptions.chars.letters_up + symbols = Settings.passwordStrengthOptions.chars.symbols + } + digits = digits || '1234567890' + letters = letters || 'abcdefghijklmnopqrstuvwxyz' + lettersUp = lettersUp || 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + symbols = symbols || '@#$%^&*()-_=+[]{};:<>/?!£€.,' + + for (let charIndex = 0; charIndex <= password.length - 1; charIndex++) { + if ( + digits.indexOf(password[charIndex]) === -1 && + letters.indexOf(password[charIndex]) === -1 && + lettersUp.indexOf(password[charIndex]) === -1 && + symbols.indexOf(password[charIndex]) === -1 + ) { + return false + } + } + return true + }, + + + async ldapAuth(query, password, onSuccessCreateUserIfNotExistent, callback, user) { + const client = new Client({ + url: process.env.LDAP_SERVER, + }); + + const ldap_reader = process.env.LDAP_BIND_USER + const ldap_reader_pass = process.env.LDAP_BIND_PW + const ldap_base = process.env.LDAP_BASE + + var mail = query.email + var uid = query.email.split('@')[0] + var firstname = "" + var lastname = "" + var isAdmin = false + var userDn = "" + + //replace all appearences of %u with uid and all %m with mail: + const replacerUid = new RegExp("%u", "g") + const replacerMail = new RegExp("%m","g") + const filterstr = process.env.LDAP_USER_FILTER.replace(replacerUid, ldapEscape.filter`${uid}`).replace(replacerMail, ldapEscape.filter`${mail}`) //replace all appearances + // check bind + try { + if(process.env.LDAP_BINDDN){ //try to bind directly with the user trying to log in + userDn = process.env.LDAP_BINDDN.replace(replacerUid,ldapEscape.filter`${uid}`).replace(replacerMail, ldapEscape.filter`${mail}`); + await client.bind(userDn,password); + }else{// use fixed bind user + await client.bind(ldap_reader, ldap_reader_pass); + } + } catch (ex) { + if(process.env.LDAP_BINDDN){ + console.log("Could not bind user: " + userDn); + }else{ + console.log("Could not bind LDAP reader: " + ldap_reader + " err: " + String(ex)) + } + return callback(null, null) + } + + // get user data + try { + const {searchEntries, searchRef,} = await client.search(ldap_base, { + scope: 'sub', + filter: filterstr , + }); + await searchEntries + console.log(JSON.stringify(searchEntries)) + if (searchEntries[0]) { + mail = searchEntries[0].mail + uid = searchEntries[0].uid + firstname = searchEntries[0].givenName + lastname = searchEntries[0].sn + if(!process.env.LDAP_BINDDN){ //dn is already correctly assembled + userDn = searchEntries[0].dn + } + console.log("Found user: " + mail + " Name: " + firstname + " " + lastname + " DN: " + userDn) + } + } catch (ex) { + console.log("An Error occured while getting user data during ldapsearch: " + String(ex)) + await client.unbind(); + return callback(null, null) + } + + try { + // if admin filter is set - only set admin for user in ldap group + // does not matter - admin is deactivated: managed through ldap + if (process.env.LDAP_ADMIN_GROUP_FILTER) { + const adminfilter = process.env.LDAP_ADMIN_GROUP_FILTER.replace(replacerUid, ldapEscape.filter`${uid}`).replace(replacerMail, ldapEscape.filter`${mail}`) + adminEntry = await client.search(ldap_base, { + scope: 'sub', + filter: adminfilter, + }); + await adminEntry; + //console.log("Admin Search response:" + JSON.stringify(adminEntry.searchEntries)) + if (adminEntry.searchEntries[0]) { + console.log("is Admin") + isAdmin=true; + } + } + } catch (ex) { + console.log("An Error occured while checking for admin rights - setting admin rights to false: " + String(ex)) + isAdmin = false; + } finally { + await client.unbind(); + } + if (mail == "" || userDn == "") { + console.log("Mail / userDn not set - exit. This should not happen - please set mail-entry in ldap.") + return callback(null, null) + } + + if(!process.env.BINDDN){//since we used a fixed bind user to obtain the correct userDn we need to bind again to authenticate + try { + await client.bind(userDn, password); + } catch (ex) { + console.log("Could not bind User: " + userDn + " err: " + String(ex)) + return callback(null, null) + } finally{ + await client.unbind() + } + } + //console.log("Logging in user: " + mail + " Name: " + firstname + " " + lastname + " isAdmin: " + String(isAdmin)) + // we are authenticated now let's set the query to the correct mail from ldap + query.email = mail + User.findOne(query, (error, user) => { + if (error) { + console.log(error) + } + if (user && user.hashedPassword) { + //console.log("******************** LOGIN ******************") + AuthenticationManager.login(user, "randomPass", callback) + } else { + onSuccessCreateUserIfNotExistent(query, user, callback, uid, firstname, lastname, mail, isAdmin) + } + }) + } + + + +} + +AuthenticationManager.promises = { + authenticate: util.promisify(AuthenticationManager.authenticate), + hashPassword: util.promisify(AuthenticationManager.hashPassword), + setUserPassword: util.promisify(AuthenticationManager.setUserPassword), +} + +module.exports = AuthenticationManager diff --git a/ldap-overleaf-sl/sharelatex/ContactController.js b/ldap-overleaf-sl/sharelatex/ContactController.js new file mode 100644 index 0000000..4146982 --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/ContactController.js @@ -0,0 +1,140 @@ +/* eslint-disable + camelcase, + max-len, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let ContactsController +const AuthenticationController = require('../Authentication/AuthenticationController') +const SessionManager = require('../Authentication/SessionManager') +const ContactManager = require('./ContactManager') +const UserGetter = require('../User/UserGetter') +const logger = require('logger-sharelatex') +const Modules = require('../../infrastructure/Modules') +const { Client } = require('ldapts'); + +module.exports = ContactsController = { + getContacts(req, res, next) { + // const user_id = AuthenticationController.getLoggedInUserId(req) + const user_id = SessionManager.getLoggedInUserId(req.session) + return ContactManager.getContactIds( + user_id, + { limit: 50 }, + function (error, contact_ids) { + if (error != null) { + return next(error) + } + return UserGetter.getUsers( + contact_ids, + { + email: 1, + first_name: 1, + last_name: 1, + holdingAccount: 1, + }, + function (error, contacts) { + if (error != null) { + return next(error) + } + + // UserGetter.getUsers may not preserve order so put them back in order + const positions = {} + for (let i = 0; i < contact_ids.length; i++) { + const contact_id = contact_ids[i] + positions[contact_id] = i + } + + contacts.sort( + (a, b) => + positions[a._id != null ? a._id.toString() : undefined] - + positions[b._id != null ? b._id.toString() : undefined] + ) + + // Don't count holding accounts to discourage users from repeating mistakes (mistyped or wrong emails, etc) + contacts = contacts.filter(c => !c.holdingAccount) + ContactsController.getLdapContacts(contacts).then((ldapcontacts) => { + contacts.push(ldapcontacts) + contacts = contacts.map(ContactsController._formatContact) + return Modules.hooks.fire('getContacts', user_id, contacts, function( + error, + additional_contacts + ) { + if (error != null) { + return next(error) + } + contacts = contacts.concat(...Array.from(additional_contacts || [])) + return res.send({ + contacts + }) + }) + }).catch(e => console.log("Error appending ldap contacts" + e)) + + } + ) + }) + }, + async getLdapContacts(contacts) { + if (process.env.LDAP_CONTACTS === undefined || !(process.env.LDAP_CONTACTS.toLowerCase() === 'true')) { + return contacts + } + const client = new Client({ + url: process.env.LDAP_SERVER, + }); + + // if we need a ldap user try to bind + if (process.env.LDAP_BIND_USER) { + try { + await client.bind(process.env.LDAP_BIND_USER, process.env.LDAP_BIND_PW); + } catch (ex) { + console.log("Could not bind LDAP reader user: " + String(ex) ) + } + } + + const ldap_base = process.env.LDAP_BASE + // get user data + try { + // if you need an client.bind do it here. + const {searchEntries,searchReferences,} = await client.search(ldap_base, {scope: 'sub',filter: process.env.LDAP_CONTACT_FILTER ,}); + await searchEntries; + for (var i = 0; i < searchEntries.length; i++) { + var entry = new Map() + var obj = searchEntries[i]; + entry['_id'] = undefined + entry['email'] = obj['mail'] + entry['first_name'] = obj['givenName'] + entry['last_name'] = obj['sn'] + entry['type'] = "user" + // Only add to contacts if entry is not there. + if(contacts.indexOf(entry) === -1) { + contacts.push(entry); + } + } + } catch (ex) { + console.log(String(ex)) + } + //console.log(JSON.stringify(contacts)) + finally { + // even if we did not use bind - the constructor of + // new Client() opens a socket to the ldap server + client.unbind() + return contacts + } + }, + _formatContact(contact) { + return { + id: contact._id != null ? contact._id.toString() : undefined, + email: contact.email || '', + first_name: contact.first_name || '', + last_name: contact.last_name || '', + type: 'user', + } + }, +} diff --git a/ldap-overleaf-sl/sharelatex/admin-index.pug b/ldap-overleaf-sl/sharelatex/admin-index.pug new file mode 100644 index 0000000..88e264b --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/admin-index.pug @@ -0,0 +1,57 @@ +extends ../layout + +block content + .content.content-alt + .container + .row + .col-xs-12 + .card(ng-controller="RegisterUsersController") + .page-header + h1 Admin Panel + tabset(ng-cloak) + tab(heading="System Messages") + each message in systemMessages + .alert.alert-info.row-spaced(ng-non-bindable) #{message.content} + hr + form(method='post', action='/admin/messages') + input(name="_csrf", type="hidden", value=csrfToken) + .form-group + label(for="content") + input.form-control(name="content", type="text", placeholder="Message...", required) + button.btn.btn-primary(type="submit") Post Message + hr + form(method='post', action='/admin/messages/clear') + input(name="_csrf", type="hidden", value=csrfToken) + button.btn.btn-danger(type="submit") Clear all messages + + + tab(heading="Register non LDAP User") + form.form + .row + .col-md-4.col-xs-8 + input.form-control( + name="email", + type="text", + placeholder="jane@example.com, joe@example.com", + ng-model="inputs.emails", + on-enter="registerUsers()" + ) + .col-md-8.col-xs-4 + button.btn.btn-primary(ng-click="registerUsers()") #{translate("register")} + + .row-spaced(ng-show="error").ng-cloak.text-danger + p Sorry, an error occured + + .row-spaced(ng-show="users.length > 0").ng-cloak.text-success + p We've sent out welcome emails to the registered users. + p You can also manually send them URLs below to allow them to reset their password and log in for the first time. + p (Password reset tokens will expire after one week and the user will need registering again). + + hr(ng-show="users.length > 0").ng-cloak + table(ng-show="users.length > 0").table.table-striped.ng-cloak + tr + th #{translate("email")} + th Set Password Url + tr(ng-repeat="user in users") + td {{ user.email }} + td(style="word-break: break-all;") {{ user.setNewPasswordUrl }} diff --git a/ldap-overleaf-sl/sharelatex/admin-sysadmin.pug b/ldap-overleaf-sl/sharelatex/admin-sysadmin.pug new file mode 100644 index 0000000..c7131a3 --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/admin-sysadmin.pug @@ -0,0 +1,79 @@ +extends ../layout + +block content + .content.content-alt + .container + .row + .col-xs-12 + .card(ng-controller="RegisterUsersController") + .page-header + h1 Admin Panel + tabset(ng-cloak) + tab(heading="System Messages") + each message in systemMessages + .alert.alert-info.row-spaced(ng-non-bindable) #{message.content} + hr + form(method='post', action='/admin/messages') + input(name="_csrf", type="hidden", value=csrfToken) + .form-group + label(for="content") + input.form-control(name="content", type="text", placeholder="Message...", required) + button.btn.btn-primary(type="submit") Post Message + hr + form(method='post', action='/admin/messages/clear') + input(name="_csrf", type="hidden", value=csrfToken) + button.btn.btn-danger(type="submit") Clear all messages + + + tab(heading="Register non LDAP User") + form.form + .row + .col-md-4.col-xs-8 + input.form-control( + name="email", + type="text", + placeholder="jane@example.com, joe@example.com", + ng-model="inputs.emails", + on-enter="registerUsers()" + ) + .col-md-8.col-xs-4 + button.btn.btn-primary(ng-click="registerUsers()") #{translate("register")} + + .row-spaced(ng-show="error").ng-cloak.text-danger + p Sorry, an error occured + + .row-spaced(ng-show="users.length > 0").ng-cloak.text-success + p We've sent out welcome emails to the registered users. + p You can also manually send them URLs below to allow them to reset their password and log in for the first time. + p (Password reset tokens will expire after one week and the user will need registering again). + + hr(ng-show="users.length > 0").ng-cloak + table(ng-show="users.length > 0").table.table-striped.ng-cloak + tr + th #{translate("email")} + th Set Password Url + tr(ng-repeat="user in users") + td {{ user.email }} + td(style="word-break: break-all;") {{ user.setNewPasswordUrl }} + tab(heading="Open/Close Editor" bookmarkable-tab="open-close-editor") + if hasFeature('saas') + | The "Open/Close Editor" feature is not available in SAAS. + else + .row-spaced + form(method='post',action='/admin/closeEditor') + input(name="_csrf", type="hidden", value=csrfToken) + button.btn.btn-danger(type="submit") Close Editor + p.small Will stop anyone opening the editor. Will NOT disconnect already connected users. + + .row-spaced + form(method='post',action='/admin/disconnectAllUsers') + input(name="_csrf", type="hidden", value=csrfToken) + button.btn.btn-danger(type="submit") Disconnect all users + p.small Will force disconnect all users with the editor open. Make sure to close the editor first to avoid them reconnecting. + + .row-spaced + form(method='post',action='/admin/openEditor') + input(name="_csrf", type="hidden", value=csrfToken) + button.btn.btn-danger(type="submit") Reopen Editor + p.small Will reopen the editor after closing. + diff --git a/ldap-overleaf-sl/sharelatex/login.pug b/ldap-overleaf-sl/sharelatex/login.pug new file mode 100644 index 0000000..7af4bae --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/login.pug @@ -0,0 +1,51 @@ +extends ../layout + +block vars + - metadata = { viewport: true } + +block content + main.content.content-alt + .container + .row + .col-md-6.col-md-offset-3.col-lg-4.col-lg-offset-4 + .card + .page-header + h1 #{translate("log_in")} + form(async-form="login", name="loginForm", action='/login', method="POST", ng-cloak) + input(name='_csrf', type='hidden', value=csrfToken) + form-messages(for="loginForm") + .form-group + input.form-control( + type='email', + name='email', + required, + placeholder='email@example.com', + ng-model="email", + ng-model-options="{ updateOn: 'blur' }", + focus="true" + ) + span.small.text-primary(ng-show="loginForm.email.$invalid && loginForm.email.$dirty") + | #{translate("must_be_email_address")} + .form-group + input.form-control( + type='password', + name='password', + required, + placeholder='********', + ng-model="password" + ) + span.small.text-primary(ng-show="loginForm.password.$invalid && loginForm.password.$dirty") + | #{translate("required")} + .actions + button.btn-primary.btn.btn-block( + type='submit', + ng-disabled="loginForm.inflight" + ) + span(ng-show="!loginForm.inflight") #{translate("login_with_email")} + span(ng-show="loginForm.inflight") #{translate("logging_in")}… + .form-group.text-center(style="padding-top: 10px") + a.btn-block.login-btn(href="/oauth/redirect" style='padding-left: 0px') + | Log in via SUSTech CRA SSO / CAS + p + | homepage-notice-html + diff --git a/ldap-overleaf-sl/sharelatex/navbar.pug b/ldap-overleaf-sl/sharelatex/navbar.pug new file mode 100644 index 0000000..f391630 --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/navbar.pug @@ -0,0 +1,84 @@ +nav.navbar.navbar-default.navbar-main + .container-fluid + .navbar-header + button.navbar-toggle(ng-init="navCollapsed = true", ng-click="navCollapsed = !navCollapsed", ng-class="{active: !navCollapsed}", aria-label="Toggle " + translate('navigation')) + i.fa.fa-bars(aria-hidden="true") + if settings.nav.custom_logo + a(href='/', aria-label=settings.appName, style='background-image:url("'+settings.nav.custom_logo+'")').navbar-brand + else if (nav.title) + a(href='/', aria-label=settings.appName, ng-non-bindable).navbar-title #{nav.title} + else + a(href='/', aria-label=settings.appName).navbar-brand + + .navbar-collapse.collapse(collapse="navCollapsed") + + ul.nav.navbar-nav.navbar-right + if (getSessionUser() && getSessionUser().isAdmin) + li + a(href="/admin") Admin + + + // loop over header_extras + each item in nav.header_extras + - + if ((item.only_when_logged_in && getSessionUser()) + || (item.only_when_logged_out && (!getSessionUser())) + || (!item.only_when_logged_out && !item.only_when_logged_in && !item.only_content_pages) + || (item.only_content_pages && (typeof(suppressNavContentLinks) == "undefined" || !suppressNavContentLinks)) + ){ + var showNavItem = true + } else { + var showNavItem = false + } + + if showNavItem + if item.dropdown + li.dropdown(class=item.class, dropdown) + a.dropdown-toggle(href, dropdown-toggle) + | !{translate(item.text)} + b.caret + ul.dropdown-menu + each child in item.dropdown + if child.divider + li.divider + else + li + if child.url + a(href=child.url, class=child.class) !{translate(child.text)} + else + | !{translate(child.text)} + else + li(class=item.class) + if item.url + a(href=item.url, class=item.class) !{translate(item.text)} + else + | !{translate(item.text)} + + // logged out + if !getSessionUser() + // login link + li + a(href="/login") #{translate('log_in')} + + // projects link and account menu + if getSessionUser() + li + a(href="/project") #{translate('Projects')} + li.dropdown(dropdown) + a.dropdown-toggle(href, dropdown-toggle) + | #{translate('Account')} + b.caret + ul.dropdown-menu + //li + // div.subdued(ng-non-bindable) #{getUserEmail()} + //li.divider.hidden-xs.hidden-sm + li + a(href="/user/settings") #{translate('Account Settings')} + if nav.showSubscriptionLink + li + a(href="/user/subscription") #{translate('subscription')} + li.divider.hidden-xs.hidden-sm + li + form(method="POST" action="/logout") + input(name='_csrf', type='hidden', value=csrfToken) + button.btn-link.text-left.dropdown-menu-button #{translate('log_out')} diff --git a/ldap-overleaf-sl/sharelatex/router-append.js b/ldap-overleaf-sl/sharelatex/router-append.js new file mode 100644 index 0000000..869a596 --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/router-append.js @@ -0,0 +1,5 @@ + webRouter.get('/oauth/redirect', AuthenticationController.oauth2Redirect) + webRouter.get('/oauth/callback', AuthenticationController.oauth2Callback) + AuthenticationController.addEndpointToLoginWhitelist('/oauth/redirect') + AuthenticationController.addEndpointToLoginWhitelist('/oauth/callback') +} diff --git a/ldap-overleaf-sl/sharelatex/settings.pug b/ldap-overleaf-sl/sharelatex/settings.pug new file mode 100644 index 0000000..8cdd18c --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/settings.pug @@ -0,0 +1,178 @@ +extends ../layout + +block content + .content.content-alt + .container + .row + .col-md-12.col-lg-10.col-lg-offset-1 + if ssoError + .alert.alert-danger + | #{translate('sso_link_error')}: #{translate(ssoError)} + .card + .page-header + h1 #{translate("account_settings")} + .account-settings(ng-controller="AccountSettingsController", ng-cloak) + + if hasFeature('affiliations') + include settings/user-affiliations + + .row + .col-md-5 + h3 #{translate("update_account_info")} + form(async-form="settings", name="settingsForm", method="POST", action="/user/settings", novalidate) + input(type="hidden", name="_csrf", value=csrfToken) + if !hasFeature('affiliations') + // show the email, non-editable + .form-group + label.control-label #{translate("email")} + div.form-control( + readonly="true", + ng-non-bindable + ) #{user.email} + + if shouldAllowEditingDetails + .form-group + label(for='firstName').control-label #{translate("first_name")} + input.form-control( + id="firstName" + type='text', + name='first_name', + value=user.first_name + ng-non-bindable + ) + .form-group + label(for='lastName').control-label #{translate("last_name")} + input.form-control( + id="lastName" + type='text', + name='last_name', + value=user.last_name + ng-non-bindable + ) + .form-group + form-messages(aria-live="polite" for="settingsForm") + .alert.alert-success(ng-show="settingsForm.response.success") + | #{translate("thanks_settings_updated")} + .actions + button.btn.btn-primary( + type='submit', + ng-disabled="settingsForm.$invalid" + ) #{translate("update")} + else + .form-group + label.control-label #{translate("first_name")} + div.form-control( + readonly="true", + ng-non-bindable + ) #{user.first_name} + .form-group + label.control-label #{translate("last_name")} + div.form-control( + readonly="true", + ng-non-bindable + ) #{user.last_name} + + .col-md-5.col-md-offset-1 + h3 + | Set Password for Email login + p + | Note: you can not change the LDAP password from here. You can set/reset a password for + | your email login: + | #[a(href="/user/password/reset", target='_blank') Reset.] + + | !{moduleIncludes("userSettings", locals)} + hr + + h3 + | Contact + div + | If you need any help, please contact your sysadmins. + + p #{translate("need_to_leave")} + a(href, ng-click="deleteAccount()") #{translate("delete_your_account")} + + + + script(type='text/ng-template', id='deleteAccountModalTemplate') + .modal-header + h3 #{translate("delete_account")} + div.modal-body#delete-account-modal + p !{translate("delete_account_warning_message_3")} + if settings.createV1AccountOnLogin && settings.overleaf + p + strong + | Your Overleaf v2 projects will be deleted if you delete your account. + | If you want to remove any remaining Overleaf v1 projects in your account, + | please first make sure they are imported to Overleaf v2. + + if settings.overleaf && !hasPassword + p + b + | #[a(href="/user/password/reset", target='_blank') #{translate("delete_acct_no_existing_pw")}]. + else + form(novalidate, name="deleteAccountForm") + label #{translate('email')} + input.form-control( + type="text", + autocomplete="off", + placeholder="", + ng-model="state.deleteText", + focus-on="open", + ng-keyup="checkValidation()" + ) + + label #{translate('password')} + input.form-control( + type="password", + autocomplete="off", + placeholder="", + ng-model="state.password", + ng-keyup="checkValidation()" + ) + + div.confirmation-checkbox-wrapper + input( + type="checkbox" + ng-model="state.confirmV1Purge" + ng-change="checkValidation()" + ).pull-left + label(style="display: inline")  I have left, purged or imported my projects on Overleaf v1 (if any)   + + div.confirmation-checkbox-wrapper + input( + type="checkbox" + ng-model="state.confirmSharelatexDelete" + ng-change="checkValidation()" + ).pull-left + label(style="display: inline")  I understand this will delete all projects in my Overleaf v2 account (and ShareLaTeX account, if any) with email address #[em {{ userDefaultEmail }}] + + div(ng-if="state.error") + div.alert.alert-danger(ng-switch="state.error.code") + span(ng-switch-when="InvalidCredentialsError") + | #{translate('email_or_password_wrong_try_again')} + span(ng-switch-when="SubscriptionAdminDeletionError") + | #{translate('subscription_admins_cannot_be_deleted')} + span(ng-switch-when="UserDeletionError") + | #{translate('user_deletion_error')} + span(ng-switch-default) + | #{translate('generic_something_went_wrong')} + if settings.createV1AccountOnLogin && settings.overleaf + div(ng-if="state.error && state.error.code == 'InvalidCredentialsError'") + div.alert.alert-info + | If you can't remember your password, or if you are using Single-Sign-On with another provider + | to sign in (such as Twitter or Google), please + | #[a(href="/user/password/reset", target='_blank') reset your password], + | and try again. + .modal-footer + button.btn.btn-default( + ng-click="cancel()" + ) #{translate("cancel")} + button.btn.btn-danger( + ng-disabled="!state.isValid || state.inflight" + ng-click="delete()" + ) + span(ng-hide="state.inflight") #{translate("delete")} + span(ng-show="state.inflight") #{translate("deleting")}... + + script(type='text/javascript'). + window.passwordStrengthOptions = !{StringHelper.stringifyJsonForScript(settings.passwordStrengthOptions || {})}