For Wt 4.11.0
1. Prerequisites
In this tutorial, we use an example as a hands-on introduction to the Wt authentication
module. This example is included in the Wt distribution, in
examples/feature/auth1
.
This introduction assumes that you have a reasonable understanding of Wt itself, in particular its stateful session model and the widget concept. If you haven’t done so yet, you may want to go through the Wt tutorial first.
2. Introduction
The authentication module implements the logic and widgets involved in getting users registered on your application, and letting them sign in. Note that this module is entirely optional, and simply implemented on top of Wt.
The module implements
authentication. Its main
purpose is to securely authenticate a user to sign in to your
application. From your application, you will interact with the
authentication module using a
Wt::Auth::Login
object, which you
typically hold in your application object. It indicates the user
currently signed in (if any), and propagates authentication events.
How you use this information for authorization or to customize the user experience is out of the scope of the module. Because of Wt’s built-in security features, with strong session hijacking mitigation, this is as straightforward as one can conceive it.
Currently, the module provides the following features, which can be separately enabled, configured or customized:
-
Password authentication, using best practices including salted hashing with strong cryptographic hash functions (such as bcrypt) and password strength checking.
-
Remember me functionality, again using best practices, by associating authentication tokens stored in cookies to a user.
-
Verified email addresses using the typical confirmation email process.
-
Lost password functionality that uses the verified email address to prompt a user to enter a new password.
-
Authentication using 3rd party Identity Providers, using OAuth 2.0 (including standard implementations for Facebook, Google, and generic OpenID Connect) and SAML.
-
Registration logic, which includes also the logic needed to merge new (federated login) identities into existing user profiles. For example, if a user previously registered using a username and password, they may later also authenticate using for example their Google Account and this new identity is added to his existing account.
-
Multi Factor logic, allowing for more security when logging in. An additional factor ensures that more than one type of evidence is required from the user when they log in. By chosing the types of credentials fittingly, there is a large chance to stop any account compromises.
The logic for these features is implemented separately from the user interface components, which can be customized or completely replaced with your own widgets.
Obviously, the authentication logic needs to talk to a storage system, and it is designed to hook into a storage system using an abstract interface. A default implementation that leverages Wt::Dbo, Wt’s ORM, is provided.
3. Module organization
The following picture illustrates the main classes of the module.
It uses a classical separation between Model classes and View classes (which are the widgets).
There are three types of model classes:
-
Service classes are designed to be shared across all sessions (they do not have any state besides configuration). They contain logic which does not require transient state in a session.
-
Session-bound model classes are usually kept in a session for the entire lifetime of a session (but don’t need to be).
-
Transient model classes play an active role in the user interface, and are instantiated in the context of certain view components. They implement logic which involves state while the user is progressing through the login and registration process.
4. Example
We’ll walk through a small example which is a basic application that uses the authentication module (included in the Wt distribution in examples/feature/auth1). It is about 200 lines of C++ (which we’ll discuss below), and has the following features:
-
Password-based authentication and registration
-
OAuth 2 login and registration, for Google and Facebook accounts
-
Password attempt throttling
-
Email verification and a lost password procedure
-
Remember-me tokens
-
And by virtue of Wt itself, falls back to plain HTML behavior if the browser does not support Ajax, strong security, spam resilience, etc.
This example should help you to understand how to add authentication support to a new or existing Wt project.
4.1. Setting up a user database
We will be using the default implementation for an authentication
database using Wt::Dbo
, with the default persistence classes for
authentication. This database implementation is found in
Wt::Auth::Dbo::UserDatabase
,
and it uses
Wt::Auth::Dbo::AuthInfo
as the persistence class for authentication information, which itself
references two other persistence classes:
-
A user’s "identities" are stored in a separate table. An identity uniquely identifies a user. Traditionally, a user would have only a single identity which is their login name (which could be their email address). But a user may accumulate more identities, corresponding to accounts with 3rd party identity providers. Or by setting up
MFA
on their account. The default implementation ofTOTP
will always generate an identity of the name "multifactor". By allowing multiple identities, the user may identify using a choice of methods. -
Authentication tokens are stored in a separate table. An authentication token usually corresponds to a "remember-me" cookie, and a user may have multiple "remember-me" cookies when using different computers.
In addition, we define a User
type to which we can add the
application data for a particular user (this could be address
information, birthdate, preferences, user role, etc…), and which we
want to link up with the authentication system.
The definition and persistence mapping for (a currently empty) User
type is as given below:
#include <Wt/Dbo/Types.h>
#include <Wt/WGlobal.h>
class User;
using AuthInfo = Wt::Auth::Dbo::AuthInfo<User>;
class User {
public:
template<class Action>
void persist(Action& a)
{
}
};
DBO_EXTERN_TEMPLATES(User)
We declare a type alias for AuthInfo
, which links the authentication
information persistence class to our custom User
information
persistence class.
Next, we define a session class, which encapsulates the connection to
the database to store authentication information, and which also
tracks the user currently logged in, in a web session. We choose to
use the
Wt::Dbo::Session
class as a base class (which could just as well be an embedded
member).
Later on, we’ll see how each web session will instantiate its own
persistence/authentication Session
object.
#include <Wt/Auth/Login.h>
#include <Wt/Auth/UserDatabase.h>
#include <Wt/Dbo/Session.h>
#include <Wt/Dbo/ptr.h>
#include "User.h"
namespace dbo = Wt::Dbo;
using UserDatabase = Wt::Auth::Dbo::UserDatabase<AuthInfo>;
class Session : public dbo::Session
{
public:
explicit Session(const std::string& sqliteDb);
Wt::Auth::AbstractUserDatabase& users();
Wt::Auth::Login& login() { return login_; }
...
private:
std::unique_ptr<UserDatabase> users_;
Wt::Auth::Login login_;
...
};
Notice the type alias for UserDatabase
, which states that we will be
using the
Wt::Auth::Dbo::UserDatabase
implementation using AuthInfo
, for which we declared a type alias
earlier on. You are of course free to provide another implementation
for
Wt::Auth::AbstractUserDatabase
which is not based on Wt::Dbo
.
We also embed a
Wt::Auth::Login
member here, which is a small model class that holds the current login
information. The login/logout widgets will manipulate this login
object, while the rest of our application will listen to login changes
from this object to adapt to the user currently logged in.
The Session
constructor sets up the database session.
#include "Session.h"
#include "User.h"
#include <Wt/Auth/Dbo/AuthInfo.h>
#include <Wt/Dbo/backend/Sqlite3.h>
using namespace Wt;
Session::Session(const std::string& sqliteDb)
{
auto connection = std::make_unique<Dbo::backend::Sqlite3>(sqliteDb);
setConnection(std::move(connection_));
mapClass<User>("user");
mapClass<AuthInfo>("auth_info");
mapClass<AuthInfo::AuthIdentityType>("auth_identity");
mapClass<AuthInfo::AuthTokenType>("auth_token");
try {
createTables();
std::cerr << "Created database.\n";
} catch (Wt::Dbo::Exception& e) {
std::cerr << e.what() << '\n';
std::cerr << "Using existing database\n";
}
users_ = std::make_unique<UserDatabase>(*this);
}
The example uses an SQLite3 database, a cuddly database convenient for development, and we map four persistence classes to tables.
We then create the data schema if needed, which will automatically issue the following SQL:
create table "user" (
"id" integer primary key autoincrement,
"version" integer not null
);
create table "auth_info" (
"id" integer primary key autoincrement,
"version" integer not null,
"user_id" bigint,
"password_hash" varchar(100) not null,
"password_method" varchar(20) not null,
"password_salt" varchar(20) not null,
"status" integer not null,
"failed_login_attempts" integer not null,
"last_login_attempt" text,
"email" varchar(256) not null,
"unverified_email" varchar(256) not null,
"email_token" varchar(64) not null,
"email_token_expires" text,
"email_token_role" integer not null,
constraint "fk_auth_info_user"
foreign key ("user_id") references "user" ("id")
on delete cascade deferrable initially deferred
);
create table "auth_token" (
"id" integer primary key autoincrement,
"version" integer not null,
"auth_info_id" bigint,
"value" varchar(64) not null,
"expires" text,
constraint "fk_auth_token_auth_info"
foreign key ("auth_info_id") references "auth_info" ("id")
on delete cascade deferrable initially deferred
);
create table "auth_identity" (
"id" integer primary key autoincrement,
"version" integer not null,
"auth_info_id" bigint,
"provider" varchar(64) not null,
"identity" varchar(512) not null,
constraint "fk_auth_identity_auth_info"
foreign key ("auth_info_id") references "auth_info" ("id")
on delete cascade deferrable initially deferred
);
Notice the auth_info
, auth_token
and auth_identity
tables that
define the storage for our authentication system.
4.2. Configuring authentication
The service classes
(Wt::Auth::AuthService
,
Wt::Auth::PasswordService
,
and
Wt::Auth::OAuthService
),
can be shared between sessions and contain the configuration and logic
which does not require transient session state.
A good location to add these service classes are inside a specialized
Wt::WServer
instance, of which you usually also have only one in a Wt process. You
could also create a singleton for them. To keep the example simple, we
will declare them simply as global variables (but within file scope):
myAuthService
, myPasswordService
, and myOAuthServices
.
#include <Wt/Auth/AuthService.h>
#include <Wt/Auth/HashFunction.h>
#include <Wt/Auth/PasswordService.h>
#include <Wt/Auth/PasswordStrengthValidator.h>
#include <Wt/Auth/PasswordVerifier.h>
#include <Wt/Auth/GoogleService.h>
#include <Wt/Auth/FacebookService.h>
namespace {
Wt::Auth::AuthService myAuthService;
Wt::Auth::PasswordService myPasswordService(myAuthService);
std::vector<std::unique_ptr<Wt::Auth::OAuthService>> myOAuthServices;
}
void Session::configureAuth()
{
myAuthService.setAuthTokensEnabled(true, "logincookie");
myAuthService.setEmailVerificationEnabled(true);
myAuthService.setEmailVerificationRequired(true);
auto verifier = std::make_unique<Wt::Auth::PasswordVerifier>();
verifier->addHashFunction(std::make_unique<Wt::Auth::BCryptHashFunction>(7));
myPasswordService.setVerifier(std::move(verifier));
myPasswordService.setPasswordThrottle(std::make_unique<Wt::Auth::AuthThrottle>());
myPasswordService.setStrengthValidator(
std::make_unique<Wt::Auth::PasswordStrengthValidator>());
if (Wt::Auth::GoogleService::configured()) {
myOAuthServices.push_back(std::make_unique<Wt::Auth::GoogleService>(myAuthService));
}
if (Wt::Auth::FacebookService::configured()) {
myOAuthServices.push_back(std::make_unique<Wt::Auth::FacebookService>(myAuthService));
}
for (const auto& oAuthService : myOAuthServices) {
oAuthService->generateRedirectEndpoint();
}
}
Wt::Auth::AbstractUserDatabase& Session::users()
{
return *users_;
}
const Wt::Auth::AuthService& Session::auth()
{
return myAuthService;
}
const Wt::Auth::PasswordService& Session::passwordAuth()
{
return myPasswordService;
}
std::vector<const Wt::Auth::OAuthService *> Session::oAuth()
{
std::vector<const Auth::OAuthService *> result;
result.reserve(myOAuthServices.size());
for (const auto& auth : myOAuthServices) {
result.push_back(auth.get());
}
return result;
}
The AuthService
is configured to support "remember-me" functionality, and email
verification.
The
PasswordService
needs a hash function to safely store passwords. You can actually
define more than one hash function, which is useful only if you want
to migrate to a new hash function while still supporting existing
passwords. When a user logs in, and they are not using the "preferred"
hash function, their password will be rehashed with the preferred
one. In this example, we will use
bcrypt, which is included as a
hash
function in Wt::Auth.
We also enable password attempt throttling: this mitigates brute force password guessing attempts.
Finally, we also use two (but later, perhaps more)
OAuthService
classes. You need one service per identity provider. In this case,
we add Google and Facebook as identity providers.
4.3. The user interface
We create a specialized
WApplication
which contains our authentication session, and instantiates an
AuthWidget
. This
widget shows a login or logout form (depending on the login status),
and also hooks into default forms for registration, lost passwords,
and handling of email-sent tokens in URLs).
#include <Wt/WApplication.h>
#include <Wt/WBootstrap2Theme.h>
#include <Wt/WContainerWidget.h>
#include <Wt/WServer.h>
#include <Wt/Auth/AuthWidget.h>
#include <Wt/Auth/PasswordService.h>
#include "model/Session.h"
class AuthApplication : public Wt::WApplication {
public:
explicit AuthApplication(const Wt::WEnvironment& env)
: Wt::WApplication(env),
session_(appRoot() + "auth.db")
{
session_.login().changed().connect(this, &AuthApplication::authEvent);
root()->addStyleClass("container");
setTheme(std::make_shared<Wt::WBootstrap2Theme>());
useStyleSheet("css/style.css");
auto authWidget = std::make_unique<Wt::Auth::AuthWidget>(
Session::auth(), session_.users(), session_.login());
authWidget->model()->addPasswordAuth(&Session::passwordAuth());
authWidget->model()->addOAuth(Session::oAuth());
authWidget->setRegistrationEnabled(true);
authWidget->processEnvironment();
root()->addWidget(std::move(authWidget));
}
void authEvent() {
if (session_.login().loggedIn()) {
const Wt::Auth::User& u = session_.login().user();
log("notice")
<< "User " << u.id()
<< " (" << u.identity(Wt::Auth::Identity::LoginName) << ")"
<< " logged in.";
} else
log("notice") << "User logged out.";
}
private:
Session session_;
};
The last part is our main function where we setup the application server:
std::unique_ptr<Wt::WApplication> createApplication(const Wt::WEnvironment &env)
{
return std::make_unique<AuthApplication>(env);
}
int main(int argc, char **argv)
{
try {
Wt::WServer server{argc, argv, WTHTTP_CONFIGURATION};
server.addEntryPoint(Wt::EntryPointType::Application, createApplication);
Session::configureAuth();
server.run();
} catch (Wt::WServer::Exception& e) {
std::cerr << e.what() << '\n';
} catch (Wt::Dbo::Exception &e) {
std::cerr << "Dbo exception: " << e.what() << '\n';
} catch (std::exception &e) {
std::cerr << "exception: " << e.what() << '\n';
}
}
5. Multi-Factor Authentication (MFA)
As of Wt 4.11.0 MFA is now also supported. Simply speaking this is an approach to authentication that takes more than a singular point of evidence the user that is performing the login event, is indeed who they claim to be. Thus offering an additional layer of security, reducing the chance an account will be compromised. There are various ways to implement MFA. With minimal configuration, the included TOTP approach can be used by developers. The Wt API does allow for the MFA process to be completely customized, so that your own preferred methods can be implemented.
5.1. Overview of TOTP
TOTP is a well-known and often used implementation when it comes to MFA. Its specification can be found here. It is a way to generate a unique secret for an application that is only valid for a limited time. This reduces the risk when a combination of a username/password is compromised. Any attacker will have a more limited time to gain access. The TOTP code will often have expired by that point.
The approach looks like this:
-
the server securely generates a random secret for an individual user and shares it with the user (typically through a QR code) to store in an authentication app.
-
from this secret key, we can by means of the TOTP algorithm create temporary codes.
-
upon an authentication event, this code will be provided by the user to the server. The server will use the same algorithm to compute the code from the shared secret, and verifies that the codes correspond. Each interval of 30 seconds will have a different code. Any moment within the same interval (e.g.
11:45:07
and11:45:24
) will have the same code.
5.2. Default Configuration
While the specification doesn’t enforce certain values, it does serve some recommendations, most of which were taken as defaults for Wt.
The chosen defaults are:
-
the time step is of length 30 seconds
-
the generated code is 6 digits long
-
the generated secret key is 32 characters long
To start using MFA in Wt, the configuration is minimal. This will enable
TOTP by default. AuthService
needs to be configured such that setMfaProvider()
is set. This is the only requirement to activate the functionality.
However, at this point is will not always be shown to users.
Then a developer has two options now:
-
either they enforce the usage of MFA globally, meaning each user is required to enter an MFA challenge (see:
setMfaRequired()
). -
or they manage this on a per-user basis. They will either need to mark a user as being subject to MFA in some way, or allow the user a way to configure the feature for themselves (see:
hasMfaStep()
).
For simplicity we will consider the enabled and required case first.
When configured in such a way, each user will encounter the TotpProcess
after they logged in using username/password. This process' functionality
is twofold:
-
setup: first the TOTP secret needs to be set up for the user, and an initial validation round is enforced. This ensures that the user was able to correctly use the generated secret key (shown as both a string, and a QR code for convenience). The user is required to put this secret key into an authenticator app, or browser extension that is able to generate code from the secret. They will enter the code the app or extension returns to them. At this point the framework knows the user now has their MFA step correctly configured, and it will save it to the database. This is done by means of an
Identity
. The identity’s name will be equal to what was configured onAuthService
, and its value will be the secret key.[1] -
validation: once set up the secret key and QR code are never again exposed to the user. Rather they will only be shown an input field where they need to put the code generated by their authenticator app or extension.
Both these possible view are constructed by the TotpProcess
, but the
view itself is managed by createMfaView()
.
The example can be found here: examples/authentication/mfa/totp.
5.3. Note on throttling
Since the MFA process is implemented very open-ended, and no set UI has been provided for it, a custom implementation of it will require developers to explicitly take throttling into account if they wish to use it.
The most important aspects of implementing this successfully are the methods:
-
setAuthenticated()
which is to be called after each authentication event. It will ensure that the login failure or success is recorded in the database. This state can then be used by the actual delay calculation. -
delayForNextAttempt()
calculates the number of seconds the throttling needs to apply. -
getAuthenticationThrottle()
specifies some predefined rules on the throttling delay given a certain number of consecutive failed login attempts.
5.4. Custom implementation (similar to default)
THIS IS NOT A RECOMMENDED MFA STRATEGY, it is purely illustrative to demonstrate the API flexibility
We’ll now follow an example that deviates a little from the default. It is very similar to the TOTP implementation, and requires next to no set-up. It will generate a random PIN for each user, which they will have to remember (or write down!).
A proper approach to MFA requires that the user should use factors from different "sources". A password is an example of something they know. TOTP is an example of something they have. If the same source is used twice, this is not considered good practice. After all a user may write down a password. If using this approach, it is very likely they will also write down the PIN.
The example can be found here: examples/authentication/mfa/pin.
We’ll go over a couple important parts and set up an example that will:
-
require a password-based login
-
allow the MFA login to be remembered
We’ll go ahead and adapt the example Session
from Configuring authentication.
In the configureAuth
method, we will simply add two more lines.
Note that all other configuration remained the same, meaning auth
tokens are enabled, and password authentication was configured.
So here we set the AuthService
to be able to use MFA, and use the
default name for it (resulting in "multifactor". We also then require
MFA to be used by all users.
myAuthService.setMfaProvider(Wt::Auth::Identity::MultiFactor);
myAuthService.setMfaRequired(true);
The PinProcess
will be our MFA process, and will inherit from AbstractMfaProcess
.
This base class dictates how an MFA process should behave, and what
interface it ought to follow. This widget also shows a different way to
define the UI from TotpProcess
.
The way this process is shown in the UI is managed by createMfaView()
.
We use a WCompositeWidget
here
that uses a WTemplate
as its
implementation. It’s a very minimal template that just binds a single
widget. This is the widget generated by the create...
virtual methods.
class PinProcess final : public Wt::Auth::Mfa::AbstractMfaProcess
{
public:
PinProcess(const Wt::Auth::AuthService& authService, Wt::Auth::AbstractUserDatabase& users, Wt::Auth::Login& login)
: Wt::Auth::Mfa::AbstractMfaProcess(authService, users, login)
{
}
We’ll then have to implement a couple methods that are purely virtual. These methods essentially dictate what the content is the two possible views of the MFA process hold.
The next example Custom implementation (far from default) will show a more alternative way as a demonstration.
public:
std::unique_ptr<Wt::WWidget> createSetupView() final;
std::unique_ptr<Wt::WWidget> createInputView() final;
These methods dictate how the widget behaves in the regular AuthWidget
flow. The create...
functions generate the views that are to be
displayed.
For convenience, and to ensure we do not repeat code too much, we have
introduced the createBaseView()
method. This generates the pointer
to the template, allows that to use the id()
,
and tr()
functions. The condition setCondition()
is the main distinguishing factor between the two views. Only during the
setup phase will the generated code be displayed to the user. After that
they will have to supply the PIN code themselves.
std::unique_ptr<Wt::WTemplate> PinProcess::createBaseView(bool isSetup)
{
auto view = std::make_unique<Wt::WTemplate>(Wt::WTemplate::tr("pin-template"));
view->addFunction("id", &Wt::WTemplate::Functions::id);
view->addFunction("tr", &Wt::WTemplate::Functions::tr);
view->setCondition("if:is-setup", isSetup);
view_ = view.get();
return view;
}
std::unique_ptr<Wt::WWidget> PinProcess::createSetupView()
{
auto view = createBaseView(true);
createCodeGenerator();
createCodeInput();
createLoginButton();
createRememberMe();
return std::move(view);
}
std::unique_ptr<Wt::WWidget> PinProcess::createInputView()
{
auto view = createBaseView(false);
createCodeInput();
createLoginButton();
createRememberMe();
return std::move(view);
}
You may have noticed the WTemplate::tr()
call here. This will load content from a resource bundle, which can produce
localised strings. For this to work, the application needs to be able to
use these messages.
messageResourceBundle().use(appRoot() + "template");
That takes care of nearly all the required setup. We now only have to
create the actual PinProcess
further. Here we will create the actual
widgets, and manage how the class receives, validates and responds to
input.
protected:
void createCodeGenerator();
void createCodeInput();
void createLoginButton();
void createRememberMe();
void checkCodeInput();
void update();
private:
const int NUMBER_OF_DIGITS = 5;
Wt::WTemplate* view_ = nullptr;
std::string currentCode_;
Wt::WLineEdit* codeInput_;
Wt::WCheckBox* rememberMeField_;
Wt::Signal<Wt::Auth::Mfa::AuthenticationResult> authenticated_;
Wt::WTemplate* impl() { return static_cast<Wt::WTemplate*>(implementation()); }
};
Some explanation for all methods:
-
createCodeGenerator will generate a random PIN code, from a random value, seeded by the current time. A number is created of length
NUMBER_OF_DIGITS
. -
createCodeInput will add a
WLineEdit
to the template, where the user is able to insert the PIN code. Pressing enter will start the process to verify the input. -
createLoginButton will add a
WPushButton
that also starts the check to ascertain the PIN is valid. -
createRememberMe will add two things:
-
a
WCheckBox
that indicates whether "remember-me" is enabled or not, which can be set by the user to enable it. By default it will be disabled. This DOES requiresetAuthTokensEnabled()
to be set totrue
. -
it displays a label next to the
WCheckBox
that indicates how long the "remember-me" will last.
-
-
checkCodeInput will validate the input the user supplied in the
WLineEdit
. Either this code is already present in the class, or it is retrieved from the user’s identity usinguserIdentity()
. The persisted or just created value is then checked against the user’s input. This input can either:-
not match, in which case the failure is indicated to them visually by update.
-
match, in which case they are logged into the system, and the identity is added to the database, if it hasn’t been already. In both cases the
authenticated
signal is fired. Here we will also have to implement some custom logic if we wish to enable throttling. Developers can check how this is done onTotpProcess
. Since the way the MFA process is implemented allows for any approach, this will require a developer to explicitly manage throttling.
-
-
update will indicate the failure to authenticate to the user by displaying a message and indicating the field red.
void PinProcess::createCodeGenerator()
{
// Set seed for randomness to current time
std::srand(std::time(0));
std::string code;
int value = 0;
for (int i = 0; i < NUMBER_OF_DIGITS; ++i) {
value = std::rand() / (RAND_MAX / 10);
code += std::to_string(value);
}
currentCode_ = code;
view_->bindString("code", code);
}
void PinProcess::createCodeInput()
{
codeInput_ = view_->bindNew<Wt::WLineEdit>("input");
codeInput_->enterPressed().connect([this] {
checkCodeInput();
});
// Validation message
view_->bindEmpty("code-info");
}
void PinProcess::createLoginButton()
{
auto login = view_->bindNew<Wt::WPushButton>("login", tr("Wt.Auth.login"));
login->clicked().connect([this] {
checkCodeInput();
});
}
void PinProcess::createRememberMe()
{
view_->setCondition("if:remember-me", true);
rememberMeField_ = view_->bindNew<Wt::WCheckBox>("remember-me");
int days = baseAuth().mfaTokenValidity() / 24 / 60;
Wt::WString info;
if (days % 7 != 0) {
info = Wt::WString::trn("Wt.Auth.remember-me-info.days", days).arg(days);
} else if (days == 0) {
info = Wt::WString::tr("Wt.Auth.remember-me-info.indefinite");
} else {
info = Wt::WString::trn("Wt.Auth.remember-me-info.weeks", days/7).arg(days/7);
}
view_->bindString("remember-me-info", info);
}
void PinProcess::checkCodeInput()
{
const std::string enteredCode = codeInput_->text().toUTF8();
auto savedCode = currentCode_.empty() ? userIdentity() : currentCode_;
if (enteredCode != savedCode) {
update();
authenticated_.emit(Wt::Auth::Mfa::AuthenticationResult(Wt::Auth::Mfa::AuthenticationStatus::Failure, "The validation failed"));
} else {
createUserIdentity(savedCode);
if (rememberMeField_->isChecked()) {
setRememberMeCookie(login().user());
}
login().login(login().user());
authenticated_.emit(Wt::Auth::Mfa::AuthenticationResult(Wt::Auth::Mfa::AuthenticationStatus::Success));
}
}
void PinProcess::update()
{
codeInput_->addStyleClass("is-invalid Wt-invalid");
// Validation message and color
view_->bindString("code-info", "Wrong PIN code");
view_->bindString("label", "invalid-feedback");
}
To ensure that the correct widget is shown (and not the default
TotpProcess
), one needs to override createMfaProcess()
.
Here we will also take care of the additional authenticated
signal.
This will make sure that the application can listen to it.
std::unique_ptr<Wt::Auth::Mfa::AbstractMfaProcess> AuthWidget::createMfaProcess()
{
auto process = std::make_unique<PinProcess>(*model()->baseAuth(), model()->users(), login());
process->authenticated().connect([this](Wt::Auth::Mfa::AuthenticationResult res) {
authenticated_.emit(res);
});
return std::move(process);
}
A user can now register, or using the default setup use the "admin:admin"
or "user:user" accounts. They’ll notice that after they log in, or follow
the link to register, they will be shown the PinProcess
. Only after
they enter the same PIN that is displayed to them, will they be
authenticated.
One concern that is indicated in the documentation in a note on AbstractMfaProcess
,
is that the Login::changed()
signal is fired BOTH when the user passes the normal username/password
authentication step, AND when they pass the MFA step. This is why we
make use of a custom signal authenticated
.
5.5. Custom implementation (far from default)
THIS IS NOT A RECOMMENDED MFA STRATEGY, it is purely illustrative to demonstrate the API flexibility
The previous example offers a very basic case, with minimal configuration,
but also with minimal advantage. We will now discuss another way to
approach MFA, with a more custom widget. This takes some inspiration from
the examples/qrlogin
. While it is far from
a good approach to MFA again, it will illustrate some more nuance, than
the previous example, and guide any developer towards the way to implement
fully custom MFA processes.
5.5.1. Overview of the application
So, what does this application do? You can find the complete source at
examples/authentication/mfa/phone
.
This requires your phone to be able to access your application.
Simply speaking, it will always display a QR code. This code can be scanned by your smartphone, which will direct it to an endpoint, managed by this application. Upon initially using this, some information of your phone will be stored in the database. This will not be stored as the identity, but in a more complex table. That is the end of the setup phase.
After each subsequent login, the user will then be prompted to scan a similar QR code, which that checks if the already saved data from the user’s phone corresponds with the current access. If it does, the MFA step will be successful.
This is again purely illustrative. The metrics taken from the phone are not a good way to uniquely identify a user. The phone can be changed out, a software update or a different browser may produce different results. So it is not a robust MFA step. It is only a way to illustrate what is necessary to ensure a proper MFA implementation that integrates well with Wt.
Important note: This will not work correctly if you use session tracking
"Combined". If the QR code is then scanned on your phone, Wt will complain
that no multi-session cookie is present in the browser. To get around this
use the session tracking configuration of "URL". This is set in wt_config.xml
.
This exposes the session of the login to the QR code, and thus to any
device that scans the QR code. After the MFA step this session ID is
replaced, but this is still not a good idea. This example is purely
illustrative and should NOT BE IMPLEMENTED in ANY production
environment.
5.5.2. First configuration
We will again start from a very basic Session
. This one will be
identical to the one from above, except that it will map an extra class.
Like with the above example, a default "admin:admin", and "user:user"
account are provided for convenience. The user account does not require
the MFA step, whereas the admin account does. This is to avoid having MFA
be forced on each user. We no longer set setMfaRequired()
to be true
. Now we define this for each user separately, using the
MyUser::requires_mfa_
member.
mapClass<AuthEntry>("auth_entry");
This class will contain information about the phone that initially was used to set up MFA. And thus if subsequent requests are made through the "same phone". As the metrics gathered are very basic, the "same phone" is between heavy quotation marks. This can obviously be extended, but as said before. This is purely illustrative.
#pragma once #include "myuser.h" #include "Wt/Dbo/Dbo.h" class AuthEntry : public Wt::Dbo::Dbo<AuthEntry> { public: AuthEntry() = default; AuthEntry(const Wt::Dbo::ptr<AuthInfo>& authInfo, const std::string& host, const std::string& userAgent, const std::string& language) : authInfo_(authInfo), host_(host), userAgent_(userAgent), language_(language) { } const std::string& host() const { return host_; } const std::string& userAgent() const { return userAgent_; } const std::string& language() const { return language_; } template<class Action> void persist(Action& a) { Wt::Dbo::belongsTo(a, authInfo_, "auth_info"); Wt::Dbo::field(a, host_, "host"); Wt::Dbo::field(a, userAgent_, "userAgent"); Wt::Dbo::field(a, language_, "language"); } private: Wt::Dbo::ptr<AuthInfo> authInfo_; std::string host_; std::string userAgent_; std::string language_; };
We then come to the widget that actually manages the login, which then
inherits from AbstractMfaProcess
.
This we will call PhoneProcess
for now.
It follows the same structure as the PinProcess
, or TotpProcess
,
but differs how it show its information. This time we will use a
WDialog
to contain the widget and show
it in the UI.
One other major difference is that we set up a WResource
that is going to manage the interaction with our phone. We will call this
class QrCodeHandler
.
The header of the PhoneProcess
looks like:
#pragma once
#include "mysession.h"
#include "qrcodehandler.h"
#include "Wt/Auth/Mfa/AbstractMfaProcess.h"
#include "Wt/WCheckBox.h"
#include "Wt/WDialog.h"
#include "Wt/WLineEdit.h"
#include "Wt/WTemplate.h"
class PhoneProcess final : public Wt::Auth::Mfa::AbstractMfaProcess
{
public:
PhoneProcess(MySession& session);
std::unique_ptr<Wt::WWidget> createSetupView() final;
std::unique_ptr<Wt::WWidget> createInputView() final;
void processEnvironment() final;
void setUpUserIdentity();
Wt::Signal<Wt::Auth::Mfa::AuthenticationResult>& authenticated() { return authenticated_; }
private:
MySession& session_;
std::unique_ptr<QrCodeHandler> qrHandler_;
Wt::WTemplate* view_ = nullptr;
bool doRememberMe_ = false;
Wt::Signal<Wt::Auth::Mfa::AuthenticationResult> authenticated_;
void createQRCode(bool isSetup);
void createRememberMe();
void createQRHandlerResource();
std::unique_ptr<Wt::WTemplate> createBaseView(bool isSetup);
};
Its implementation will look like:
#include "phonewidget.h"
#include "qrcodepainter.h"
#include "Wt/Auth/AuthService.h"
#include "Wt/Auth/Login.h"
#include "Wt/WApplication.h"
#include "Wt/WPushButton.h"
#include "Wt/WWidget.h"
PhoneProcess::PhoneProcess(MySession& session)
: AbstractMfaProcess(session.auth(), session.users(), session.login()),
session_(session)
{
createQRHandlerResource();
}
void PhoneProcess::processEnvironment()
{
Wt::Auth::User user = processMfaToken();
if (user.isValid()) {
login().login(user, Wt::Auth::LoginState::Weak);
authenticated_.emit(Wt::Auth::Mfa::AuthenticationResult(Wt::Auth::Mfa::AuthenticationStatus::Success));
return;
}
}
void PhoneProcess::setUpUserIdentity()
{
createUserIdentity("verified");
}
void PhoneProcess::createQRHandlerResource()
{
qrHandler_ = std::make_unique<QrCodeHandler>(session_);
qrHandler_->allowSignin().connect([this] {
{
auto app = Wt::WApplication::instance();
Wt::WApplication::UpdateLock lock(app);
login().login(login().user());
app->triggerUpdate();
}
authenticated_.emit(Wt::Auth::Mfa::AuthenticationResult(Wt::Auth::Mfa::AuthenticationStatus::Success));
if (doRememberMe_) {
setRememberMeCookie(login().user());
}
});
}
std::unique_ptr<Wt::WTemplate> PhoneProcess::createBaseView(bool isSetup)
{
auto view = std::make_unique<Wt::WTemplate>(Wt::WTemplate::tr("phone-template"));
view->addFunction("id", &Wt::WTemplate::Functions::id);
view->addFunction("tr", &Wt::WTemplate::Functions::tr);
view_ = view.get();
return view;
}
std::unique_ptr<Wt::WWidget> PhoneProcess::createSetupView()
{
auto view = createBaseView(true);
createQRCode(true);
createRememberMe();
return std::move(view);
}
std::unique_ptr<Wt::WWidget> PhoneProcess::createInputView()
{
auto view = createBaseView(false);
createQRCode(false);
createRememberMe();
return std::move(view);
}
void PhoneProcess::createQRCode(bool isSetup)
{
std::string handleUrl = Wt::WApplication::instance()->makeAbsoluteUrl(qrHandler_->url());
if (isSetup) {
handleUrl += "&issetup";
}
view_->bindNew<QrCodePainter>("code", handleUrl);
}
void PhoneProcess::createRememberMe()
{
view_->setCondition("if:remember-me", true);
auto rememberMe = view_->bindNew<Wt::WCheckBox>("remember-me");
rememberMe->changed().connect([this] { doRememberMe_ = true; });
int days = baseAuth().mfaTokenValidity() / 24 / 60;
Wt::WString info;
if (days % 7 != 0) {
info = Wt::WString::trn("Wt.Auth.remember-me-info.days", days).arg(days);
} else if (days == 0) {
info = Wt::WString::tr("Wt.Auth.remember-me-info.indefinite");
} else {
info = Wt::WString::trn("Wt.Auth.remember-me-info.weeks", days/7).arg(days/7);
}
view_->bindString("remember-me-info", info);
}
The way we generate the URL is not that important here, and will be
omitted. What is important is the way that we choose to handle the
incoming requests that come from any phone that scans the QR code. This
will be handled by QrCodeHandler
.
#pragma once
#include "mysession.h"
#include "Wt/Http/Request.h"
#include "Wt/Http/Response.h"
#include "Wt/WResource.h"
class PhoneProcess;
class QrCodeHandler final : public Wt::WResource
{
public:
QrCodeHandler(MySession& session, PhoneProcess* phoneWidget);
void handleRequest(const Wt::Http::Request& request, Wt::Http::Response& response) final;
Wt::Signal<>& allowSignin() { return allowSignin_; }
private:
MySession& session_;
PhoneProcess* phoneWidget_ = nullptr;
Wt::Signal<> allowSignin_;
};
This is simply a WResource
that
listens to an incoming request, tied to the session. A response will be
generated that essentially contains either 200/400 status and a small
string that indicates the result of the request. The handleRequest()
method will check some headers of the incoming request, and check them
against the database.
A request can have issetup appended to it, indicating the record is to be created still, and it’s the user’s first time using MFA. Otherwise the current request is compared to the existing record.
When creating a new record, or when a match has taken place, the response is a HTTP 200. Otherwise it will be 400.
#include "qrcodehandler.h"
#include "authentry.h"
QrCodeHandler::QrCodeHandler(MySession& session, PhoneProcess* phoneWidget)
: session_(session),
phoneWidget_(phoneWidget)
{
}
void QrCodeHandler::handleRequest(const Wt::Http::Request& request, Wt::Http::Response& response)
{
std::string userAgent = request.headerValue("User-Agent");
if (userAgent.find("Mobile") == std::string::npos) {
response.setStatus(400);
return;
}
const Wt::Auth::User& user = session_.login().user();
if (!user.isValid()) {
response.setStatus(400);
return;
}
std::string query = request.queryString();
bool isSetup = query.find("issetup") != std::string::npos;
std::string host = request.headerValue("Host");
std::string language = request.headerValue("Accept-Language");
Wt::Dbo::Transaction t(session_);
const Wt::Dbo::ptr<AuthInfo>& authInfo = session_.find<AuthInfo>("where id = ?").bind(user.id());
Wt::Dbo::ptr<AuthEntry> entry = session_.find<AuthEntry>("where auth_info_id = ?").bind(authInfo);
if (!entry && !isSetup) {
response.setStatus(400);
return;
} else if (!entry && isSetup) {
session_.addNew<AuthEntry>(authInfo, host, userAgent, language);
phoneWidget_->setUpUserIdentity();
response.setStatus(200);
response.out() << "An entry was created.";
allowSignin_.emit();
return;
} else if (entry && isSetup) {
response.setStatus(400);
response.out() << "An entry already exists. Cannot set up a new entry.";
return;
}
if (entry->host() == host && entry->userAgent() == userAgent && entry->language() == language) {
response.setStatus(200);
response.out() << "Validation has been accepted.";
allowSignin_.emit();
} else {
response.setStatus(400);
response.out() << "Validation has been denied.";
}
}
At the end we emit a signal allowSignin_
, if a valid request has
taken place. This will indicate to the application that a successful MFA
step was completed, and that the user can be properly logged in.
To ensure that we use the right widget, we again need to do some
overriding of methods of AuthWidget
.
This is similar to the last example, where we specify which widget needs
to be created in createMfaWidget()
.
But we do need one more entry. Since we no longer rely on the basic
implementation, which would create an Identity
,
the application cannot rely on the default link:../reference/html/classWt_1_1Auth_1_1AuthWidget.html#a31f1e6343c9e298e071c9f525be29981[createMfaView()`].
We *can* opt to set the `+Identity
as well, but this shows how one can
get around that if desired. We then need logic to decide whether the setup
or input state of the MFA process needs to be displayed.
The call to processMfaToken()
makes sure that the remember-me functionality can be used.
We also perform somewhat of a hack here, by calling createUserIdentity()
.
Done throught the wrapper of setUpUserIdentity
, so it can be
performed in the QrCodeHandler
. This identity is meant to keep track
of whether the user can be identified by a certain MFA provider. The
value that is attached to it is normally more descriptive, or contains
some MFA specific stuff.
In the default TOTP approach for example, it will contain the
secret key. This MFA approach however does not feature such a
centralised key, but leans in the data in AuthEntry
.
We can provide a custom implementation of processMfaToken()
to take this into account, but this seems a little out of scope. Hence
the choice to generate a "placeholder" identity.
Normally this identity should, for convenience’s sake, carry the same
name as setMfaProvider()
.
However, this case also shows that the environment token (cookie) can be
matched regardless of the identity name. This can be added as an extra
requirement. But if a developer opts for this, do take into account that
this may invalidate existing cookies for users that already clicked the
"remember-me" checkbox.
#pragma once
#include "mysession.h"
#include "Wt/Auth/AuthWidget.h"
class MyAuthWidget final : public Wt::Auth::AuthWidget
{
public:
MyAuthWidget(MySession& session);
std::unique_ptr<Wt::Auth::Mfa::AbstractMfaProcess> createMfaProcess() final;
void createMfaView() final;
private:
MySession& session_;
};
With the following implementation:
#include "myauthwidget.h"
#include "authentry.h"
#include "phoneprocess.h"
#include "Wt/Dbo/Transaction.h"
MyAuthWidget::MyAuthWidget(MySession& session)
: Wt::Auth::AuthWidget(session.auth(), session.users(), session.login()),
session_(session)
{
}
std::unique_ptr<Wt::Auth::Mfa::AbstractMfaProcess> MyAuthWidget::createMfaProcess()
{
return std::make_unique<PhoneProcess>(session_);
}
void MyAuthWidget::createMfaView()
{
phoneProcess_ = createMfaProcess();
PhoneProcess* process = dynamic_cast<PhoneProcess*>(phoneProcess_.get());
const Wt::Auth::User user = session_.login().user();
Wt::Dbo::Transaction t(session_);
Wt::Dbo::ptr<AuthEntry> hasEntry = session_.find<AuthEntry>("where auth_info_id = ?").bind(user.id());
auto dialog = addChild(std::make_unique<Wt::WDialog>("MFA Phone Verification"));
if (!hasEntry) {
dialog->contents()->addWidget(std::move(process->createSetupView()));
dialog->finished().connect([dialog] {
dialog->parent()->removeChild(dialog);
});
dialog->show();
} else {
process->processEnvironment();
if (login().state() != Wt::Auth::LoginState::RequiresMfa) {
return;
}
dialog->contents()->addWidget(std::move(process->createInputView()));
dialog->finished().connect([dialog] {
dialog->parent()->removeChild(dialog);
});
dialog->show();
}
process->authenticated().connect([this, dialog](Wt::Auth::Mfa::AuthenticationResult result) {
if (result.status() == Wt::Auth::Mfa::AuthenticationStatus::Success) {
createLoggedInView();
Wt::WApplication::instance()->triggerUpdate();
dialog->accept();
}
});
}
We also made another important change, namely to hasMfaStep()
,
which is used to indicate whether a user should be displayed the MFA step
or not. This can be used to enforce the MFA step to show, even if
mfaRequired()
is set to false
(but it should be enabled mfaEnabled()
).
#pragma once
#include "mysession.h"
#include "Wt/Auth/AuthModel.h"
class MyAuthModel : public Wt::Auth::AuthModel
{
public:
MyAuthModel(MySession& session);
protected:
bool hasMfaStep(const Wt::Auth::User& user) const final;
private:
MySession& session_;
};
This will now check whether the user has the requires_mfa
field set
in the database, attached to them. This does also require the MyUser
class to change slightly, to allow for this value to be set.
It has the following implementation:
#include "myauthmodel.h"
#include "Wt/Dbo/Transaction.h"
#include "authentry.h"
MyAuthModel::MyAuthModel(MySession& session)
: Wt::Auth::AuthModel(session.auth(), session.users()),
session_(session)
{
}
bool MyAuthModel::hasMfaStep(const Wt::Auth::User& user) const
{
Wt::Dbo::Transaction t(session_);
Wt::Dbo::Query<bool> result = session_.query<bool>("select requires_mfa from user join auth_info on auth_info.user_id = user.id where auth_info.id = ?")
.bind(user.id());
return result.resultValue();
}
And thus also the following changes to the MyUser
record.
public:
...
void setRequiresMfa(bool value)
{
requiresMfa_ = value;
}
template<class Action>
void persist(Action& a)
{
...
Wt::Dbo::field(a, requiresMfa_, "requires_mfa");
...
}
private:
...
bool requiresMfa_ = false;
...
Do also note that there are a couple places where we call UpdateLock
together with triggerUpdate()
.
This is necessary because we are no longer working in the main event loop.
With a normal authentication widget, we will listen to the user to update
the server. They will press "Login", or enter in the last field. This will
prompt the server to enter its main loop, (see notify()
)
and respond to the request after the client submits it.
However, in this case we the server is listening not to actual user input, but to a response coming into the resource. As such, we need to update the UI based on what happens there. This may happen at any point, and from any thread of the application. Updates to the DOM tree should be done in a single thread (the main UI thread). This can be done by taking the application update lock.
To facilitate this we do several things:
-
we attach a handler to the
WCheckBox
that is used to remember the MFA step. Which will prompt an update on the server side. There we keep track of the state indoRememberMe_
. -
once we then catch an authentication event. We check the state of the boolean and act accordingly (setting a cookie if necessary, using
setRememberMeCookie()
). We also calltriggerUpdate()
before that, to ensure that we push out the change insessionId()
. If we would not, Wt would lose track of the session, and try to contact the session that was just renamed by its old ID. -
last we also ensure that
showInputView()
doesn’t pop up when it shouldn’t. We guard this by checking the loginstate()
which is set either toLoginState::Weak
inprocessEnvironment()
if a token match is found, orLoginState::RequiresMfa
otherwise.
These examples show you how implement MFA in a very basic, slightly more custom, and a very custom manner. Using the tools at your disposal, you are now able to implement your own method if you so desire.