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