Skip to content

Commit

Permalink
feat: multifactor authentication flow
Browse files Browse the repository at this point in the history
  • Loading branch information
karpovr committed Dec 18, 2023
1 parent 12edfa0 commit c2582e2
Show file tree
Hide file tree
Showing 4 changed files with 89 additions and 67 deletions.
6 changes: 6 additions & 0 deletions source-web/js/browser/app_presenter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 = '';

Expand Down
80 changes: 46 additions & 34 deletions source-web/js/browser/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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;
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
});

Expand All @@ -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 {
Expand Down
8 changes: 5 additions & 3 deletions source-web/js/browser/backend_browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,15 @@ 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<object>} A Promise that resolves to the server response.
*
* The server response object will have the following properties:
* - ticket: The ID of the ticket.
* - 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 = {
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}

/**
Expand Down
62 changes: 32 additions & 30 deletions source-web/js/browser/backend_error.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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',
}
}

0 comments on commit c2582e2

Please sign in to comment.