Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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

Expand Down
11 changes: 9 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
<properties>
<changelist>999999-SNAPSHOT</changelist>
<!-- https://www.jenkins.io/doc/developer/plugin-development/choosing-jenkins-baseline/ -->
<jenkins.baseline>2.528</jenkins.baseline>
<jenkins.baseline>2.568</jenkins.baseline>
<jenkins.version>${jenkins.baseline}.3</jenkins.version>
<gitHubRepo>jenkinsci/${project.artifactId}-plugin</gitHubRepo>

Expand Down Expand Up @@ -61,7 +61,7 @@
<dependency>
<groupId>io.jenkins.tools.bom</groupId>
<artifactId>bom-${jenkins.baseline}.x</artifactId>
<version>6055.v35edb_dc8d0f9</version>
<version>7093.v37de7b_4a_8a_4f</version>
<type>pom</type>
<scope>import</scope>
</dependency>
Expand Down Expand Up @@ -123,6 +123,13 @@
<version>6.2.3</version>
<scope>test</scope>
</dependency>

<!-- Configuration as Code, to verify JCasC binding of global config -->
<dependency>
<groupId>io.jenkins</groupId>
<artifactId>configuration-as-code</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<repositories>
Expand Down
50 changes: 36 additions & 14 deletions src/main/java/io/jenkins/plugins/openmfa/MFAFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -63,6 +64,9 @@
private ExemptionService exemptionService =
MFAContext.i().getService(ExemptionService.class);

private RememberMFAService rememberMFAService =
MFAContext.i().getService(RememberMFAService.class);

private SessionService sessionService =
MFAContext.i().getService(SessionService.class);

Expand Down Expand Up @@ -103,6 +107,11 @@
Optional<User> user = JenkinsUtil.getCurrentUser();
log.fine("Current user: " + user.map(User::getId).orElse("<anonymous>"));

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);
Expand Down Expand Up @@ -138,14 +147,7 @@
* 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 = "/";
}
Expand Down Expand Up @@ -212,6 +214,14 @@
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(
Expand All @@ -236,19 +246,31 @@
}

/**
* 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;

Check warning on line 266 in src/main/java/io/jenkins/plugins/openmfa/MFAFilter.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 266 is not covered by tests
}

/**
* 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
Expand Down
Loading
Loading