Added 2FA

This commit is contained in:
2026-08-08 15:09:26 +02:00
parent b5de4d6ac7
commit 9f311ab4ae
19 changed files with 1341 additions and 1 deletions
@@ -0,0 +1,154 @@
package com.example.base.ui;
import com.vaadin.flow.component.ComponentEvent;
import com.vaadin.flow.component.ComponentEventListener;
import com.vaadin.flow.component.ComponentUtil;
import com.vaadin.flow.component.Key;
import com.vaadin.flow.component.customfield.CustomField;
import com.vaadin.flow.component.dependency.StyleSheet;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.value.ValueChangeMode;
import com.vaadin.flow.shared.Registration;
import org.jspecify.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
/**
* A one-time-password entry field: a row of single-digit boxes with auto-advance, backspace
* navigation, and paste support, combining into a single String value. Built the same way as
* {@link ViewTitle} — a small server-side Java composite, no client-side code — extended from
* {@link CustomField} instead of {@link com.vaadin.flow.component.Composite} so it gets a value,
* validation state, and Binder compatibility for free.
*/
@StyleSheet("otp-field.css")
public class OtpField extends CustomField<String> {
private static final int DEFAULT_LENGTH = 6;
// Package-private (rather than private) so same-package tests can drive individual boxes.
final List<TextField> digits;
// Guards against setPresentationValue's programmatic writes re-triggering the paste handler.
private boolean distributing = false;
public OtpField() {
this(DEFAULT_LENGTH);
}
public OtpField(int length) {
super(""); // getValue() returns "" rather than null before anything is typed
addClassName("otp-field");
digits = new ArrayList<>(length);
var row = new HorizontalLayout();
row.addClassName("otp-field-row");
row.setSpacing(false);
for (var i = 0; i < length; i++) {
var digit = new TextField();
digit.addClassName("otp-field-digit");
digit.setValueChangeMode(ValueChangeMode.EAGER);
digit.setAllowedCharPattern("[0-9]");
var index = i;
digit.addValueChangeListener(event -> onDigitChanged(index, event.getValue()));
digit.addKeyDownListener(Key.BACKSPACE, event -> onBackspace(index));
digits.add(digit);
row.add(digit);
}
add(row);
}
@Override
public void focus() {
digits.stream().filter(d -> d.getValue().isEmpty()).findFirst().orElse(digits.get(0)).focus();
}
/** Fires once all digit boxes hold a value, i.e. as soon as the code is complete. */
public Registration addCompletedListener(ComponentEventListener<CompletedEvent> listener) {
return ComponentUtil.addListener(this, CompletedEvent.class, listener);
}
@Override
protected String generateModelValue() {
var value = new StringBuilder();
for (var digit : digits) {
value.append(digit.getValue());
}
return value.toString();
}
@Override
protected void setPresentationValue(String value) {
distributing = true;
try {
for (var i = 0; i < digits.size(); i++) {
digits.get(i).setValue(i < value.length() ? String.valueOf(value.charAt(i)) : "");
}
} finally {
distributing = false;
}
}
private void onDigitChanged(int index, String value) {
if (distributing) {
return;
}
if (value.length() > 1) {
// A paste landed in one box; distribute it across this box and the following ones.
distributeFrom(index, value);
return;
}
if (!value.isEmpty() && index < digits.size() - 1) {
digits.get(index + 1).focus();
}
updateValue();
if (isComplete()) {
fireEvent(new CompletedEvent(this, true));
}
}
private void distributeFrom(int startIndex, String pasted) {
distributing = true;
try {
var chars = pasted.replaceAll("\\D", "");
var target = startIndex;
for (var i = 0; i < chars.length() && target < digits.size(); i++, target++) {
digits.get(target).setValue(String.valueOf(chars.charAt(i)));
}
digits.get(Math.min(target, digits.size() - 1)).focus();
} finally {
distributing = false;
}
updateValue();
if (isComplete()) {
fireEvent(new CompletedEvent(this, true));
}
}
private void onBackspace(int index) {
if (digits.get(index).getValue().isEmpty() && index > 0) {
digits.get(index - 1).focus();
}
}
private boolean isComplete() {
return digits.stream().noneMatch(d -> d.getValue().isEmpty());
}
/** Resets all digit boxes and moves focus back to the first one. */
public void reset() {
clear();
focus();
}
public static class CompletedEvent extends ComponentEvent<OtpField> {
CompletedEvent(OtpField source, boolean fromClient) {
super(source, fromClient);
}
}
}
@@ -0,0 +1,55 @@
package com.example.security;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.WriterException;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Map;
/**
* Renders a QR code as a PNG, for scanning the TOTP enrollment URI with an authenticator app.
* <p>
* Only {@code com.google.zxing:core} is used, not {@code zxing:javase} — the conversion from
* {@link BitMatrix} to an image is a few lines, and skipping {@code javase} avoids pulling in
* its {@code jcommander}/{@code jai-imageio-core} dependencies for a CLI tool this app never
* invokes.
*/
final class QrCodeGenerator {
private QrCodeGenerator() {
}
static byte[] pngFor(String content, int sizePx) {
try {
var hints = Map.<EncodeHintType, Object>of(
EncodeHintType.MARGIN, 1,
EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M);
var matrix = new QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, sizePx, sizePx, hints);
var out = new ByteArrayOutputStream();
ImageIO.write(toImage(matrix), "png", out);
return out.toByteArray();
} catch (WriterException | IOException e) {
throw new IllegalStateException("Failed to generate QR code", e);
}
}
private static BufferedImage toImage(BitMatrix matrix) {
var width = matrix.getWidth();
var height = matrix.getHeight();
var image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
for (var x = 0; x < width; x++) {
for (var y = 0; y < height; y++) {
image.setRGB(x, y, matrix.get(x, y) ? 0x000000 : 0xFFFFFF);
}
}
return image;
}
}
@@ -16,7 +16,14 @@ import org.springframework.security.web.SecurityFilterChain;
class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
SecurityFilterChain securityFilterChain(
HttpSecurity http, TwoFactorAwareAuthenticationProvider twoFactorAwareAuthenticationProvider)
throws Exception {
// Registered explicitly so it's the only provider used for the username/password check;
// it wraps the standard DaoAuthenticationProvider check with the 2FA downgrade described
// on TwoFactorAwareAuthenticationProvider.
http.authenticationProvider(twoFactorAwareAuthenticationProvider);
return http.with(VaadinSecurityConfigurer.vaadin(), configurer -> {
// Vaadin 25 denies any request not otherwise matched by default, which
// blocks framework error views (e.g. RouteNotFoundError) at the HTTP layer
@@ -0,0 +1,96 @@
package com.example.security;
import org.apache.commons.codec.binary.Base32;
import org.jspecify.annotations.Nullable;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URLEncoder;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Optional;
/**
* Time-based one-time passwords per RFC 6238 (HMAC-SHA1, 30-second steps, 6 digits), the
* de-facto format used by authenticator apps (Google Authenticator, 1Password, Authy, etc.).
* <p>
* No Spring dependencies here on purpose — this is pure math over JCA primitives, so it's
* verifiable directly against the RFC's published test vectors (see TotpGeneratorTest).
* <p>
* Public so tests in com.example.security.ui can compute a valid code for a pending secret
* shown by TwoFactorSetupView/TwoFactorVerifyView, the same reason TwoFactorService is public.
*/
public final class TotpGenerator {
public static final int DIGITS = 6;
public static final int TIME_STEP_SECONDS = 30;
private static final int SECRET_BYTES = 20;
private static final int SKEW_STEPS = 1; // accept the previous/next step for clock drift
private static final int MOD = (int) Math.pow(10, DIGITS);
private TotpGenerator() {
}
/** A fresh random Base32-encoded shared secret, suitable for an otpauth:// URI. */
public static String generateSecret() {
var bytes = new byte[SECRET_BYTES];
new SecureRandom().nextBytes(bytes);
return new Base32().encodeToString(bytes).replace("=", "");
}
/**
* The {@code otpauth://totp/...} URI an authenticator app scans (as a QR code) or accepts
* for manual entry, per Google's Key URI Format.
*/
public static String otpAuthUri(String issuer, String accountName, String secret) {
var label = URLEncoder.encode(issuer + ":" + accountName, StandardCharsets.UTF_8);
return "otpauth://totp/%s?secret=%s&issuer=%s&digits=%d&period=%d"
.formatted(label, secret, URLEncoder.encode(issuer, StandardCharsets.UTF_8), DIGITS, TIME_STEP_SECONDS);
}
/** The 6-digit code for the given time step, per RFC 4226 dynamic truncation. */
public static String codeAt(String base32Secret, long timeStep) {
var key = new Base32().decode(base32Secret);
var data = ByteBuffer.allocate(8).putLong(timeStep).array();
try {
var mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(key, "HmacSHA1"));
var hash = mac.doFinal(data);
var offset = hash[hash.length - 1] & 0x0F;
var binary = ((hash[offset] & 0x7F) << 24)
| ((hash[offset + 1] & 0xFF) << 16)
| ((hash[offset + 2] & 0xFF) << 8)
| (hash[offset + 3] & 0xFF);
return String.format("%0" + DIGITS + "d", binary % MOD);
} catch (GeneralSecurityException e) {
throw new IllegalStateException("Failed to compute TOTP code", e);
}
}
/**
* Checks {@code code} against the secret across a small window around {@code now} to
* tolerate clock drift, using a constant-time comparison. Returns the matched time step
* (for replay tracking) or empty if no step in the window matches.
*/
public static Optional<Long> verify(String base32Secret, @Nullable String code, Instant now) {
if (code == null || !code.matches("\\d{" + DIGITS + "}")) {
return Optional.empty();
}
var currentStep = now.getEpochSecond() / TIME_STEP_SECONDS;
for (var delta = -SKEW_STEPS; delta <= SKEW_STEPS; delta++) {
var step = currentStep + delta;
var candidate = codeAt(base32Secret, step);
if (MessageDigest.isEqual(
candidate.getBytes(StandardCharsets.US_ASCII), code.getBytes(StandardCharsets.US_ASCII))) {
return Optional.of(step);
}
}
return Optional.empty();
}
}
@@ -0,0 +1,30 @@
package com.example.security;
import com.vaadin.flow.spring.annotation.VaadinSessionScope;
import org.springframework.stereotype.Component;
/**
* Counts failed 2FA code attempts for the current session, so a live pre-auth session can't be
* used to brute-force a 6-digit code. Public: used from TwoFactorVerifyView in com.example.security.ui.
*/
@Component
@VaadinSessionScope
public class TwoFactorAttemptTracker {
public static final int MAX_ATTEMPTS = 5;
private int failedAttempts = 0;
/** Records a failed attempt and returns the new failure count. */
public int recordFailure() {
return ++failedAttempts;
}
public boolean isLimitExceeded() {
return failedAttempts >= MAX_ATTEMPTS;
}
public void reset() {
failedAttempts = 0;
}
}
@@ -0,0 +1,60 @@
package com.example.security;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Wraps the standard username/password check with a downgrade step: if the authenticating user
* has two-factor authentication enabled, the returned token carries only {@link #PRE_AUTH_ROLE}
* instead of their real authorities.
* <p>
* This is the enforcement mechanism for 2FA — a session holding only that role satisfies no
* {@code @PermitAll}/{@code @RolesAllowed} check on real application views, no matter what the
* navigation layer does. {@link TwoFactorNavigationGuard} is the UX layer on top: it notices the
* downgraded role and routes the user to the code-entry view. {@link TwoFactorService#verify}
* plus {@link TwoFactorService#completeAuthentication} is what upgrades the session once the
* correct code is supplied.
*/
@Component
class TwoFactorAwareAuthenticationProvider implements AuthenticationProvider {
// Deliberately not in the Role enum: that enum models roles persisted per-user, while this
// is a transient authentication state no user is ever granted.
static final String PRE_AUTH_ROLE = "PRE_AUTH_2FA";
static final String PRE_AUTH_AUTHORITY = "ROLE_" + PRE_AUTH_ROLE;
private final DaoAuthenticationProvider delegate;
private final TwoFactorService twoFactorService;
TwoFactorAwareAuthenticationProvider(
UserDetailsService userDetailsService, PasswordEncoder passwordEncoder, TwoFactorService twoFactorService) {
delegate = new DaoAuthenticationProvider(userDetailsService);
delegate.setPasswordEncoder(passwordEncoder);
this.twoFactorService = twoFactorService;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
var fullyAuthenticated = delegate.authenticate(authentication);
if (fullyAuthenticated == null || !twoFactorService.isTwoFactorEnabled(fullyAuthenticated.getName())) {
return fullyAuthenticated;
}
return UsernamePasswordAuthenticationToken.authenticated(
fullyAuthenticated.getPrincipal(), fullyAuthenticated.getCredentials(),
List.of(new SimpleGrantedAuthority(PRE_AUTH_AUTHORITY)));
}
@Override
public boolean supports(Class<?> authentication) {
return delegate.supports(authentication);
}
}
@@ -0,0 +1,55 @@
package com.example.security;
import com.example.security.ui.TwoFactorVerifyView;
import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.HasErrorParameter;
import com.vaadin.flow.server.ServiceInitEvent;
import com.vaadin.flow.server.VaadinServiceInitListener;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
/**
* Routes a user whose session is pending 2FA (see {@link TwoFactorAwareAuthenticationProvider})
* to the code-entry view, on every navigation.
* <p>
* This has to be a global {@code BeforeEnterListener} rather than a custom
* {@code NavigationAccessChecker}: an access checker can only allow/deny a navigation, it can't
* forward to a view of its choosing. Application views stay {@code @PermitAll} — a pending-2FA
* session is authenticated, so it already satisfies that annotation; this listener is what
* actually stops it from reaching anywhere but the verify view.
* <p>
* Public: TARGET_LOCATION_SESSION_ATTRIBUTE is referenced from TwoFactorVerifyView in the .ui
* subpackage, the same reason LoginView is public.
*/
@Component
public class TwoFactorNavigationGuard implements VaadinServiceInitListener {
public static final String TARGET_LOCATION_SESSION_ATTRIBUTE =
TwoFactorNavigationGuard.class.getName() + ".targetLocation";
@Override
public void serviceInit(ServiceInitEvent event) {
event.getSource().addUIInitListener(uiEvent -> uiEvent.getUI().addBeforeEnterListener(this::beforeEnter));
}
private void beforeEnter(BeforeEnterEvent event) {
var target = event.getNavigationTarget();
if (target.equals(TwoFactorVerifyView.class) || HasErrorParameter.class.isAssignableFrom(target)) {
return;
}
if (!isPendingTwoFactor()) {
return;
}
event.getUI().getSession().setAttribute(
TARGET_LOCATION_SESSION_ATTRIBUTE, event.getLocation().getPathWithQueryParameters());
event.forwardTo(TwoFactorVerifyView.class);
}
private boolean isPendingTwoFactor() {
var authentication = SecurityContextHolder.getContext().getAuthentication();
return authentication != null && authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(TwoFactorAwareAuthenticationProvider.PRE_AUTH_AUTHORITY::equals);
}
}
@@ -0,0 +1,105 @@
package com.example.security;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
/**
* Enrollment and verification of TOTP-based two-factor authentication.
* <p>
* Public because it's used from com.example.security.ui (TwoFactorSetupView, TwoFactorVerifyView),
* the same reason TaskService is public for com.example.examplefeature.ui.
*/
@Service
public class TwoFactorService {
// Shown in the authenticator app next to the account name; kept short since there's no
// branding/config elsewhere in the app to source a nicer value from.
private static final String ISSUER = "Task App";
private final UserRepository userRepository;
private final AppUserDetailsService userDetailsService;
TwoFactorService(UserRepository userRepository, AppUserDetailsService userDetailsService) {
this.userRepository = userRepository;
this.userDetailsService = userDetailsService;
}
@Transactional(readOnly = true)
public boolean isTwoFactorEnabled(String username) {
var user = userRepository.findByUsername(username);
return user != null && user.isTwoFactorEnabled();
}
/** A fresh secret for a new enrollment attempt. Not persisted until {@link #confirmEnrollment} succeeds. */
public String startEnrollment() {
return TotpGenerator.generateSecret();
}
/** The otpauth:// URI to render as a QR code for the given pending secret. */
public String otpAuthUri(String username, String secret) {
return TotpGenerator.otpAuthUri(ISSUER, username, secret);
}
/** PNG bytes of a QR code encoding {@code otpAuthUri}, for display during enrollment. */
public byte[] qrCodePng(String otpAuthUri, int sizePx) {
return QrCodeGenerator.pngFor(otpAuthUri, sizePx);
}
/**
* Confirms a pending enrollment: verifies {@code code} against {@code secret} first, and
* only persists + enables 2FA if it matches. Returns false (and changes nothing) otherwise,
* so a mistyped confirmation code can never half-enable 2FA.
*/
@Transactional
public boolean confirmEnrollment(String username, String secret, String code) {
if (TotpGenerator.verify(secret, code, Instant.now()).isEmpty()) {
return false;
}
var user = requireUser(username);
user.enableTwoFactor(secret);
userRepository.save(user);
return true;
}
/**
* Verifies a login-challenge code against the user's enrolled secret. Rejects a code whose
* time step was already accepted, so an observed code can't be replayed within its window.
*/
@Transactional
public boolean verify(String username, String code) {
var user = requireUser(username);
var secret = user.getTotpSecret();
if (secret == null) {
return false;
}
var matchedStep = TotpGenerator.verify(secret, code, Instant.now());
if (matchedStep.isEmpty() || matchedStep.get().equals(user.getTotpLastUsedTimeStep())) {
return false;
}
user.recordAcceptedTotpTimeStep(matchedStep.get());
userRepository.save(user);
return true;
}
/**
* Builds the fully-authenticated token for a user who just passed the 2FA challenge, with
* their real authorities (as opposed to the pre-auth token they held during the challenge).
*/
@Transactional(readOnly = true)
public Authentication completeAuthentication(String username) {
var userDetails = userDetailsService.loadUserByUsername(username);
return UsernamePasswordAuthenticationToken.authenticated(userDetails, null, userDetails.getAuthorities());
}
private User requireUser(String username) {
var user = userRepository.findByUsername(username);
if (user == null) {
throw new IllegalStateException("No user found with username '" + username + "'");
}
return user;
}
}
@@ -26,6 +26,17 @@ public class User {
@Column(name = "enabled", nullable = false)
private boolean enabled = true;
@Column(name = "totp_secret")
private @Nullable String totpSecret;
@Column(name = "two_factor_enabled", nullable = false)
private boolean twoFactorEnabled = false;
// The most recently accepted TOTP time step, tracked to reject replay of an
// observed code within its own validity window (see TwoFactorService#verify).
@Column(name = "totp_last_used_step")
private @Nullable Long totpLastUsedTimeStep;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "app_user_role", joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "role", nullable = false)
@@ -85,6 +96,36 @@ public class User {
roles.add(role);
}
public @Nullable String getTotpSecret() {
return totpSecret;
}
public boolean isTwoFactorEnabled() {
return twoFactorEnabled;
}
public @Nullable Long getTotpLastUsedTimeStep() {
return totpLastUsedTimeStep;
}
/**
* Enables two-factor authentication with the given TOTP secret. The caller
* must have already verified a code against this secret before calling
* this method — it does not verify anything itself.
*/
public void enableTwoFactor(String totpSecret) {
if (totpSecret.isBlank()) {
throw new IllegalArgumentException("TOTP secret must not be blank");
}
this.totpSecret = totpSecret;
this.twoFactorEnabled = true;
this.totpLastUsedTimeStep = null;
}
public void recordAcceptedTotpTimeStep(long timeStep) {
this.totpLastUsedTimeStep = timeStep;
}
@Override
public boolean equals(Object obj) {
if (obj == null || !getClass().isAssignableFrom(obj.getClass())) {
@@ -0,0 +1,141 @@
package com.example.security.ui;
import com.example.base.ui.OtpField;
import com.example.base.ui.ViewTitle;
import com.example.security.TwoFactorService;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.Image;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Pre;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Menu;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.streams.DownloadHandler;
import com.vaadin.flow.server.streams.DownloadResponse;
import com.vaadin.flow.spring.security.AuthenticationContext;
import jakarta.annotation.security.PermitAll;
import org.jspecify.annotations.Nullable;
import java.io.ByteArrayInputStream;
/**
* Self-service enrollment: shows a QR code for the pending secret and only persists it once the
* user confirms it with a real code from their authenticator app, so a half-finished enrollment
* (QR scanned but never confirmed) never leaves an account in a broken state.
*/
@Route(value = "security/two-factor")
@PageTitle("Two-Factor Authentication")
@Menu(order = 10, icon = "vaadin:key", title = "Two-Factor Auth")
@PermitAll
public class TwoFactorSetupView extends VerticalLayout {
private final transient TwoFactorService twoFactorService;
// AuthenticationContext is not Serializable by design, so this field must stay transient.
private final transient AuthenticationContext authenticationContext;
private final String username;
final Paragraph statusMessage;
final Button enableBtn;
final Image qrImage;
final Pre secretText;
final OtpField confirmCode;
final Button confirmBtn;
private @Nullable String pendingSecret;
TwoFactorSetupView(TwoFactorService twoFactorService, AuthenticationContext authenticationContext) {
this.twoFactorService = twoFactorService;
this.authenticationContext = authenticationContext;
this.username = authenticationContext.getPrincipalName().orElseThrow();
statusMessage = new Paragraph();
enableBtn = new Button("Enable two-factor authentication", event -> startEnrollment());
enableBtn.addThemeVariants(ButtonVariant.PRIMARY);
qrImage = new Image();
qrImage.setAlt("Two-factor authentication QR code");
qrImage.setWidth("220px");
qrImage.setHeight("220px");
qrImage.setVisible(false);
secretText = new Pre();
secretText.getStyle().set("user-select", "all");
secretText.setVisible(false);
confirmCode = new OtpField();
confirmCode.setLabel("Enter the 6-digit code to confirm");
confirmCode.setVisible(false);
confirmCode.addCompletedListener(event -> confirmEnrollment());
confirmBtn = new Button("Confirm", event -> confirmEnrollment());
confirmBtn.addThemeVariants(ButtonVariant.PRIMARY);
confirmBtn.setVisible(false);
add(new ViewTitle("Two-Factor Authentication"), statusMessage, enableBtn, qrImage, secretText, confirmCode,
confirmBtn);
refreshStatus();
}
private void refreshStatus() {
var enabled = twoFactorService.isTwoFactorEnabled(username);
statusMessage.setText(enabled
? "Two-factor authentication is enabled for your account."
: "Two-factor authentication is not enabled yet. Scan the QR code below with your "
+ "authenticator app to turn it on.");
enableBtn.setVisible(!enabled);
if (enabled) {
hideEnrollmentFields();
}
}
private void startEnrollment() {
pendingSecret = twoFactorService.startEnrollment();
var uri = twoFactorService.otpAuthUri(username, pendingSecret);
var png = twoFactorService.qrCodePng(uri, 220);
qrImage.setSrc(DownloadHandler.fromInputStream(event -> new DownloadResponse(
new ByteArrayInputStream(png), "two-factor-qr.png", "image/png", png.length)).inline());
qrImage.setVisible(true);
secretText.setText(pendingSecret);
secretText.setVisible(true);
confirmCode.clear();
confirmCode.setVisible(true);
confirmBtn.setVisible(true);
enableBtn.setVisible(false);
}
private void confirmEnrollment() {
var secret = pendingSecret;
if (secret == null) {
return;
}
if (!twoFactorService.confirmEnrollment(username, secret, confirmCode.getValue())) {
confirmCode.reset();
Notification.show("Incorrect code, please try again", 3000, Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.ERROR);
return;
}
pendingSecret = null;
hideEnrollmentFields();
Notification.show("Two-factor authentication is now enabled", 3000, Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.SUCCESS);
refreshStatus();
}
private void hideEnrollmentFields() {
qrImage.setVisible(false);
secretText.setVisible(false);
confirmCode.setVisible(false);
confirmBtn.setVisible(false);
}
}
@@ -0,0 +1,105 @@
package com.example.security.ui;
import com.example.base.ui.OtpField;
import com.example.security.TwoFactorAttemptTracker;
import com.example.security.TwoFactorNavigationGuard;
import com.example.security.TwoFactorService;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.html.Main;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.VaadinServletRequest;
import com.vaadin.flow.server.VaadinServletResponse;
import com.vaadin.flow.spring.security.AuthenticationContext;
import jakarta.annotation.security.PermitAll;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
// Public: referenced from com.example.security.TwoFactorNavigationGuard via forwardTo(...),
// the same reason LoginView is public for com.example.security.SecurityConfig.
@Route(value = "login/verify", autoLayout = false)
@PageTitle("Verify")
@PermitAll
public class TwoFactorVerifyView extends Main {
final OtpField code;
final Span error;
final Button verifyBtn;
private final transient TwoFactorService twoFactorService;
private final transient TwoFactorAttemptTracker attemptTracker;
// AuthenticationContext is not Serializable by design, so this field must stay transient.
private final transient AuthenticationContext authenticationContext;
TwoFactorVerifyView(
TwoFactorService twoFactorService, TwoFactorAttemptTracker attemptTracker,
AuthenticationContext authenticationContext) {
this.twoFactorService = twoFactorService;
this.attemptTracker = attemptTracker;
this.authenticationContext = authenticationContext;
code = new OtpField();
code.setLabel("Enter the 6-digit code from your authenticator app");
code.addCompletedListener(event -> verify());
error = new Span();
error.getStyle().set("color", "var(--aura-color-error, red)");
error.setVisible(false);
verifyBtn = new Button("Verify", event -> verify());
verifyBtn.addThemeVariants(ButtonVariant.PRIMARY);
var layout = new VerticalLayout(new H1("Two-factor verification"), code, error, verifyBtn);
layout.setAlignItems(FlexComponent.Alignment.CENTER);
layout.setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
layout.setSizeFull();
add(layout);
setSizeFull();
}
private void verify() {
var username = authenticationContext.getPrincipalName().orElse(null);
if (username == null || !twoFactorService.verify(username, code.getValue())) {
handleFailure();
return;
}
completeLogin(username);
}
private void handleFailure() {
code.reset();
var attempts = attemptTracker.recordFailure();
if (attemptTracker.isLimitExceeded()) {
authenticationContext.logout();
return;
}
error.setText("Incorrect code. " + (TwoFactorAttemptTracker.MAX_ATTEMPTS - attempts) + " attempts left.");
error.setVisible(true);
}
private void completeLogin(String username) {
attemptTracker.reset();
var authentication = twoFactorService.completeAuthentication(username);
var securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(authentication);
SecurityContextHolder.setContext(securityContext);
var request = VaadinServletRequest.getCurrent().getHttpServletRequest();
var response = VaadinServletResponse.getCurrent().getHttpServletResponse();
new HttpSessionSecurityContextRepository().saveContext(securityContext, request, response);
var session = UI.getCurrent().getSession();
var target = (String) session.getAttribute(TwoFactorNavigationGuard.TARGET_LOCATION_SESSION_ATTRIBUTE);
session.setAttribute(TwoFactorNavigationGuard.TARGET_LOCATION_SESSION_ATTRIBUTE, null);
UI.getCurrent().getPage().setLocation(target != null ? target : "/");
}
}
@@ -0,0 +1,13 @@
.otp-field-row {
gap: var(--aura-space-s, 0.5rem);
}
.otp-field-digit {
width: 2.5em;
}
.otp-field-digit input {
text-align: center;
font-size: var(--aura-font-size-l, 1.25rem);
font-variant-numeric: tabular-nums;
}
@@ -0,0 +1,86 @@
package com.example.base.ui;
import com.vaadin.browserless.SpringBrowserlessTest;
import com.vaadin.flow.component.ComponentUtil;
import com.vaadin.flow.component.Key;
import com.vaadin.flow.component.KeyDownEvent;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@Transactional
@WithMockUser(roles = "USER")
class OtpFieldTest extends SpringBrowserlessTest {
@Test
void typing_single_digits_advances_focus_and_completes() {
var view = navigate(OtpFieldTestView.class);
var digits = view.otpField.digits;
for (var i = 0; i < digits.size(); i++) {
test(digits.get(i)).setValue(String.valueOf(i + 1));
}
assertThat(view.otpField.getValue()).isEqualTo("123456");
assertThat(view.completedCount.getText()).isEqualTo("1");
}
@Test
void pasting_the_full_code_fills_all_boxes_and_completes_once() {
var view = navigate(OtpFieldTestView.class);
test(view.otpField.digits.getFirst()).setValue("123456");
assertThat(view.otpField.getValue()).isEqualTo("123456");
assertThat(view.completedCount.getText()).isEqualTo("1");
}
@Test
void pasting_a_short_code_fills_only_the_available_boxes() {
var view = navigate(OtpFieldTestView.class);
test(view.otpField.digits.getFirst()).setValue("123");
assertThat(view.otpField.getValue()).isEqualTo("123");
assertThat(view.completedCount.getText()).isEqualTo("0");
}
@Test
void incomplete_entry_does_not_fire_completed() {
var view = navigate(OtpFieldTestView.class);
var digits = view.otpField.digits;
for (var i = 0; i < digits.size() - 1; i++) {
test(digits.get(i)).setValue(String.valueOf(i + 1));
}
assertThat(view.completedCount.getText()).isEqualTo("0");
}
@Test
void backspace_on_an_empty_box_does_not_throw_and_leaves_value_unchanged() {
// The server side can't observe client focus, so this only verifies the backspace
// handler (which moves focus back a box) runs cleanly and doesn't touch the value.
var view = navigate(OtpFieldTestView.class);
var digits = view.otpField.digits;
test(digits.getFirst()).setValue("1");
ComponentUtil.fireEvent(digits.get(1), new KeyDownEvent(digits.get(1), Key.BACKSPACE.getKeys().getFirst()));
assertThat(view.otpField.getValue()).isEqualTo("1");
}
@Test
void reset_clears_all_boxes() {
var view = navigate(OtpFieldTestView.class);
test(view.otpField.digits.getFirst()).setValue("123456");
view.otpField.reset();
assertThat(view.otpField.getValue()).isEmpty();
}
}
@@ -0,0 +1,26 @@
package com.example.base.ui;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import jakarta.annotation.security.PermitAll;
/** Test-only host route so OtpFieldTest can exercise the component inside an attached UI. */
@Route("test/otp-field")
@PageTitle("OtpField test host")
@PermitAll
class OtpFieldTestView extends Div {
final OtpField otpField;
final Span completedCount;
private int completions = 0;
OtpFieldTestView() {
otpField = new OtpField();
completedCount = new Span("0");
otpField.addCompletedListener(event -> completedCount.setText(String.valueOf(++completions)));
add(otpField, completedCount);
}
}
@@ -0,0 +1,94 @@
package com.example.security;
import org.apache.commons.codec.binary.Base32;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
class TotpGeneratorTest {
// The RFC 6238 (Appendix B) test vectors use the raw ASCII string below as the HMAC-SHA1 key
// directly, not Base32-decoded — so it has to be Base32-encoded here to hand to codeAt/verify,
// which both expect a Base32 secret (matching what generateSecret() produces).
private static final String RFC_6238_SHA1_SECRET =
new Base32().encodeToString("12345678901234567890".getBytes(StandardCharsets.US_ASCII));
// Each RFC 6238 test vector publishes an 8-digit code; truncating to the last 6 digits gives
// the expected 6-digit code, since (x % 10^8) % 10^6 == x % 10^6.
@Test
void matches_rfc_6238_test_vectors() {
assertThat(TotpGenerator.codeAt(RFC_6238_SHA1_SECRET, Long.parseLong("1", 16))).isEqualTo("287082");
assertThat(TotpGenerator.codeAt(RFC_6238_SHA1_SECRET, Long.parseLong("23523EC", 16))).isEqualTo("081804");
assertThat(TotpGenerator.codeAt(RFC_6238_SHA1_SECRET, Long.parseLong("23523ED", 16))).isEqualTo("050471");
assertThat(TotpGenerator.codeAt(RFC_6238_SHA1_SECRET, Long.parseLong("273EF07", 16))).isEqualTo("005924");
assertThat(TotpGenerator.codeAt(RFC_6238_SHA1_SECRET, Long.parseLong("3F940AA", 16))).isEqualTo("279037");
assertThat(TotpGenerator.codeAt(RFC_6238_SHA1_SECRET, Long.parseLong("27BC86AA", 16))).isEqualTo("353130");
}
@Test
void verify_accepts_the_current_step() {
var secret = TotpGenerator.generateSecret();
var now = Instant.ofEpochSecond(1_700_000_000L);
var code = TotpGenerator.codeAt(secret, now.getEpochSecond() / TotpGenerator.TIME_STEP_SECONDS);
assertThat(TotpGenerator.verify(secret, code, now)).isPresent();
}
@Test
void verify_accepts_one_step_of_clock_skew() {
var secret = TotpGenerator.generateSecret();
var now = Instant.ofEpochSecond(1_700_000_000L);
var currentStep = now.getEpochSecond() / TotpGenerator.TIME_STEP_SECONDS;
var previousStepCode = TotpGenerator.codeAt(secret, currentStep - 1);
var nextStepCode = TotpGenerator.codeAt(secret, currentStep + 1);
assertThat(TotpGenerator.verify(secret, previousStepCode, now)).contains(currentStep - 1);
assertThat(TotpGenerator.verify(secret, nextStepCode, now)).contains(currentStep + 1);
}
@Test
void verify_rejects_more_than_one_step_of_clock_skew() {
var secret = TotpGenerator.generateSecret();
var now = Instant.ofEpochSecond(1_700_000_000L);
var currentStep = now.getEpochSecond() / TotpGenerator.TIME_STEP_SECONDS;
var twoStepsAheadCode = TotpGenerator.codeAt(secret, currentStep + 2);
assertThat(TotpGenerator.verify(secret, twoStepsAheadCode, now)).isEmpty();
}
@Test
void verify_rejects_wrong_length_or_non_numeric_input() {
var secret = TotpGenerator.generateSecret();
var now = Instant.now();
assertThat(TotpGenerator.verify(secret, "12345", now)).isEmpty();
assertThat(TotpGenerator.verify(secret, "1234567", now)).isEmpty();
assertThat(TotpGenerator.verify(secret, "abcdef", now)).isEmpty();
assertThat(TotpGenerator.verify(secret, null, now)).isEmpty();
}
@Test
void generated_secrets_are_valid_base32_and_unique() {
var first = TotpGenerator.generateSecret();
var second = TotpGenerator.generateSecret();
assertThat(first).isNotEqualTo(second);
assertThat(new Base32().isInAlphabet(first)).isTrue();
}
@Test
void otp_auth_uri_contains_secret_issuer_and_account() {
var uri = TotpGenerator.otpAuthUri("Task App", "alice", "JBSWY3DPEHPK3PXP");
assertThat(uri).startsWith("otpauth://totp/");
assertThat(uri).contains("secret=JBSWY3DPEHPK3PXP");
assertThat(uri).contains("issuer=Task");
assertThat(uri).contains("digits=6");
assertThat(uri).contains("period=30");
}
}
@@ -0,0 +1,86 @@
package com.example.security;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@Transactional
class TwoFactorServiceTest {
@Autowired
TwoFactorService twoFactorService;
@Test
void enrollment_is_not_persisted_until_confirmed() {
var secret = twoFactorService.startEnrollment();
assertThat(twoFactorService.isTwoFactorEnabled("user")).isFalse();
var confirmed = twoFactorService.confirmEnrollment("user", secret, "000000");
assertThat(confirmed).isFalse();
assertThat(twoFactorService.isTwoFactorEnabled("user")).isFalse();
}
@Test
void confirming_with_the_correct_code_enables_two_factor() {
var secret = twoFactorService.startEnrollment();
var code = currentCode(secret);
var confirmed = twoFactorService.confirmEnrollment("user", secret, code);
assertThat(confirmed).isTrue();
assertThat(twoFactorService.isTwoFactorEnabled("user")).isTrue();
}
@Test
void verify_accepts_a_correct_code_once_enrolled() {
var secret = twoFactorService.startEnrollment();
twoFactorService.confirmEnrollment("user", secret, currentCode(secret));
assertThat(twoFactorService.verify("user", currentCode(secret))).isTrue();
}
@Test
void verify_rejects_replaying_an_already_accepted_code() {
var secret = twoFactorService.startEnrollment();
twoFactorService.confirmEnrollment("user", secret, currentCode(secret));
var code = currentCode(secret);
assertThat(twoFactorService.verify("user", code)).isTrue();
assertThat(twoFactorService.verify("user", code)).isFalse();
}
@Test
void verify_rejects_a_wrong_code() {
var secret = twoFactorService.startEnrollment();
twoFactorService.confirmEnrollment("user", secret, currentCode(secret));
assertThat(twoFactorService.verify("user", "000000")).isFalse();
}
@Test
void verify_returns_false_when_two_factor_is_not_enabled() {
assertThat(twoFactorService.isTwoFactorEnabled("admin")).isFalse();
assertThat(twoFactorService.verify("admin", "123456")).isFalse();
}
@Test
void completed_authentication_carries_the_users_real_authorities() {
var authentication = twoFactorService.completeAuthentication("admin");
assertThat(authentication.isAuthenticated()).isTrue();
assertThat(authentication.getAuthorities()).extracting(Object::toString).containsExactly("ROLE_ADMIN");
}
private static String currentCode(String secret) {
var now = Instant.now();
return TotpGenerator.codeAt(secret, now.getEpochSecond() / TotpGenerator.TIME_STEP_SECONDS);
}
}
@@ -0,0 +1,79 @@
package com.example.security.ui;
import com.example.security.TotpGenerator;
import com.example.security.TwoFactorService;
import com.vaadin.browserless.SpringBrowserlessTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@Transactional
class TwoFactorSetupViewTest extends SpringBrowserlessTest {
@Autowired
TwoFactorService twoFactorService;
@Test
@WithMockUser(username = "user", roles = "USER")
void clicking_enable_shows_the_qr_code_and_secret() {
var view = navigate(TwoFactorSetupView.class);
test(view.enableBtn).click();
assertThat(view.qrImage.isVisible()).isTrue();
assertThat(view.secretText.isVisible()).isTrue();
assertThat(view.secretText.getText()).isNotBlank();
assertThat(view.confirmCode.isVisible()).isTrue();
}
@Test
@WithMockUser(username = "user", roles = "USER")
void wrong_confirmation_code_does_not_enable_two_factor() {
var view = navigate(TwoFactorSetupView.class);
test(view.enableBtn).click();
view.confirmCode.setValue("000000");
test(view.confirmBtn).click();
assertThat(twoFactorService.isTwoFactorEnabled("user")).isFalse();
assertThat(view.confirmCode.isVisible()).isTrue();
}
@Test
@WithMockUser(username = "user", roles = "USER")
void correct_confirmation_code_enables_two_factor() {
var view = navigate(TwoFactorSetupView.class);
test(view.enableBtn).click();
view.confirmCode.setValue(currentCodeFor(view.secretText.getText()));
test(view.confirmBtn).click();
assertThat(twoFactorService.isTwoFactorEnabled("user")).isTrue();
assertThat(view.confirmCode.isVisible()).isFalse();
assertThat(view.enableBtn.isVisible()).isFalse();
}
@Test
@WithMockUser(username = "user", roles = "USER")
void status_message_reflects_an_already_enabled_account() {
var secret = twoFactorService.startEnrollment();
twoFactorService.confirmEnrollment("user", secret, currentCodeFor(secret));
var view = navigate(TwoFactorSetupView.class);
assertThat(view.enableBtn.isVisible()).isFalse();
assertThat(view.statusMessage.getText()).contains("enabled");
}
private static String currentCodeFor(String secret) {
var now = Instant.now();
return TotpGenerator.codeAt(secret, now.getEpochSecond() / TotpGenerator.TIME_STEP_SECONDS);
}
}
@@ -0,0 +1,95 @@
package com.example.security.ui;
import com.example.security.TotpGenerator;
import com.example.security.TwoFactorAttemptTracker;
import com.example.security.TwoFactorService;
import com.vaadin.browserless.SpringBrowserlessTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@Transactional
class TwoFactorVerifyViewTest extends SpringBrowserlessTest {
@Autowired
TwoFactorService twoFactorService;
@Test
@WithMockUser(username = "user", authorities = "ROLE_PRE_AUTH_2FA")
void a_pending_2fa_session_is_forwarded_here_from_any_route() {
enroll("user");
// TwoFactorNavigationGuard should forward away from the root route to this view.
navigate("", TwoFactorVerifyView.class);
}
@Test
@WithMockUser(username = "user", authorities = "ROLE_PRE_AUTH_2FA")
void wrong_code_shows_an_error_and_leaves_the_session_pending() {
enroll("user");
var view = navigate(TwoFactorVerifyView.class);
view.code.setValue("000000");
test(view.verifyBtn).click();
assertThat(view.error.isVisible()).isTrue();
assertThat(SecurityContextHolder.getContext().getAuthentication().getAuthorities())
.extracting(Object::toString).containsExactly("ROLE_PRE_AUTH_2FA");
}
@Test
@WithMockUser(username = "user", authorities = "ROLE_PRE_AUTH_2FA")
void correct_code_upgrades_the_session_to_the_users_real_authorities() {
var secret = enroll("user");
var view = navigate(TwoFactorVerifyView.class);
view.code.setValue(currentCodeFor(secret));
test(view.verifyBtn).click();
assertThat(SecurityContextHolder.getContext().getAuthentication().getAuthorities())
.extracting(Object::toString).containsExactly("ROLE_USER");
}
@Test
@WithMockUser(username = "user", authorities = "ROLE_PRE_AUTH_2FA")
void exceeding_the_attempt_limit_logs_out() {
enroll("user");
var view = navigate(TwoFactorVerifyView.class);
// One below the limit: still pending, still shows the error, session still alive.
for (var i = 0; i < TwoFactorAttemptTracker.MAX_ATTEMPTS - 1; i++) {
view.code.setValue("000000");
test(view.verifyBtn).click();
}
assertThat(view.error.isVisible()).isTrue();
assertThat(SecurityContextHolder.getContext().getAuthentication().getAuthorities())
.extracting(Object::toString).containsExactly("ROLE_PRE_AUTH_2FA");
// The attempt that crosses the limit triggers AuthenticationContext.logout(), which
// invalidates the session as part of the same interaction.
view.code.setValue("000000");
assertThatThrownBy(() -> test(view.verifyBtn).click())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("invalidated");
}
private String enroll(String username) {
var secret = twoFactorService.startEnrollment();
twoFactorService.confirmEnrollment(username, secret, currentCodeFor(secret));
return secret;
}
private static String currentCodeFor(String secret) {
var now = Instant.now();
return TotpGenerator.codeAt(secret, now.getEpochSecond() / TotpGenerator.TIME_STEP_SECONDS);
}
}