Kaynağa Gözat

security: add rate limiting, TOTP window tolerance, session expiry, and backup codes

Allan Barcelos 4 ay önce
ebeveyn
işleme
49fcf66ea8
24 değiştirilmiş dosya ile 920 ekleme ve 184 silme
  1. 10 0
      pom.xml
  2. 82 0
      src/main/java/io/jenkins/plugins/BackupCodeUtil.java
  3. 9 0
      src/main/java/io/jenkins/plugins/MfaConstants.java
  4. 30 35
      src/main/java/io/jenkins/plugins/MfaFilter.java
  5. 17 14
      src/main/java/io/jenkins/plugins/MfaGlobalConfig.java
  6. 53 0
      src/main/java/io/jenkins/plugins/MfaRateLimiter.java
  7. 47 18
      src/main/java/io/jenkins/plugins/MfaUserProperty.java
  8. 36 30
      src/main/java/io/jenkins/plugins/MfaVerifyAction.java
  9. 35 35
      src/main/java/io/jenkins/plugins/QrCodeAction.java
  10. 11 18
      src/main/java/io/jenkins/plugins/TOTPUtil.java
  11. 17 2
      src/main/resources/io/jenkins/plugins/Messages.properties
  12. 17 1
      src/main/resources/io/jenkins/plugins/Messages_pt_BR.properties
  13. 8 4
      src/main/resources/io/jenkins/plugins/MfaGlobalConfig/config.jelly
  14. 8 3
      src/main/resources/io/jenkins/plugins/MfaGlobalConfig/config.properties
  15. 25 4
      src/main/resources/io/jenkins/plugins/MfaUserProperty/config.jelly
  16. 6 1
      src/main/resources/io/jenkins/plugins/MfaUserProperty/config.properties
  17. 48 14
      src/main/resources/io/jenkins/plugins/MfaUserProperty/script.js
  18. 38 1
      src/main/resources/io/jenkins/plugins/MfaUserProperty/style.css
  19. 13 4
      src/main/resources/io/jenkins/plugins/MfaVerifyAction/index.jelly
  20. 7 0
      src/main/resources/io/jenkins/plugins/MfaVerifyAction/index.properties
  21. 114 0
      src/test/java/io/jenkins/plugins/BackupCodeUtilTest.java
  22. 114 0
      src/test/java/io/jenkins/plugins/MfaFilterTest.java
  23. 80 0
      src/test/java/io/jenkins/plugins/MfaRateLimiterTest.java
  24. 95 0
      src/test/java/io/jenkins/plugins/TOTPUtilTest.java

+ 10 - 0
pom.xml

@@ -96,6 +96,11 @@
     </dependency>
 
     <!-- Testing -->
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter</artifactId>
+      <scope>test</scope>
+    </dependency>
     <dependency>
       <groupId>org.junit.vintage</groupId>
       <artifactId>junit-vintage-engine</artifactId>
@@ -106,6 +111,11 @@
       <artifactId>mockito-core</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-junit-jupiter</artifactId>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
   <repositories>

+ 82 - 0
src/main/java/io/jenkins/plugins/BackupCodeUtil.java

@@ -0,0 +1,82 @@
+package io.jenkins.plugins;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+
+public final class BackupCodeUtil {
+    private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+    // Unambiguous characters: no 0/O, 1/I/L confusion
+    private static final String CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+    private static final int CODE_LENGTH = 8;
+    static final int CODE_COUNT = 10;
+
+    public static String[] generateCodes() {
+        String[] codes = new String[CODE_COUNT];
+        for (int i = 0; i < CODE_COUNT; i++) {
+            StringBuilder sb = new StringBuilder(CODE_LENGTH);
+            for (int j = 0; j < CODE_LENGTH; j++) {
+                sb.append(CHARS.charAt(SECURE_RANDOM.nextInt(CHARS.length())));
+            }
+            codes[i] = sb.toString();
+        }
+        return codes;
+    }
+
+    public static String hash(String code) {
+        try {
+            byte[] bytes = MessageDigest.getInstance("SHA-256")
+                    .digest(code.toUpperCase().trim().getBytes(StandardCharsets.UTF_8));
+            return Base64.getEncoder().encodeToString(bytes);
+        } catch (NoSuchAlgorithmException e) {
+            throw new IllegalStateException("SHA-256 not available", e);
+        }
+    }
+
+    public static String[] hashCodes(String[] codes) {
+        String[] hashes = new String[codes.length];
+        for (int i = 0; i < codes.length; i++) {
+            hashes[i] = hash(codes[i]);
+        }
+        return hashes;
+    }
+
+    public static boolean isBackupCode(String code) {
+        if (code == null) return false;
+        String trimmed = code.trim();
+        // Backup codes are 8 uppercase alphanumeric characters; TOTP codes are 6 digits
+        return trimmed.length() == CODE_LENGTH && !trimmed.matches("\\d+");
+    }
+
+    /**
+     * Checks if the code matches any stored hash and removes it (single-use).
+     * Returns true if the code was valid and consumed.
+     */
+    public static boolean consume(String code, List<String> storedHashes) {
+        String h = hash(code);
+        return storedHashes.remove(h);
+    }
+
+    public static List<String> fromStorageString(String stored) {
+        List<String> list = new ArrayList<>();
+        if (stored != null && !stored.isEmpty()) {
+            for (String h : stored.split(",")) {
+                String trimmed = h.trim();
+                if (!trimmed.isEmpty()) list.add(trimmed);
+            }
+        }
+        return list;
+    }
+
+    public static String toStorageString(List<String> hashes) {
+        return String.join(",", hashes);
+    }
+
+    public static String toStorageString(String[] hashes) {
+        return String.join(",", hashes);
+    }
+}

+ 9 - 0
src/main/java/io/jenkins/plugins/MfaConstants.java

@@ -0,0 +1,9 @@
+package io.jenkins.plugins;
+
+public final class MfaConstants {
+    public static final String MFA_VERIFIED_ATTR = "mfa-verified";
+    public static final String MFA_VERIFIED_AT_ATTR = "mfa-verified-at";
+    public static final String MFA_PENDING_BACKUP_CODES_ATTR = "mfa-pending-backup-codes";
+
+    private MfaConstants() {}
+}

+ 30 - 35
src/main/java/io/jenkins/plugins/MfaFilter.java

@@ -1,17 +1,3 @@
-/*
- * Project: MFA TOTP Plugin
- *
- * Class: MfaFilter
- *
- * Unified servlet filter that enforces multi-factor authentication (MFA) for Jenkins users.
- * It intercepts HTTP requests and redirects users with MFA enabled but not yet verified
- * to the MFA verification page. Exclusions include static resources, login pages, and
- * the verification page itself.
- *
- * Author: Allan Barcelos
- * Date: 2025-07-18 (Updated for Jakarta EE and unified implementation)
- */
-
 package io.jenkins.plugins;
 
 import hudson.Extension;
@@ -19,6 +5,7 @@ import hudson.model.User;
 import jakarta.servlet.*;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
 import java.io.IOException;
 import java.util.logging.Level;
 import java.util.logging.Logger;
@@ -31,14 +18,10 @@ public class MfaFilter implements Filter {
     private static final Logger LOGGER = Logger.getLogger(MfaFilter.class.getName());
 
     @Override
-    public void init(FilterConfig filterConfig) {
-        // Initialization not needed
-    }
+    public void init(FilterConfig filterConfig) {}
 
     @Override
-    public void destroy() {
-        // Cleanup not needed
-    }
+    public void destroy() {}
 
     @Override
     public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
@@ -52,7 +35,6 @@ public class MfaFilter implements Filter {
         HttpServletRequest req = (HttpServletRequest) request;
         HttpServletResponse rsp = (HttpServletResponse) response;
 
-        // Security headers for all responses
         rsp.setHeader("X-Frame-Options", "DENY");
         rsp.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
         rsp.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
@@ -60,7 +42,6 @@ public class MfaFilter implements Filter {
         String path = req.getRequestURI();
         String contextPath = req.getContextPath();
 
-        // Skip if Jenkins isn't fully initialized
         if (Jenkins.getInstanceOrNull() == null || isExcludedPath(path, contextPath)) {
             chain.doFilter(request, response);
             return;
@@ -74,20 +55,22 @@ public class MfaFilter implements Filter {
             return;
         }
 
-        // Check if it is a tokenized API call (if the option is enabled)
         if (globalConfig.isExcludeApiTokens() && isApiTokenRequest(req)) {
             chain.doFilter(request, response);
             return;
         }
 
         MfaUserProperty mfa = user.getProperty(MfaUserProperty.class);
-
         boolean mfaRequired = (mfa != null && mfa.isMfaEnabled()) || globalConfig.isEnforceMfaForAllUsers();
 
         if (mfaRequired) {
+            HttpSession session = req.getSession(false);
+            boolean verified = session != null
+                    && Boolean.TRUE.equals(session.getAttribute(MfaConstants.MFA_VERIFIED_ATTR));
 
-            boolean verified = req.getSession() != null
-                    && Boolean.TRUE.equals(req.getSession().getAttribute("mfa-verified"));
+            if (verified) {
+                verified = !isSessionExpired(session, globalConfig);
+            }
 
             if (!verified) {
                 LOGGER.log(Level.INFO, "MFA required for user {0}", user.getId());
@@ -99,18 +82,31 @@ public class MfaFilter implements Filter {
         chain.doFilter(request, response);
     }
 
-    private boolean isApiTokenRequest(HttpServletRequest req) {
+    private boolean isSessionExpired(HttpSession session, MfaGlobalConfig globalConfig) {
+        int timeoutMinutes = globalConfig.getMfaSessionTimeoutMinutes();
+        if (timeoutMinutes <= 0) return false;
+        Long verifiedAt = (Long) session.getAttribute(MfaConstants.MFA_VERIFIED_AT_ATTR);
+        if (verifiedAt == null) return false;
+        long elapsedMs = System.currentTimeMillis() - verifiedAt;
+        if (elapsedMs > (long) timeoutMinutes * 60_000L) {
+            session.removeAttribute(MfaConstants.MFA_VERIFIED_ATTR);
+            session.removeAttribute(MfaConstants.MFA_VERIFIED_AT_ATTR);
+            return true;
+        }
+        return false;
+    }
+
+    // Package-private for testing
+    boolean isApiTokenRequest(HttpServletRequest req) {
         String authHeader = req.getHeader("Authorization");
         if (authHeader == null) return false;
-        return authHeader.startsWith("Bearer ")
-                || (authHeader.startsWith("Basic ") && req.getRequestURI().startsWith(req.getContextPath() + "/api/"));
+        // Exclude programmatic API calls authenticated via any scheme on /api/ paths.
+        // Jenkins API tokens use HTTP Basic auth; some integrations use Bearer tokens.
+        return req.getRequestURI().startsWith(req.getContextPath() + "/api/");
     }
 
-    /**
-     * Determines if the requested path should be excluded from MFA enforcement
-     */
-    private boolean isExcludedPath(String path, String contextPath) {
-        // List of paths that don't require MFA verification
+    // Package-private for testing
+    boolean isExcludedPath(String path, String contextPath) {
         return path.startsWith(contextPath + "/static/")
                 || path.startsWith(contextPath + "/adjuncts/")
                 || path.startsWith(contextPath + "/mfa-verify")
@@ -118,7 +114,6 @@ public class MfaFilter implements Filter {
                 || path.startsWith(contextPath + "/signup")
                 || path.startsWith(contextPath + "/error")
                 || path.startsWith(contextPath + "/securityRealm")
-                || path.startsWith(contextPath + "/api/")
                 || path.startsWith(contextPath + "/favicon.ico");
     }
 }

+ 17 - 14
src/main/java/io/jenkins/plugins/MfaGlobalConfig.java

@@ -1,17 +1,3 @@
-/*
- * Project: MFA TOTP Auth Plugin
- *
- * Class: MfaGlobalConfig
- *
- * --
- * Author: Allan Barcelos
- * Date: 2025-07-18
- *
- * Author: Allan Barcelos
- * Date: 2025-07-21
- * The class should override getCategory() so that the config is shown as part of the security configuration page
- */
-
 package io.jenkins.plugins;
 
 import hudson.Extension;
@@ -24,6 +10,12 @@ public class MfaGlobalConfig extends GlobalConfiguration {
 
     private boolean enforceMfaForAllUsers;
     private boolean excludeApiTokens;
+    /**
+     * Nullable: null means "never set by user", treated as the default (480 min = 8 h).
+     * Using Integer so XStream can leave it null when the field was absent in old XML,
+     * distinguishing that from an explicit user choice of 0 (no expiry).
+     */
+    private Integer mfaSessionTimeoutMinutes;
 
     public static MfaGlobalConfig get() {
         return GlobalConfiguration.all().get(MfaGlobalConfig.class);
@@ -57,4 +49,15 @@ public class MfaGlobalConfig extends GlobalConfiguration {
         this.excludeApiTokens = excludeApiTokens;
         save();
     }
+
+    /** Returns the configured session timeout in minutes, defaulting to 480 (8 hours). */
+    public int getMfaSessionTimeoutMinutes() {
+        return mfaSessionTimeoutMinutes != null ? mfaSessionTimeoutMinutes : 480;
+    }
+
+    @DataBoundSetter
+    public void setMfaSessionTimeoutMinutes(int mfaSessionTimeoutMinutes) {
+        this.mfaSessionTimeoutMinutes = Math.max(0, mfaSessionTimeoutMinutes);
+        save();
+    }
 }

+ 53 - 0
src/main/java/io/jenkins/plugins/MfaRateLimiter.java

@@ -0,0 +1,53 @@
+package io.jenkins.plugins;
+
+import java.util.concurrent.ConcurrentHashMap;
+
+public final class MfaRateLimiter {
+    static final int MAX_ATTEMPTS = 5;
+    static final long LOCKOUT_SECONDS = 900; // 15 minutes
+
+    private static final ConcurrentHashMap<String, AttemptRecord> ATTEMPTS = new ConcurrentHashMap<>();
+
+    public static boolean isLocked(String userId) {
+        AttemptRecord record = ATTEMPTS.get(userId);
+        if (record == null) return false;
+        if (record.count >= MAX_ATTEMPTS) {
+            long now = System.currentTimeMillis() / 1000;
+            if (now - record.lastAttemptAt < LOCKOUT_SECONDS) {
+                return true;
+            }
+            ATTEMPTS.remove(userId);
+        }
+        return false;
+    }
+
+    public static void recordFailure(String userId) {
+        long now = System.currentTimeMillis() / 1000;
+        ATTEMPTS.compute(userId, (k, v) -> {
+            AttemptRecord r = (v != null) ? v : new AttemptRecord();
+            r.count++;
+            r.lastAttemptAt = now;
+            return r;
+        });
+    }
+
+    public static void reset(String userId) {
+        ATTEMPTS.remove(userId);
+    }
+
+    static int getAttemptCount(String userId) {
+        AttemptRecord r = ATTEMPTS.get(userId);
+        return r == null ? 0 : r.count;
+    }
+
+    // Exposed for testing: simulate time passage without sleeping
+    static void setLastAttemptAt(String userId, long epochSeconds) {
+        AttemptRecord r = ATTEMPTS.get(userId);
+        if (r != null) r.lastAttemptAt = epochSeconds;
+    }
+
+    private static class AttemptRecord {
+        int count;
+        long lastAttemptAt;
+    }
+}

+ 47 - 18
src/main/java/io/jenkins/plugins/MfaUserProperty.java

@@ -1,19 +1,3 @@
-/*
- * Project: MFA TOTP Auth Plugin
- *
- * Class: MfaUserProperty
- *
- * Represents a user property that manages multi-factor authentication (MFA) settings
- * for a Jenkins user. It stores whether MFA is enabled and the secret key used for
- * TOTP verification. The constructor validates the TOTP code when MFA is enabled.
- *
- * Includes an inner Descriptor class to integrate with Jenkins user property UI,
- * providing validation and display name.
- *
- * Author: Allan Barcelos
- * Date: 2025-07-18
- */
-
 package io.jenkins.plugins;
 
 import hudson.Extension;
@@ -23,21 +7,36 @@ import hudson.model.UserProperty;
 import hudson.model.UserPropertyDescriptor;
 import hudson.util.FormValidation;
 import hudson.util.Secret;
+import java.io.IOException;
+import java.util.List;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 import org.kohsuke.stapler.DataBoundConstructor;
 import org.kohsuke.stapler.QueryParameter;
+import org.kohsuke.stapler.Stapler;
+import org.kohsuke.stapler.StaplerRequest;
 
 public class MfaUserProperty extends UserProperty {
     private static final Logger LOGGER = Logger.getLogger(MfaUserProperty.class.getName());
 
     private final boolean userConfiguredMfa;
     private final Secret secretKey;
+    private Secret backupCodesData;
 
     @DataBoundConstructor
     public MfaUserProperty(boolean mfaEnabled, String secretKey, String totpCode) throws FormException {
         if (mfaEnabled) {
             if (secretKey == null || secretKey.isEmpty()) {
+                // Re-save with MFA already configured: preserve existing secret
+                User currentUser = User.current();
+                MfaUserProperty existing = currentUser != null
+                        ? currentUser.getProperty(MfaUserProperty.class) : null;
+                if (existing != null && existing.secretKey != null) {
+                    this.userConfiguredMfa = true;
+                    this.secretKey = existing.secretKey;
+                    this.backupCodesData = existing.backupCodesData;
+                    return;
+                }
                 throw new FormException(Messages.MfaUserProperty_secretKey_missing(), "secretKey");
             }
             if (totpCode == null || totpCode.isEmpty()) {
@@ -49,18 +48,48 @@ public class MfaUserProperty extends UserProperty {
         }
 
         this.userConfiguredMfa = mfaEnabled;
-        this.secretKey = secretKey != null ? Secret.fromString(secretKey) : null;
+        this.secretKey = (secretKey != null && !secretKey.isEmpty()) ? Secret.fromString(secretKey) : null;
+
+        // Load backup codes from session if they were just generated
+        if (mfaEnabled) {
+            StaplerRequest currentReq = Stapler.getCurrentRequest();
+            if (currentReq != null) {
+                String pending = (String) currentReq.getSession()
+                        .getAttribute(MfaConstants.MFA_PENDING_BACKUP_CODES_ATTR);
+                if (pending != null && !pending.isEmpty()) {
+                    this.backupCodesData = Secret.fromString(pending);
+                    currentReq.getSession().removeAttribute(MfaConstants.MFA_PENDING_BACKUP_CODES_ATTR);
+                }
+            }
+        }
     }
 
     public boolean isMfaEnabled() {
         MfaGlobalConfig globalConfig = MfaGlobalConfig.get();
-        return globalConfig != null && globalConfig.isEnforceMfaForAllUsers() || userConfiguredMfa;
+        boolean globalEnforce = globalConfig != null && globalConfig.isEnforceMfaForAllUsers();
+        return globalEnforce || (userConfiguredMfa && secretKey != null);
     }
 
     public Secret getSecretKey() {
         return secretKey;
     }
 
+    public boolean hasBackupCodes() {
+        return backupCodesData != null && !backupCodesData.getPlainText().isEmpty();
+    }
+
+    /**
+     * Checks the submitted code against stored backup code hashes and consumes it if valid.
+     * Caller must invoke {@code user.save()} afterwards to persist the change.
+     */
+    public boolean consumeBackupCode(String code) throws IOException {
+        if (backupCodesData == null) return false;
+        List<String> hashes = BackupCodeUtil.fromStorageString(backupCodesData.getPlainText());
+        if (!BackupCodeUtil.consume(code, hashes)) return false;
+        backupCodesData = hashes.isEmpty() ? null : Secret.fromString(BackupCodeUtil.toStorageString(hashes));
+        return true;
+    }
+
     @Extension
     public static final class DescriptorImpl extends UserPropertyDescriptor {
         public DescriptorImpl() {

+ 36 - 30
src/main/java/io/jenkins/plugins/MfaVerifyAction.java

@@ -1,17 +1,3 @@
-/*
- * Project: MFA TOTP Auth Plugin
- *
- * Class: MfaVerifyAction
- *
- * Provides the intermediate MFA verification page at the URL /mfa-verify.
- * Handles the submission of the TOTP code from users with MFA enabled.
- * On successful verification, marks the session as MFA-verified and redirects to the Jenkins main page.
- * If verification fails, redirects back to the MFA verification page with an error indication.
- * Users without MFA enabled are redirected to the main Jenkins page directly.
- *
- * Author: Allan Barcelos
- * Date: 2025-07-18
- */
 package io.jenkins.plugins;
 
 import hudson.Extension;
@@ -23,18 +9,15 @@ import java.util.logging.Logger;
 import org.kohsuke.stapler.StaplerRequest;
 import org.kohsuke.stapler.StaplerResponse;
 
-/**
- * Intermediate page for the second factor (MFA).
- * URL: /mfa-verify
- */
 @Extension
 public class MfaVerifyAction implements RootAction {
 
     private static final Logger LOGGER = Logger.getLogger(MfaVerifyAction.class.getName());
+    private static final Logger AUDIT = Logger.getLogger("io.jenkins.plugins.mfa.audit");
 
     @Override
     public String getIconFileName() {
-        return null; // does not show in the side menu
+        return null;
     }
 
     @Override
@@ -47,31 +30,54 @@ public class MfaVerifyAction implements RootAction {
         return "mfa-verify";
     }
 
-    /**
-     * Processes POST with TOTP code
-     */
     public void doVerify(StaplerRequest req, StaplerResponse rsp) throws IOException {
         User u = User.current();
-
         if (u == null) {
             rsp.sendRedirect(req.getContextPath() + "/login");
             return;
         }
 
-        MfaUserProperty mfa = u.getProperty(MfaUserProperty.class);
+        if (MfaRateLimiter.isLocked(u.getId())) {
+            AUDIT.log(Level.WARNING, "MFA_LOCKED user={0} ip={1}",
+                    new Object[]{u.getId(), req.getRemoteAddr()});
+            rsp.sendRedirect(req.getContextPath() + "/mfa-verify?error=locked");
+            return;
+        }
 
+        MfaUserProperty mfa = u.getProperty(MfaUserProperty.class);
         if (mfa != null && mfa.isMfaEnabled()) {
             String code = req.getParameter("totpCode");
-            if (TOTPUtil.verifyCode(mfa.getSecretKey(), code)) {
-                req.getSession().setAttribute("mfa-verified", true);
-                LOGGER.log(Level.INFO, "MFA verification successful for user: " + u.getId());
+            boolean verified = false;
+
+            if (BackupCodeUtil.isBackupCode(code)) {
+                try {
+                    verified = mfa.consumeBackupCode(code);
+                    if (verified) {
+                        u.save();
+                        AUDIT.log(Level.INFO, "MFA_BACKUP_CODE_USED user={0} ip={1}",
+                                new Object[]{u.getId(), req.getRemoteAddr()});
+                    }
+                } catch (IOException e) {
+                    LOGGER.log(Level.SEVERE, "Failed to save user after backup code consumption", e);
+                }
+            } else {
+                verified = TOTPUtil.verifyCode(mfa.getSecretKey(), code);
+            }
+
+            if (verified) {
+                MfaRateLimiter.reset(u.getId());
+                req.getSession().setAttribute(MfaConstants.MFA_VERIFIED_ATTR, true);
+                req.getSession().setAttribute(MfaConstants.MFA_VERIFIED_AT_ATTR, System.currentTimeMillis());
+                AUDIT.log(Level.INFO, "MFA_SUCCESS user={0} ip={1}",
+                        new Object[]{u.getId(), req.getRemoteAddr()});
                 rsp.sendRedirect(req.getContextPath() + "/");
-                return;
             } else {
-                LOGGER.log(Level.WARNING, "Failed MFA attempt for user: " + u.getId());
+                MfaRateLimiter.recordFailure(u.getId());
+                AUDIT.log(Level.WARNING, "MFA_FAILURE user={0} ip={1} attempts={2}",
+                        new Object[]{u.getId(), req.getRemoteAddr(), MfaRateLimiter.getAttemptCount(u.getId())});
                 rsp.sendRedirect(req.getContextPath() + "/mfa-verify?error=1");
-                return;
             }
+            return;
         }
 
         rsp.sendRedirect(req.getContextPath() + "/");

+ 35 - 35
src/main/java/io/jenkins/plugins/QrCodeAction.java

@@ -1,15 +1,3 @@
-/*
- * Project: MFA TOTP Auth Plugin
- *
- * Class: QrCodeAction
- *
- * Provides HTTP endpoints for TOTP-based MFA setup.
- * Generates secrets and QR codes compatible with any TOTP app.
- *
- * URLs:
- *   /plugin/mfa-totp/generateSecret - generates secret and otpauth URL
- *   /plugin/mfa-totp/qrcode        - returns QR code PNG image
- */
 package io.jenkins.plugins;
 
 import com.google.zxing.BarcodeFormat;
@@ -19,9 +7,12 @@ import com.google.zxing.common.BitMatrix;
 import com.google.zxing.qrcode.QRCodeWriter;
 import hudson.Extension;
 import hudson.model.RootAction;
+import hudson.model.User;
 import java.awt.image.BufferedImage;
 import java.io.IOException;
+import java.util.Arrays;
 import java.util.logging.Logger;
+import net.sf.json.JSONArray;
 import net.sf.json.JSONObject;
 import org.kohsuke.stapler.StaplerRequest;
 import org.kohsuke.stapler.StaplerResponse;
@@ -31,6 +22,8 @@ public class QrCodeAction implements RootAction {
 
     private static final Logger LOGGER = Logger.getLogger(QrCodeAction.class.getName());
     private static final String ISSUER = "Jenkins";
+    // Base32 alphabet: A-Z and 2-7, with optional padding
+    private static final String BASE32_PATTERN = "[A-Za-z2-7]+=*";
 
     @Override
     public String getIconFileName() {
@@ -48,12 +41,32 @@ public class QrCodeAction implements RootAction {
     }
 
     public void doGenerateSecret(StaplerRequest req, StaplerResponse rsp) throws IOException {
+        User currentUser = User.current();
+        if (currentUser == null) {
+            rsp.sendError(401, "Not authenticated");
+            return;
+        }
         try {
-            String username = getCurrentUsername(req);
+            String username = currentUser.getId();
             String secret = TOTPUtil.generateSecret();
-            String otpAuthUrl = buildOtpAuthUrl(username, secret);
+            String otpAuthUrl = TOTPUtil.getQRBarcodeURL(username, ISSUER, secret);
+
+            String[] backupCodes = BackupCodeUtil.generateCodes();
+            String[] backupHashes = BackupCodeUtil.hashCodes(backupCodes);
+            // Store hashes in session so they survive form submission
+            req.getSession().setAttribute(
+                    MfaConstants.MFA_PENDING_BACKUP_CODES_ATTR,
+                    BackupCodeUtil.toStorageString(backupHashes));
 
-            sendJsonResponse(rsp, secret, otpAuthUrl);
+            JSONObject json = new JSONObject();
+            json.put("secret", secret);
+            json.put("otpAuthUrl", otpAuthUrl);
+            JSONArray codesArray = new JSONArray();
+            codesArray.addAll(Arrays.asList(backupCodes));
+            json.put("backupCodes", codesArray);
+
+            rsp.setContentType("application/json;charset=UTF-8");
+            rsp.getWriter().print(json.toString());
         } catch (Exception e) {
             LOGGER.severe("Failed to generate secret: " + e.getMessage());
             rsp.sendError(500, "Failed to generate secret");
@@ -66,34 +79,21 @@ public class QrCodeAction implements RootAction {
             rsp.sendError(400, "Missing secret parameter");
             return;
         }
+        if (!secret.matches(BASE32_PATTERN)) {
+            rsp.sendError(400, "Invalid secret format");
+            return;
+        }
 
+        User currentUser = User.current();
+        String username = currentUser != null ? currentUser.getId() : "user";
         try {
-            generateQRCodeImage(rsp, buildOtpAuthUrl("user", secret));
+            generateQRCodeImage(rsp, TOTPUtil.getQRBarcodeURL(username, ISSUER, secret.toUpperCase()));
         } catch (Exception e) {
             LOGGER.severe("QR Code generation failed: " + e.getMessage());
             rsp.sendError(500, "Failed to generate QR Code");
         }
     }
 
-    private String getCurrentUsername(StaplerRequest req) {
-        Object userAttr = req.getSession().getAttribute("jenkins.security.SecurityRealm.user");
-        return userAttr != null ? userAttr.toString() : "user";
-    }
-
-    private String buildOtpAuthUrl(String username, String secret) {
-        return String.format(
-                "otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30",
-                ISSUER, username, secret, ISSUER);
-    }
-
-    private void sendJsonResponse(StaplerResponse rsp, String secret, String otpAuthUrl) throws IOException {
-        rsp.setContentType("application/json;charset=UTF-8");
-        JSONObject json = new JSONObject();
-        json.put("secret", secret);
-        json.put("otpAuthUrl", otpAuthUrl);
-        rsp.getWriter().print(json.toString());
-    }
-
     private void generateQRCodeImage(StaplerResponse rsp, String otpAuth) throws IOException, WriterException {
         QRCodeWriter qrCodeWriter = new QRCodeWriter();
         BitMatrix bitMatrix = qrCodeWriter.encode(otpAuth, BarcodeFormat.QR_CODE, 200, 200);

+ 11 - 18
src/main/java/io/jenkins/plugins/TOTPUtil.java

@@ -1,15 +1,3 @@
-/*
- * Project: MFA TOTP Auth Plugin
- *
- * Class: TOTPUtil
- *
- * Utility class for standards-compliant TOTP (RFC 6238) operations.
- * Provides methods to generate secrets, build otpauth URIs, and verify codes.
- * Compatible with all major authenticator apps.
- *
- * Author: Allan Barcelos
- * Date: 2025-07-17
- */
 package io.jenkins.plugins;
 
 import hudson.util.Secret;
@@ -26,8 +14,10 @@ public class TOTPUtil {
     private static final Logger LOGGER = Logger.getLogger(TOTPUtil.class.getName());
     private static final SecureRandom SECURE_RANDOM = new SecureRandom();
 
-    private static final int CODE_LENGTH = 6;
-    private static final int TIME_STEP = 30; // seconds
+    static final int CODE_LENGTH = 6;
+    static final int TIME_STEP = 30; // seconds
+    // RFC 6238 recommends accepting ±1 time step to handle clock skew
+    private static final int WINDOW = 1;
 
     public static String generateSecret() {
         byte[] buffer = new byte[20]; // 160 bits
@@ -55,17 +45,20 @@ public class TOTPUtil {
             if (secret == null || code == null || code.length() != CODE_LENGTH) {
                 return false;
             }
-
             long time = System.currentTimeMillis() / 1000 / TIME_STEP;
-            String expectedCode = generateTOTP(secret, time);
-            return expectedCode.equals(code);
+            for (int i = -WINDOW; i <= WINDOW; i++) {
+                if (generateTOTP(secret, time + i).equals(code)) {
+                    return true;
+                }
+            }
+            return false;
         } catch (Exception e) {
             LOGGER.log(Level.SEVERE, "TOTP verification failed", e);
             return false;
         }
     }
 
-    private static String generateTOTP(String secret, long time) throws NoSuchAlgorithmException, InvalidKeyException {
+    static String generateTOTP(String secret, long time) throws NoSuchAlgorithmException, InvalidKeyException {
         Base32 base32 = new Base32();
         byte[] keyBytes = base32.decode(secret);
         byte[] timeBytes = new byte[8];

+ 17 - 2
src/main/resources/io/jenkins/plugins/Messages.properties

@@ -14,8 +14,23 @@ MfaUserProperty.enable.label=Enable MFA (TOTP)
 MfaUserProperty.qrCode.label=QR Code
 MfaUserProperty.qrCode.alt=Scan this QR code with your authenticator app
 MfaUserProperty.verificationCode.label=Verification Code
+MfaUserProperty.status.enabled=MFA is enabled
+
+# Backup Codes
+MfaUserProperty.backupCodes.label=Backup Codes
+MfaUserProperty.backupCodes.warning=Save these codes now — they will not be shown again. Each code can only be used once.
+MfaUserProperty.backupCodes.available=backup codes available
+MfaUserProperty.backupCodes.depleted=no backup codes remaining
 
 # Global Configuration
 MfaGlobalConfig.enforceForAllUsers.label=Enforce MFA for all users
-MfaGlobalConfig.excludeApiTokens.label=Exclude API tokens from MFA
-MfaGlobalConfig.section.title=MFA Global Settings
+MfaGlobalConfig.excludeApiTokens.label=Exclude API token requests from MFA (/api/ paths)
+MfaGlobalConfig.section.title=MFA Global Settings
+MfaGlobalConfig.sessionTimeout.label=MFA session timeout (minutes)
+MfaGlobalConfig.sessionTimeout.description=How long an MFA-verified session lasts. Set to 0 for no expiry. Default: 480 (8 hours).
+
+# Verification Page
+MfaVerifyAction.title=Two-Factor Authentication
+MfaVerifyAction.submit=Verify
+MfaVerifyAction.error.locked=Too many failed attempts. Please wait 15 minutes before trying again.
+MfaVerifyAction.backupCodeHint=Lost access to your authenticator app? Enter one of your backup codes instead.

+ 17 - 1
src/main/resources/io/jenkins/plugins/Messages_pt_BR.properties

@@ -14,7 +14,23 @@ MfaUserProperty.enable.label=Ativar MFA (TOTP)
 MfaUserProperty.qrCode.label=Código QR
 MfaUserProperty.qrCode.alt=Escaneie este código QR com seu aplicativo autenticador
 MfaUserProperty.verificationCode.label=Código de verificação
+MfaUserProperty.status.enabled=MFA está ativo
+
+# Backup Codes
+MfaUserProperty.backupCodes.label=Códigos de recuperação
+MfaUserProperty.backupCodes.warning=Salve estes códigos agora — eles não serão exibidos novamente. Cada código só pode ser usado uma vez.
+MfaUserProperty.backupCodes.available=códigos de recuperação disponíveis
+MfaUserProperty.backupCodes.depleted=nenhum código de recuperação restante
 
 # Global Configuration
 MfaGlobalConfig.enforceForAllUsers.label=Exigir MFA para todos os usuários
-MfaGlobalConfig.excludeApiTokens.label=Excluir tokens de API do MFA
+MfaGlobalConfig.excludeApiTokens.label=Excluir requisições com token de API do MFA (caminhos /api/)
+MfaGlobalConfig.section.title=Configurações Globais do MFA
+MfaGlobalConfig.sessionTimeout.label=Tempo limite da sessão MFA (minutos)
+MfaGlobalConfig.sessionTimeout.description=Por quanto tempo uma sessão verificada pelo MFA dura. Defina 0 para nunca expirar. Padrão: 480 (8 horas).
+
+# Verification Page
+MfaVerifyAction.title=Autenticação em Dois Fatores
+MfaVerifyAction.submit=Verificar
+MfaVerifyAction.error.locked=Muitas tentativas com falha. Aguarde 15 minutos antes de tentar novamente.
+MfaVerifyAction.backupCodeHint=Perdeu acesso ao seu aplicativo autenticador? Digite um dos seus códigos de recuperação.

+ 8 - 4
src/main/resources/io/jenkins/plugins/MfaGlobalConfig/config.jelly

@@ -1,12 +1,16 @@
 <!-- /src/main/resources/io/jenkins/plugins/MfaGlobalConfig/config.jelly -->
 <?jelly escape-by-default='true'?>
 <j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form">
-    <f:section title="Global MFA Settings">
-        <f:entry title="Enforce MFA for all users" field="enforceMfaForAllUsers">
+    <f:section title="${%MfaGlobalConfig.section.title}">
+        <f:entry title="${%MfaGlobalConfig.enforceForAllUsers.label}" field="enforceMfaForAllUsers">
             <f:checkbox />
         </f:entry>
-        <f:entry title="Exclude API tokens from MFA" field="excludeApiTokens">
+        <f:entry title="${%MfaGlobalConfig.excludeApiTokens.label}" field="excludeApiTokens">
             <f:checkbox />
         </f:entry>
+        <f:entry title="${%MfaGlobalConfig.sessionTimeout.label}" field="mfaSessionTimeoutMinutes">
+            <f:number clazz="setting-input number" min="0" />
+            <div class="setting-description">${%MfaGlobalConfig.sessionTimeout.description}</div>
+        </f:entry>
     </f:section>
-</j:jelly>
+</j:jelly>

+ 8 - 3
src/main/resources/io/jenkins/plugins/MfaGlobalConfig/config.properties

@@ -1,6 +1,11 @@
 # /src/main/resources/io/jenkins/plugins/MfaGlobalConfig/config.properties
-enforceMfaForAllUsers.displayName=Enforce MFA for all users
+MfaGlobalConfig.section.title=MFA Global Settings
+
+MfaGlobalConfig.enforceForAllUsers.label=Enforce MFA for all users
 enforceMfaForAllUsers.description=When enabled, all users will be required to set up and use MFA.
 
-excludeApiTokens.displayName=Exclude API tokens from MFA
-excludeApiTokens.description=Allow API token authentication to bypass MFA requirement.
+MfaGlobalConfig.excludeApiTokens.label=Exclude API token requests from MFA (/api/ paths)
+excludeApiTokens.description=Allow API token authentication on /api/ paths to bypass MFA.
+
+MfaGlobalConfig.sessionTimeout.label=MFA session timeout (minutes)
+MfaGlobalConfig.sessionTimeout.description=How long an MFA-verified session lasts. Set to 0 for no expiry. Default: 480 (8 hours).

+ 25 - 4
src/main/resources/io/jenkins/plugins/MfaUserProperty/config.jelly

@@ -1,7 +1,7 @@
 <!-- /src/main/resources/io/jenkins/plugins/MfaUserProperty/config.jelly -->
 <?jelly escape-by-default='true'?>
-<j:jelly xmlns:j="jelly:core" 
-         xmlns:f="/lib/form" 
+<j:jelly xmlns:j="jelly:core"
+         xmlns:f="/lib/form"
          xmlns:l="/lib/layout"
          xmlns:st="jelly:stapler">
 
@@ -12,6 +12,7 @@
     <f:checkbox id="mfaEnabledCheckbox" />
   </f:entry>
 
+  <!-- Setup container: always rendered; JS controls visibility -->
   <j:if test="${!instance.mfaEnabled}">
     <div id="mfaSetupContainer" class="hidden">
       <f:entry title="${%MfaUserProperty.qrCode.label}">
@@ -22,7 +23,27 @@
         <f:textbox field="totpCode" />
       </f:entry>
 
-      <input type="hidden" name="_.secretKey" />
+      <input type="hidden" name="_.secretKey" id="mfaSecretKey" />
+
+      <div id="backupCodesSection" class="hidden">
+        <f:entry title="${%MfaUserProperty.backupCodes.label}">
+          <div class="backup-codes-warning">${%MfaUserProperty.backupCodes.warning}</div>
+          <div id="backupCodesList" class="backup-codes-list"></div>
+        </f:entry>
+      </div>
     </div>
   </j:if>
-</j:jelly>
+
+  <j:if test="${instance.mfaEnabled}">
+    <div class="mfa-status-enabled">
+      <span>${%MfaUserProperty.status.enabled}</span>
+      <j:if test="${instance.hasBackupCodes()}">
+        <span class="backup-codes-remaining"> — ${%MfaUserProperty.backupCodes.available}</span>
+      </j:if>
+      <j:if test="${!instance.hasBackupCodes()}">
+        <span class="backup-codes-depleted"> — ${%MfaUserProperty.backupCodes.depleted}</span>
+      </j:if>
+    </div>
+  </j:if>
+
+</j:jelly>

+ 6 - 1
src/main/resources/io/jenkins/plugins/MfaUserProperty/config.properties

@@ -13,4 +13,9 @@ MfaUserProperty.totpCode.valid=Code is valid
 MfaUserProperty.enable.label=Enable MFA (TOTP)
 MfaUserProperty.qrCode.label=QR Code
 MfaUserProperty.qrCode.alt=Scan this QR code with your authenticator app
-MfaUserProperty.verificationCode.label=Verification Code
+MfaUserProperty.verificationCode.label=Verification Code
+MfaUserProperty.status.enabled=MFA is enabled
+MfaUserProperty.backupCodes.label=Backup Codes
+MfaUserProperty.backupCodes.warning=Save these codes now — they will not be shown again. Each code can only be used once.
+MfaUserProperty.backupCodes.available=backup codes available
+MfaUserProperty.backupCodes.depleted=no backup codes remaining

+ 48 - 14
src/main/resources/io/jenkins/plugins/MfaUserProperty/script.js

@@ -1,34 +1,68 @@
 // /src/main/resources/io/jenkins/plugins/MfaUserProperty/script.js
-document.addEventListener('DOMContentLoaded', function() {
+document.addEventListener('DOMContentLoaded', function () {
     initMfaToggle();
 });
 
+function getCsrfHeaders() {
+    var crumbField = document.querySelector('input[name=".crumb"], input[name="Jenkins-Crumb"]');
+    if (!crumbField) return {};
+    return { 'Jenkins-Crumb': crumbField.value };
+}
+
 function initMfaToggle() {
-    const checkbox = document.getElementById('mfaEnabledCheckbox');
-    const container = document.getElementById('mfaSetupContainer');
-    const qrImage = document.getElementById('qrCodeImage');
-    const secretInput = document.querySelector('input[name="_.secretKey"]');
+    var checkbox = document.getElementById('mfaEnabledCheckbox');
+    var container = document.getElementById('mfaSetupContainer');
+    var qrImage = document.getElementById('qrCodeImage');
+    var secretInput = document.getElementById('mfaSecretKey');
+    var backupSection = document.getElementById('backupCodesSection');
+    var backupList = document.getElementById('backupCodesList');
+
+    if (!checkbox || !container) return;
 
     function showOrHideMfaSetup() {
         if (checkbox.checked) {
             container.classList.remove('hidden');
             container.classList.add('visible');
 
-            if (!qrImage.src) {
-                fetch(window.rootURL + '/mfa-totp/generateSecret')
-                    .then(resp => resp.json())
-                    .then(data => {
-                        qrImage.src = window.rootURL + '/mfa-totp/qrcode?secret=' + 
-                                    encodeURIComponent(data.secret);
+            if (!qrImage.src || qrImage.src === window.location.href) {
+                fetch(window.rootURL + '/mfa-totp/generateSecret', {
+                    method: 'GET',
+                    headers: Object.assign({ 'Accept': 'application/json' }, getCsrfHeaders()),
+                    credentials: 'same-origin'
+                })
+                    .then(function (resp) {
+                        if (!resp.ok) throw new Error('HTTP ' + resp.status);
+                        return resp.json();
+                    })
+                    .then(function (data) {
+                        qrImage.src = window.rootURL + '/mfa-totp/qrcode?secret=' +
+                            encodeURIComponent(data.secret);
                         secretInput.value = data.secret;
+
+                        if (data.backupCodes && data.backupCodes.length > 0) {
+                            backupList.innerHTML = '';
+                            data.backupCodes.forEach(function (code) {
+                                var span = document.createElement('span');
+                                span.className = 'backup-code';
+                                span.textContent = code;
+                                backupList.appendChild(span);
+                            });
+                            backupSection.classList.remove('hidden');
+                            backupSection.classList.add('visible');
+                        }
                     })
-                    .catch(err => {
-                        console.error('Error generating QR Code:', err);
+                    .catch(function (err) {
+                        console.error('Error generating MFA secret:', err);
+                        alert('Failed to generate MFA secret. Please refresh and try again.');
                     });
             }
         } else {
             container.classList.remove('visible');
             container.classList.add('hidden');
+            if (backupSection) {
+                backupSection.classList.remove('visible');
+                backupSection.classList.add('hidden');
+            }
             qrImage.src = '';
             secretInput.value = '';
         }
@@ -39,4 +73,4 @@ function initMfaToggle() {
     if (checkbox.checked) {
         showOrHideMfaSetup();
     }
-}
+}

+ 38 - 1
src/main/resources/io/jenkins/plugins/MfaUserProperty/style.css

@@ -11,4 +11,41 @@
 .error-message {
     color: red;
     font-weight: bold;
-}
+}
+
+.mfa-status-enabled {
+    color: #2e7d32;
+    font-weight: bold;
+    margin: 4px 0;
+}
+
+.backup-codes-warning {
+    color: #e65100;
+    font-weight: bold;
+    margin-bottom: 8px;
+}
+
+.backup-codes-list {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+    margin-top: 4px;
+}
+
+.backup-code {
+    font-family: monospace;
+    font-size: 1.1em;
+    background: #f5f5f5;
+    border: 1px solid #ccc;
+    border-radius: 4px;
+    padding: 4px 10px;
+    letter-spacing: 2px;
+}
+
+.backup-codes-depleted {
+    color: #c62828;
+}
+
+.backup-codes-remaining {
+    color: #2e7d32;
+}

+ 13 - 4
src/main/resources/io/jenkins/plugins/MfaVerifyAction/index.jelly

@@ -3,20 +3,29 @@
 <j:jelly xmlns:j="jelly:core" xmlns:l="/lib/layout" xmlns:f="/lib/form">
   <l:layout title="MFA Verification">
     <l:main-panel>
-      <h1>MFA Verify</h1>
-      <j:if test="${request.getParameter('error') != null}">
+      <h1>${%MfaVerifyAction.title}</h1>
+
+      <j:if test="${request.getParameter('error') == 'locked'}">
+        <div class="error-message">
+          ${%MfaVerifyAction.error.locked}
+        </div>
+      </j:if>
+      <j:if test="${request.getParameter('error') == '1'}">
         <div class="error-message">
           ${%MfaUserProperty.totpCode.invalid}
         </div>
       </j:if>
+
       <form method="post" action="${rootURL}/mfa-verify/verify">
         <f:entry title="${%MfaUserProperty.verificationCode.label}">
           <f:textbox name="totpCode" />
         </f:entry>
         <f:entry>
-          <f:submit value="Verify" />
+          <f:submit value="${%MfaVerifyAction.submit}" />
         </f:entry>
       </form>
+
+      <p class="setting-description">${%MfaVerifyAction.backupCodeHint}</p>
     </l:main-panel>
   </l:layout>
-</j:jelly>
+</j:jelly>

+ 7 - 0
src/main/resources/io/jenkins/plugins/MfaVerifyAction/index.properties

@@ -0,0 +1,7 @@
+# /src/main/resources/io/jenkins/plugins/MfaVerifyAction/index.properties
+MfaVerifyAction.title=Two-Factor Authentication
+MfaVerifyAction.submit=Verify
+MfaVerifyAction.error.locked=Too many failed attempts. Please wait 15 minutes before trying again.
+MfaVerifyAction.backupCodeHint=Lost access to your authenticator app? Enter one of your backup codes instead.
+MfaUserProperty.verificationCode.label=Verification Code
+MfaUserProperty.totpCode.invalid=Invalid code. Check your authenticator app.

+ 114 - 0
src/test/java/io/jenkins/plugins/BackupCodeUtilTest.java

@@ -0,0 +1,114 @@
+package io.jenkins.plugins;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class BackupCodeUtilTest {
+
+    @Test
+    void generateCodes_correctCount() {
+        String[] codes = BackupCodeUtil.generateCodes();
+        assertEquals(BackupCodeUtil.CODE_COUNT, codes.length);
+    }
+
+    @Test
+    void generateCodes_allUnique() {
+        String[] codes = BackupCodeUtil.generateCodes();
+        assertEquals(codes.length, new HashSet<>(Arrays.asList(codes)).size(),
+                "All backup codes should be unique");
+    }
+
+    @Test
+    void generateCodes_correctLength() {
+        String[] codes = BackupCodeUtil.generateCodes();
+        for (String code : codes) {
+            assertEquals(8, code.length(), "Each backup code should be 8 characters");
+        }
+    }
+
+    @Test
+    void hash_isConsistent() {
+        String hash1 = BackupCodeUtil.hash("ABCD1234");
+        String hash2 = BackupCodeUtil.hash("ABCD1234");
+        assertEquals(hash1, hash2);
+    }
+
+    @Test
+    void hash_isCaseInsensitive() {
+        assertEquals(BackupCodeUtil.hash("abcd1234"), BackupCodeUtil.hash("ABCD1234"));
+    }
+
+    @Test
+    void hash_differentiatesInputs() {
+        assertNotEquals(BackupCodeUtil.hash("ABCD1234"), BackupCodeUtil.hash("ABCD1235"));
+    }
+
+    @Test
+    void consume_validCode_returnsTrue() {
+        String[] codes = BackupCodeUtil.generateCodes();
+        String[] hashes = BackupCodeUtil.hashCodes(codes);
+        List<String> hashList = Arrays.asList(hashes);
+        assertTrue(BackupCodeUtil.consume(codes[0], hashList));
+    }
+
+    @Test
+    void consume_invalidCode_returnsFalse() {
+        String[] hashes = BackupCodeUtil.hashCodes(BackupCodeUtil.generateCodes());
+        List<String> hashList = Arrays.asList(hashes);
+        assertFalse(BackupCodeUtil.consume("ZZZZZZZZ", hashList));
+    }
+
+    @Test
+    void consume_isSingleUse() {
+        String[] codes = BackupCodeUtil.generateCodes();
+        String[] hashes = BackupCodeUtil.hashCodes(codes);
+        List<String> hashList = new java.util.ArrayList<>(Arrays.asList(hashes));
+        assertTrue(BackupCodeUtil.consume(codes[0], hashList));
+        assertFalse(BackupCodeUtil.consume(codes[0], hashList), "Code should only work once");
+    }
+
+    @Test
+    void consume_removesFromList() {
+        String[] codes = BackupCodeUtil.generateCodes();
+        String[] hashes = BackupCodeUtil.hashCodes(codes);
+        List<String> hashList = new java.util.ArrayList<>(Arrays.asList(hashes));
+        int before = hashList.size();
+        BackupCodeUtil.consume(codes[0], hashList);
+        assertEquals(before - 1, hashList.size());
+    }
+
+    @Test
+    void isBackupCode_withValidBackupCode() {
+        assertTrue(BackupCodeUtil.isBackupCode("ABCD1234"));
+        assertTrue(BackupCodeUtil.isBackupCode("  ABCD1234  ")); // trim handled
+    }
+
+    @Test
+    void isBackupCode_withTotpCode_returnsFalse() {
+        assertFalse(BackupCodeUtil.isBackupCode("123456")); // TOTP codes are all digits
+    }
+
+    @Test
+    void isBackupCode_withNull_returnsFalse() {
+        assertFalse(BackupCodeUtil.isBackupCode(null));
+    }
+
+    @Test
+    void storageRoundTrip() {
+        String[] codes = BackupCodeUtil.generateCodes();
+        String[] hashes = BackupCodeUtil.hashCodes(codes);
+        String stored = BackupCodeUtil.toStorageString(hashes);
+        List<String> loaded = BackupCodeUtil.fromStorageString(stored);
+        assertEquals(Arrays.asList(hashes), loaded);
+    }
+
+    @Test
+    void fromStorageString_emptyString_returnsEmptyList() {
+        assertTrue(BackupCodeUtil.fromStorageString("").isEmpty());
+        assertTrue(BackupCodeUtil.fromStorageString(null).isEmpty());
+    }
+}

+ 114 - 0
src/test/java/io/jenkins/plugins/MfaFilterTest.java

@@ -0,0 +1,114 @@
+package io.jenkins.plugins;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class MfaFilterTest {
+
+    private MfaFilter filter;
+
+    @Mock
+    private HttpServletRequest req;
+
+    @BeforeEach
+    void setUp() {
+        filter = new MfaFilter();
+    }
+
+    // --- isExcludedPath ---
+
+    @Test
+    void isExcludedPath_staticResource() {
+        assertTrue(filter.isExcludedPath("/jenkins/static/abc.js", "/jenkins"));
+    }
+
+    @Test
+    void isExcludedPath_adjunct() {
+        assertTrue(filter.isExcludedPath("/jenkins/adjuncts/xyz.js", "/jenkins"));
+    }
+
+    @Test
+    void isExcludedPath_mfaVerify() {
+        assertTrue(filter.isExcludedPath("/jenkins/mfa-verify", "/jenkins"));
+        assertTrue(filter.isExcludedPath("/jenkins/mfa-verify/verify", "/jenkins"));
+    }
+
+    @Test
+    void isExcludedPath_loginPage() {
+        assertTrue(filter.isExcludedPath("/jenkins/login", "/jenkins"));
+    }
+
+    @Test
+    void isExcludedPath_signupPage() {
+        assertTrue(filter.isExcludedPath("/jenkins/signup", "/jenkins"));
+    }
+
+    @Test
+    void isExcludedPath_favicon() {
+        assertTrue(filter.isExcludedPath("/jenkins/favicon.ico", "/jenkins"));
+    }
+
+    @Test
+    void isExcludedPath_regularPage_notExcluded() {
+        assertFalse(filter.isExcludedPath("/jenkins/job/my-job", "/jenkins"));
+    }
+
+    @Test
+    void isExcludedPath_dashboard_notExcluded() {
+        assertFalse(filter.isExcludedPath("/jenkins/", "/jenkins"));
+    }
+
+    @Test
+    void isExcludedPath_emptyContextPath() {
+        assertTrue(filter.isExcludedPath("/static/abc.js", ""));
+        assertFalse(filter.isExcludedPath("/job/my-job", ""));
+    }
+
+    // --- isApiTokenRequest ---
+
+    @Test
+    void isApiTokenRequest_apiPathWithAuth_returnsTrue() {
+        when(req.getHeader("Authorization")).thenReturn("Basic dXNlcjp0b2tlbg==");
+        when(req.getRequestURI()).thenReturn("/jenkins/api/json");
+        when(req.getContextPath()).thenReturn("/jenkins");
+        assertTrue(filter.isApiTokenRequest(req));
+    }
+
+    @Test
+    void isApiTokenRequest_apiPathWithBearerToken_returnsTrue() {
+        when(req.getHeader("Authorization")).thenReturn("Bearer eyJhbGciOiJSUzI1NiJ9...");
+        when(req.getRequestURI()).thenReturn("/jenkins/api/json");
+        when(req.getContextPath()).thenReturn("/jenkins");
+        assertTrue(filter.isApiTokenRequest(req));
+    }
+
+    @Test
+    void isApiTokenRequest_nonApiPathWithAuth_returnsFalse() {
+        when(req.getHeader("Authorization")).thenReturn("Basic dXNlcjp0b2tlbg==");
+        when(req.getRequestURI()).thenReturn("/jenkins/job/my-job");
+        when(req.getContextPath()).thenReturn("/jenkins");
+        assertFalse(filter.isApiTokenRequest(req));
+    }
+
+    @Test
+    void isApiTokenRequest_noAuthHeader_returnsFalse() {
+        when(req.getHeader("Authorization")).thenReturn(null);
+        assertFalse(filter.isApiTokenRequest(req));
+    }
+
+    @Test
+    void isApiTokenRequest_bearerOnNonApiPath_returnsFalse() {
+        when(req.getHeader("Authorization")).thenReturn("Bearer sometoken");
+        when(req.getRequestURI()).thenReturn("/jenkins/job/build");
+        when(req.getContextPath()).thenReturn("/jenkins");
+        assertFalse(filter.isApiTokenRequest(req));
+    }
+}

+ 80 - 0
src/test/java/io/jenkins/plugins/MfaRateLimiterTest.java

@@ -0,0 +1,80 @@
+package io.jenkins.plugins;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class MfaRateLimiterTest {
+
+    @BeforeEach
+    void resetState() {
+        MfaRateLimiter.reset("alice");
+        MfaRateLimiter.reset("bob");
+    }
+
+    @Test
+    void notLockedInitially() {
+        assertFalse(MfaRateLimiter.isLocked("alice"));
+    }
+
+    @Test
+    void notLockedBeforeMaxAttempts() {
+        for (int i = 0; i < MfaRateLimiter.MAX_ATTEMPTS - 1; i++) {
+            MfaRateLimiter.recordFailure("alice");
+        }
+        assertFalse(MfaRateLimiter.isLocked("alice"));
+    }
+
+    @Test
+    void lockedAfterMaxAttempts() {
+        for (int i = 0; i < MfaRateLimiter.MAX_ATTEMPTS; i++) {
+            MfaRateLimiter.recordFailure("alice");
+        }
+        assertTrue(MfaRateLimiter.isLocked("alice"));
+    }
+
+    @Test
+    void lockoutExpiresAfterTimeout() {
+        for (int i = 0; i < MfaRateLimiter.MAX_ATTEMPTS; i++) {
+            MfaRateLimiter.recordFailure("alice");
+        }
+        assertTrue(MfaRateLimiter.isLocked("alice"));
+
+        // Simulate time passage past the lockout window
+        long expiredTime = (System.currentTimeMillis() / 1000) - MfaRateLimiter.LOCKOUT_SECONDS - 1;
+        MfaRateLimiter.setLastAttemptAt("alice", expiredTime);
+
+        assertFalse(MfaRateLimiter.isLocked("alice"),
+                "Lock should be released after lockout period expires");
+    }
+
+    @Test
+    void resetClearsAttempts() {
+        for (int i = 0; i < MfaRateLimiter.MAX_ATTEMPTS; i++) {
+            MfaRateLimiter.recordFailure("alice");
+        }
+        assertTrue(MfaRateLimiter.isLocked("alice"));
+        MfaRateLimiter.reset("alice");
+        assertFalse(MfaRateLimiter.isLocked("alice"));
+        assertEquals(0, MfaRateLimiter.getAttemptCount("alice"));
+    }
+
+    @Test
+    void differentUsersAreIndependent() {
+        for (int i = 0; i < MfaRateLimiter.MAX_ATTEMPTS; i++) {
+            MfaRateLimiter.recordFailure("alice");
+        }
+        assertTrue(MfaRateLimiter.isLocked("alice"));
+        assertFalse(MfaRateLimiter.isLocked("bob"));
+    }
+
+    @Test
+    void attemptCountIncrements() {
+        assertEquals(0, MfaRateLimiter.getAttemptCount("alice"));
+        MfaRateLimiter.recordFailure("alice");
+        assertEquals(1, MfaRateLimiter.getAttemptCount("alice"));
+        MfaRateLimiter.recordFailure("alice");
+        assertEquals(2, MfaRateLimiter.getAttemptCount("alice"));
+    }
+}

+ 95 - 0
src/test/java/io/jenkins/plugins/TOTPUtilTest.java

@@ -0,0 +1,95 @@
+package io.jenkins.plugins;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.commons.codec.binary.Base32;
+import org.junit.jupiter.api.Test;
+
+class TOTPUtilTest {
+
+    @Test
+    void generateSecret_producesValidBase32() {
+        String secret = TOTPUtil.generateSecret();
+        assertNotNull(secret);
+        assertFalse(secret.isEmpty());
+        // Base32 charset: A-Z and 2-7 with optional padding
+        assertTrue(secret.matches("[A-Z2-7]+=*"), "Secret should be valid Base32: " + secret);
+    }
+
+    @Test
+    void generateSecret_isDecodable() {
+        String secret = TOTPUtil.generateSecret();
+        byte[] decoded = new Base32().decode(secret);
+        assertEquals(20, decoded.length, "Decoded secret should be 160 bits (20 bytes)");
+    }
+
+    @Test
+    void verifyCode_withCurrentWindow() throws Exception {
+        String secret = TOTPUtil.generateSecret();
+        long time = System.currentTimeMillis() / 1000 / TOTPUtil.TIME_STEP;
+        String code = TOTPUtil.generateTOTP(secret, time);
+        assertTrue(TOTPUtil.verifyCode(secret, code));
+    }
+
+    @Test
+    void verifyCode_withPreviousWindow_succeeds() throws Exception {
+        String secret = TOTPUtil.generateSecret();
+        long time = System.currentTimeMillis() / 1000 / TOTPUtil.TIME_STEP;
+        String previousCode = TOTPUtil.generateTOTP(secret, time - 1);
+        assertTrue(TOTPUtil.verifyCode(secret, previousCode),
+                "Should accept code from previous 30-second window (clock skew tolerance)");
+    }
+
+    @Test
+    void verifyCode_withNextWindow_succeeds() throws Exception {
+        String secret = TOTPUtil.generateSecret();
+        long time = System.currentTimeMillis() / 1000 / TOTPUtil.TIME_STEP;
+        String nextCode = TOTPUtil.generateTOTP(secret, time + 1);
+        assertTrue(TOTPUtil.verifyCode(secret, nextCode),
+                "Should accept code from next 30-second window (clock skew tolerance)");
+    }
+
+    @Test
+    void verifyCode_withOldWindow_fails() throws Exception {
+        String secret = TOTPUtil.generateSecret();
+        long time = System.currentTimeMillis() / 1000 / TOTPUtil.TIME_STEP;
+        String staleCode = TOTPUtil.generateTOTP(secret, time - 2);
+        assertFalse(TOTPUtil.verifyCode(secret, staleCode),
+                "Should reject code older than 1 window");
+    }
+
+    @Test
+    void verifyCode_withWrongCode_fails() {
+        String secret = TOTPUtil.generateSecret();
+        assertFalse(TOTPUtil.verifyCode(secret, "000000"));
+    }
+
+    @Test
+    void verifyCode_withNullSecret_returnsFalse() {
+        assertFalse(TOTPUtil.verifyCode((String) null, "123456"));
+    }
+
+    @Test
+    void verifyCode_withNullCode_returnsFalse() {
+        assertFalse(TOTPUtil.verifyCode(TOTPUtil.generateSecret(), null));
+    }
+
+    @Test
+    void verifyCode_withWrongLength_returnsFalse() {
+        String secret = TOTPUtil.generateSecret();
+        assertFalse(TOTPUtil.verifyCode(secret, "12345"));   // too short
+        assertFalse(TOTPUtil.verifyCode(secret, "1234567")); // too long
+    }
+
+    @Test
+    void getQRBarcodeURL_containsRequiredParts() {
+        String url = TOTPUtil.getQRBarcodeURL("alice", "Jenkins", "SECRETKEY");
+        assertTrue(url.startsWith("otpauth://totp/"));
+        assertTrue(url.contains("Jenkins:alice"));
+        assertTrue(url.contains("secret=SECRETKEY"));
+        assertTrue(url.contains("issuer=Jenkins"));
+        assertTrue(url.contains("algorithm=SHA1"));
+        assertTrue(url.contains("digits=6"));
+        assertTrue(url.contains("period=30"));
+    }
+}