2727package org .opensearch .security .http ;
2828
2929import java .nio .file .Path ;
30+ import java .security .cert .CertificateParsingException ;
31+ import java .security .cert .X509Certificate ;
3032import java .util .ArrayList ;
33+ import java .util .Collection ;
3134import java .util .Collections ;
35+ import java .util .EnumSet ;
3236import java .util .List ;
37+ import java .util .Locale ;
38+ import java .util .Map ;
39+ import java .util .Objects ;
3340import java .util .Optional ;
41+ import java .util .stream .Collectors ;
3442import javax .naming .InvalidNameException ;
3543import javax .naming .ldap .LdapName ;
3644import 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