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