This is the extension of the Spring Boot Angular Social Login application. In this article, we are going to implement Two Factor Authentication with Spring Security and a Soft Token.
What is Two Factor Authentication?
Two Factor Authentication follows the principle “something the user knows and something the user has“. Simply put, it adds one more level of security with a Time-based One Time Password (TOTP) verification on top of username and password based authentication.
What You’ll Build
Register
Registeration Success
Login
TOTP Verification
Login Success
What You’ll Need
- Spring Tool Suite 4
- JDK 11
- MySQL Server 8
- node.js
- Google / Microsoft Authenticator App
Design
Spring Boot (Backend) Implementation
We will be implementing 2FA authentication with Spring Security for performing 3 operations:
- Generating JWT – On passing the correct username and password, If the user enabled 2FA during registration, then it will generate a JSON Web Token (JWT) with an expiry time of 5 minutes. Also, put an
authenticated
flag into the token to indicate that the user is not fully authenticated yet. - Validating Soft Token (TOTP) – Expose a POST API with mapping /verify. On passing the correct token, it will generate a JSON Web Token (JWT) with an expiry time of 10 days and return it in the response along with the user details.
- Validating JWT – If a user tries to access the REST API, it will allow access only if request has a valid token. If
authenticated
flag in the token is false, then only thePRE_VERIFICATION_USER
role will be granted to the user. With this role, only the /verify endpoint can be accessed by the user.
We are going to use the Google / Microsoft Authenticator app to Scan the QR code for generating the soft token.
Add dependencies
There are multiple TOTP libraries available. We are going to use samstevens TOTP library since it has totp-spring-boot-starter
dependency for easy integration.
pom.xml
<dependency>
<groupId>dev.samstevens.totp</groupId>
<artifactId>totp-spring-boot-starter</artifactId>
<version>1.7.1</version>
</dependency>
Enable Two Factor Authentication
If the user opts for 2FA during registration, then we need to enable 2FA for that user and generate a secret key which will be used to validate the token when the user logs in.
User.java
Add the following fields in the User entity to store the 2FA option value and secret.
@Column(name = "USING_2FA")
private boolean using2FA;
private String secret;
Role.java
Add a constant for the PRE_VERIFICATION_USER
role.
public static final String ROLE_PRE_VERIFICATION_USER = "ROLE_PRE_VERIFICATION_USER";
UserServiceImpl.java
During user registration, if the user opted for 2FA authentication, then
- Set
using_2fa
flag totrue
- Generate a secret using the
SecretGenerator
from the TOTP library and set it in thesecret
field.
@Autowired
private SecretGenerator secretGenerator;
@Override
@Transactional(value = "transactionManager")
public User registerNewUser(final SignUpRequest signUpRequest) throws UserAlreadyExistAuthenticationException {
if (signUpRequest.getUserID() != null && userRepository.existsById(signUpRequest.getUserID())) {
throw new UserAlreadyExistAuthenticationException("User with User id " + signUpRequest.getUserID() + " already exist");
} else if (userRepository.existsByEmail(signUpRequest.getEmail())) {
throw new UserAlreadyExistAuthenticationException("User with email id " + signUpRequest.getEmail() + " already exist");
}
User user = buildUser(signUpRequest);
Date now = Calendar.getInstance().getTime();
user.setCreatedDate(now);
user.setModifiedDate(now);
user = userRepository.save(user);
userRepository.flush();
return user;
}
private User buildUser(final SignUpRequest formDTO) {
User user = new User();
user.setDisplayName(formDTO.getDisplayName());
user.setEmail(formDTO.getEmail());
user.setPassword(passwordEncoder.encode(formDTO.getPassword()));
final HashSet<Role> roles = new HashSet<Role>();
roles.add(roleRepository.findByName(Role.ROLE_USER));
user.setRoles(roles);
user.setProvider(formDTO.getSocialProvider().getProviderType());
user.setEnabled(true);
user.setProviderUserId(formDTO.getProviderUserId());
if (formDTO.isUsing2FA()) {
user.setUsing2FA(true);
user.setSecret(secretGenerator.generate());
}
return user;
}
TokenProvider.java
If authenticated
flag is true, then generate a long lived token else a short lived token
Put the authenticated
flag into the JWT claims. So that we can use this flag to determine the user role while validating the token in the request.
Add a method to retrieve this flag from the token
@Service
public class TokenProvider {
private static final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
private static final String AUTHENTICATED = "authenticated";
public static final long TEMP_TOKEN_VALIDITY_IN_MILLIS = 300000;
private AppProperties appProperties;
public TokenProvider(AppProperties appProperties) {
this.appProperties = appProperties;
}
public String createToken(LocalUser userPrincipal, boolean authenticated) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + (authenticated ? appProperties.getAuth().getTokenExpirationMsec() : TEMP_TOKEN_VALIDITY_IN_MILLIS));
return Jwts.builder().setSubject(Long.toString(userPrincipal.getUser().getId())).claim(AUTHENTICATED, authenticated).setIssuedAt(new Date()).setExpiration(expiryDate)
.signWith(SignatureAlgorithm.HS512, appProperties.getAuth().getTokenSecret()).compact();
}
public Long getUserIdFromToken(String token) {
Claims claims = Jwts.parser().setSigningKey(appProperties.getAuth().getTokenSecret()).parseClaimsJws(token).getBody();
return Long.parseLong(claims.getSubject());
}
public Boolean isAuthenticated(String token) {
Claims claims = Jwts.parser().setSigningKey(appProperties.getAuth().getTokenSecret()).parseClaimsJws(token).getBody();
return claims.get(AUTHENTICATED, Boolean.class);
}
public boolean validateToken(String authToken) {
try {
Jwts.parser().setSigningKey(appProperties.getAuth().getTokenSecret()).parseClaimsJws(authToken);
return true;
} catch (SignatureException ex) {
logger.error("Invalid JWT signature");
} catch (MalformedJwtException ex) {
logger.error("Invalid JWT token");
} catch (ExpiredJwtException ex) {
logger.error("Expired JWT token");
} catch (UnsupportedJwtException ex) {
logger.error("Unsupported JWT token");
} catch (IllegalArgumentException ex) {
logger.error("JWT claims string is empty.");
}
return false;
}
}
TokenAuthenticationFilter.java
Retrieve the authenticated
flag from the token,
- If it is true means, the user is fully authenticated and we can assign the actual user roles.
- Else, assign
PRE_VERIFICATION_USER
role only which can be used to access only the /verify REST endpoint.
public class TokenAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private TokenProvider tokenProvider;
@Autowired
private LocalUserDetailService customUserDetailsService;
private static final Logger logger = LoggerFactory.getLogger(TokenAuthenticationFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
String jwt = getJwtFromRequest(request);
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Long userId = tokenProvider.getUserIdFromToken(jwt);
UserDetails userDetails = customUserDetailsService.loadUserById(userId);
Collection<? extends GrantedAuthority> authorities = tokenProvider.isAuthenticated(jwt)
? userDetails.getAuthorities()
: List.of(new SimpleGrantedAuthority(Role.ROLE_PRE_VERIFICATION_USER));
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) {
logger.error("Could not set user authentication in security context", ex);
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7, bearerToken.length());
}
return null;
}
}
AuthController.java
- Modify the
authenticateUser
method to generate a short-lived token if the user is using 2FA. This token can be used for accessing the /verify REST endpoint for verification of the TOTP obtained from the Authenticator app. - Modify the
registerUser
method to generate QR code image data if the user opted for 2FA and return it in the response - Expose a POST API with mapping /verify. On passing the correct token, it will generate a JSON Web Token (JWT) with an expiry time of 10 days and return it in the response along with the user details. Secure this service for the users having PRE_VERIFICATION_USER role only.
@Slf4j
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
AuthenticationManager authenticationManager;
@Autowired
UserService userService;
@Autowired
TokenProvider tokenProvider;
@Autowired
private QrDataFactory qrDataFactory;
@Autowired
private QrGenerator qrGenerator;
@Autowired
private CodeVerifier verifier;
@PostMapping("/signin")
public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(loginRequest.getEmail(), loginRequest.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authentication);
LocalUser localUser = (LocalUser) authentication.getPrincipal();
boolean authenticated = !localUser.getUser().isUsing2FA();
String jwt = tokenProvider.createToken(localUser, authenticated);
return ResponseEntity.ok(new JwtAuthenticationResponse(jwt, authenticated, authenticated ? GeneralUtils.buildUserInfo(localUser) : null));
}
@PostMapping("/signup")
public ResponseEntity<?> registerUser(@Valid @RequestBody SignUpRequest signUpRequest) {
try {
User user = userService.registerNewUser(signUpRequest);
if (signUpRequest.isUsing2FA()) {
QrData data = qrDataFactory.newBuilder().label(user.getEmail()).secret(user.getSecret()).issuer("JavaChinna").build();
// Generate the QR code image data as a base64 string which can
// be used in an <img> tag:
String qrCodeImage = getDataUriForImage(qrGenerator.generate(data), qrGenerator.getImageMimeType());
return ResponseEntity.ok().body(new SignUpResponse(true, qrCodeImage));
}
} catch (UserAlreadyExistAuthenticationException e) {
log.error("Exception Ocurred", e);
return new ResponseEntity<>(new ApiResponse(false, "Email Address already in use!"), HttpStatus.BAD_REQUEST);
} catch (QrGenerationException e) {
log.error("QR Generation Exception Ocurred", e);
return new ResponseEntity<>(new ApiResponse(false, "Unable to generate QR code!"), HttpStatus.BAD_REQUEST);
}
return ResponseEntity.ok().body(new ApiResponse(true, "User registered successfully"));
}
@PostMapping("/verify")
@PreAuthorize("hasRole('PRE_VERIFICATION_USER')")
public ResponseEntity<?> verifyCode(@NotEmpty @RequestBody String code, @CurrentUser LocalUser user) {
if (!verifier.isValidCode(user.getUser().getSecret(), code)) {
return new ResponseEntity<>(new ApiResponse(false, "Invalid Code!"), HttpStatus.BAD_REQUEST);
}
String jwt = tokenProvider.createToken(user, true);
return ResponseEntity.ok(new JwtAuthenticationResponse(jwt, true, GeneralUtils.buildUserInfo(user)));
}
}
JwtAuthenticationResponse.java
Add a new field to indicate if the user is fully authenticated. So that the frontend can show TOTP verification page if authenticated flag is false.
@Value
public class JwtAuthenticationResponse {
private String accessToken;
private boolean authenticated;
private UserInfo user;
}
SignUpRequest.java
Add a field for capturing the user 2FA preference
private boolean using2FA;
SignUpResponse.java
If the user enabled 2FA, then return the QR code image data. So that the frontend can display the QR image after successful registration.
@Value
public class SignUpResponse {
private boolean using2FA;
private String qrCodeImage;
}
OAuth2AuthenticationSuccessHandler.java
Though it is not related to the 2FA implementation, we need to do the following change since we have changed the signature of tokenProvider.createToken
method.
@Override
protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
Optional<String> redirectUri = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME).map(Cookie::getValue);
if (redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
throw new BadRequestException("Sorry! We've got an Unauthorized Redirect URI and can't proceed with the authentication");
}
String targetUrl = redirectUri.orElse(getDefaultTargetUrl());
LocalUser user = (LocalUser) authentication.getPrincipal();
String token = tokenProvider.createToken(user, true);
return UriComponentsBuilder.fromUriString(targetUrl).queryParam("token", token).build().toUriString();
}
Angular Client (Frontend) Implementation
Create a TOTP component
This component binds form data (6 digit code) to AuthService.verify()
method that returns an Observable
object. If code verification is successful, then it stores the token and calls the login()
method.
ngOnInit()
: If a token is present in the Browser Session Storage then it sets the isLoggedIn
flag to true
and currentUser
from the Storage.
login()
method does the following:
- Saves the user in Session Storage.
- Sets the
isLoggedIn
flag to true - Sets the
currentUser
from the Storage. - Reloads the page
totp.component.ts
import { Component, OnInit } from '@angular/core';
import { AuthService } from '../_services/auth.service';
import { TokenStorageService } from '../_services/token-storage.service';
@Component({
selector: 'app-totp',
templateUrl: './totp.component.html',
styleUrls: ['./totp.component.css']
})
export class TotpComponent implements OnInit {
form: any = {};
isLoggedIn = false;
isLoginFailed = false;
errorMessage = '';
currentUser: any;
constructor(private authService: AuthService, private tokenStorage: TokenStorageService) {}
ngOnInit(): void {
if (this.tokenStorage.getUser()) {
this.isLoggedIn = true;
this.currentUser = this.tokenStorage.getUser();
}
}
onSubmit(): void {
this.authService.verify(this.form).subscribe(
data => {
this.tokenStorage.saveToken(data.accessToken);
this.login(data.user);
},
err => {
this.errorMessage = err.error.message;
this.isLoginFailed = true;
}
);
}
login(user): void {
this.tokenStorage.saveUser(user);
this.isLoginFailed = false;
this.isLoggedIn = true;
this.currentUser = this.tokenStorage.getUser();
window.location.reload();
}
}
totp.component.html
<div class="col-md-12">
<div class="card card-container">
<img id="profile-img" src="//ssl.gstatic.com/accounts/ui/avatar_2x.png" class="profile-img-card" />
<form *ngIf="!isLoggedIn" name="form" (ngSubmit)="f.form.valid && onSubmit()" #f="ngForm" novalidate>
<div class="form-group">
<label for="code">Enter the 6-digit code from your authenticator app</label> <input type="text" class="form-control" name="code" [(ngModel)]="form.code" required minlength="6" #code="ngModel" />
<div class="alert alert-danger" role="alert" *ngIf="f.submitted && code.invalid">
<div *ngIf="code.errors.required">Code is required</div>
<div *ngIf="code.errors.minlength">Code must be at least 6 characters</div>
</div>
</div>
<div class="form-group">
<button class="btn btn-primary btn-block">Login</button>
</div>
<div class="form-group">
<div class="alert alert-danger" role="alert" *ngIf="isLoginFailed">Login failed: {{ errorMessage }}</div>
</div>
</form>
<div class="alert alert-success" *ngIf="isLoggedIn">Welcome {{currentUser.displayName}} <br>Logged in as {{ currentUser.roles }}.</div>
</div>
</div>
totp.component.css
.card-container.card {
max-width: 400px !important;
padding: 40px 40px;
}
.card {
background-color: #f7f7f7;
padding: 20px 25px 30px;
margin: 0 auto 25px;
margin-top: 50px;
-moz-border-radius: 2px;
-webkit-border-radius: 2px;
border-radius: 2px;
-moz-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3);
-webkit-box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3);
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3);
}
.profile-img-card {
width: 96px;
height: 96px;
margin: 0 auto 10px;
display: block;
-moz-border-radius: 50%;
-webkit-border-radius: 50%;
border-radius: 50%;
}
Modify Register component
register.component.ts
If 2FA enabled, then display QR Code image along with “Registration Successful” message. So that, user can scan the QR code using an Authenticator app and get the TOTP to login to the application.
import { Component, OnInit } from '@angular/core';
import { AuthService } from '../_services/auth.service';
@Component({
selector: 'app-register',
templateUrl: './register.component.html',
styleUrls: ['./register.component.css']
})
export class RegisterComponent implements OnInit {
form: any = {};
isSuccessful = false;
isSignUpFailed = false;
isUsing2FA = false;
errorMessage = '';
qrCodeImage = '';
constructor(private authService: AuthService) { }
ngOnInit(): void {
}
onSubmit(): void {
this.authService.register(this.form).subscribe(
data => {
if(data.using2FA){
this.isUsing2FA = true;
this.qrCodeImage = data.qrCodeImage;
}
this.isSuccessful = true;
this.isSignUpFailed = false;
},
err => {
this.errorMessage = err.error.message;
this.isSignUpFailed = true;
}
);
}
}
register.component.html
Add a checkbox to enable 2FA
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="using2FA" [(ngModel)]="form.using2FA" #using2FA="ngModel" /><label class="form-check-label" for="using2FA">Use
Two Step Verification</label>
</div>
Modify the following block to show the QR code image if the user enabled 2FA post successful registration.
<div class="alert alert-success" *ngIf="isSuccessful">
Your registration is successful!
<div *ngIf="isUsing2FA">
<p>Scan this QR code using Google Authenticator app on your phone to use it later to login</p>
<img src="{{qrCodeImage}}" class="img-fluid" />
</div>
</div>
Modify Login Component
login.component.ts
Import Router
import { Router, ActivatedRoute } from '@angular/router';
Add the router
in the constructor
On initialization, set isLoggedIn
flag and currentUser
if the user
is available in the Browser Session.
Note: The REST API will return the user details only after successful verification of the TOTP if 2FA is enabled. That is why we have changed the logic to check for the user in the session instead of the token.
constructor(private authService: AuthService, private tokenStorage: TokenStorageService, private route: ActivatedRoute, private userService: UserService, private router: Router) {}
ngOnInit(): void {
const token: string = this.route.snapshot.queryParamMap.get('token');
const error: string = this.route.snapshot.queryParamMap.get('error');
if (this.tokenStorage.getUser()) {
this.isLoggedIn = true;
this.currentUser = this.tokenStorage.getUser();
}
On Login Submit,
- If the REST API returns
authenticated = true
in the response, then store the user details in the Browser Session Storage and reload the page. - Else, navigate to the TOTP page for the code verification.
onSubmit(): void {
this.authService.login(this.form).subscribe(
data => {
this.tokenStorage.saveToken(data.accessToken);
if(data.authenticated){
this.login(data.user);
} else {
this.router.navigate(['/totp']);
}
},
err => {
this.errorMessage = err.error.message;
this.isLoginFailed = true;
}
);
}
Modify App Component
app.component.ts
On initialization, set isLoggedIn
flag based on the user
availability in the Browser Session.
ngOnInit(): void {
this.isLoggedIn = !!this.tokenStorageService.getUser();
if (this.isLoggedIn) {
const user = this.tokenStorageService.getUser();
this.roles = user.roles;
this.showAdminBoard = this.roles.includes('ROLE_ADMIN');
this.showModeratorBoard = this.roles.includes('ROLE_MODERATOR');
this.username = user.displayName;
}
}
Modify Auth Service
auth.service.ts
Add a new field using2FA
in the Sign Up request
Add a new method to call the /verify REST service for TOTP verification.
register(user): Observable<any> {
return this.http.post(AppConstants.AUTH_API + 'signup', {
displayName: user.displayName,
email: user.email,
password: user.password,
matchingPassword: user.matchingPassword,
socialProvider: 'LOCAL',
using2FA: user.using2FA
}, httpOptions);
}
verify(credentials): Observable<any> {
return this.http.post(AppConstants.AUTH_API + 'verify', credentials.code, {
headers: new HttpHeaders({ 'Content-Type': 'text/plain' })
});
}
Define Module
app.module.ts
Import and add TotpComponent
in the module declarations
import { TotpComponent } from './totp/totp.component';
import { authInterceptorProviders } from './_helpers/auth.interceptor';
@NgModule({
declarations: [
AppComponent,
LoginComponent,
RegisterComponent,
HomeComponent,
ProfileComponent,
BoardAdminComponent,
BoardModeratorComponent,
BoardUserComponent,
TotpComponent
],
imports: [
BrowserModule,
AppRoutingModule,
FormsModule,
HttpClientModule
],
providers: [authInterceptorProviders],
bootstrap: [AppComponent]
})
export class AppModule { }
Define Module Routing
app-routing.module.ts
Import and add TotpComponent
in the route declarations
import { TotpComponent } from './totp/totp.component';
const routes: Routes = [
{ path: 'home', component: HomeComponent },
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
{ path: 'profile', component: ProfileComponent },
{ path: 'user', component: BoardUserComponent },
{ path: 'mod', component: BoardModeratorComponent },
{ path: 'admin', component: BoardAdminComponent },
{ path: 'totp', component: TotpComponent },
{ path: '', redirectTo: 'home', pathMatch: 'full' }
];
Run Spring Boot App with Maven
You can run the application using mvn clean spring-boot:run
and the REST API services can be accessed via http://localhost:8080
Run the Angular App
You can run this App with the below command and hit the URL http://localhost:8081/ in browser
ng serve --port 8081
Source Code
https://github.com/JavaChinna/spring-boot-angular-2fa-demo
Conclusion
That’s all folks. In this article, we have implemented 2 Factor Authentication in our Spring Boot Angular application.
Thank you for reading.
Thanks a lot… It’s a complete base code to start any project. God bless you.
Hi ! Thank you very much for your work !!
Can i ask you why a JWT with 5 minutes expiration is needed ? And where are you using that expiration time in the front end ? Thanks !!
For 2FA, there are 2 types of tokens being used:
authenticated=false
– This means user is partially authenticated and TOTP verification is yet to be done. At this point, the user can access only/api/auth/verify
endpoint to verify the TOTPauthenticated=true
– This means the code verification is done and the user is fully authenticated.If 2FA is enabled, when the user logs in, we will authenticate the user with the credentials. If authentication is successful, then it will generate a short-lived token that will be stored in the browser’s session storage. Then the client will show the code verification page where the user has to enter the code and click on the ‘Verify’ button. This will call the code verification endpoint with the short-lived token in the authorization header. The server will validate the token and extract the authenticated flag from it. If
authenticated=false
, only ‘PRE_VERIFICATION_USER’ role will be given to the user. If the TOTP verification is successful, then we will generate a token withauthenticated=true
that is valid for 10 days.Interesting. So what happens if a user had a role other than ROLE_USER. What if the user had a role like USER_ADMIN. How do you account for that. Based on what I am reading the user would be given ROLE_USER after login, so that would present an issue.
The REST endpoints have role-based method-level security, and the front end has also been designed to show content based on the User role. So it will work fine even for a user with ROLE_ADMIN. You will be able to see the “Admin Board” page. But if you try to access the “User Board”, then it will show a “Forbidden” message. If you want to add some other different role, then the application will still let you log in. but you won’t be able to access the admin or user-specific page as designed. You can change the code to show any page based on any user role as per your requirement. Hope it clarifies.
What would be nice is if the token would auto refresh if there is activity from the user.
Yes. It is good to have the refresh token implementation. The TOTP library used in this project was last updated in 2020 and no updates after that. So it is not getting auto-configured with Spring Boot v3.x. Though it works fine with a workaround (explicit auto-configuration), I think it’s better to implement this 2FA from scratch including the refresh_token support with the latest version of Spring Security.