diff --git a/controllers/login.js b/controllers/login.js index f86052d7da..ce542cc771 100644 --- a/controllers/login.js +++ b/controllers/login.js @@ -94,7 +94,13 @@ router.post('/login/email', async (req, res) => { password, }; - await authHelper.loginUser(req, res, 'local', payload, redirect, 'Email'); + try { + const loginEmailRedirect = await authHelper.loginUser(req, res, 'local', payload, redirect); + + res.redirect(loginEmailRedirect.redirect); + } catch (ldapEmailError) { + return authHelper.handleLoginError(req, res, ldapEmailError.error, redirect, 'local'); + } }); router.get('/login/email', (req, res) => { @@ -125,7 +131,7 @@ router.post('/login/ldap', async (req, res) => { return authHelper.handleLoginError(req, res, { type: 'BAD_REQUEST', code: 400, - }, redirect); + }, redirect, 'ldap'); } const systemIdAndAliasCombination = system.split('//'); @@ -134,7 +140,7 @@ router.post('/login/ldap', async (req, res) => { return authHelper.handleLoginError(req, res, { type: 'BAD_REQUEST', code: 400, - }, redirect); + }, redirect, 'ldap'); } const systemId = systemIdAndAliasCombination[0]; @@ -146,7 +152,13 @@ router.post('/login/ldap', async (req, res) => { schoolId, }; - await authHelper.loginUser(req, res, 'ldap', payload, redirect, 'LDAP'); + try { + const loginLdapRedirect = await authHelper.loginUser(req, res, 'ldap', payload, redirect); + + res.redirect(loginLdapRedirect.redirect); + } catch (ldapLoginError) { + return authHelper.handleLoginError(req, res, ldapLoginError.error, redirect, 'ldap'); + } }); router.get('/login/ldap', (req, res) => { @@ -167,9 +179,10 @@ router.get('/login/ldap', (req, res) => { const redirectOAuth2Authentication = async (req, res, systemId, migration, redirect) => { let system; try { - system = await api(req, { version: 'v3' }).get(`/systems/public/${systemId}`); + system = await api(req, { version: 'v3' }) + .get(`/systems/public/${systemId}`); } catch (error) { - return authHelper.handleLoginError(req, res, error.error, redirect); + return authHelper.handleLoginError(req, res, error.error, redirect, 'oauth2'); } const { oauthConfig } = system; @@ -178,7 +191,7 @@ const redirectOAuth2Authentication = async (req, res, systemId, migration, redir return authHelper.handleLoginError(req, res, { type: 'UNPROCESSABLE_ENTITY', code: 422, - }, redirect); + }, redirect, 'oauth2'); } const state = shortid.generate(); @@ -191,6 +204,8 @@ const redirectOAuth2Authentication = async (req, res, systemId, migration, redir systemName: system.displayName, postLoginRedirect: redirect, migration, + logoutEndpoint: oauthConfig.logoutEndpoint, + provider: oauthConfig.provider, }; res.redirect(authenticationUrl.toString()); @@ -218,23 +233,26 @@ router.get('/login/oauth2/:systemId', async (req, res) => { // eslint-disable-next-line consistent-return router.get('/login/oauth2-callback', async (req, res) => { - const { code, error } = req.query; + const { + code, + error, + } = req.query; const { oauth2State } = req.session; if (!oauth2State || !oauth2State.systemId) { return authHelper.handleLoginError(req, res, { type: 'UNAUTHORIZED', code: 401, - }); + }, undefined, 'oauth2'); } - const redirect = oauth2State.postLoginRedirect; + const { postLoginRedirect } = oauth2State; if (error) { return authHelper.handleLoginError(req, res, { type: error.toUpperCase(), code: 401, - }, redirect); + }, postLoginRedirect, 'oauth2', oauth2State.systemName, oauth2State.provider); } const payload = { @@ -243,13 +261,48 @@ router.get('/login/oauth2-callback', async (req, res) => { redirectUri: authHelper.oauth2RedirectUri, }; + let loginResponse; if (oauth2State.migration && await authHelper.isAuthenticated(req)) { - await authHelper.migrateUser(req, res, payload); - } else { - await authHelper.loginUser(req, res, 'oauth2', payload, redirect, oauth2State.systemName); + const migrationRedirect = await authHelper.migrateUser(req, res, payload); + delete req.session.oauth2State; + + return res.redirect(migrationRedirect); + } + + try { + loginResponse = await authHelper.loginUser( + req, + res, + 'oauth2', + payload, + postLoginRedirect, + ); + } catch (loginError) { + return authHelper.handleLoginError( + req, + res, + loginError, + postLoginRedirect, + 'oauth2', + oauth2State.systemName, + oauth2State.provider, + ); + } + + let loginRedirect = loginResponse.redirect; + if (oauth2State.logoutEndpoint && loginResponse.login?.externalIdToken) { + loginRedirect = authHelper.getLogoutUrl( + req, + res, + oauth2State.logoutEndpoint, + loginResponse.login.externalIdToken, + loginRedirect, + ); } delete req.session.oauth2State; + + res.redirect(loginRedirect); }); const redirectAuthenticated = (req, res) => { @@ -271,7 +324,7 @@ const determineRedirectUrl = (req) => { }; const filterSchoolsWithLdapLogin = (schools) => schools - // eslint-disable-next-line max-len +// eslint-disable-next-line max-len .filter((school) => school.systems.some((system) => system.type === 'ldap' && !system.oauthConfig)); async function getOauthSystems(req) { @@ -328,6 +381,7 @@ const renderLogin = async (req, res) => { let oauthErrorLogout = false; + // TODO N21-1374: remove old login flow if (req.query.error) { res.locals.notification = { type: 'danger', @@ -341,6 +395,12 @@ const renderLogin = async (req, res) => { } } + if (req.session.oauth2Logout) { + oauthErrorLogout = req.session.oauth2Logout.provider; + + delete req.session.oauth2Logout; + } + const strategyOfSchool = req.query.strategy; const idOfSchool = req.query.schoolId; @@ -473,7 +533,7 @@ router.get('/logout/', (req, res, next) => { logger.error('error during logout.', formatError(err)); }); return authHelper.clearCookie(req, res, sessionDestroyer) - // eslint-disable-next-line prefer-template, no-return-assign + // eslint-disable-next-line prefer-template, no-return-assign .then(() => { res.statusCode = 307; res.redirect('/'); diff --git a/helpers/authentication.js b/helpers/authentication.js index fd07ca7c54..54604325fc 100644 --- a/helpers/authentication.js +++ b/helpers/authentication.js @@ -8,7 +8,10 @@ const api = require('../api'); const permissionsHelper = require('./permissions'); const wordlist = require('../static/other/wordlist'); -const { SW_ENABLED, MINIMAL_PASSWORD_LENGTH } = require('../config/global'); +const { + SW_ENABLED, + MINIMAL_PASSWORD_LENGTH, +} = require('../config/global'); const logger = require('./logger'); const { formatError } = require('./logFilter'); @@ -33,7 +36,8 @@ const generatePassword = () => { // iterate 3 times, to add 3 password parts [1, 2, 3].forEach(() => { passphraseParts.push( - wordlist[crypto.randomBytes(2).readUInt16LE(0) % wordlist.length], + wordlist[crypto.randomBytes(2) + .readUInt16LE(0) % wordlist.length], ); }); return passphraseParts.join(' '); @@ -83,12 +87,15 @@ const isAuthenticated = (req) => { return Promise.resolve(false); } - return api(req).post('/authentication', { - json: { - strategy: 'jwt', - accessToken: req.cookies.jwt, - }, - }).then(() => true).catch(() => false); + return api(req) + .post('/authentication', { + json: { + strategy: 'jwt', + accessToken: req.cookies.jwt, + }, + }) + .then(() => true) + .catch(() => false); }; const populateCurrentUser = async (req, res) => { @@ -101,7 +108,9 @@ const populateCurrentUser = async (req, res) => { } catch (err) { logger.error('Broken JWT / JWT decoding failed', formatError(err)); return clearCookie(req, res, { destroySession: true }) - .catch((err) => { logger.error('clearCookie failed during jwt check', formatError(err)); }) + .catch((err) => { + logger.error('clearCookie failed during jwt check', formatError(err)); + }) .finally(() => res.redirect('/')); } } @@ -122,41 +131,47 @@ const populateCurrentUser = async (req, res) => { return Promise.resolve(res.locals.currentSchoolData); } return Promise.all([ - api(req).get(`/users/${payload.userId}`), - api(req).get(`/roles/user/${payload.userId}`), - ]).then(([user, roles]) => { - const data = { - ...user, - roles, - permissions: roles.reduce((acc, role) => [...new Set(acc.concat(role.permissions))], []), - }; - res.locals.currentUser = data; - setTestGroup(res.locals.currentUser); - res.locals.currentRole = rolesDisplayName[data.roles[0].name]; - res.locals.roles = data.roles.map(({ name }) => name); - res.locals.roleNames = data.roles.map((r) => rolesDisplayName[r.name]); - return api(req).get(`/schools/${res.locals.currentUser.schoolId}`, { - qs: { - $populate: ['federalState'], - }, - }).then((data2) => { - res.locals.currentSchool = res.locals.currentUser.schoolId; - res.locals.currentSchoolData = data2; - res.locals.currentSchoolData.isExpertSchool = data2.purpose === 'expert'; - return data2; - }); - }).catch((e) => { - // 400 for missing information in jwt, 401 for invalid jwt, not-found for deleted user - if (e.statusCode === 400 || e.statusCode === 401 || e.error.className === 'not-found') { - return clearCookie(req, res, { destroySession: true }) - .catch((err) => { - const meta = { error: err.toString() }; - logger.error('clearCookie failed during populateUser', meta); + api(req) + .get(`/users/${payload.userId}`), + api(req) + .get(`/roles/user/${payload.userId}`), + ]) + .then(([user, roles]) => { + const data = { + ...user, + roles, + permissions: roles.reduce((acc, role) => [...new Set(acc.concat(role.permissions))], []), + }; + res.locals.currentUser = data; + setTestGroup(res.locals.currentUser); + res.locals.currentRole = rolesDisplayName[data.roles[0].name]; + res.locals.roles = data.roles.map(({ name }) => name); + res.locals.roleNames = data.roles.map((r) => rolesDisplayName[r.name]); + return api(req) + .get(`/schools/${res.locals.currentUser.schoolId}`, { + qs: { + $populate: ['federalState'], + }, }) - .finally(() => res.redirect('/')); - } - throw e; - }); + .then((data2) => { + res.locals.currentSchool = res.locals.currentUser.schoolId; + res.locals.currentSchoolData = data2; + res.locals.currentSchoolData.isExpertSchool = data2.purpose === 'expert'; + return data2; + }); + }) + .catch((e) => { + // 400 for missing information in jwt, 401 for invalid jwt, not-found for deleted user + if (e.statusCode === 400 || e.statusCode === 401 || e.error.className === 'not-found') { + return clearCookie(req, res, { destroySession: true }) + .catch((err) => { + const meta = { error: err.toString() }; + logger.error('clearCookie failed during populateUser', meta); + }) + .finally(() => res.redirect('/')); + } + throw e; + }); } return Promise.resolve(); @@ -226,7 +241,7 @@ const authChecker = (req, res, next) => { const redirectUrl = Configuration.get('NOT_AUTHENTICATED_REDIRECT_URL'); if (isAuthenticated2) { - // fetch user profile + // fetch user profile populateCurrentUser(req, res) .then(() => checkSuperhero(req, res)) .then(() => checkConsent(req, res)) @@ -300,7 +315,10 @@ const loginErrorHandler = (res, next) => (e) => { }; const setErrorNotification = (res, req, error, systemName) => { - let message = res.$t(mapErrorToTranslationKey(error), { systemName, shortTitle: res.locals.theme.short_title }); + let message = res.$t(mapErrorToTranslationKey(error), { + systemName, + shortTitle: res.locals.theme.short_title, + }); // Email Domain Blocked if (error.code === 400 && error.message === 'EMAIL_DOMAIN_BLOCKED') { @@ -321,7 +339,7 @@ const setErrorNotification = (res, req, error, systemName) => { }; }; -const handleLoginError = async (req, res, error, postLoginRedirect, strategy, systemName) => { +const handleLoginError = async (req, res, error, postLoginRedirect, strategy, systemName, provider) => { setErrorNotification(res, req, error, systemName); if (req.session.oauth2State) { @@ -334,8 +352,13 @@ const handleLoginError = async (req, res, error, postLoginRedirect, strategy, sy if (postLoginRedirect) { queryString.append('redirect', redirectHelper.getValidRedirect(postLoginRedirect)); } + if (strategy === 'ldap' || strategy === 'email') { queryString.append('strategy', strategy); + } else if (strategy === 'oauth2' && provider) { + req.session.oauth2Logout = { + provider, + }; } const redirect = redirectHelper.joinPathWithQuery('/login', queryString.toString()); @@ -347,16 +370,19 @@ const login = (payload = {}, req, res, next) => { const { redirect } = payload; delete payload.redirect; if (payload.strategy === 'local') { - return api(req, { version: 'v3' }).post('/authentication/local', { json: payload }) + return api(req, { version: 'v3' }) + .post('/authentication/local', { json: payload }) .then(loginSuccessfulHandler(res, redirect)) .catch(loginErrorHandler(res, next)); } if (payload.strategy === 'ldap') { - return api(req, { version: 'v3' }).post('/authentication/ldap', { json: payload }) + return api(req, { version: 'v3' }) + .post('/authentication/ldap', { json: payload }) .then(loginSuccessfulHandler(res, redirect)) .catch(loginErrorHandler(res, next)); } - return api(req, { version: 'v1' }).post('/authentication', { json: payload }) + return api(req, { version: 'v1' }) + .post('/authentication', { json: payload }) .then(loginSuccessfulHandler(res, redirect)) .catch(loginErrorHandler(res, next)); }; @@ -386,67 +412,80 @@ const getAuthenticationUrl = (oauthConfig, state, migration) => { const requestLogin = (req, strategy, payload = {}) => { switch (strategy) { case 'local': - return api(req, { version: 'v3' }).post('/authentication/local', { json: payload }); + return api(req, { version: 'v3' }) + .post('/authentication/local', { json: payload }); case 'ldap': - return api(req, { version: 'v3' }).post('/authentication/ldap', { json: payload }); + return api(req, { version: 'v3' }) + .post('/authentication/ldap', { json: payload }); case 'oauth2': - return api(req, { version: 'v3' }).post('/authentication/oauth2', { json: payload }); + return api(req, { version: 'v3' }) + .post('/authentication/oauth2', { json: payload }); default: - return api(req, { version: 'v1' }).post('/authentication', { json: { strategy, ...payload } }); + return api(req, { version: 'v1' }) + .post('/authentication', { json: { strategy, ...payload } }); } }; const getMigrationStatus = async (req, res, userId, accessToken) => { - const { data } = await api(req, { version: 'v3', accessToken }).get('/user-login-migrations', { - qs: { - userId, - }, - }); + const { data } = await api(req, { + version: 'v3', + accessToken, + }) + .get('/user-login-migrations', { + qs: { + userId, + }, + }); const migration = Array.isArray(data) && data.length > 0 ? data[0] : null; return migration; }; -// eslint-disable-next-line consistent-return -const loginUser = async (req, res, strategy, payload, postLoginRedirect, systemName) => { - let accessToken; - try { - const loginResponse = await requestLogin(req, strategy, payload); - - accessToken = loginResponse.accessToken; - } catch (errorResponse) { - logger.error('Login failed.'); +const loginUser = async (req, res, strategy, payload, postLoginRedirect) => { + const loginResponse = await requestLogin(req, strategy, payload); - return handleLoginError(req, res, errorResponse.error, postLoginRedirect, strategy, systemName); - } + const { accessToken } = loginResponse; const currentUser = jwt.decode(accessToken); - let migration; - try { - migration = await getMigrationStatus(req, res, currentUser.userId, accessToken); - } catch (errorResponse) { - logger.error('Fetching migration status failed'); - - return handleLoginError(req, res, errorResponse.error, postLoginRedirect, strategy, systemName); - } + const migration = await getMigrationStatus(req, res, currentUser.userId, accessToken); setCookie(res, 'jwt', accessToken); if (migration && !migration.closedAt) { - res.redirect('/migration'); - } else { - const queryString = new URLSearchParams(); + return { + login: loginResponse, + redirect: '/migration', + }; + } - if (postLoginRedirect) { - queryString.append('redirect', redirectHelper.getValidRedirect(postLoginRedirect)); - } + const queryString = new URLSearchParams(); + + if (postLoginRedirect) { + queryString.append('redirect', redirectHelper.getValidRedirect(postLoginRedirect)); + } - const redirect = redirectHelper.joinPathWithQuery('/login/success', queryString.toString()); + const redirect = redirectHelper.joinPathWithQuery('/login/success', queryString.toString()); - res.redirect(redirect); + return { + login: loginResponse, + redirect, + }; +}; + +const getLogoutUrl = (req, res, logoutEndpoint, idTokenHint, redirect) => { + if (!logoutEndpoint) { + logger.info('Logout failed. Missing logout endpoint.'); } + + const logoutUrl = new URL(logoutEndpoint); + logoutUrl.searchParams.append('id_token_hint', idTokenHint); + + const postLoginRedirect = `${Configuration.get('HOST')}${redirect || '/dashboard'}`; + logoutUrl.searchParams.append('post_logout_redirect_uri', postLoginRedirect); + + return logoutUrl.toString(); }; // eslint-disable-next-line consistent-return @@ -458,9 +497,10 @@ const migrateUser = async (req, res, payload) => { let redirect = redirectHelper.joinPathWithQuery('/migration/success', queryString.toString()); try { - await api(req, { version: 'v3' }).post('/user-login-migrations/migrate-to-oauth2', { - json: payload, - }); + await api(req, { version: 'v3' }) + .post('/user-login-migrations/migrate-to-oauth2', { + json: payload, + }); } catch (errorResponse) { if (errorResponse.error && errorResponse.error.details) { logger.error('Migration failed'); @@ -478,7 +518,7 @@ const migrateUser = async (req, res, payload) => { await clearCookie(req, res); - res.redirect(redirect); + return redirect; }; module.exports = { @@ -497,4 +537,5 @@ module.exports = { loginUser, migrateUser, handleLoginError, + getLogoutUrl, }; diff --git a/static/scripts/login.js b/static/scripts/login.js index a00fef277a..2576950538 100644 --- a/static/scripts/login.js +++ b/static/scripts/login.js @@ -152,11 +152,19 @@ $(document).ready(() => { } }); - if ($oauthErrorLogout && $oauthSystems.length > 0 && $oauthErrorLogout.eq(0).text() === 'true') { - const $iservButton = $oauthSystems.find('.btn-oauth[data-provider="iserv"]'); + if ($oauthErrorLogout && $oauthSystems.length > 0 && $oauthErrorLogout.eq(0).text()) { + const logoutErrorOrProvider = $oauthErrorLogout.eq(0).text(); + + let $loginButton; + if (logoutErrorOrProvider === 'true') { + // TODO N21-1374: remove old login flow + $loginButton = $oauthSystems.find('.btn-oauth[data-provider="iserv"]'); + } else if (logoutErrorOrProvider !== 'false') { + $loginButton = $oauthSystems.find(`.btn-oauth[data-provider="${logoutErrorOrProvider}"]`); + } - if ($iservButton.length > 0) { - const logoutWindow = window.open($iservButton.eq(0).data('logout')); + if ($loginButton && $loginButton.length > 0) { + const logoutWindow = window.open($loginButton.eq(0).data('logout')); window.focus(); setTimeout(() => { logoutWindow.close(); @@ -165,6 +173,7 @@ $(document).ready(() => { } } + // TODO N21-1374: remove old login flow $oauthSystems.each((index, element) => { const $oauthButton = $(element).find('.btn-oauth').eq(0); diff --git a/views/authentication/forms/login.hbs b/views/authentication/forms/login.hbs index 7732a7e5ea..a93696ff3f 100644 --- a/views/authentication/forms/login.hbs +++ b/views/authentication/forms/login.hbs @@ -87,7 +87,6 @@