From 9f311ab4ae2642ebdd2931a9593737b527c3ccd6 Mon Sep 17 00:00:00 2001 From: Pit Friedrich Date: Sat, 8 Aug 2026 15:09:26 +0200 Subject: [PATCH] Added 2FA --- pom.xml | 12 ++ .../java/com/example/base/ui/OtpField.java | 154 ++++++++++++++++++ .../com/example/security/QrCodeGenerator.java | 55 +++++++ .../com/example/security/SecurityConfig.java | 9 +- .../com/example/security/TotpGenerator.java | 96 +++++++++++ .../security/TwoFactorAttemptTracker.java | 30 ++++ .../TwoFactorAwareAuthenticationProvider.java | 60 +++++++ .../security/TwoFactorNavigationGuard.java | 55 +++++++ .../example/security/TwoFactorService.java | 105 ++++++++++++ src/main/java/com/example/security/User.java | 41 +++++ .../security/ui/TwoFactorSetupView.java | 141 ++++++++++++++++ .../security/ui/TwoFactorVerifyView.java | 105 ++++++++++++ .../META-INF/resources/otp-field.css | 13 ++ .../com/example/base/ui/OtpFieldTest.java | 86 ++++++++++ .../com/example/base/ui/OtpFieldTestView.java | 26 +++ .../example/security/TotpGeneratorTest.java | 94 +++++++++++ .../security/TwoFactorServiceTest.java | 86 ++++++++++ .../security/ui/TwoFactorSetupViewTest.java | 79 +++++++++ .../security/ui/TwoFactorVerifyViewTest.java | 95 +++++++++++ 19 files changed, 1341 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/example/base/ui/OtpField.java create mode 100644 src/main/java/com/example/security/QrCodeGenerator.java create mode 100644 src/main/java/com/example/security/TotpGenerator.java create mode 100644 src/main/java/com/example/security/TwoFactorAttemptTracker.java create mode 100644 src/main/java/com/example/security/TwoFactorAwareAuthenticationProvider.java create mode 100644 src/main/java/com/example/security/TwoFactorNavigationGuard.java create mode 100644 src/main/java/com/example/security/TwoFactorService.java create mode 100644 src/main/java/com/example/security/ui/TwoFactorSetupView.java create mode 100644 src/main/java/com/example/security/ui/TwoFactorVerifyView.java create mode 100644 src/main/resources/META-INF/resources/otp-field.css create mode 100644 src/test/java/com/example/base/ui/OtpFieldTest.java create mode 100644 src/test/java/com/example/base/ui/OtpFieldTestView.java create mode 100644 src/test/java/com/example/security/TotpGeneratorTest.java create mode 100644 src/test/java/com/example/security/TwoFactorServiceTest.java create mode 100644 src/test/java/com/example/security/ui/TwoFactorSetupViewTest.java create mode 100644 src/test/java/com/example/security/ui/TwoFactorVerifyViewTest.java diff --git a/pom.xml b/pom.xml index f190ff8..19e39d4 100644 --- a/pom.xml +++ b/pom.xml @@ -61,6 +61,18 @@ com.h2database h2 + + + commons-codec + commons-codec + + + + com.google.zxing + core + 3.5.3 + org.springframework.boot spring-boot-starter-test diff --git a/src/main/java/com/example/base/ui/OtpField.java b/src/main/java/com/example/base/ui/OtpField.java new file mode 100644 index 0000000..f53ccf4 --- /dev/null +++ b/src/main/java/com/example/base/ui/OtpField.java @@ -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 { + + private static final int DEFAULT_LENGTH = 6; + + // Package-private (rather than private) so same-package tests can drive individual boxes. + final List 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 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 { + CompletedEvent(OtpField source, boolean fromClient) { + super(source, fromClient); + } + } +} diff --git a/src/main/java/com/example/security/QrCodeGenerator.java b/src/main/java/com/example/security/QrCodeGenerator.java new file mode 100644 index 0000000..957dead --- /dev/null +++ b/src/main/java/com/example/security/QrCodeGenerator.java @@ -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. + *

+ * 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.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; + } +} diff --git a/src/main/java/com/example/security/SecurityConfig.java b/src/main/java/com/example/security/SecurityConfig.java index 22b5ea5..3c50676 100644 --- a/src/main/java/com/example/security/SecurityConfig.java +++ b/src/main/java/com/example/security/SecurityConfig.java @@ -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 diff --git a/src/main/java/com/example/security/TotpGenerator.java b/src/main/java/com/example/security/TotpGenerator.java new file mode 100644 index 0000000..bf7f3d9 --- /dev/null +++ b/src/main/java/com/example/security/TotpGenerator.java @@ -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.). + *

+ * 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 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(); + } +} diff --git a/src/main/java/com/example/security/TwoFactorAttemptTracker.java b/src/main/java/com/example/security/TwoFactorAttemptTracker.java new file mode 100644 index 0000000..b124a1b --- /dev/null +++ b/src/main/java/com/example/security/TwoFactorAttemptTracker.java @@ -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; + } +} diff --git a/src/main/java/com/example/security/TwoFactorAwareAuthenticationProvider.java b/src/main/java/com/example/security/TwoFactorAwareAuthenticationProvider.java new file mode 100644 index 0000000..6ecfcde --- /dev/null +++ b/src/main/java/com/example/security/TwoFactorAwareAuthenticationProvider.java @@ -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. + *

+ * 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); + } +}