Skip to content
Merged
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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ It introduces setup, verification, and management flows for user accounts inside
- [User Flow](#user-flow)
- [MFA User Management](#mfa-user-management)
- [Configuration as Code (JCasC)](#configuration-as-code-jcasc)
- [MFA Exemptions](#mfa-exemptions)
- [Security Notes](#security-notes)
- [Development](#development)
- [VS Code Debugging](#vs-code-debugging)
Expand All @@ -36,6 +37,7 @@ It introduces setup, verification, and management flows for user accounts inside
- Session-based MFA verification after primary login
- 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

Expand All @@ -48,6 +50,8 @@ It introduces setup, verification, and management flows for user accounts inside
3. Configure:
- `Issuer Name`
- `Require MFA for all users`
- `Exempt Users` (optional)
- `Exempt Roles` (optional)
4. Save settings.
5. Ask users to enroll via the OpenMFA setup page before broad enforcement.

Expand Down Expand Up @@ -105,8 +109,29 @@ security:
openMFA:
issuer: 'Jenkins'
requireMFA: true
exemptUsers:
- svc-deploy
- break-glass
exemptRoles:
- ci-agents
- service-accounts
```

## MFA Exemptions

You can exempt specific users or roles from MFA requirements:

- **Exempt Users**: Individual usernames that bypass MFA (case-insensitive)
- **Exempt Roles**: Group/role names that bypass MFA for all members

Exemptions are useful for:

- Service accounts used by CI/CD pipelines
- Emergency break-glass accounts
- Automated agents that cannot complete MFA

**Note:** Exempted users bypass MFA entirely. Do not exempt regular user accounts.

## Security Notes

- Treat JCasC files and Jenkins configuration files as privileged, since they control authentication behavior.
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/io/jenkins/plugins/openmfa/MFAFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import hudson.model.User;
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.SessionService;
import io.jenkins.plugins.openmfa.util.JenkinsUtil;
import io.jenkins.plugins.openmfa.util.SecurityUtil;
Expand Down Expand Up @@ -59,6 +60,9 @@ public class MFAFilter implements Filter {
PluginConstants.Urls.SECURITY_CHECK_ENDPOINT
);

private ExemptionService exemptionService =
MFAContext.i().getService(ExemptionService.class);

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

Expand Down Expand Up @@ -172,6 +176,13 @@ private boolean handleMFAVerification(
throws IOException {

String username = user.getId();

// Check if user is exempt from MFA
if (exemptionService.isExempt(user)) {
log.fine("User " + username + " is exempt from MFA, allowing access");
return true;
}

if (!TOTPUtil.isMFAEnabled()) {
if (TOTPUtil.isMFARequired()) {
resp.sendRedirect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import hudson.Extension;
import hudson.ExtensionList;
import io.jenkins.plugins.openmfa.constant.UIConstants;
import java.util.concurrent.atomic.AtomicReference;
import jenkins.model.GlobalConfiguration;
import jenkins.model.GlobalConfigurationCategory;
import lombok.Getter;
Expand All @@ -23,14 +24,41 @@
@Symbol("openMFA")
public class MFAGlobalConfiguration extends GlobalConfiguration {

/**
* Cached instance for fallback when ExtensionList is not available.
*/
private static final AtomicReference<MFAGlobalConfiguration> FALLBACK_INSTANCE =
new AtomicReference<>();

/**
* Get the singleton instance of this configuration.
*/
@NonNull
public static MFAGlobalConfiguration get() {
return ExtensionList.lookupSingleton(MFAGlobalConfiguration.class);
// Try to get from ExtensionList first (works when Jenkins is fully initialized)
try {
return ExtensionList.lookupSingleton(MFAGlobalConfiguration.class);
} catch (IllegalStateException e) {
// Fallback: use a cached instance if ExtensionList is not available
// This can happen in some test scenarios
FALLBACK_INSTANCE.compareAndSet(null, new MFAGlobalConfiguration());
return FALLBACK_INSTANCE.get();

Check warning on line 45 in src/main/java/io/jenkins/plugins/openmfa/MFAGlobalConfiguration.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 41-45 are not covered by tests
}
}

/**
* Reset the fallback instance. For test use only.
*/
public static void resetFallbackInstance() {
FALLBACK_INSTANCE.set(null);
}

/** Comma/newline-separated list of roles/groups exempt from MFA */
private String exemptRoles = "";

/** Comma/newline-separated list of usernames exempt from MFA */
private String exemptUsers = "";

/** The issuer name shown in authenticator apps */
private String issuer = UIConstants.Defaults.DEFAULT_ISSUER;

Expand Down Expand Up @@ -70,6 +98,32 @@
return Messages.DisplayNames_OPENMFA_GLOBAL_CONFIGURATION();
}

/**
* Get the list of exempt roles (for Jelly view).
* Returns one role per line for easier editing.
*/
public String getExemptRoles() {
return exemptRoles;
}

/**
* Get the list of exempt users (for Jelly view).
* Returns one user per line for easier editing.
*/
public String getExemptUsers() {
return exemptUsers;
}

@DataBoundSetter
public void setExemptRoles(String exemptRoles) {
this.exemptRoles = exemptRoles != null ? exemptRoles : "";

Check warning on line 119 in src/main/java/io/jenkins/plugins/openmfa/MFAGlobalConfiguration.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 119 is only partially covered, one branch is missing
}

@DataBoundSetter
public void setExemptUsers(String exemptUsers) {
this.exemptUsers = exemptUsers != null ? exemptUsers : "";

Check warning on line 124 in src/main/java/io/jenkins/plugins/openmfa/MFAGlobalConfiguration.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 124 is only partially covered, one branch is missing
}

@DataBoundSetter
public void setIssuer(String issuer) {
this.issuer =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@

@Override
public String getDescription() {
return Messages.MFAManagementLink_description();
return Messages.MFAManagementLink_DESCRIPTION();

Check warning on line 136 in src/main/java/io/jenkins/plugins/openmfa/MFAManagementLink.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 136 is not covered by tests
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package io.jenkins.plugins.openmfa.service;

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;
import org.springframework.security.core.GrantedAuthority;

/**
* Service for checking if a user is exempt from MFA requirements.
* Exemptions can be configured by username or by role/group membership.
*/
@Log
@Service
public class ExemptionService {

/**
* Checks if the given user is exempt from MFA requirements.
* A user is exempt if:
* - Their username is in the exempt users list (case-insensitive)
* - They have any role/group in the exempt roles list
*
* @param user
* the user to check
* @return true if the user is exempt, false otherwise
*/
public boolean isExempt(User user) {
if (user == null) {

Check warning on line 32 in src/main/java/io/jenkins/plugins/openmfa/service/ExemptionService.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 32 is only partially covered, one branch is missing
return false;

Check warning on line 33 in src/main/java/io/jenkins/plugins/openmfa/service/ExemptionService.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 33 is not covered by tests
}

MFAGlobalConfiguration config = MFAGlobalConfiguration.get();

// Check username exemption
if (isExemptByUsername(user, config)) {
log.fine("User " + user.getId() + " is exempt by username");
return true;
}

// Check role exemption
if (isExemptByRole(user, config)) {

Check warning on line 45 in src/main/java/io/jenkins/plugins/openmfa/service/ExemptionService.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 45 is only partially covered, one branch is missing
log.fine("User " + user.getId() + " is exempt by role");
return true;

Check warning on line 47 in src/main/java/io/jenkins/plugins/openmfa/service/ExemptionService.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 46-47 are not covered by tests
}

return false;
}

private boolean isExemptByRole(User user, MFAGlobalConfiguration config) {
List<String> exemptRoles = parseList(config.getExemptRoles());
if (exemptRoles.isEmpty()) {
return false;
}

// Get user's authentication to check authorities
Authentication auth = user.impersonate2();

return auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(
role -> exemptRoles.stream()
.anyMatch(exempt -> exempt.equalsIgnoreCase(role))
);
}

private boolean isExemptByUsername(User user, MFAGlobalConfiguration config) {
List<String> exemptUsers = parseList(config.getExemptUsers());
if (exemptUsers.isEmpty()) {
return false;
}

String userId = user.getId().toLowerCase().trim();
return exemptUsers.stream()
.anyMatch(exempt -> exempt.toLowerCase().trim().equals(userId));
}

private List<String> parseList(String input) {
if (input == null || input.trim().isEmpty()) {

Check warning on line 82 in src/main/java/io/jenkins/plugins/openmfa/service/ExemptionService.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 82 is only partially covered, one branch is missing
return Collections.emptyList();
}

return Arrays.stream(input.split("\n"))
.map(String::trim)
.filter(s -> !s.isEmpty())

Check warning on line 88 in src/main/java/io/jenkins/plugins/openmfa/service/ExemptionService.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 88 is only partially covered, one branch is missing
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@
<f:checkbox />
</f:entry>

<f:entry title="${%config.exemptUsers.title}" field="exemptUsers">
<f:textarea />
</f:entry>
<f:description> ${%config.exemptUsers.description} </f:description>

<f:entry title="${%config.exemptRoles.title}" field="exemptRoles">
<f:textarea />
</f:entry>
<f:description> ${%config.exemptRoles.description} </f:description>

<f:entry>
<a href="${rootURL}/manage/mfa-management" class="jenkins-button">
<l:icon src="symbol-settings" class="icon-sm" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,10 @@ config.section.title=OpenMFA Configuration
config.requireMFA.title=Require MFA for all users
config.issuer.title=Issuer Name
config.mfaManagement.title=MFA User Management

# Exemption configuration messages
config.exemptions.title=MFA Exemptions
config.exemptUsers.title=Exempt Users
config.exemptUsers.description=One username per line. Exempted users bypass MFA entirely.
config.exemptRoles.title=Exempt Roles/Groups
config.exemptRoles.description=One role per line. Users with these roles bypass MFA.
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,10 @@ 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

# Exemption configuration messages
config.exemptions.title=Exenciones de MFA
config.exemptUsers.title=Usuarios Exentos
config.exemptUsers.description=Un nombre de usuario por l\u00ednea. Los usuarios exentos omiten MFA completamente.
config.exemptRoles.title=Roles/Grupos Exentos
config.exemptRoles.description=Un rol por l\u00ednea. Los usuarios con estos roles omiten MFA.
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,10 @@ config.section.title=OpenMFA 配置
config.requireMFA.title=要求所有用户启用 MFA
config.issuer.title=发行者名称
config.mfaManagement.title=MFA 用户管理

# Exemption configuration messages
config.exemptions.title=MFA 豁免
config.exemptUsers.title=豁免用户
config.exemptUsers.description=每行一个用户名。豁免用户可完全绕过 MFA。
config.exemptRoles.title=豁免角色/组
config.exemptRoles.description=每行一个角色。具有这些角色的用户可绕过 MFA。
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?jelly escape-by-default='true'?>
<div>
List of role/group names exempt from MFA requirements. Enter one role per
line. Users with any of these roles bypass MFA verification. Use for CI/CD
service accounts or automated agents.
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?jelly escape-by-default='true'?>
<div>
List of usernames exempt from MFA requirements. Enter one username per line.
Exempted users can access Jenkins without completing MFA verification. Use for
service accounts or emergency access accounts.
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ DisplayNames.MULTI_FACTOR_AUTHENTICATION=Multi-Factor Authentication (MFA)
DisplayNames.OPENMFA_GLOBAL_CONFIGURATION=OpenMFA

# Management link description
MFAManagementLink.description=View and manage MFA status for all users
MFAManagementLink.DESCRIPTION=Manage user MFA enrollment and reset MFA for users
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ DisplayNames.MULTI_FACTOR_AUTHENTICATION=Autenticaci\u00f3n Multifactor (MFA)
DisplayNames.OPENMFA_GLOBAL_CONFIGURATION=OpenMFA

# Management link description
MFAManagementLink.description=Ver y gestionar el estado de MFA para todos los usuarios
MFAManagementLink.DESCRIPTION=Ver y gestionar el estado de MFA para todos los usuarios
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ DisplayNames.MULTI_FACTOR_AUTHENTICATION=多因素认证 (MFA)
DisplayNames.OPENMFA_GLOBAL_CONFIGURATION=OpenMFA 全局配置

# Management link description
MFAManagementLink.description=查看并管理所有用户的 MFA 状态
MFAManagementLink.DESCRIPTION=查看并管理所有用户的 MFA 状态
Loading
Loading