+ * 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.
+ * 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).
+ *
+ * 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
+ * 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);
+ }
+}
diff --git a/src/main/java/com/example/security/TwoFactorNavigationGuard.java b/src/main/java/com/example/security/TwoFactorNavigationGuard.java
new file mode 100644
index 0000000..b04f7f8
--- /dev/null
+++ b/src/main/java/com/example/security/TwoFactorNavigationGuard.java
@@ -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.
+ *
+ * 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.
+ *
+ * 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);
+ }
+}
diff --git a/src/main/java/com/example/security/TwoFactorService.java b/src/main/java/com/example/security/TwoFactorService.java
new file mode 100644
index 0000000..b15daaa
--- /dev/null
+++ b/src/main/java/com/example/security/TwoFactorService.java
@@ -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.
+ *
+ * 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;
+ }
+}
diff --git a/src/main/java/com/example/security/User.java b/src/main/java/com/example/security/User.java
index 81bbdf9..7d401ec 100644
--- a/src/main/java/com/example/security/User.java
+++ b/src/main/java/com/example/security/User.java
@@ -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())) {
diff --git a/src/main/java/com/example/security/ui/TwoFactorSetupView.java b/src/main/java/com/example/security/ui/TwoFactorSetupView.java
new file mode 100644
index 0000000..e1d6278
--- /dev/null
+++ b/src/main/java/com/example/security/ui/TwoFactorSetupView.java
@@ -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);
+ }
+}
diff --git a/src/main/java/com/example/security/ui/TwoFactorVerifyView.java b/src/main/java/com/example/security/ui/TwoFactorVerifyView.java
new file mode 100644
index 0000000..79963f6
--- /dev/null
+++ b/src/main/java/com/example/security/ui/TwoFactorVerifyView.java
@@ -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 : "/");
+ }
+}
diff --git a/src/main/resources/META-INF/resources/otp-field.css b/src/main/resources/META-INF/resources/otp-field.css
new file mode 100644
index 0000000..cc5d24d
--- /dev/null
+++ b/src/main/resources/META-INF/resources/otp-field.css
@@ -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;
+}
diff --git a/src/test/java/com/example/base/ui/OtpFieldTest.java b/src/test/java/com/example/base/ui/OtpFieldTest.java
new file mode 100644
index 0000000..0ba2873
--- /dev/null
+++ b/src/test/java/com/example/base/ui/OtpFieldTest.java
@@ -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();
+ }
+}
diff --git a/src/test/java/com/example/base/ui/OtpFieldTestView.java b/src/test/java/com/example/base/ui/OtpFieldTestView.java
new file mode 100644
index 0000000..9a8d063
--- /dev/null
+++ b/src/test/java/com/example/base/ui/OtpFieldTestView.java
@@ -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);
+ }
+}
diff --git a/src/test/java/com/example/security/TotpGeneratorTest.java b/src/test/java/com/example/security/TotpGeneratorTest.java
new file mode 100644
index 0000000..714f926
--- /dev/null
+++ b/src/test/java/com/example/security/TotpGeneratorTest.java
@@ -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");
+ }
+}
diff --git a/src/test/java/com/example/security/TwoFactorServiceTest.java b/src/test/java/com/example/security/TwoFactorServiceTest.java
new file mode 100644
index 0000000..80077b4
--- /dev/null
+++ b/src/test/java/com/example/security/TwoFactorServiceTest.java
@@ -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);
+ }
+}
diff --git a/src/test/java/com/example/security/ui/TwoFactorSetupViewTest.java b/src/test/java/com/example/security/ui/TwoFactorSetupViewTest.java
new file mode 100644
index 0000000..f197cc8
--- /dev/null
+++ b/src/test/java/com/example/security/ui/TwoFactorSetupViewTest.java
@@ -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);
+ }
+}
diff --git a/src/test/java/com/example/security/ui/TwoFactorVerifyViewTest.java b/src/test/java/com/example/security/ui/TwoFactorVerifyViewTest.java
new file mode 100644
index 0000000..8d8d769
--- /dev/null
+++ b/src/test/java/com/example/security/ui/TwoFactorVerifyViewTest.java
@@ -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);
+ }
+}