Two-Factor Authentication

Structr supports the TOTP (Time-Based One-Time Password) flavor of two-factor authentication and supports multiple configuration options to enable two-factor-authentication. They can be configured via structr.conf or the configuration servlet (http://host:port/structr/config).

Because the TOTP algorithm is used, it is important that the clocks of both factors (server and mobile device) are synced to a NTP server so they are as close as possible.

Application Configuration

security.twofactorauthentication.level

0 = off Two-Factor Authentication is turned off completely
1 = optional (Default) Two-Factor Authentication can be turned on on a per-user basis.
2 = forced Every user is forced to use Two-Factor Authentication

security.twofactorauthentication.issuer

The issuer parameter is a string value indicating the provider or service this account is associated with, URL-encoded according to RFC 3986. If the issuer parameter is absent, issuer information may be taken from the issuer prefix of the label. If both issuer parameter and issuer label prefix are present, they should be equal.

Valid values corresponding to the label prefix examples above would be: issuer=Example, issuer=Provider1, and issuer=Big+Corporation.

Older Google Authenticator implementations ignore the issuer parameter and rely upon the issuer label prefix to disambiguate accounts. Newer implementations will use the issuer parameter for internal disambiguation, it will not be displayed to the user. We recommend using both issuer label prefix and issuer parameter together to safely support both old and new Google Authenticator versions.

security.twofactorauthentication.algorithm

The available algorithms are:

  • SHA1 (Default)
  • SHA256
  • SHA512

Changing this setting after users are already confirmed will effectively lock them out. Set <User>.twoFactorConfirmed = false for all affected users in order for them to get a new QR code when they log in next.

security.twofactorauthentication.digits

The digits parameter may have the values 6 or 8, and determines how long of a one-time passcode to display to the user. The default is 6.

Changing this setting after users are already confirmed will effectively lock them out. Set <User>.twoFactorConfirmed = false for all affected users in order for them to get a new QR code when they log in next.

security.twofactorauthentication.period

The period parameter defines a period that a TOTP code will be valid for, in seconds. The default value is 30.

Changing this setting after users are already confirmed will effectively lock them out. Set <User>.twoFactorConfirmed = false for all affected users in order for them to get a new QR code when they log in next.

security.twofactorauthentication.logintimeout

Defines how long the two-factor login time window in seconds is. After entering the username and password the user has this amount of time to enter a two factor token before he has to re-authenticate via password. Default is 30.

security.twofactorauthentication.loginpage

The application page where the user enters the current two factor token. Until the user has verified his two factor token (by logging in once), the user is shown a QR code after every login attempt. Default is /twofactor

To allow maximum flexibility, this page is part of the web-application and not part of Structr.

security.twofactorauthentication.whitelistedIPs

A comma-separated (,) list of IPs for which two factor authentication is disabled. IPv4 and IPv6 are supported.

User Configuration

There are three main properties you can change to control the two factor authentication behaviour for a specific user node.

User.twoFactorConfirmed This is automatically set to true after a user authenticates via two-factor authentication. If this is set to false the user will always be shown the QR code for him to scan.
User.isTwoFactorUser Controls if the user wants to authenticate via two factor authentication. This only works if the setting TwoFactor.level is set to 1. If the setting is set to 2, the flag will automatically be set to true after a user logs in.
User.twoFactorSecret This is the secret which is used to generate tokens. It is automatically generated for every user.

Example Implementation

Step 1: Create the basic login page/form as per usual. A regular HTML <form> with custom submission handling to log in.

If the user is not configured for two-factor authentication, they are logged in.

If the status is 202 is returned, two-factor authentication is required. The returned headers from the login POST contain the required data to use on the next page (and are passed as request parameters in the example code):

twoFactorLoginPage The page configured in security.twofactorauthentication.loginpage
token The token required to log in
qrdata The URL-safe base64 encoded data of the QR code which can be scanned with an authenticator app

// get all required elements
const loginForm = document.getElementById('login-form');
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
const loginButton = document.getElementById('login-button');

loginForm.addEventListener('submit', async (event) => {

event.preventDefault();

loginButton.disabled = true;

// get from values
const email = emailInput.value;
const password = passwordInput.value;

// login
const response = await fetch('/structr/rest/login', {
method: 'POST',
body: JSON.stringify({
eMail: email,
password: password
})
});

if (response.status === 202) {

let redirectUrl = response.headers.get('twoFactorLoginPage')
+ '?token=' + response.headers.get('token')
+ '&qrdata=' + (response.headers.get('qrdata') ?? '');

window.location.href = redirectUrl;

} else {

if (response.ok) {

loginButton.textContent = 'Login successful';
window.location.href = '/';

} else {

loginButton.disabled = false;
loginButton.textContent = 'Wrong username or password.';

window.setTimeout(function() {
loginButton.value = 'Login';
loginButton.disabled = false;
}, 2000);
}
}
});

Step 2: Create the page corresponding to the application configuration security.twofactorauthentication.loginpage - in the default case /twofactor

If the one-time password was correct, the user is finally logged in and redirected to the base page (and will not be shown the QR code the next time they log in).

<div id="qrimage-wrapper">
<img id="qrimage" style="margin-left: calc(50% - 100px);">
<div>To use two factor authentication, scan this QR code with an authenticator app on your smartphone.</div>

<div class="text-sm mt-6">
<div>
<b>Android: </b>
<a class="cursor-pointer hover:text-blue-400 text-blue-700" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en&gl=US">Google Authenticator</a> on Google Playstore
</div>
<div>
<b>Apple iOS: </b>
<a class="cursor-pointer hover:text-blue-400 text-blue-700" href="https://apps.apple.com/us/app/google-authenticator/id388497605">Google Authenticator</a> on App Store
</div>
</div>
</div>

<form action="#" id="twoFactorForm">
<input id="twoFactorToken" type="hidden" value="${request.token}">
<div class="my-6">
<label class="block text-sm font-medium leading-5 text-gray-700">Two Factor Code</label>
<input id="twoFactorCode" class="appearance-none block w-full px-3 py-2 bg-blue-100 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5">
</div>
<button type="submit" id="login-button" class="w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-500 focus:outline-none focus:border-blue-700 focus:shadow-outline-indigo active:bg-blue-700 transition duration-150 ease-in-out">Login</button>
</form>

<script>
document.addEventListener('DOMContentLoaded', () => {

// get all required elements
const qrimageWrapper = document.getElementById('qrimage-wrapper');
const token = document.getElementById('twoFactorToken').value;
const codeInput = document.getElementById('twoFactorCode').value;
const loginButton = document.getElementById('login-button');

let qrdata = (new URLSearchParams(location.search)).get('qrdata');

if (!qrdata) {
// remove qr code placeholder if user is not shown qr code
qrimageWrapper.remove();
} else {
// transform url-safe qr code to regular base64 to display as image
qrimageWrapper.querySelector('#qrimage').src = 'data:image/png;base64, ' + qrdata.replaceAll('_', '/').replaceAll('-', '+');
}

document.getElementById('twoFactorForm').addEventListener('submit', async (event) => {

event.preventDefault();

loginButton.disabled = true;

const response = await fetch('/structr/rest/login', {
method: 'POST',
body: JSON.stringify({
twoFactorToken: token,
twoFactorCode: codeInput
})
});

if (response.ok) {

loginButton.textContent = 'Login successful';
window.location.href = '/';

} else {

let buttonText = 'Login failed - is device time correct?';

let reason = response.headers.get('reason');

if (reason === 'wrongTwoFactorCode') {
buttonText = 'Two Factor Code is not correct';
}

loginButton.disabled = false;
loginButton.textContent = buttonText;

window.setTimeout(function() {
loginButton.textContent = 'Login';
loginButton.disabled = false;
}, 2000);
}
});
});
</script>