In this blog post, we are going to implement an authentication system with Spring Security that uses username and password and TOTP (Time-based One-Time Password) as the second factor.
We implement this system as a Spring Boot application. The application uses jOOQ to access the user information that is stored in a file-based H2 database.
The client is an Angular web application written in TypeScript and uses the UI library PrimeNG. But the focus for this blog post is the Java code, and I don't discuss the client code. The solution I present here should work with any client-side framework. If you are interested in the client code, you find it on GitHub: https://github.com/ralscha/springsecuritytotp/tree/master/client
TOTP ¶
TOTP (Time-based One-Time Password) is a mechanism that is added as the second factor to a username/password authentication flow to increase security.
TOTP is an algorithm based on the HOTP (HMAC-based One-time Password) but uses a time-based component instead of a counter.
TOTP and HOTP depend on a secret that two parties share. The secret is a randomly generated token that is usually displayed in Base32 to the user. The server generates the secret, stores it into the database during the sign-up process, and shows it to the user. The user then types or copies the secret into an authenticator app that supports TOTP.
Many TOTP apps are available for mobile devices, desktops, and browsers. I use the Google Authenticator on an Android device.
Demo application ¶
You find the source for the demo application on GitHub:
https://github.com/ralscha/springsecuritytotp
Here is the online demo: https://totp-omed.hplar.ch/index.html
The server
directory contains the Spring Boot application and can be started from a shell with ./mvnw spring-boot:run
.
The Angular application is located in the client
folder, and you can start it with ng serve
.
The first time you start the server, it creates the database and inserts three users.
Username | Password | Secret | ||
---|---|---|---|---|
admin | admin | W4AU5VIXXCPZ3S6T | ||
user | user | LRVLAZ4WVFOU3JBF | ||
lazy | lazy |
The demo application supports users with and without 2nd-factor authentication (2FA).
Install a TOTP authenticator app create a new entry with the given secret.
You can either scan the QR code or enter the secret manually. The QR code is also "clickable" because the image is wrapped in a <a>
tag with a otpauth://
href.
A click on such a link should open an installed authenticator app.
Base ¶
The server application is a regular Spring Boot application, created with Spring Initializr.
I added security
, jooq
, flyway
, and web
as dependencies. Open the pom.xml
to see all the dependencies.
The application uses jOOQ to access the database and Flyway for database migrations. The setup in this application follows the description in my blog post about jOOQ
I also added aerogear-otp as an additional dependency to the project.
<dependency>
<groupId>org.jboss.aerogear</groupId>
<artifactId>aerogear-otp-java</artifactId>
<version>1.0.0</version>
</dependency>
The library provides methods for verifying TOTP codes and for generating secrets. Because I had a few additional requirements, I wrote my own TOTP verifier, based on the aerogear-top code.
Database ¶
The demo application uses this table to store user information.
CREATE TABLE app_user (
id BIGINT NOT NULL AUTO_INCREMENT,
username VARCHAR(255) NOT NULL,
password_hash VARCHAR(255),
secret VARCHAR(16),
enabled BOOLEAN not null,
additional_security BOOLEAN not null,
PRIMARY KEY(id),
UNIQUE(username)
);
username
and password_hash
are used for the traditional username/password login (first factor).
secret
is required for the second-factor authentication with TOTP. This is the code that the client and server have to share.
You see how the demo application exchanges this secret in the sign-up process.
additional_security
is an important flag used during the sign-in workflow. Initially, this flag is false
.
When a user enters the wrong TOTP code, this flag will be set to true
, requiring additional security verification. You learn more about this flag in the sign-in section.
enabled
is used for the registration process. The sign-up workflow consists of two steps. The user is inserted into the database during the first step. But at this time, the application doesn't know if the user has finished the registration process. So the handler that inserts the user sets this flag to false
. Because this flag is false, the user can't log in yet. When the user finishes the sign-up process successfully, the handler changes the value of this field to true
.
Spring Security ¶
I wrote my own authentication system for this application, so I disabled the Spring Boot auto-configuration of Spring Security with the following code.
import com.codahale.passpol.BreachDatabase;
import com.codahale.passpol.PasswordPolicy;
@Configuration
public class SecurityConfig {
@Bean
AuthenticationManager authenticationManager() {
return authentication -> {
The application still uses Spring Security for authorization. Therefore, this code only disables the authentication part of Spring Security.
The application uses Argon2 for password hashing.
};
}
@Bean
The Spring Security documentation recommends to tune the parameters to take about 1 second to verify a password on your system.
Note that Argon2PasswordEncoder
is a class provided by the Spring Security library, but it depends on Bouncy Castle.
Add the following dependency to your project.
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.70</version>
</dependency>
This demo leverages the traditional HTTP session with the session cookie approach. This is not a requirement for TOTP, and you can use other authentication workflows like JWT.
The application configures Spring Security with the following code.
}
@Bean
public DelegatingSecurityContextRepository delegatingSecurityContextRepository() {
return new DelegatingSecurityContextRepository(
new RequestAttributeSecurityContextRepository(),
new HttpSessionSecurityContextRepository());
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, MvcRequestMatcher.Builder mvc)
throws Exception {
http.csrf(CsrfConfigurer::disable)
.securityContext(securityContext -> securityContext
I disable CSRF protection in this demo application. Don't do that in a production application if you use session cookies for authentication.
Unless you only target modern browsers and use same-site=strict
cookies, protecting against CSRF attacks.
See my blog post, where I demonstrate a solution with same-site
cookies.
The code then configures a list of endpoints that don't need authentication. These are all part of the sign-up and sign-in workflow. I describe these endpoints in more detail in the following sections.
Every endpoint that is not listed can't be accessed without authentication.
Lastly, the application configures the logout handler. This handler by default sends back a redirect request, but for single-page
applications, it is easier when the endpoint returns an HTTP status code. This is what HttpStatusReturningLogoutSuccessHandler
does; it returns the status 200 by default. You can change this by passing another code to the constructor.
Sign Up ¶
The sign-up workflow consists of three pages. On the first page, the user enters his username and password, and if he wants to enable two-factor authentication. If he selects the 2FA checkbox, the application displays a random secret as a QR code on the next page (1a). The user has to create a new entry in his authenticator app and enter the given secret. Then he has to verify the registration with the code the authenticator app shows him. Finally, the application displays a success message if the verification code is valid (2).
1. Username and Password ¶
The client application sends username, password, and the value of the 2FA checkbox to the /signup
endpoint. This handler receives the three input values as request parameters and sends back SignupResponse
, which is converted into a JSON.
@PostMapping("/signup")
public SignupResponse signup(@RequestParam("username") @NotEmpty String username,
@RequestParam("password") @NotEmpty String password,
@RequestParam("totp") boolean totp) {
SignupResponse
contains the following fields.
public class SignupResponse {
enum Status {
OK, USERNAME_TAKEN, WEAK_PASSWORD
}
private final Status status;
private final String username;
private final String secret;
The /signup
handler first checks with a SQL select statement if a user is already registered with the same username.
If yes, the handler returns the status USERNAME_TAKEN
.
int count = this.dsl.selectCount().from(APP_USER)
.where(APP_USER.USERNAME.equalIgnoreCase(username)).fetchOne(0, int.class);
if (count > 0) {
return new SignupResponse(SignupResponse.Status.USERNAME_TAKEN);
}
Next, the handler checks if the password conforms with the configured password policy. This application leverages the passpol library for this purpose.
If the given password fails the policy check, the handler returns the status WEAK_PASSWORD
.
Status status = this.passwordPolicy.check(password);
if (status != Status.OK) {
return new SignupResponse(SignupResponse.Status.WEAK_PASSWORD);
}
The handler must create a secret if the user selected the 2FA checkbox. The method then inserts the user into the database and sends back an OK
response with username and secret. Note that the application sets the enabled
field to false. The user is not fully registered yet, because he has to validate the TOTP code in the second step. There are other ways to implement that. You could, for example, store the user information in the HTTP session and only insert the user if the validation was successful.
if (totp) {
String secret = Base32.random();
this.dsl
.insertInto(APP_USER, APP_USER.USERNAME, APP_USER.PASSWORD_HASH,
APP_USER.ENABLED, APP_USER.SECRET, APP_USER.ADDITIONAL_SECURITY)
.values(username, this.passwordEncoder.encode(password), false, secret, false)
.execute();
return new SignupResponse(SignupResponse.Status.OK, username, secret);
}
If the user did not enable 2FA, the handler inserts the user and sets enabled
to true.
The user can now log in.
this.dsl
.insertInto(APP_USER, APP_USER.USERNAME, APP_USER.PASSWORD_HASH, APP_USER.ENABLED,
APP_USER.SECRET, APP_USER.ADDITIONAL_SECURITY)
.values(username, this.passwordEncoder.encode(password), true, null, false)
.execute();
return new SignupResponse(SignupResponse.Status.OK);
2. Verification ¶
After creating a new entry in his authenticator app, the user enters the current TOTP code into the field. Then, the client sends this code to the /signup-confirm-secret
endpoint.
The handler reads the user from the database and verifies the code. If the user exists and the TOTP code is valid,
the handler sets the enabled
field on the user record to true
and responds with true
.
@PostMapping("/signup-confirm-secret")
public boolean signupConfirmSecret(@RequestParam("username") String username,
@RequestParam("code") @NotEmpty String code) {
var record = this.dsl.select(APP_USER.ID, APP_USER.SECRET).from(APP_USER)
.where(APP_USER.USERNAME.eq(username)).fetchOne();
if (record != null) {
String secret = record.get(APP_USER.SECRET);
Totp totp = new Totp(secret);
if (totp.verify(code)) {
this.dsl.update(APP_USER).set(APP_USER.ENABLED, true)
.where(APP_USER.ID.eq(record.get(APP_USER.ID))).execute();
return true;
}
}
return false;
}
Sign In ¶
The sign-in workflow consists of three pages and the home page, which is only displayed when the login is successful. The user enters his username and password on the first page, and the application sends them to the back end.
If the username and password are correct, the application redirects the user to a second dialog where he has to enter his current TOTP code (1a). Users without 2FA are redirected directly to the home page (1b).
The application displays the home screen if the TOTP code is correct (2a). However, suppose the user enters an incorrect code. In that case, the application sets the user into the "additional verification" mode by setting the database field additional_security
to true
, and displaying a mask where the user has to enter three consecutive TOTP codes (2b).
The purpose of this "additional verification" mode is to prevent brute force attacks. For example, imaging an attacker knows the username and password. The TOTP code is only a six-digit number, so there are only 1 million possible codes. The code changes every 30 seconds. This demo application considers codes up to 1 minute in the past and up to 1 minute in the future as valid, so five codes are valid at any time. An attacker only has to send multiple requests and try all TOTP codes between 000000 and 999999 until the system lets him in, and the chance that this is happening is relatively high.
A legitimate user can also enter the "additional verification" mode. Either by mistyping the TOTP code, or when the system clock on his device is not in sync with the clock on the server. This demo application only tolerates a difference of +1 and -1 minutes.
After the user enters the three consecutive codes, the server validates them. This validation considers every TOTP code in the period -25 and +25 hours. If they are correct and consecutive, the application resets the database's flag and lets the user into the application.
0. Application Start ¶
The application's first action when it has been started is to send a GET request to the /authenticate
endpoint. This endpoint checks if the user is logged in or not. Depending on the server response, the web client either presents the sign-in dialog or the home page.
The application fetches the authentication object from the security context to check if a user is logged in. This object is of type AppUserAuthentication
, and the application inserts the object into the security context after a successful sign-in attempt.
@GetMapping("/authenticate")
public AuthenticationFlow authenticate(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth instanceof AppUserAuthentication) {
return AuthenticationFlow.AUTHENTICATED;
}
HttpSession httpSession = request.getSession(false);
if (httpSession != null) {
httpSession.invalidate();
}
return AuthenticationFlow.NOT_AUTHENTICATED;
}
1. Username + Password ¶
The signin
endpoint receives username and password. But, first, it fetches the user record from the database.
@PostMapping("/signin")
public ResponseEntity<AuthenticationFlow> login(@RequestParam String username,
@RequestParam String password, HttpSession httpSession, HttpServletRequest request,
HttpServletResponse response) {
AppUserRecord appUserRecord = this.dsl.selectFrom(APP_USER)
If the user exists, the handler checks if the given password matches the password in the database.
if (appUserRecord != null) {
boolean pwMatches = this.passwordEncoder.matches(password,
appUserRecord.getPasswordHash());
if (pwMatches && appUserRecord.getEnabled().booleanValue()) {
If the passwords match, the handler creates an AppUserAuthentication
instance and stores it into the HTTP session when the user has 2FA enabled.
If the user is in "additional verification" mode, the handler sends back the string "TOTP_ADDITIONAL_SECURITY"
in the response body, otherwise "TOTP"
.
If the user does not have 2FA enabled, the code puts the authentication object into the security context and sends back the string "AUTHENTICATED"
.
Spring Security handles the authentication object from here on. It stores it into the HTTP session, creating a session cookie.
AppUserDetail detail = new AppUserDetail(appUserRecord);
AppUserAuthentication userAuthentication = new AppUserAuthentication(detail);
if (isNotBlank(appUserRecord.getSecret())) {
httpSession.setAttribute(USER_AUTHENTICATION_OBJECT, userAuthentication);
if (isUserInAdditionalSecurityMode(detail.getAppUserId())) {
return ResponseEntity.ok().body(AuthenticationFlow.TOTP_ADDITIONAL_SECURITY);
}
return ResponseEntity.ok().body(AuthenticationFlow.TOTP);
}
SecurityContextHolder.getContext().setAuthentication(userAuthentication);
this.securityContextRepository.saveContext(SecurityContextHolder.getContext(),
request, response);
return ResponseEntity.ok().body(AuthenticationFlow.AUTHENTICATED);
If the user does not exist, the handler runs a fake plaintext password through the password encoder. This is important to hide the fact that the user does not exist.
If the method would return without this step, an attacker can deduce from the difference in response time if a user exists or not.
The handler returns "NOT_AUTHENTICATED"
in the response's body.
else {
this.passwordEncoder.matches(password, this.userNotFoundEncodedPassword);
}
2. TOTP code ¶
The /verify-totp
receives the TOTP code and checks if an AppUserAuthentication
instance is stored in the HTTP session.
If there is no such object, the method returns the string NOT_AUTHENTICATED
in the response body. That means the user did not sign with username/password.
@PostMapping("/verify-totp")
public ResponseEntity<AuthenticationFlow> totp(@RequestParam String code,
HttpSession httpSession, HttpServletRequest request, HttpServletResponse response) {
AppUserAuthentication userAuthentication = (AppUserAuthentication) httpSession
.getAttribute(USER_AUTHENTICATION_OBJECT);
if (userAuthentication == null) {
return ResponseEntity.ok().body(AuthenticationFlow.NOT_AUTHENTICATED);
}
Next, the handler must check if the user is in "additional verification" mode. This is, as explained before, to thwart brute force attacks.
If the user is in this mode, the method returns the string "TOTP_ADDITIONAL_SECURITY"
.
AppUserDetail detail = (AppUserDetail) userAuthentication.getPrincipal();
if (isUserInAdditionalSecurityMode(detail.getAppUserId())) {
return ResponseEntity.ok().body(AuthenticationFlow.TOTP_ADDITIONAL_SECURITY);
}
If the user is not in "additional verification" mode, verify the given TOTP code with the secret stored in the database. If the code is valid, the method puts the AppUserAuthentication
instance into the security context and sends back "AUTHENTICATED"
.
If the code is invalid, the application puts the user into "additional verification" mode by setting the additional_security
field in the user record to true
, then sending back the response "TOTP_ADDITIONAL_SECURITY"
.
String secret = ((AppUserDetail) userAuthentication.getPrincipal()).getSecret();
if (isNotBlank(secret) && isNotBlank(code)) {
CustomTotp totp = new CustomTotp(secret);
if (totp.verify(code, 2, 2).isValid()) {
SecurityContextHolder.getContext().setAuthentication(userAuthentication);
this.securityContextRepository.saveContext(SecurityContextHolder.getContext(),
request, response);
return ResponseEntity.ok().body(AuthenticationFlow.AUTHENTICATED);
}
setAdditionalSecurityFlag(detail.getAppUserId());
return ResponseEntity.ok().body(AuthenticationFlow.TOTP_ADDITIONAL_SECURITY);
}
With the 2nd and 3rd arguments of the verify()
method, you can tweak how many 30-seconds intervals the method should check into the past and future.
Here the method is configured to check two intervals into the past and the future.
3. Additional Security Verification ¶
The /verify-totp-additional-security
endpoint receives the three TOTP codes as request parameters and checks if an AppUserAuthentication
instance is stored in the HTTP session. If not, then the user did not sign in with username and password, and the method returns "NOT_AUTHENTICATED"
public ResponseEntity<AuthenticationFlow> verifyTotpAdditionalSecurity(
@RequestParam String code1, @RequestParam String code2, @RequestParam String code3,
HttpSession httpSession, HttpServletRequest request, HttpServletResponse response) {
AppUserAuthentication userAuthentication = (AppUserAuthentication) httpSession
.getAttribute(USER_AUTHENTICATION_OBJECT);
if (userAuthentication == null || code1.equals(code2) || code1.equals(code3)
|| code2.equals(code3)) {
return ResponseEntity.ok().body(AuthenticationFlow.NOT_AUTHENTICATED);
}
Next, the handler must check if the three codes are valid and consecutive. He does that with the help of the verify()
method of the CustomTotp
class. This method expects the codes in a List
as the first argument. 2nd and 3rd arguments define the number of 30-second intervals the method should check. This example goes back 25 hours and forward 25 hours.
String secret = ((AppUserDetail) userAuthentication.getPrincipal()).getSecret();
if (isNotBlank(secret) && isNotBlank(code1) && isNotBlank(code2)
&& isNotBlank(code3)) {
CustomTotp totp = new CustomTotp(secret);
// check 25 hours into the past and future.
long noOf30SecondsIntervals = TimeUnit.HOURS.toSeconds(25) / 30;
CustomTotp.Result result = totp.verify(List.of(code1, code2, code3),
noOf30SecondsIntervals, noOf30SecondsIntervals);
if (result.isValid()) {
if (result.getShift() > 2 || result.getShift() < -2) {
httpSession.setAttribute("totp-shift", result.getShift());
}
The verify()
method returns two values, a boolean (valid
) that says if the codes are valid. Valid means the method found the three codes in the given time range, and they are consecutive. If valid, the method also returns an integer that states the number of 30-second intervals the codes are in the past or the future. The handler stores this value into the session attribute "totp-shift"
, if it is outside the acceptable range of -2 and 2
If the three codes are valid, the handler sets the additional_security
field in the user record back to false
, puts the AppUserAuthentication
instance into the security context, and sends back "AUTHENTICATED
.
If the three codes are invalid, the handler returns "NOT_AUTHENTICATED"
, without further action.
if (result.getShift() > 2 || result.getShift() < -2) {
httpSession.setAttribute("totp-shift", result.getShift());
}
AppUserDetail detail = (AppUserDetail) userAuthentication.getPrincipal();
clearAdditionalSecurityFlag(detail.getAppUserId());
httpSession.removeAttribute(USER_AUTHENTICATION_OBJECT);
SecurityContextHolder.getContext().setAuthentication(userAuthentication);
this.securityContextRepository.saveContext(SecurityContextHolder.getContext(),
request, response);
return ResponseEntity.ok().body(AuthenticationFlow.AUTHENTICATED);
}
}
return ResponseEntity.ok().body(AuthenticationFlow.NOT_AUTHENTICATED);
4. TOTP time shift ¶
When the user enters the correct three codes, the application logs the user in and displays a message about how big the time difference is between the client and the server clock. To get this information, the client sends a GET request to the /totp-shift
endpoint.
This handler checks the session attribute "totp-shift"
that the application set in the previous step. If it does exist, the handler creates a human-readable string and sends it back to the client. The handler returns null
if there is no "totp-shift"
session attribute.
You've reached the end of this tutorial about setting up a Spring Security authentication system with username/password and TOTP as second-factor. If you find a bug or security issue open an issue on GitHub. If you have other questions send me a message.