Understanding TOTP Two-Factor Authentication
Time-based One-Time Password (TOTP) is a widely adopted second-factor authentication mechanism that generates temporary codes using a shared secret key and the current timestamp. Unlike traditional SMS-based verification, TOTP relies on authenticator applications (such as Google Authenticator or Microsoft Authenticator) to produce rotating six-digit codes every 30 seconds, providing enhanced security for user acccounts.
Implementation Overview
This guide walks through integrating TOTP-based 2FA into a Spring Boot application, covering the complete flow from generating secrets to validating user-provided codes.
Project Dependencies
Add the following dependencies to your pom.xml to support secret generation and QR code rendering:
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.15</version>
</dependency>
<dependency>
<groupId>org.iherus</groupId>
<artifactId>qrext4j</artifactId>
<version>1.3.1</version>
</dependency>
Database Schema Updates
Add a secret_key column to your user table to store each user's unique TOTP secret. This secret is generated when a user enables 2FA and is used for both QR code generation and subsequent code verification.
TOTP Authentication Service Classes
Core Authentication Utility
The following utility class handles secret generation, QR code URI construction, and code verification:
/**
* TOTP authenticator implementation
*/
public class TotpAuthenticator {
private static final int TIME_STEP_SECONDS = 30;
private static final String HMAC_ALGORITHM = "HmacSHA1";
private static int TIME_WINDOW = 0;
/**
* Generate a cryptographically secure secret key for a user
*/
public static String generateSecret() {
SecureRandom random = new SecureRandom();
byte[] buffer = new byte[20];
random.nextBytes(buffer);
Base32 encoder = new Base32();
return encoder.encodeToString(buffer).toUpperCase();
}
/**
* Build the otpauth URI for authenticator app scanning
*/
public static String buildOtpUri(String secret, String username, String appName) {
String normalizedKey = secret.replaceAll("\\s", "").toUpperCase();
try {
String prefix = !StringUtils.isEmpty(appName) ? appName + ":" : "";
return "otpauth://totp/"
+ URLEncoder.encode(prefix + username, "UTF-8").replace("+", "%20")
+ "?secret=" + URLEncoder.encode(normalizedKey, "UTF-8").replace("+", "%20")
+ (!StringUtils.isEmpty(appName) ? ("&issuer=" + URLEncoder.encode(appName, "UTF-8").replace("+", "%20")) : "");
} catch (UnsupportedEncodingException e) {
throw new IllegalStateException(e);
}
}
/**
* Generate current TOTP code for testing purposes
*/
public static String generateCurrentCode(String secret) {
String normalizedKey = secret.replaceAll("\\s", "").toUpperCase();
byte[] decodedBytes = new Base32().decode(normalizedKey);
String hexKey = Hex.encodeHexString(decodedBytes);
long counter = (System.currentTimeMillis() / 1000) / TIME_STEP_SECONDS;
String hexCounter = Long.toHexString(counter);
return TotpGenerator.generate(hexKey, hexCounter, "6", HMAC_ALGORITHM);
}
/**
* Verify submitted code against stored secret
*/
public static boolean verify(String secret, long submittedCode, long currentTime) {
byte[] keyBytes = new Base32().decode(secret);
long counter = (currentTime / 1000L) / TIME_STEP_SECONDS;
for (int offset = -TIME_WINDOW; offset <= TIME_WINDOW; ++offset) {
try {
long computedCode = computeCode(keyBytes, counter + offset);
if (computedCode == submittedCode) {
return true;
}
} catch (Exception e) {
throw new RuntimeException(e.getMessage());
}
}
return false;
}
private static long computeCode(byte[] key, long counter) throws NoSuchAlgorithmException, InvalidKeyException {
byte[] data = new byte[8];
for (int i = 8; i-- > 0; counter >>>= 8) {
data[i] = (byte) counter;
}
SecretKeySpec signingKey = new SecretKeySpec(key, HMAC_ALGORITHM);
Mac hmac = Mac.getInstance(HMAC_ALGORITHM);
hmac.init(signingKey);
byte[] hash = hmac.doFinal(data);
int offset = hash[hash.length - 1] & 0xF;
long result = 0;
for (int i = 0; i < 4; ++i) {
result = (result << 8) | (hash[offset + i] & 0xFF);
}
result = result & 0x7FFFFFFF;
return result % 1000000;
}
}
TOTP Code Generator
This helper class implements the RFC 6238 TOTP algorithm:
/**
* TOTP code generation utility
*/
public class TotpGenerator {
private static final int[] DIGIT_POWERS = {1, 10, 100, 1000, 10000, 100000, 1000000};
private static byte[] hmacCompute(String algorithm, byte[] key, byte[] message) {
try {
Mac mac = Mac.getInstance(algorithm);
mac.init(new SecretKeySpec(key, "RAW"));
return mac.doFinal(message);
} catch (GeneralSecurityException e) {
throw new UndeclaredThrowableException(e);
}
}
private static byte[] hexToBytes(String hex) {
byte[] temp = new BigInteger("10" + hex, 16).toByteArray();
byte[] result = new byte[temp.length - 1];
System.arraycopy(temp, 1, result, 0, result.length);
return result;
}
/**
* Generate TOTP code
* @param keyHex Secret key in hexadecimal format
* @param counterHex Current counter value in hexadecimal
* @param digitCount Number of digits (typically 6)
* @param algorithm HMAC algorithm (HmacSHA1, HmacSHA256, etc.)
*/
public static String generate(String keyHex, String counterHex, String digitCount, String algorithm) {
int digits = Integer.decode(digitCount);
while (counterHex.length() < 16) {
counterHex = "0" + counterHex;
}
byte[] message = hexToBytes(counterHex);
byte[] key = hexToBytes(keyHex);
byte[] hash = hmacCompute(algorithm, key, message);
int startOffset = hash[hash.length - 1] & 0xF;
int binary = ((hash[startOffset] & 0x7F) << 24)
| ((hash[startOffset + 1] & 0xFF) << 16)
| ((hash[startOffset + 2] & 0xFF) << 8)
| (hash[startOffset + 3] & 0xFF);
int code = binary % DIGIT_POWERS[digits];
String result = Integer.toString(code);
while (result.length() < digits) {
result = "0" + result;
}
return result;
}
}
Service Layer Implementation
@Transactional(isolation = Isolation.REPEATABLE_READ, propagation = Propagation.REQUIRED)
@Service
public class TwoFactorAuthService {
@Autowired
private UserRepository userRepository;
public String retrieveSecret(Integer userId) {
return userRepository.findById(userId)
.map(User::getSecretKey)
.orElse(null);
}
public void persistSecret(Integer userId, String secret) {
userRepository.updateSecretKey(userId, secret);
}
public boolean validateCode(User userAccount, String providedCode) {
String storedSecret = userAccount.getSecretKey();
if (storedSecret == null || storedSecret.isEmpty()) {
return true;
}
if (providedCode == null || providedCode.isEmpty()) {
throw new AuthenticationException("2FA enabled. Please provide verification code.");
}
boolean isValid = TotpAuthenticator.verify(
storedSecret,
Long.parseLong(providedCode),
System.currentTimeMillis()
);
if (!isValid) {
throw new AuthenticationException("Invalid verification code.");
}
return true;
}
}
Authentication Controller Integration
Integrate 2FA verification into you're login endpoint:
@Controller
@RequestMapping("/auth")
public class AuthenticationController {
@Autowired
private UserService userService;
@Autowired
private AuditLogService auditLogService;
@Autowired
private TwoFactorAuthService twoFactorAuthService;
@PostMapping("/login")
@ResponseBody
public ResponseEntity<LoginResponse> authenticate(
@RequestParam String username,
@RequestParam String password,
@RequestParam(required = false) String verificationCode) {
UsernamePasswordToken token = new UsernamePasswordToken(username, password);
Subject subject = SecurityUtils.getSubject();
subject.login(token);
User authenticatedUser = (User) subject.getPrincipal();
twoFactorAuthService.validateCode(authenticatedUser, verificationCode);
AuditLog logEntry = new AuditLog();
// ... log entry population ...
return ResponseEntity.ok(LoginResponse.success());
}
}
2FA Management Endpoints
@RestController
@RequestMapping("/api/2fa")
public class TwoFactorAuthController {
@Autowired
private TwoFactorAuthService authService;
@GetMapping("/qrcode")
public QrCodeResponse fetchQrCode(
@RequestParam Integer userId,
@RequestParam String username) {
String secret = authService.retrieveSecret(userId);
QrCodeResponse response = new QrCodeResponse();
if (secret == null || secret.isEmpty()) {
secret = TotpAuthenticator.generateSecret();
authService.persistSecret(userId, secret);
response.setAlreadyLinked(false);
} else {
response.setAlreadyLinked(true);
}
String otpUri = TotpAuthenticator.buildOtpUri(secret, username, "your-app-name");
BufferedImage qrImage = new SimpleQrcodeGenerator().generate(otpUri).getImage();
response.setSecret(secret);
response.setImageData(encodeImageToBase64(qrImage));
return response;
}
@GetMapping("/refresh")
public QrCodeResponse refreshSecret(
@RequestParam Integer userId,
@RequestParam String username) {
String newSecret = TotpAuthenticator.generateSecret();
authService.persistSecret(userId, newSecret);
QrCodeResponse response = new QrCodeResponse();
response.setAlreadyLinked(false);
response.setSecret(newSecret);
String otpUri = TotpAuthenticator.buildOtpUri(newSecret, username, "your-app-name");
BufferedImage qrImage = new SimpleQrcodeGenerator().generate(otpUri).getImage();
response.setImageData(encodeImageToBase64(qrImage));
return response;
}
private String encodeImageToBase64(BufferedImage image) {
ByteArrayOutputStream output = new ByteArrayOutputStream();
ImageIO.write(image, "png", output);
return Base64.getEncoder().encodeToString(output.toByteArray());
}
public static class QrCodeResponse {
private String secret;
private String imageData;
private boolean alreadyLinked;
// getters and setters
}
}
Popular Authenticator Applications
- Google Authenticator: Available on Google Play and Apple App Store
- Microsoft Authenticator: Available on Google Play and Apple App Store
- Authenticator Pro (open source): Available on GitHub for users preferring self-hosted solutions