From c2582e26e564f0e486df6961ce26340b12b6a055 Mon Sep 17 00:00:00 2001 From: Roman Karpov Date: Tue, 19 Dec 2023 01:53:05 +0300 Subject: [PATCH] feat: multifactor authentication flow --- source-web/js/browser/app_presenter.js | 6 ++ source-web/js/browser/auth.js | 80 ++++++++++++++---------- source-web/js/browser/backend_browser.js | 8 ++- source-web/js/browser/backend_error.js | 62 +++++++++--------- 4 files changed, 89 insertions(+), 67 deletions(-) diff --git a/source-web/js/browser/app_presenter.js b/source-web/js/browser/app_presenter.js index f1116407b..77dcdc31b 100644 --- a/source-web/js/browser/app_presenter.js +++ b/source-web/js/browser/app_presenter.js @@ -47,6 +47,7 @@ export default function AppPresenter (manifest) { * @this {Element} - The clicked element * @return {void} */ + function anchorHandler (event) { event.preventDefault(); const hash = this.getAttribute('href'); @@ -178,6 +179,11 @@ export default function AppPresenter (manifest) { if (starting === true) return; starting = true; + // Restore hash from query params after redirect + const url = new URL(window.location.href); + const hash = url.searchParams.get('hash'); + if (hash) window.location = new URL(window.location.origin + window.location.pathname + hash); + const loadIndicator = document.getElementById('load-indicator'); loadIndicator.style.display = ''; diff --git a/source-web/js/browser/auth.js b/source-web/js/browser/auth.js index 8dec6fb1a..d8de10cad 100644 --- a/source-web/js/browser/auth.js +++ b/source-web/js/browser/auth.js @@ -91,7 +91,11 @@ function submitLoginPassword (event) { }) .catch((error) => { console.error('NTLM auth failed'); - return Backend.authenticate(login, hash); + return Backend.authenticate({ + login, + password: hash, + href: `${window.location.origin}${window.location.pathname}${window.location.hash ? `?hash=${encodeURIComponent(window.location.hash)}` : ''}`, + }); }) .then(handleLoginSuccess) .catch(handleLoginError); @@ -269,6 +273,9 @@ function handleLoginError (error) { hide(ok); }; break; + case 303: // See Other + const href = error?.response?.headers?.get('Location'); + window.location.href = href; case 423: // Password change is allowed once a day show(frequentPassChangeWarning); show(ok); @@ -416,23 +423,17 @@ function handleLoginSuccess (authResult) { initWithCredentials(authResult); } -/** - * Set ticket cookie - * @param {string} ticket - * @param {number} expires - * @return {void} - */ -function setTicketCookie (ticket, expires) { - const cookie = 'ticket=' + ticket + '; expires=' + new Date(parseInt(expires)).toGMTString() + '; samesite=strict; path=/;' + (window.location.protocol === 'https:' ? 'secure;' : ''); - document.cookie = cookie; +function getCookie (name) { + const cookies = new Map(document.cookie.split('; ').map((v)=>v.split(/=(.*)/s).map(decodeURIComponent))); + return cookies.get(name); } -/** - * Delete ticket cookie - * @return {void} - */ -function delTicketCookie () { - setTicketCookie(null, 0); +function setCookie (name, value, expires) { + document.cookie = 'name=' + value + '; expires=' + new Date(parseInt(expires)).toGMTString() + '; samesite=strict; path=/;' + (window.location.protocol === 'https:' ? 'secure;' : ''); +} + +function delCookie (name) { + setCookie(name, null, 0); } /** @@ -443,14 +444,14 @@ function handleAuthError () { const appContainer = document.getElementById('app'); clear(appContainer); - delete storage.ticket; - delete storage.user_uri; - delete storage.end_time; - delTicketCookie(); + storage.removeItem('ticket'); + storage.removeItem('user_uri'); + storage.removeItem('end_time'); + delCookie('ticket'); - if (storage.logout) { + if (storage.getItem('logout')) { show(loginForm); - delete storage.logout; + storage.removeItem('logout'); return; } @@ -498,10 +499,13 @@ let refreshInterval; */ function handleAuthSuccess (authResult, isBroadcast = false) { if (!isBroadcast) bc.postMessage(authResult); - veda.user_uri = storage.user_uri = authResult.user_uri; - veda.ticket = storage.ticket = authResult.ticket; - veda.end_time = storage.end_time = authResult.end_time; - setTicketCookie(veda.ticket, veda.end_time); + veda.ticket = authResult.ticket; + storage.setItem('ticket', veda.ticket); + veda.user_uri = authResult.user_uri; + storage.setItem('user_uri', veda.user_uri); + veda.end_time = authResult.end_time; + storage.setItem('end_time', veda.end_time); + setCookie('ticket', veda.ticket, veda.end_time); // Re-login on ticket expiration if ( veda.end_time ) { const granted = Date.now(); @@ -552,11 +556,11 @@ function initWithCredentials (authResult) { // Logout handler delegateHandler(document.body, 'click', '#logout, .logout', function () { Backend.logout(veda.ticket).catch((error) => console.log('Logout failed', error)); - delete storage.ticket; - delete storage.user_uri; - delete storage.end_time; - delTicketCookie(); - storage.logout = true; + storage.removeItem('ticket'); + storage.removeItem('user_uri'); + storage.removeItem('end_time'); + delCookie('ticket'); + storage.setItem('logout', true); window.location.reload(); }); @@ -568,10 +572,18 @@ export default async function auth () { const loadIndicator = document.getElementById('load-indicator'); const loadIndicatorTimer = setTimeout(() => loadIndicator.style.display = '', 250); + // Try to get auth result from cookie + let auth = getCookie('auth'); + if (auth) { + auth = JSON.parse(atob(auth)); + Object.entries(auth).forEach(([key, value]) => storage.setItem(key, value)); + delCookie('auth'); + } + // Check if ticket is valid - const ticket = storage.ticket; - const user_uri = storage.user_uri; - const end_time = (new Date() < new Date(parseInt(storage.end_time))) && storage.end_time; + const ticket = storage.getItem('ticket'); + const user_uri = storage.getItem('user_uri'); + const end_time = (new Date() < new Date(parseInt(storage.getItem('end_time')))) && storage.getItem('end_time'); let valid; if (ticket && user_uri && end_time) { try { diff --git a/source-web/js/browser/backend_browser.js b/source-web/js/browser/backend_browser.js index 1aa069fc3..130572576 100644 --- a/source-web/js/browser/backend_browser.js +++ b/source-web/js/browser/backend_browser.js @@ -92,6 +92,7 @@ export default class BrowserBackend { * @param {string|object} login - The login or an object with the "login" property. * @param {string} password - The password of the user. * @param {string} secret - The secret associated with the user. + * @param {string} href - Redirect href. * @return {Promise} A Promise that resolves to the server response. * * The server response object will have the following properties: @@ -99,7 +100,7 @@ export default class BrowserBackend { * - user_uri: The URI of the authenticated user. * - end_time: The adjusted end time of the ticket in milliseconds since January 1, 1970 (UNIX timestamp). */ - static async authenticate (login, password, secret) { + static async authenticate (login, password, secret, href) { const arg = login; const isObj = typeof arg === 'object'; const params = { @@ -109,6 +110,7 @@ export default class BrowserBackend { 'login': isObj ? arg.login : login, 'password': isObj ? arg.password : password, 'secret': isObj ? arg.secret : secret, + 'href': isObj ? arg.href : href, }, }; return call_server(params).then(adjustTicket); @@ -592,9 +594,9 @@ async function call_server (params) { }), }); if (response.ok) { - return response.json(); + return await response.json(); } - throw new BackendError(response.status); + throw new BackendError(response.status, response); } /** diff --git a/source-web/js/browser/backend_error.js b/source-web/js/browser/backend_error.js index 1250d2de1..d0a0c7432 100644 --- a/source-web/js/browser/backend_error.js +++ b/source-web/js/browser/backend_error.js @@ -6,12 +6,13 @@ export default class BackendError extends Error { * Creates a new instance of BackendError. * @param {number} code - The error code associated with the backend error. */ - constructor(code) { + constructor(code, response) { const message = typeof code !== 'undefined' ? `${BackendError.#errorCodes[code]}` : undefined; super(message); this.name = 'BackendError'; this.code = code; this.message = message; + this.response = response; } /** @@ -27,36 +28,37 @@ export default class BackendError extends Error { */ static #errorCodes = { 0: 'Server unavailable', - 400: 'Bad request', + 303: 'See Other', + 400: 'Bad Request', 403: 'Forbidden', - 404: 'Not found', - 422: 'Unprocessable entity', + 404: 'Not Found', + 422: 'Unprocessable Entity', 423: 'Locked', - 429: 'Too many requests', - 430: 'Too many password change fails', - 463: 'Password change is not allowed', - 464: 'Secret expired', - 465: 'Empty password', - 466: 'New password is equal to old', - 467: 'Invalid password', - 468: 'Invalid secret', - 469: 'Password expired', - 470: 'Ticket not found', - 471: 'Ticket expired', - 472: 'Not authorized', - 473: 'Authentication failed', - 474: 'Not ready', - 475: 'Fail open transaction', - 476: 'Fail commit', - 477: 'Fail store', - 500: 'Internal server error', - 501: 'Not implemented', - 503: 'Service unavailable', - 904: 'Invalid identifier', - 999: 'Database modified error', - 1021: 'Disk full', - 1022: 'Duplicate key', - 1118: 'Size too large', - 4000: 'Connect error', + 429: 'Too Many Requests', + 430: 'Too Many Password Change Fails', + 463: 'Password Change Is Not Allowed', + 464: 'Secret Expired', + 465: 'Empty Password', + 466: 'New Password Is Equal To Old', + 467: 'Invalid Password', + 468: 'Invalid Secret', + 469: 'Password Expired', + 470: 'Ticket Not Found', + 471: 'Ticket Expired', + 472: 'Not Authorized', + 473: 'Authentication Failed', + 474: 'Not Ready', + 475: 'Fail Open Transaction', + 476: 'Fail Commit', + 477: 'Fail Store', + 500: 'Internal Server Error', + 501: 'Not Implemented', + 503: 'Service Unavailable', + 904: 'Invalid Identifier', + 999: 'Database Modified Error', + 1021: 'Disk Full', + 1022: 'Duplicate Key', + 1118: 'Size Too Large', + 4000: 'Connect Error', } }