Skip to content

Commit 31d8db9

Browse files
laminelamLamine Idjeraouicwperks
authored
[FEATURE] Add support for X509v3 extensions for authentication (#5701)
Signed-off-by: Lamine Idjeraoui <[email protected]> Co-authored-by: Lamine Idjeraoui <[email protected]> Co-authored-by: Craig Perkins <[email protected]>
1 parent c7d2e78 commit 31d8db9

File tree

3 files changed

+388
-39
lines changed

3 files changed

+388
-39
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
1717
- Adding Alerting V2 roles to roles.yml ([#5747](https://github.com/opensearch-project/security/pull/5747))
1818
- add suggest api to ad read access role ([#5754](https://github.com/opensearch-project/security/pull/5754))
1919
- Get list of headersToCopy from core and use getHeader(String headerName) instead of getHeaders() ([#5769](https://github.com/opensearch-project/security/pull/5769))
20+
- Add support for X509 v3 extensions (SAN) for authentication ([#5701](https://github.com/opensearch-project/security/pull/5701))
2021

2122
### Bug Fixes
2223
- Create a WildcardMatcher.NONE when creating a WildcardMatcher with an empty string ([#5694](https://github.com/opensearch-project/security/pull/5694))

src/main/java/org/opensearch/security/http/HTTPClientCertAuthenticator.java

Lines changed: 223 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,18 @@
2727
package org.opensearch.security.http;
2828

2929
import java.nio.file.Path;
30+
import java.security.cert.CertificateParsingException;
31+
import java.security.cert.X509Certificate;
3032
import java.util.ArrayList;
33+
import java.util.Collection;
3134
import java.util.Collections;
35+
import java.util.EnumSet;
3236
import java.util.List;
37+
import java.util.Locale;
38+
import java.util.Map;
39+
import java.util.Objects;
3340
import java.util.Optional;
41+
import java.util.stream.Collectors;
3442
import javax.naming.InvalidNameException;
3543
import javax.naming.ldap.LdapName;
3644
import javax.naming.ldap.Rdn;
@@ -54,66 +62,130 @@ public class HTTPClientCertAuthenticator implements HTTPAuthenticator {
5462
public static final String OPENDISTRO_SECURITY_SSL_SKIP_USERS = "skip_users";
5563
protected final Settings settings;
5664
private final WildcardMatcher skipUsersMatcher;
65+
private final ParsedAttribute parsedUsernameAttr;
66+
private final ParsedAttribute parsedRolesAttr;
5767

58-
public HTTPClientCertAuthenticator(final Settings settings, final Path configPath) {
59-
this.settings = settings;
60-
this.skipUsersMatcher = WildcardMatcher.from(settings.getAsList(OPENDISTRO_SECURITY_SSL_SKIP_USERS));
68+
private enum AttributeType {
69+
DN,
70+
SAN
6171
}
6272

63-
@Override
64-
public AuthCredentials extractCredentials(final SecurityRequest request, final ThreadContext threadContext) {
73+
private record ParsedSAN(int type, WildcardMatcher matcher) {
74+
}
6575

66-
final String principal = threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_SSL_PRINCIPAL);
76+
private record ParsedAttribute(AttributeType type, String dnAttr, ParsedSAN san) {
77+
78+
static ParsedAttribute dn(String dnAttr) {
79+
return new ParsedAttribute(AttributeType.DN, dnAttr, null);
80+
}
81+
82+
static ParsedAttribute san(ParsedSAN san) {
83+
return new ParsedAttribute(AttributeType.SAN, null, san);
84+
}
85+
}
6786

68-
if (!Strings.isNullOrEmpty(principal)) {
87+
// Accept forms:
88+
// "cn" -> DN:cn
89+
// "dn:cn" -> DN:cn
90+
// "san:EMAIL" -> SAN type EMAIL, no glob (match all of that SAN)
91+
// "san:EMAIL:glob" -> SAN type EMAIL, glob
92+
private ParsedAttribute parseAttributeSetting(String raw) {
93+
if (Strings.isNullOrEmpty(raw)) return null; // “not configured”
94+
final String s = raw.trim();
6995

70-
final String usernameAttribute = settings.get("username_attribute");
71-
final String rolesAttribute = settings.get("roles_attribute");
96+
// SAN form: san:TYPE[:glob]
97+
if (s.regionMatches(true, 0, "san:", 0, 4)) {
98+
final String rest = s.substring(4); // after "san:"
99+
final int firstColon = rest.indexOf(':');
100+
final String sanField = (firstColon >= 0) ? rest.substring(0, firstColon) : rest;
101+
final String glob = (firstColon >= 0) ? rest.substring(firstColon + 1) : null;
72102

73-
if (skipUsersMatcher.test(principal)) {
74-
log.debug("Skipped user client cert authentication of user {} as its in skip_users list ", principal);
103+
final SANType sanType;
104+
try {
105+
sanType = parseSanTypeToken(sanField);
106+
} catch (IllegalArgumentException e) {
107+
log.warn("Unsupported SAN type '{}' in attribute '{}'", sanField, raw);
75108
return null;
76109
}
77110

78-
try {
79-
final LdapName rfc2253dn = new LdapName(principal);
80-
String username = principal.trim();
81-
String[] backendRoles = null;
82-
83-
if (usernameAttribute != null && usernameAttribute.length() > 0) {
84-
final List<String> usernames = getDnAttribute(rfc2253dn, usernameAttribute);
85-
if (usernames.isEmpty() == false) {
86-
username = usernames.get(0);
87-
}
111+
WildcardMatcher matcher = WildcardMatcher.ANY;
112+
if (!Strings.isNullOrEmpty(glob)) {
113+
// we only support '*' for now
114+
if (glob.indexOf('?') >= 0 || (glob.startsWith("/") && glob.endsWith("/"))) {
115+
log.warn("Unsupported SAN glob (only literals and '*' are allowed, case-insensitive). attribute='{}'", raw);
116+
return null;
88117
}
118+
matcher = "*".equals(glob.trim()) ? WildcardMatcher.ANY : WildcardMatcher.from(glob).ignoreCase();
119+
}
120+
return ParsedAttribute.san(new ParsedSAN(sanType.getValue(), matcher));
121+
}
89122

90-
if (rolesAttribute != null && rolesAttribute.length() > 0) {
91-
final List<String> roles = getDnAttribute(rfc2253dn, rolesAttribute);
92-
if (roles.isEmpty() == false) {
93-
backendRoles = roles.toArray(new String[0]);
94-
}
95-
}
123+
// DN form: either "dn:cn" or just "cn"
124+
final String dnAttr = s.regionMatches(true, 0, "dn:", 0, 3) ? s.substring(3) : s;
125+
return ParsedAttribute.dn(dnAttr);
126+
}
96127

97-
return new AuthCredentials(username, backendRoles).markComplete();
98-
} catch (InvalidNameException e) {
99-
log.error("Client cert had no properly formed DN (was: {})", principal);
100-
return null;
101-
}
128+
public HTTPClientCertAuthenticator(final Settings settings, final Path configPath) {
129+
this.settings = settings;
130+
this.skipUsersMatcher = WildcardMatcher.from(settings.getAsList(OPENDISTRO_SECURITY_SSL_SKIP_USERS));
131+
this.parsedUsernameAttr = parseAttributeSetting(settings.get("username_attribute"));
132+
this.parsedRolesAttr = parseAttributeSetting(settings.get("roles_attribute"));
133+
}
102134

103-
} else {
135+
@Override
136+
public AuthCredentials extractCredentials(final SecurityRequest request, final ThreadContext threadContext) {
137+
final String principal = threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_SSL_PRINCIPAL);
138+
if (Strings.isNullOrEmpty(principal)) {
104139
log.trace("No CLIENT CERT, send 401");
105140
return null;
106141
}
142+
if (skipUsersMatcher.test(principal)) {
143+
log.debug("Skipped user client cert authentication of user {} as its in skip_users list ", principal);
144+
return null;
145+
}
146+
147+
try {
148+
final String username = extractUsername(threadContext, principal);
149+
final String[] roles = extractRoles(threadContext, principal);
150+
return new AuthCredentials(username, roles).markComplete();
151+
} catch (InvalidNameException e) {
152+
if (log.isDebugEnabled()) {
153+
log.warn("Invalid client certificate DN; principal='{}'", principal, e);
154+
} else {
155+
log.warn("Client cert had no properly formed DN. {}", e.getMessage());
156+
}
157+
158+
return null;
159+
}
107160
}
108161

109-
@Override
110-
public Optional<SecurityResponse> reRequestAuthentication(final SecurityRequest response, AuthCredentials creds) {
111-
return Optional.empty();
162+
private String extractUsername(ThreadContext ctx, String principal) throws InvalidNameException {
163+
// Default: full DN
164+
if (parsedUsernameAttr == null || (parsedUsernameAttr.type == AttributeType.DN && parsedUsernameAttr.dnAttr == null)) {
165+
return principal;
166+
}
167+
168+
List<String> usernames;
169+
if (parsedUsernameAttr.type == AttributeType.DN) {
170+
usernames = getDnAttribute(new LdapName(principal), parsedUsernameAttr.dnAttr);
171+
} else {
172+
usernames = extractFromSAN(ctx, parsedUsernameAttr.san);
173+
}
174+
// Username rule: pick the FIRST match; else fallback to full DN
175+
return (usernames == null || usernames.isEmpty()) ? principal : usernames.get(0);
112176
}
113177

114-
@Override
115-
public String getType() {
116-
return "clientcert";
178+
private String[] extractRoles(ThreadContext ctx, String principal) throws InvalidNameException {
179+
if (parsedRolesAttr == null || (parsedRolesAttr.type == AttributeType.DN && parsedRolesAttr.dnAttr == null)) {
180+
return null;
181+
}
182+
List<String> roles;
183+
if (parsedRolesAttr.type == AttributeType.DN) {
184+
roles = getDnAttribute(new LdapName(principal), parsedRolesAttr.dnAttr);
185+
} else {
186+
roles = extractFromSAN(ctx, parsedRolesAttr.san);
187+
}
188+
return roles == null || roles.isEmpty() ? null : roles.toArray(new String[0]);
117189
}
118190

119191
private List<String> getDnAttribute(LdapName rfc2253dn, String attribute) {
@@ -129,4 +201,116 @@ private List<String> getDnAttribute(LdapName rfc2253dn, String attribute) {
129201

130202
return Collections.unmodifiableList(attrValues);
131203
}
204+
205+
private static final int MAX_SAN_MATCHES = 16;
206+
private static final int MAX_SAN_VALUE_LEN = 8192;
207+
208+
private List<String> extractFromSAN(ThreadContext ctx, ParsedSAN psan) {
209+
if (psan == null) return Collections.emptyList();
210+
211+
final X509Certificate[] peerCertificates = ctx.getTransient(ConfigConstants.OPENDISTRO_SECURITY_SSL_PEER_CERTIFICATES);
212+
if (peerCertificates == null || peerCertificates.length == 0) {
213+
return Collections.emptyList();
214+
}
215+
216+
try {
217+
final Collection<List<?>> altNames = peerCertificates[0].getSubjectAlternativeNames();
218+
if (altNames == null) return Collections.emptyList();
219+
220+
final int sanType = psan.type();
221+
final WildcardMatcher matcher = psan.matcher();
222+
223+
return altNames.stream()
224+
.filter(entry -> entry != null && entry.size() >= 2)
225+
.filter(entry -> entry.get(0) instanceof Integer i && i.intValue() == sanType)
226+
.map(entry -> sanValueToString(sanType, entry.get(1)))
227+
.map(v -> {
228+
if (Strings.isNullOrEmpty(v)) return null;
229+
// Bound input length for safety before glob
230+
final String s = v.length() > MAX_SAN_VALUE_LEN ? v.substring(0, MAX_SAN_VALUE_LEN) : v;
231+
return matcher.test(s) ? s : null;
232+
})
233+
.filter(Objects::nonNull)
234+
.limit(MAX_SAN_MATCHES)
235+
.collect(Collectors.toList());
236+
} catch (CertificateParsingException e) {
237+
log.error("Error parsing X509 certificate", e);
238+
return Collections.emptyList();
239+
}
240+
}
241+
242+
// sometimes IP address is of type of byte[]
243+
private static String sanValueToString(int type, Object value) {
244+
if (value == null) return null;
245+
if (value instanceof String) return (String) value;
246+
if (type == SANType.IP_ADDRESS.value && value instanceof byte[]) {
247+
byte[] addr = (byte[]) value;
248+
try {
249+
return java.net.InetAddress.getByAddress(addr).getHostAddress();
250+
} catch (java.net.UnknownHostException e) {
251+
return null;
252+
}
253+
}
254+
return null;
255+
}
256+
257+
@Override
258+
public Optional<SecurityResponse> reRequestAuthentication(final SecurityRequest response, AuthCredentials creds) {
259+
return Optional.empty();
260+
}
261+
262+
@Override
263+
public String getType() {
264+
return "clientcert";
265+
}
266+
267+
private static SANType parseSanTypeToken(String token) {
268+
final String t = token.toLowerCase(Locale.ROOT);
269+
return switch (t) {
270+
case "othername" -> SANType.OTHER_NAME;
271+
case "rfc822name" -> SANType.EMAIL;
272+
case "dnsname" -> SANType.DNS; // RFC prints dNSName
273+
case "x400address" -> SANType.X400_ADDRESS;
274+
case "directoryname" -> SANType.DIRECTORY_NAME;
275+
case "edipartyname" -> SANType.EDI_PARTY_NAME;
276+
case "uniformresourceidentifier" -> SANType.URI;
277+
case "ipaddress" -> SANType.IP_ADDRESS; // RFC prints iPAddress
278+
case "registeredid" -> SANType.REGISTERED_ID;
279+
default -> throw new IllegalArgumentException("Unsupported SAN type token: " + token);
280+
};
281+
}
282+
283+
/**
284+
* Enumeration of supported SAN (Subject Alternative Name) types as defined in RFC 5280.
285+
* https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.6
286+
*/
287+
private enum SANType {
288+
OTHER_NAME(0), // OtherName
289+
EMAIL(1), // rfc822Name
290+
DNS(2), // dNSName
291+
X400_ADDRESS(3), // x400Address
292+
DIRECTORY_NAME(4), // directoryName
293+
EDI_PARTY_NAME(5), // ediPartyName
294+
URI(6), // uniformResourceIdentifier
295+
IP_ADDRESS(7), // iPAddress
296+
REGISTERED_ID(8); // registeredID
297+
298+
private static final Map<Integer, SANType> lookup = EnumSet.allOf(SANType.class)
299+
.stream()
300+
.collect(Collectors.toMap(SANType::getValue, sanType -> sanType));
301+
302+
private final int value;
303+
304+
SANType(int value) {
305+
this.value = value;
306+
}
307+
308+
public int getValue() {
309+
return value;
310+
}
311+
312+
public static SANType fromValue(int value) {
313+
return lookup.get(value);
314+
}
315+
}
132316
}

0 commit comments

Comments
 (0)