From 167155e3f0203ef67982b997983ca2259c09e627 Mon Sep 17 00:00:00 2001 From: tombo Date: Wed, 30 Sep 2026 12:48:12 +0200 Subject: [PATCH] feat: remember MFA across sessions for 'Keep me signed in' users --- README.md | 66 +++- pom.xml | 11 +- .../io/jenkins/plugins/openmfa/MFAFilter.java | 50 ++- .../openmfa/MFAGlobalConfiguration.java | 96 +++++- .../plugins/openmfa/MFALoginAction.java | 16 + .../openmfa/constant/PluginConstants.java | 18 + .../plugins/openmfa/constant/UIConstants.java | 1 + .../openmfa/service/ExemptionService.java | 17 +- .../openmfa/service/RememberMFAService.java | 284 ++++++++++++++++ .../MFAGlobalConfiguration/config.jelly | 9 +- .../MFAGlobalConfiguration/config.properties | 2 + .../config_es.properties | 2 + .../config_zh_CN.properties | 2 + .../plugins/openmfa/MFAFilterWebTest.java | 7 +- .../MFAGlobalConfigurationCasCTest.java | 56 ++++ .../openmfa/MFAGlobalConfigurationTest.java | 35 +- .../plugins/openmfa/RememberMFAWebTest.java | 307 ++++++++++++++++++ .../openmfa/service/ExemptionServiceTest.java | 9 +- .../service/RememberMFAServiceTest.java | 192 +++++++++++ .../jenkins/plugins/openmfa/casc-openmfa.yaml | 12 + 20 files changed, 1130 insertions(+), 62 deletions(-) create mode 100644 src/main/java/io/jenkins/plugins/openmfa/service/RememberMFAService.java create mode 100644 src/test/java/io/jenkins/plugins/openmfa/MFAGlobalConfigurationCasCTest.java create mode 100644 src/test/java/io/jenkins/plugins/openmfa/RememberMFAWebTest.java create mode 100644 src/test/java/io/jenkins/plugins/openmfa/service/RememberMFAServiceTest.java create mode 100644 src/test/resources/io/jenkins/plugins/openmfa/casc-openmfa.yaml diff --git a/README.md b/README.md index 05a6bb3..6438484 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,10 @@ It introduces setup, verification, and management flows for user accounts inside - [Getting Started](#getting-started) - [User Flow](#user-flow) - [MFA User Management](#mfa-user-management) + - [Remember MFA ("Keep me signed in")](#remember-mfa-keep-me-signed-in) - [Configuration as Code (JCasC)](#configuration-as-code-jcasc) - [MFA Exemptions](#mfa-exemptions) + - [Ephemeral JENKINS_HOME (Kubernetes / jenkins-operator)](#ephemeral-jenkins_home-kubernetes--jenkins-operator) - [Security Notes](#security-notes) - [Development](#development) - [VS Code Debugging](#vs-code-debugging) @@ -35,13 +37,15 @@ It introduces setup, verification, and management flows for user accounts inside - Global admin configuration for issuer and enforcement - MFA user management page for administrators - Session-based MFA verification after primary login +- Remember MFA across sessions for users who check "Keep me signed in" + (signed cookie, configurable duration, disabled by setting it to 0) - Basic brute-force protection on code verification attempts - Supports Jenkins dark theme for all UI elements - Configurable exemptions for service accounts and specific roles ## Requirements -- Jenkins `2.516.3` or newer +- Jenkins `2.568.3` or newer ## Getting Started @@ -50,6 +54,7 @@ It introduces setup, verification, and management flows for user accounts inside 3. Configure: - `Issuer Name` - `Require MFA for all users` + - `Remember MFA for "Keep me signed in" (hours)` (default: 24, 0 disables) - `Exempt Users` (optional) - `Exempt Roles` (optional) 4. Save settings. @@ -91,6 +96,37 @@ It introduces setup, verification, and management flows for user accounts inside ![MFA user management table/actions page](./images/mfa-user-management.png) (MFA user management: `Manage Jenkins` > `MFA User Management`) +## Remember MFA ("Keep me signed in") + +By default, MFA verification lives only in the HTTP session, so every new +session (new browser session, session timeout, controller restart) triggers a +new MFA challenge -- even for users who checked Jenkins' "Keep me signed in" +at login. + +With this feature, users who logged in with "Keep me signed in" receive a +long-lived `openmfa-remember` cookie after passing MFA and are not challenged +again until it expires. Users who log in without "Keep me signed in" keep the +per-session behavior. + +How it works: + +- The cookie is a stateless, HMAC-SHA256-signed token bound to the username, + an absolute expiry, and a hash of the user's current TOTP secret. Nothing is + stored server-side per device. +- Duration is configured by `Remember MFA for "Keep me signed in" (hours)` + (default: 24). Setting it to `0` disables the feature entirely. +- Re-enrolling or disabling MFA changes the TOTP secret and therefore + invalidates all previously issued cookies for that user. +- Logging out clears the cookie. +- The signing key is generated automatically on first use and persisted in the + global configuration. Rotating the key (or setting a new one via JCasC) + immediately invalidates all issued remember-MFA cookies -- this is the + emergency "forget all remembered devices" switch. + +No configuration is required on a standard installation with a persistent +`JENKINS_HOME`. For ephemeral setups, see +[Ephemeral JENKINS_HOME](#ephemeral-jenkins_home-kubernetes--jenkins-operator). + ## Configuration as Code (JCasC) This plugin exposes global configuration and can be managed through Jenkins Configuration as Code. @@ -109,6 +145,12 @@ security: openMFA: issuer: 'Jenkins' requireMFA: true + rememberMfaHours: 24 + # Optional. Omit to let the plugin generate and persist a key in + # JENKINS_HOME. Set it explicitly (via any JCasC secret source, e.g. + # Vault, env var, or mounted secret) when JENKINS_HOME is ephemeral, + # so remember-MFA cookies survive restarts. + rememberMfaKey: ${OPENMFA_REMEMBER_KEY} exemptUsers: - svc-deploy - break-glass @@ -132,11 +174,31 @@ Exemptions are useful for: **Note:** Exempted users bypass MFA entirely. Do not exempt regular user accounts. +## Ephemeral JENKINS_HOME (Kubernetes / jenkins-operator) + +On setups where `JENKINS_HOME` does not survive restarts (e.g. +[jenkins-operator](https://github.com/jenkinsci/kubernetes-operator) with its +default ephemeral home), two pieces of state need attention: + +- **Remember-MFA signing key** -- set `rememberMfaKey` via JCasC from an + external secret source (Vault, Kubernetes Secret, env var). The key is then + re-applied on every boot and issued cookies stay valid across pod restarts. + Without it, each fresh instance generates a new key and all remembered + devices are challenged again. +- **User TOTP enrollments** -- stored by Jenkins in `$JENKINS_HOME/users/` and + encrypted with `$JENKINS_HOME/secrets/master.key`. These are runtime user + data and cannot come from configuration: persist `users/` and `secrets/` + together (PVC mounts or backup/restore), always as a pair -- one without the + other is unusable. + ## Security Notes - Treat JCasC files and Jenkins configuration files as privileged, since they control authentication behavior. -- API token authentication is not challenged with interactive MFA. +- API token authentication is not challenged with interactive MFA. Basic auth with a real password is challenged; use API tokens for scripted access. - OpenMFA uses per-session verification and redirects back to the original destination after successful MFA. +- The remember-MFA cookie is `HttpOnly`, `Secure` on HTTPS, HMAC-SHA256 signed + with constant-time comparison, and carries no secret material -- only the + username, expiry, and signature. ## Development diff --git a/pom.xml b/pom.xml index 6429c68..88586bc 100644 --- a/pom.xml +++ b/pom.xml @@ -32,7 +32,7 @@ 999999-SNAPSHOT - 2.528 + 2.568 ${jenkins.baseline}.3 jenkinsci/${project.artifactId}-plugin @@ -61,7 +61,7 @@ io.jenkins.tools.bom bom-${jenkins.baseline}.x - 6055.v35edb_dc8d0f9 + 7093.v37de7b_4a_8a_4f pom import @@ -123,6 +123,13 @@ 6.2.3 test + + + + io.jenkins + configuration-as-code + test + diff --git a/src/main/java/io/jenkins/plugins/openmfa/MFAFilter.java b/src/main/java/io/jenkins/plugins/openmfa/MFAFilter.java index 0ca31da..8eb0f9c 100644 --- a/src/main/java/io/jenkins/plugins/openmfa/MFAFilter.java +++ b/src/main/java/io/jenkins/plugins/openmfa/MFAFilter.java @@ -5,6 +5,7 @@ import io.jenkins.plugins.openmfa.base.MFAContext; import io.jenkins.plugins.openmfa.constant.PluginConstants; import io.jenkins.plugins.openmfa.service.ExemptionService; +import io.jenkins.plugins.openmfa.service.RememberMFAService; import io.jenkins.plugins.openmfa.service.SessionService; import io.jenkins.plugins.openmfa.util.JenkinsUtil; import io.jenkins.plugins.openmfa.util.SecurityUtil; @@ -63,6 +64,9 @@ public class MFAFilter implements Filter { private ExemptionService exemptionService = MFAContext.i().getService(ExemptionService.class); + private RememberMFAService rememberMFAService = + MFAContext.i().getService(RememberMFAService.class); + private SessionService sessionService = MFAContext.i().getService(SessionService.class); @@ -103,6 +107,11 @@ public void doFilter( Optional user = JenkinsUtil.getCurrentUser(); log.fine("Current user: " + user.map(User::getId).orElse("")); + if (isLogout(req)) { + // Logging out also invalidates the remembered MFA verification + rememberMFAService.clearCookie(req, resp); + } + if (user.isEmpty() || shouldAllowPath(req)) { log.fine("Allowing path without MFA check: " + req.getRequestURI()); chain.doFilter(req, resp); @@ -138,14 +147,7 @@ public void init(FilterConfig filterConfig) throws ServletException { * already have query params, or empty string if there is no target */ private String buildFromParam(HttpServletRequest req) { - String uri = req.getRequestURI(); - String contextPath = req.getContextPath(); - String relativePath = uri; - if ( - contextPath != null && !contextPath.isEmpty() && uri.startsWith(contextPath) - ) { - relativePath = uri.substring(contextPath.length()); - } + String relativePath = relativePath(req); if (relativePath.isEmpty()) { relativePath = "/"; } @@ -212,6 +214,14 @@ private boolean handleMFAVerification( return true; } + // A valid remember-MFA cookie (issued after "Keep me signed in" + MFA) + // stands in for the MFA challenge in a fresh session + if (rememberMFAService.isVerifiedByCookie(req, user)) { + log.fine("Valid remember-MFA cookie for user: " + username); + MFAContext.i().getService(SessionService.class).verifySession(req); + return true; + } + // MFA is required but not yet verified, redirect to MFA page log.fine( String.format( @@ -236,19 +246,31 @@ private boolean handleMFAVerification( } /** - * Checks if the request path should be allowed without MFA verification. + * Checks if the request is a logout request. */ - private boolean shouldAllowPath(HttpServletRequest req) { + private boolean isLogout(HttpServletRequest req) { + return "/logout".equals(relativePath(req)); + } + + /** + * Returns the request path with the context path stripped. + */ + private String relativePath(HttpServletRequest req) { String uri = req.getRequestURI(); String contextPath = req.getContextPath(); - - // Remove context path to get the relative path - String relativePath = uri; if ( contextPath != null && !contextPath.isEmpty() && uri.startsWith(contextPath) ) { - relativePath = uri.substring(contextPath.length()); + return uri.substring(contextPath.length()); } + return uri; + } + + /** + * Checks if the request path should be allowed without MFA verification. + */ + private boolean shouldAllowPath(HttpServletRequest req) { + String relativePath = relativePath(req); // Check if path matches any allowed pattern // Use strict matching to prevent path bypass attacks (e.g., /loginEvil matching diff --git a/src/main/java/io/jenkins/plugins/openmfa/MFAGlobalConfiguration.java b/src/main/java/io/jenkins/plugins/openmfa/MFAGlobalConfiguration.java index a7cbb7d..077ceaf 100644 --- a/src/main/java/io/jenkins/plugins/openmfa/MFAGlobalConfiguration.java +++ b/src/main/java/io/jenkins/plugins/openmfa/MFAGlobalConfiguration.java @@ -3,7 +3,12 @@ import edu.umd.cs.findbugs.annotations.NonNull; import hudson.Extension; import hudson.ExtensionList; +import hudson.util.Secret; import io.jenkins.plugins.openmfa.constant.UIConstants; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; import java.util.concurrent.atomic.AtomicReference; import jenkins.model.GlobalConfiguration; import jenkins.model.GlobalConfigurationCategory; @@ -30,6 +35,8 @@ public class MFAGlobalConfiguration extends GlobalConfiguration { private static final AtomicReference FALLBACK_INSTANCE = new AtomicReference<>(); + private static final SecureRandom RANDOM = new SecureRandom(); + /** * Get the singleton instance of this configuration. */ @@ -53,15 +60,38 @@ public static void resetFallbackInstance() { FALLBACK_INSTANCE.set(null); } - /** Comma/newline-separated list of roles/groups exempt from MFA */ - private String exemptRoles = ""; + private static List splitLines(String text) { + if (text == null || text.isBlank()) { + return List.of(); + } + return Arrays.stream(text.split("\n")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } + + /** Roles/groups exempt from MFA */ + private List exemptRoles = List.of(); - /** Comma/newline-separated list of usernames exempt from MFA */ - private String exemptUsers = ""; + /** Usernames exempt from MFA */ + private List exemptUsers = List.of(); /** The issuer name shown in authenticator apps */ private String issuer = UIConstants.Defaults.DEFAULT_ISSUER; + /** + * How many hours a "remember MFA" cookie stays valid after a user who chose + * "Keep me signed in" passes MFA. 0 disables the feature. + */ + private int rememberMfaHours = UIConstants.Defaults.DEFAULT_REMEMBER_MFA_HOURS; + + /** + * HMAC key used to sign remember-MFA tokens. Generated on first use; + * can be set explicitly (e.g. via JCasC) so tokens survive JENKINS_HOME + * resets. Rotating it invalidates all issued remember-MFA cookies. + */ + private Secret rememberMfaKey; + /** Whether MFA is required for all users */ private boolean requireMFA = UIConstants.Defaults.DEFAULT_REQUIRE_MFA; @@ -73,6 +103,12 @@ public MFAGlobalConfiguration() { @Override public boolean configure(StaplerRequest2 req, JSONObject json) throws FormException { + // The UI submits exemptions as textarea text (one entry per line); + // convert to the list form before binding the rest + setExemptUsers(splitLines(json.optString("exemptUsers", ""))); + setExemptRoles(splitLines(json.optString("exemptRoles", ""))); + json.remove("exemptUsers"); + json.remove("exemptRoles"); req.bindJSON(this, json); // Bind the JSON object to the configuration object save(); // Save the configuration object return true; @@ -99,29 +135,47 @@ public String getDisplayName() { } /** - * Get the list of exempt roles (for Jelly view). - * Returns one role per line for easier editing. + * Exempt roles as text for the Jelly textarea, one role per line. + */ + public String getExemptRolesText() { + return String.join("\n", exemptRoles); + } + + /** + * Exempt users as text for the Jelly textarea, one user per line. */ - public String getExemptRoles() { - return exemptRoles; + public String getExemptUsersText() { + return String.join("\n", exemptUsers); } /** - * Get the list of exempt users (for Jelly view). - * Returns one user per line for easier editing. + * Returns the remember-MFA signing key, generating and persisting one on + * first use. */ - public String getExemptUsers() { - return exemptUsers; + @NonNull + public synchronized Secret getOrCreateRememberMfaKey() { + if (rememberMfaKey == null || rememberMfaKey.getPlainText().isEmpty()) { + byte[] keyBytes = new byte[32]; + RANDOM.nextBytes(keyBytes); + rememberMfaKey = + Secret.fromString(Base64.getEncoder().encodeToString(keyBytes)); + save(); + } + return rememberMfaKey; + } + + public synchronized Secret getRememberMfaKey() { + return rememberMfaKey; } @DataBoundSetter - public void setExemptRoles(String exemptRoles) { - this.exemptRoles = exemptRoles != null ? exemptRoles : ""; + public void setExemptRoles(List exemptRoles) { + this.exemptRoles = exemptRoles != null ? List.copyOf(exemptRoles) : List.of(); } @DataBoundSetter - public void setExemptUsers(String exemptUsers) { - this.exemptUsers = exemptUsers != null ? exemptUsers : ""; + public void setExemptUsers(List exemptUsers) { + this.exemptUsers = exemptUsers != null ? List.copyOf(exemptUsers) : List.of(); } @DataBoundSetter @@ -133,6 +187,16 @@ public void setIssuer(String issuer) { : UIConstants.Defaults.DEFAULT_ISSUER; } + @DataBoundSetter + public void setRememberMfaHours(int rememberMfaHours) { + this.rememberMfaHours = Math.max(0, rememberMfaHours); + } + + @DataBoundSetter + public synchronized void setRememberMfaKey(Secret rememberMfaKey) { + this.rememberMfaKey = rememberMfaKey; + } + @DataBoundSetter public void setRequireMFA(boolean requireMFA) { this.requireMFA = requireMFA; diff --git a/src/main/java/io/jenkins/plugins/openmfa/MFALoginAction.java b/src/main/java/io/jenkins/plugins/openmfa/MFALoginAction.java index 90c8ac8..f432423 100644 --- a/src/main/java/io/jenkins/plugins/openmfa/MFALoginAction.java +++ b/src/main/java/io/jenkins/plugins/openmfa/MFALoginAction.java @@ -7,6 +7,7 @@ import io.jenkins.plugins.openmfa.base.MFAContext; import io.jenkins.plugins.openmfa.constant.PluginConstants; import io.jenkins.plugins.openmfa.service.RateLimitService; +import io.jenkins.plugins.openmfa.service.RememberMFAService; import io.jenkins.plugins.openmfa.service.SessionService; import io.jenkins.plugins.openmfa.util.JenkinsUtil; import io.jenkins.plugins.openmfa.util.SecurityUtil; @@ -51,6 +52,9 @@ private static boolean isSafeRedirectTarget(String target) { private final RateLimitService rateLimitService = MFAContext.i().getService(RateLimitService.class); + private final RememberMFAService rememberMFAService = + MFAContext.i().getService(RememberMFAService.class); + private final SessionService sessionService = MFAContext.i().getService(SessionService.class); @@ -129,6 +133,18 @@ public HttpResponse doVerify() { // Mark MFA as verified in the new session sessionService.verifySession(req); + // Remember this verification if the user chose "Keep me signed in" + if ( + rememberMFAService.isEnabled() && rememberMFAService.shouldRemember(req) + ) { + rememberMFAService.issueCookie( + req, + Stapler.getCurrentResponse2(), + username, + mfaProperty.getSecret() + ); + } + // Redirect to original requested page, or root if none String from = req.getParameter(PluginConstants.FROM_PARAM); if (from != null && !from.isEmpty() && isSafeRedirectTarget(from)) { diff --git a/src/main/java/io/jenkins/plugins/openmfa/constant/PluginConstants.java b/src/main/java/io/jenkins/plugins/openmfa/constant/PluginConstants.java index 9c1e66c..391854c 100644 --- a/src/main/java/io/jenkins/plugins/openmfa/constant/PluginConstants.java +++ b/src/main/java/io/jenkins/plugins/openmfa/constant/PluginConstants.java @@ -47,6 +47,24 @@ private RateLimit() { } } + /** + * Remember-MFA cookie constants + */ + public static final class RememberMFA { + + /** Name of the long-lived remember-MFA cookie issued by this plugin */ + public static final String COOKIE_NAME = "openmfa-remember"; + + /** One hour in milliseconds */ + public static final long HOUR_MS = 60L * 60 * 1000; + + /** Name of the Jenkins "Keep me signed in" (remember-me) cookie */ + public static final String JENKINS_REMEMBER_ME_COOKIE = "remember-me"; + + private RememberMFA() { + } + } + /** * Session attribute constants */ diff --git a/src/main/java/io/jenkins/plugins/openmfa/constant/UIConstants.java b/src/main/java/io/jenkins/plugins/openmfa/constant/UIConstants.java index 8573713..c65b16b 100644 --- a/src/main/java/io/jenkins/plugins/openmfa/constant/UIConstants.java +++ b/src/main/java/io/jenkins/plugins/openmfa/constant/UIConstants.java @@ -17,6 +17,7 @@ public static final class Defaults { public static final String DEFAULT_ISSUER = "Jenkins"; public static final boolean DEFAULT_MFA_ENABLED = false; + public static final int DEFAULT_REMEMBER_MFA_HOURS = 24; public static final boolean DEFAULT_REQUIRE_MFA = false; private Defaults() { diff --git a/src/main/java/io/jenkins/plugins/openmfa/service/ExemptionService.java b/src/main/java/io/jenkins/plugins/openmfa/service/ExemptionService.java index 07b43b3..6d3fb89 100644 --- a/src/main/java/io/jenkins/plugins/openmfa/service/ExemptionService.java +++ b/src/main/java/io/jenkins/plugins/openmfa/service/ExemptionService.java @@ -3,8 +3,6 @@ import hudson.model.User; import io.jenkins.plugins.openmfa.MFAGlobalConfiguration; import io.jenkins.plugins.openmfa.base.Service; -import java.util.Arrays; -import java.util.Collections; import java.util.List; import lombok.extern.java.Log; import org.springframework.security.core.Authentication; @@ -51,7 +49,7 @@ public boolean isExempt(User user) { } private boolean isExemptByRole(User user, MFAGlobalConfiguration config) { - List exemptRoles = parseList(config.getExemptRoles()); + List exemptRoles = config.getExemptRoles(); if (exemptRoles.isEmpty()) { return false; } @@ -68,7 +66,7 @@ private boolean isExemptByRole(User user, MFAGlobalConfiguration config) { } private boolean isExemptByUsername(User user, MFAGlobalConfiguration config) { - List exemptUsers = parseList(config.getExemptUsers()); + List exemptUsers = config.getExemptUsers(); if (exemptUsers.isEmpty()) { return false; } @@ -77,15 +75,4 @@ private boolean isExemptByUsername(User user, MFAGlobalConfiguration config) { return exemptUsers.stream() .anyMatch(exempt -> exempt.toLowerCase().trim().equals(userId)); } - - private List parseList(String input) { - if (input == null || input.trim().isEmpty()) { - return Collections.emptyList(); - } - - return Arrays.stream(input.split("\n")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .toList(); - } } diff --git a/src/main/java/io/jenkins/plugins/openmfa/service/RememberMFAService.java b/src/main/java/io/jenkins/plugins/openmfa/service/RememberMFAService.java new file mode 100644 index 0000000..0a11a9d --- /dev/null +++ b/src/main/java/io/jenkins/plugins/openmfa/service/RememberMFAService.java @@ -0,0 +1,284 @@ +package io.jenkins.plugins.openmfa.service; + +import hudson.model.User; +import hudson.util.Secret; +import io.jenkins.plugins.openmfa.MFAGlobalConfiguration; +import io.jenkins.plugins.openmfa.MFAUserProperty; +import io.jenkins.plugins.openmfa.base.MFAException; +import io.jenkins.plugins.openmfa.base.Service; +import io.jenkins.plugins.openmfa.constant.PluginConstants; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Optional; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import lombok.extern.java.Log; +import org.springframework.security.authentication.RememberMeAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * Service for long-lived "remember MFA" tokens. Issues and validates + * stateless HMAC-signed tokens so users who chose "Keep me signed in" + * are not asked for MFA on every new HTTP session. + * + * Token format: {@code base64url(username):expiryEpochMillis:base64url(hmac)} + * where the HMAC covers the username, the expiry, and a hash of the user's + * TOTP secret — so changing or disabling MFA invalidates all issued tokens. + */ +@Log +@Service +public class RememberMFAService { + + private static final String HASH_ALGORITHM = "SHA-256"; + private static final String HMAC_ALGORITHM = "HmacSHA256"; + private static final String SEPARATOR = ":"; + private static final int TOKEN_PARTS = 3; + + /** + * Clears the remember-MFA cookie (e.g. on logout). + * + * @param request + * the HTTP request (for the context path) + * @param response + * the HTTP response to add the expired cookie to + */ + public void clearCookie( + HttpServletRequest request, HttpServletResponse response) { + Cookie cookie = new Cookie(PluginConstants.RememberMFA.COOKIE_NAME, ""); + cookie.setHttpOnly(true); + cookie.setSecure(request.isSecure()); + cookie.setPath(cookiePath(request)); + cookie.setMaxAge(0); + response.addCookie(cookie); + } + + /** + * Creates a signed token value for the given user. + * + * @param username + * the Jenkins user id the token is issued for + * @param totpSecret + * the user's current TOTP secret (binds the token to it) + * @param expiryEpochMillis + * absolute expiry time in epoch milliseconds + * @return the token value suitable for a cookie + */ + public String createTokenValue( + String username, Secret totpSecret, long expiryEpochMillis) { + String payload = + encode(username.getBytes(StandardCharsets.UTF_8)) + + SEPARATOR + + expiryEpochMillis; + return payload + SEPARATOR + encode(sign(payload, totpSecret)); + } + + /** + * Returns whether the remember-MFA feature is enabled in global + * configuration. + * + * @return true if the configured duration is greater than zero days + */ + public boolean isEnabled() { + return MFAGlobalConfiguration.get().getRememberMfaHours() > 0; + } + + /** + * Validates a token value against the expected user and current TOTP + * secret. Rejects malformed, expired, tampered, or foreign tokens without + * throwing. + * + * @param tokenValue + * the raw cookie value + * @param username + * the currently authenticated user id + * @param totpSecret + * the user's current TOTP secret + * @param nowMillis + * current time in epoch milliseconds + * @return true if the token is valid for this user + */ + public boolean isValidTokenValue( + String tokenValue, String username, Secret totpSecret, long nowMillis) { + if (tokenValue == null || tokenValue.isEmpty() || totpSecret == null) { + return false; + } + + String[] parts = tokenValue.split(SEPARATOR, -1); + if (parts.length != TOKEN_PARTS) { + return false; + } + + long expiry; + try { + expiry = Long.parseLong(parts[1]); + } catch (NumberFormatException e) { + return false; + } + if (expiry <= nowMillis) { + log.fine("Remember-MFA token expired for user: " + username); + return false; + } + + if ( + !parts[0].equals(encode(username.getBytes(StandardCharsets.UTF_8))) + ) { + return false; + } + + byte[] expectedMac = sign(parts[0] + SEPARATOR + parts[1], totpSecret); + byte[] actualMac; + try { + actualMac = Base64.getUrlDecoder().decode(parts[2]); + } catch (IllegalArgumentException e) { + return false; + } + return MessageDigest.isEqual(expectedMac, actualMac); + } + + /** + * Checks whether the request carries a valid remember-MFA cookie for the + * given user. + * + * @param request + * the HTTP request + * @param user + * the currently authenticated user + * @return true if a valid cookie is present and the feature is enabled + */ + public boolean isVerifiedByCookie(HttpServletRequest request, User user) { + if (!isEnabled()) { + return false; + } + MFAUserProperty property = MFAUserProperty.forUser(user); + if (property == null || property.getSecret() == null) { + return false; + } + return findCookie(request) + .map( + value -> isValidTokenValue( + value, user.getId(), property.getSecret(), System.currentTimeMillis() + ) + ) + .orElse(false); + } + + /** + * Issues a remember-MFA cookie for the user with the configured lifetime. + * + * @param request + * the HTTP request (for the context path and secure flag) + * @param response + * the HTTP response to add the cookie to + * @param username + * the Jenkins user id + * @param totpSecret + * the user's current TOTP secret + */ + public void issueCookie( + HttpServletRequest request, + HttpServletResponse response, + String username, + Secret totpSecret) { + int hours = MFAGlobalConfiguration.get().getRememberMfaHours(); + long expiry = + System.currentTimeMillis() + hours * PluginConstants.RememberMFA.HOUR_MS; + + Cookie cookie = + new Cookie( + PluginConstants.RememberMFA.COOKIE_NAME, + createTokenValue(username, totpSecret, expiry) + ); + cookie.setHttpOnly(true); + cookie.setSecure(request.isSecure()); + cookie.setPath(cookiePath(request)); + cookie.setMaxAge(hours * 60 * 60); + response.addCookie(cookie); + log.fine("Issued remember-MFA cookie for user: " + username); + } + + /** + * Returns whether the user chose "Keep me signed in": either the current + * authentication came from the Jenkins remember-me cookie, or the request + * carries that cookie (the case right after an interactive login). + * + * @param request + * the HTTP request + * @return true if the session should be remembered + */ + public boolean shouldRemember(HttpServletRequest request) { + Authentication auth = + SecurityContextHolder.getContext().getAuthentication(); + if (auth instanceof RememberMeAuthenticationToken) { + return true; + } + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return false; + } + for (Cookie cookie : cookies) { + if ( + PluginConstants.RememberMFA.JENKINS_REMEMBER_ME_COOKIE + .equals(cookie.getName()) + ) { + return true; + } + } + return false; + } + + private String cookiePath(HttpServletRequest request) { + String contextPath = request.getContextPath(); + return contextPath == null || contextPath.isEmpty() ? "/" : contextPath; + } + + private String encode(byte[] bytes) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private Optional findCookie(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return Optional.empty(); + } + for (Cookie cookie : cookies) { + if (PluginConstants.RememberMFA.COOKIE_NAME.equals(cookie.getName())) { + return Optional.ofNullable(cookie.getValue()); + } + } + return Optional.empty(); + } + + /** + * Computes the HMAC over the payload and a hash of the TOTP secret using + * the plugin's signing key. + */ + private byte[] sign(String payload, Secret totpSecret) { + try { + String message = payload + SEPARATOR + totpSecretHash(totpSecret); + Secret key = MFAGlobalConfiguration.get().getOrCreateRememberMfaKey(); + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init( + new SecretKeySpec( + key.getPlainText().getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM + ) + ); + return mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new MFAException("Error signing remember-MFA token", e); + } + } + + private String totpSecretHash(Secret totpSecret) + throws NoSuchAlgorithmException { + byte[] hash = + MessageDigest.getInstance(HASH_ALGORITHM) + .digest(totpSecret.getPlainText().getBytes(StandardCharsets.UTF_8)); + return encode(hash); + } +} diff --git a/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config.jelly b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config.jelly index 8635e2a..68016b3 100644 --- a/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config.jelly +++ b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config.jelly @@ -14,13 +14,18 @@ + + + + ${%config.rememberMfaHours.description} + - + ${%config.exemptUsers.description} - + ${%config.exemptRoles.description} diff --git a/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config.properties b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config.properties index 197a898..1afba8d 100644 --- a/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config.properties +++ b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config.properties @@ -2,6 +2,8 @@ config.section.title=OpenMFA Configuration config.requireMFA.title=Require MFA for all users config.issuer.title=Issuer Name config.mfaManagement.title=MFA User Management +config.rememberMfaHours.title=Remember MFA for "Keep me signed in" (hours) +config.rememberMfaHours.description=How many hours users who checked "Keep me signed in" at login skip repeated MFA prompts. Set to 0 to disable. # Exemption configuration messages config.exemptions.title=MFA Exemptions diff --git a/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config_es.properties b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config_es.properties index b6751a8..47188bc 100644 --- a/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config_es.properties +++ b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config_es.properties @@ -2,6 +2,8 @@ config.section.title=Configuración Global de OpenMFA config.requireMFA.title=Requerir MFA para todos los usuarios config.issuer.title=Nombre del Emisor config.mfaManagement.title=Panel de Gestión de MFA +config.rememberMfaHours.title=Recordar MFA para "Mantener sesión iniciada" (horas) +config.rememberMfaHours.description=Cuántas horas los usuarios que marcaron "Mantener sesión iniciada" al iniciar sesión omiten nuevas solicitudes de MFA. Establecer en 0 para desactivar. # Exemption configuration messages config.exemptions.title=Exenciones de MFA diff --git a/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config_zh_CN.properties b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config_zh_CN.properties index 4067d12..44b6923 100644 --- a/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config_zh_CN.properties +++ b/src/main/resources/io/jenkins/plugins/openmfa/MFAGlobalConfiguration/config_zh_CN.properties @@ -2,6 +2,8 @@ config.section.title=OpenMFA 配置 config.requireMFA.title=要求所有用户启用 MFA config.issuer.title=发行者名称 config.mfaManagement.title=MFA 用户管理 +config.rememberMfaHours.title=为"保持登录状态"记住 MFA(小时数) +config.rememberMfaHours.description=登录时勾选"保持登录状态"的用户在多少小时内无需重复进行 MFA 验证。设置为 0 可禁用。 # Exemption configuration messages config.exemptions.title=MFA 豁免 diff --git a/src/test/java/io/jenkins/plugins/openmfa/MFAFilterWebTest.java b/src/test/java/io/jenkins/plugins/openmfa/MFAFilterWebTest.java index d150987..5d878b5 100644 --- a/src/test/java/io/jenkins/plugins/openmfa/MFAFilterWebTest.java +++ b/src/test/java/io/jenkins/plugins/openmfa/MFAFilterWebTest.java @@ -6,6 +6,7 @@ import hudson.model.User; import io.jenkins.plugins.openmfa.base.MFAContext; import io.jenkins.plugins.openmfa.service.TOTPService; +import java.util.List; import org.htmlunit.html.HtmlPage; import org.junit.jupiter.api.Test; import org.jvnet.hudson.test.JenkinsRule; @@ -77,7 +78,7 @@ void testExemptUserBypassesMFA(JenkinsRule j) throws Exception { // Enable requireMFA MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); config.setRequireMFA(true); - config.setExemptUsers("exemptuser"); + config.setExemptUsers(List.of("exemptuser")); // Create exempt user @SuppressWarnings("unused") @@ -103,7 +104,7 @@ void testExemptUserCaseInsensitive(JenkinsRule j) throws Exception { // Set exemption with uppercase MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); config.setRequireMFA(true); - config.setExemptUsers("EXEMPTUSER"); + config.setExemptUsers(List.of("EXEMPTUSER")); try (var wc = j.createWebClient()) { wc.setThrowExceptionOnFailingStatusCode(false); @@ -188,7 +189,7 @@ void testNonExemptUserChallengedForMFA(JenkinsRule j) throws Exception { // Enable requireMFA with no exemptions MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); config.setRequireMFA(true); - config.setExemptUsers(""); + config.setExemptUsers(List.of()); try (var wc = j.createWebClient()) { wc.setThrowExceptionOnFailingStatusCode(false); diff --git a/src/test/java/io/jenkins/plugins/openmfa/MFAGlobalConfigurationCasCTest.java b/src/test/java/io/jenkins/plugins/openmfa/MFAGlobalConfigurationCasCTest.java new file mode 100644 index 0000000..a28d518 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/openmfa/MFAGlobalConfigurationCasCTest.java @@ -0,0 +1,56 @@ +package io.jenkins.plugins.openmfa; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.jenkins.plugins.casc.ConfigurationAsCode; +import io.jenkins.plugins.casc.ConfiguratorException; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; + +/** + * Tests that the plugin's global configuration binds correctly from + * JCasC yaml, using the upstream-compatible shape (list-valued + * exemptions). + */ +@WithJenkins +class MFAGlobalConfigurationCasCTest { + + /** + * The deployment yaml (upstream-style lists for exemptions, remember-MFA + * settings included) applies cleanly and lands in the configuration. + */ + @Test + void testAppliesConfiguration(JenkinsRule j) throws Exception { + applyCasC("casc-openmfa.yaml"); + + MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); + assertEquals("Jenkins", config.getIssuer()); + assertTrue(config.isRequireMFA(), "requireMFA should bind"); + assertEquals( + 5, config.getRememberMfaHours(), "rememberMfaHours should bind" + ); + assertEquals( + "test-signing-key", + config.getRememberMfaKey().getPlainText(), + "rememberMfaKey should bind as a Secret" + ); + assertEquals( + List.of("svc-deploy", "break-glass"), + config.getExemptUsers(), + "exemptUsers should bind as a list" + ); + assertEquals( + List.of("ci-agents", "service-accounts"), + config.getExemptRoles(), + "exemptRoles should bind as a list" + ); + } + + private void applyCasC(String resource) throws ConfiguratorException { + ConfigurationAsCode.get() + .configure(getClass().getResource(resource).toExternalForm()); + } +} diff --git a/src/test/java/io/jenkins/plugins/openmfa/MFAGlobalConfigurationTest.java b/src/test/java/io/jenkins/plugins/openmfa/MFAGlobalConfigurationTest.java index c3682e2..ad100c6 100644 --- a/src/test/java/io/jenkins/plugins/openmfa/MFAGlobalConfigurationTest.java +++ b/src/test/java/io/jenkins/plugins/openmfa/MFAGlobalConfigurationTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.*; +import java.util.List; import org.junit.jupiter.api.Test; import org.jvnet.hudson.test.JenkinsRule; import org.jvnet.hudson.test.junit.jupiter.WithJenkins; @@ -22,13 +23,39 @@ void testExemptionConfigurationPersistence(JenkinsRule j) throws Exception { MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); // Configure exemptions - config.setExemptUsers("svc-account1\nsvc-account2"); - config.setExemptRoles("ci-agents\nservice-accounts"); + config.setExemptUsers(List.of("svc-account1", "svc-account2")); + config.setExemptRoles(List.of("ci-agents", "service-accounts")); config.save(); // Reload and verify MFAGlobalConfiguration loaded = MFAGlobalConfiguration.get(); - assertEquals("svc-account1\nsvc-account2", loaded.getExemptUsers()); - assertEquals("ci-agents\nservice-accounts", loaded.getExemptRoles()); + assertEquals( + List.of("svc-account1", "svc-account2"), loaded.getExemptUsers() + ); + assertEquals( + List.of("ci-agents", "service-accounts"), loaded.getExemptRoles() + ); + } + + @Test + void testExemptionTextFormForUI(JenkinsRule j) throws Exception { + MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); + + config.setExemptUsers(List.of("svc-account1", "svc-account2")); + + assertEquals( + "svc-account1\nsvc-account2", + config.getExemptUsersText(), + "UI textarea should see one entry per line" + ); + } + + @Test + void testRememberMfaHoursDefault(JenkinsRule j) throws Exception { + MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); + + assertEquals( + 24, config.getRememberMfaHours(), "Default remember-MFA should be 24h" + ); } } diff --git a/src/test/java/io/jenkins/plugins/openmfa/RememberMFAWebTest.java b/src/test/java/io/jenkins/plugins/openmfa/RememberMFAWebTest.java new file mode 100644 index 0000000..7bd3787 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/openmfa/RememberMFAWebTest.java @@ -0,0 +1,307 @@ +package io.jenkins.plugins.openmfa; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import hudson.model.User; +import hudson.util.Secret; +import io.jenkins.plugins.openmfa.base.MFAContext; +import io.jenkins.plugins.openmfa.constant.PluginConstants; +import io.jenkins.plugins.openmfa.service.RememberMFAService; +import io.jenkins.plugins.openmfa.service.TOTPService; +import java.net.URL; +import java.util.List; +import org.htmlunit.HttpMethod; +import org.htmlunit.Page; +import org.htmlunit.WebRequest; +import org.htmlunit.util.NameValuePair; +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; + +/** + * Integration tests for the remember-MFA cookie tied to "Keep me signed in". + */ +@WithJenkins +class RememberMFAWebTest { + + private static final long ONE_DAY_MS = 24L * 60 * 60 * 1000; + + private final RememberMFAService rememberMFAService = + MFAContext.i().getService(RememberMFAService.class); + + private final TOTPService totpService = + MFAContext.i().getService(TOTPService.class); + + /** + * A valid remember-MFA cookie lets a fresh session through without an MFA + * challenge. + */ + @Test + void testFilterAcceptsValidRememberCookie(JenkinsRule j) throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + Secret secret = enrollUser("rememberuser"); + + try (var wc = j.createWebClient()) { + wc.setThrowExceptionOnFailingStatusCode(false); + wc.login("rememberuser"); + + String token = + rememberMFAService.createTokenValue( + "rememberuser", secret, System.currentTimeMillis() + ONE_DAY_MS + ); + URL url = j.getURL(); + wc.getCookieManager() + .addCookie( + new org.htmlunit.util.Cookie( + url.getHost(), + PluginConstants.RememberMFA.COOKIE_NAME, + token, + url.getPath(), + null, + false + ) + ); + + Page page = wc.goTo(""); + assertFalse( + page.getUrl().toString().contains("mfa-login"), + "Valid remember-MFA cookie should skip the MFA challenge" + ); + } + } + + /** + * A tampered remember-MFA cookie is ignored and the user is challenged. + */ + @Test + void testFilterChallengesWithInvalidRememberCookie(JenkinsRule j) + throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + enrollUser("tampereduser"); + + try (var wc = j.createWebClient()) { + wc.setThrowExceptionOnFailingStatusCode(false); + wc.login("tampereduser"); + + URL url = j.getURL(); + wc.getCookieManager() + .addCookie( + new org.htmlunit.util.Cookie( + url.getHost(), + PluginConstants.RememberMFA.COOKIE_NAME, + "bm90:12345678901234:dmFsaWQ", + url.getPath(), + null, + false + ) + ); + + Page page = wc.goTo(""); + assertTrue( + page.getUrl().toString().contains("mfa-login"), + "Invalid remember-MFA cookie should not skip the MFA challenge" + ); + } + } + + /** + * Without a remember-MFA cookie a fresh session is challenged for MFA. + */ + @Test + void testFilterChallengesWithoutRememberCookie(JenkinsRule j) + throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + enrollUser("challengeduser"); + + try (var wc = j.createWebClient()) { + wc.setThrowExceptionOnFailingStatusCode(false); + wc.login("challengeduser"); + + Page page = wc.goTo(""); + assertTrue( + page.getUrl().toString().contains("mfa-login"), + "Without a remember-MFA cookie the user should be challenged" + ); + } + } + + /** + * A valid remember-MFA cookie is ignored when the feature is disabled. + */ + @Test + void testFilterIgnoresCookieWhenDisabled(JenkinsRule j) throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + Secret secret = enrollUser("disabledfilteruser"); + + try (var wc = j.createWebClient()) { + wc.setThrowExceptionOnFailingStatusCode(false); + wc.login("disabledfilteruser"); + + String token = + rememberMFAService.createTokenValue( + "disabledfilteruser", secret, System.currentTimeMillis() + ONE_DAY_MS + ); + MFAGlobalConfiguration.get().setRememberMfaHours(0); + + URL url = j.getURL(); + wc.getCookieManager() + .addCookie( + new org.htmlunit.util.Cookie( + url.getHost(), + PluginConstants.RememberMFA.COOKIE_NAME, + token, + url.getPath(), + null, + false + ) + ); + + Page page = wc.goTo(""); + assertTrue( + page.getUrl().toString().contains("mfa-login"), + "Remember-MFA cookie should be ignored when the feature is disabled" + ); + } + } + + /** + * Logging out clears the remember-MFA cookie. + */ + @Test + void testLogoutClearsRememberCookie(JenkinsRule j) throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + Secret secret = enrollUser("logoutuser"); + + try (var wc = j.createWebClient()) { + wc.setThrowExceptionOnFailingStatusCode(false); + wc.login("logoutuser", "logoutuser", true); + + postVerify(j, wc, totpService.generateTOTP(secret)); + assertNotNull( + findRememberCookie(wc), + "Remember-MFA cookie should exist before logout" + ); + + wc.goTo("logout"); + + assertNull( + findRememberCookie(wc), + "Remember-MFA cookie should be cleared on logout" + ); + } + } + + /** + * Passing MFA with the Jenkins remember-me cookie present issues a + * remember-MFA cookie. + */ + @Test + void testVerifyIssuesCookieWithRememberMe(JenkinsRule j) throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + Secret secret = enrollUser("keepmeuser"); + + try (var wc = j.createWebClient()) { + wc.setThrowExceptionOnFailingStatusCode(false); + wc.login("keepmeuser", "keepmeuser", true); + + postVerify(j, wc, totpService.generateTOTP(secret)); + + assertNotNull( + findRememberCookie(wc), + "Remember-MFA cookie should be issued when 'Keep me signed in' is used" + ); + } + } + + /** + * No remember-MFA cookie is issued when the feature is disabled. + */ + @Test + void testVerifyIssuesNoCookieWhenDisabled(JenkinsRule j) throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + MFAGlobalConfiguration.get().setRememberMfaHours(0); + Secret secret = enrollUser("disableduser"); + + try (var wc = j.createWebClient()) { + wc.setThrowExceptionOnFailingStatusCode(false); + wc.login("disableduser", "disableduser", true); + + postVerify(j, wc, totpService.generateTOTP(secret)); + + assertNull( + findRememberCookie(wc), + "No remember-MFA cookie should be issued when the feature is disabled" + ); + } + } + + /** + * Passing MFA without the Jenkins remember-me cookie does not issue a + * remember-MFA cookie. + */ + @Test + void testVerifyIssuesNoCookieWithoutRememberMe(JenkinsRule j) + throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + Secret secret = enrollUser("plainuser"); + + try (var wc = j.createWebClient()) { + wc.setThrowExceptionOnFailingStatusCode(false); + wc.login("plainuser"); + + postVerify(j, wc, totpService.generateTOTP(secret)); + + assertNull( + findRememberCookie(wc), + "No remember-MFA cookie should be issued without 'Keep me signed in'" + ); + } + } + + /** + * Creates a user with MFA enabled and returns their TOTP secret. + */ + private Secret enrollUser(String username) throws Exception { + User user = User.getById(username, true); + MFAUserProperty property = MFAUserProperty.getOrCreate(user); + Secret secret = totpService.generateSecret(); + property.setSecret(secret); + user.save(); + return secret; + } + + /** + * Finds the remember-MFA cookie in the web client, or null. + */ + private org.htmlunit.util.Cookie findRememberCookie( + JenkinsRule.WebClient wc) { + return wc.getCookieManager() + .getCookies() + .stream() + .filter( + c -> PluginConstants.RememberMFA.COOKIE_NAME.equals(c.getName()) + ) + .findFirst() + .orElse(null); + } + + /** + * Submits a TOTP code to the MFA verify endpoint. + */ + private Page postVerify(JenkinsRule j, JenkinsRule.WebClient wc, String code) + throws Exception { + WebRequest request = + new WebRequest( + new URL(j.getURL(), "mfa-login/verify"), HttpMethod.POST + ); + request.setRequestParameters( + List.of( + new NameValuePair(PluginConstants.FormParameters.TOTP_CODE, code) + ) + ); + return wc.getPage(wc.addCrumb(request)); + } +} diff --git a/src/test/java/io/jenkins/plugins/openmfa/service/ExemptionServiceTest.java b/src/test/java/io/jenkins/plugins/openmfa/service/ExemptionServiceTest.java index 68e906b..21fdcd6 100644 --- a/src/test/java/io/jenkins/plugins/openmfa/service/ExemptionServiceTest.java +++ b/src/test/java/io/jenkins/plugins/openmfa/service/ExemptionServiceTest.java @@ -4,6 +4,7 @@ import hudson.model.User; import io.jenkins.plugins.openmfa.MFAGlobalConfiguration; +import java.util.List; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,7 +30,7 @@ void tearDown() { void testUserExemptByRole(JenkinsRule j) throws Exception { j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); - config.setExemptRoles("admin\nci-agents"); + config.setExemptRoles(List.of("admin", "ci-agents")); ExemptionService exemptionService = new ExemptionService(); User user = User.getById("adminuser", true); // User has no roles assigned yet in test environment @@ -40,7 +41,7 @@ void testUserExemptByRole(JenkinsRule j) throws Exception { void testUserExemptByUsername(JenkinsRule j) throws Exception { j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); - config.setExemptUsers("testuser\nexemptuser"); + config.setExemptUsers(List.of("testuser", "exemptuser")); ExemptionService exemptionService = new ExemptionService(); User user = User.getById("testuser", true); assertTrue(exemptionService.isExempt(user)); @@ -50,7 +51,7 @@ void testUserExemptByUsername(JenkinsRule j) throws Exception { void testUserExemptByUsernameCaseInsensitive(JenkinsRule j) throws Exception { j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); - config.setExemptUsers("TESTUSER"); + config.setExemptUsers(List.of("TESTUSER")); ExemptionService exemptionService = new ExemptionService(); User user = User.getById("testuser", true); assertTrue(exemptionService.isExempt(user)); @@ -60,7 +61,7 @@ void testUserExemptByUsernameCaseInsensitive(JenkinsRule j) throws Exception { void testUserNotExemptByUsername(JenkinsRule j) throws Exception { j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); - config.setExemptUsers("otheruser"); + config.setExemptUsers(List.of("otheruser")); ExemptionService exemptionService = new ExemptionService(); User user = User.getById("testuser", true); assertFalse(exemptionService.isExempt(user)); diff --git a/src/test/java/io/jenkins/plugins/openmfa/service/RememberMFAServiceTest.java b/src/test/java/io/jenkins/plugins/openmfa/service/RememberMFAServiceTest.java new file mode 100644 index 0000000..14907f3 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/openmfa/service/RememberMFAServiceTest.java @@ -0,0 +1,192 @@ +package io.jenkins.plugins.openmfa.service; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import hudson.util.Secret; +import io.jenkins.plugins.openmfa.MFAGlobalConfiguration; +import io.jenkins.plugins.openmfa.base.MFAContext; +import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; + +/** + * Unit tests for RememberMFAService token creation and validation. + */ +@WithJenkins +class RememberMFAServiceTest { + + private static final long ONE_DAY_MS = 24L * 60 * 60 * 1000; + + private final RememberMFAService rememberMFAService = + MFAContext.i().getService(RememberMFAService.class); + + private final Secret totpSecret = Secret.fromString("JBSWY3DPEHPK3PXP"); + + /** + * Changing the TOTP secret invalidates previously issued tokens. + */ + @Test + void testChangedTotpSecretRejected(JenkinsRule j) { + long now = System.currentTimeMillis(); + String token = + rememberMFAService.createTokenValue("alice", totpSecret, now + ONE_DAY_MS); + + Secret newSecret = Secret.fromString("KRSXG5CTMVRXEZLU"); + assertFalse( + rememberMFAService.isValidTokenValue(token, "alice", newSecret, now), + "Token bound to old TOTP secret should be rejected after secret change" + ); + } + + /** + * An expired token does not validate even with a matching signature. + */ + @Test + void testExpiredTokenRejected(JenkinsRule j) { + long now = System.currentTimeMillis(); + String token = + rememberMFAService.createTokenValue("alice", totpSecret, now - 1); + + assertFalse( + rememberMFAService.isValidTokenValue(token, "alice", totpSecret, now), + "Expired token should be rejected" + ); + } + + /** + * The feature is enabled by default (24 hours) and disabled when hours is 0. + */ + @Test + void testIsEnabledFollowsConfiguredHours(JenkinsRule j) { + MFAGlobalConfiguration config = MFAGlobalConfiguration.get(); + + assertTrue( + rememberMFAService.isEnabled(), + "Remember-MFA should be enabled with the default duration" + ); + + config.setRememberMfaHours(0); + assertFalse( + rememberMFAService.isEnabled(), + "Remember-MFA should be disabled when configured hours is 0" + ); + } + + /** + * Malformed token values are rejected without throwing. + */ + @Test + void testMalformedTokensRejected(JenkinsRule j) { + long now = System.currentTimeMillis(); + + assertFalse( + rememberMFAService.isValidTokenValue(null, "alice", totpSecret, now), + "Null token should be rejected" + ); + assertFalse( + rememberMFAService.isValidTokenValue("", "alice", totpSecret, now), + "Empty token should be rejected" + ); + assertFalse( + rememberMFAService.isValidTokenValue("garbage", "alice", totpSecret, now), + "Token without separators should be rejected" + ); + assertFalse( + rememberMFAService + .isValidTokenValue("a:notanumber:c", "alice", totpSecret, now), + "Token with non-numeric expiry should be rejected" + ); + } + + /** + * A token with a tampered expiry does not validate (expiry is signed). + */ + @Test + void testTamperedExpiryRejected(JenkinsRule j) { + long now = System.currentTimeMillis(); + String token = + rememberMFAService.createTokenValue("alice", totpSecret, now + 1); + + // Extend the expiry field without re-signing + String[] parts = token.split(":"); + parts[1] = String.valueOf(now + ONE_DAY_MS); + String tampered = String.join(":", parts); + + assertFalse( + rememberMFAService.isValidTokenValue(tampered, "alice", totpSecret, now), + "Token with tampered expiry should be rejected" + ); + } + + /** + * A token with a tampered signature does not validate. + */ + @Test + void testTamperedTokenRejected(JenkinsRule j) { + long now = System.currentTimeMillis(); + String token = + rememberMFAService.createTokenValue("alice", totpSecret, now + ONE_DAY_MS); + + // Flip the last character of the signature + char last = token.charAt(token.length() - 1); + String tampered = + token.substring(0, token.length() - 1) + (last == 'A' ? 'B' : 'A'); + + assertFalse( + rememberMFAService.isValidTokenValue(tampered, "alice", totpSecret, now), + "Token with tampered signature should be rejected" + ); + } + + /** + * Usernames containing the separator character are handled safely. + */ + @Test + void testUsernameWithSeparator(JenkinsRule j) { + long now = System.currentTimeMillis(); + String token = + rememberMFAService + .createTokenValue("alice:admin", totpSecret, now + ONE_DAY_MS); + + assertTrue( + rememberMFAService + .isValidTokenValue(token, "alice:admin", totpSecret, now), + "Username containing separator should round-trip" + ); + assertFalse( + rememberMFAService.isValidTokenValue(token, "alice", totpSecret, now), + "Token for alice:admin should not validate for alice" + ); + } + + /** + * A freshly created token for the same user and secret validates. + */ + @Test + void testValidTokenValidates(JenkinsRule j) { + long now = System.currentTimeMillis(); + String token = + rememberMFAService.createTokenValue("alice", totpSecret, now + ONE_DAY_MS); + + assertTrue( + rememberMFAService.isValidTokenValue(token, "alice", totpSecret, now), + "Fresh token for same user and secret should validate" + ); + } + + /** + * A token issued for one user does not validate for another user. + */ + @Test + void testWrongUserRejected(JenkinsRule j) { + long now = System.currentTimeMillis(); + String token = + rememberMFAService.createTokenValue("alice", totpSecret, now + ONE_DAY_MS); + + assertFalse( + rememberMFAService.isValidTokenValue(token, "bob", totpSecret, now), + "Token issued for alice should not validate for bob" + ); + } +} diff --git a/src/test/resources/io/jenkins/plugins/openmfa/casc-openmfa.yaml b/src/test/resources/io/jenkins/plugins/openmfa/casc-openmfa.yaml new file mode 100644 index 0000000..25552e0 --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/openmfa/casc-openmfa.yaml @@ -0,0 +1,12 @@ +security: + openMFA: + issuer: "Jenkins" + requireMFA: true + rememberMfaHours: 5 + rememberMfaKey: "test-signing-key" + exemptUsers: + - svc-deploy + - break-glass + exemptRoles: + - ci-agents + - service-accounts