View Javadoc
1   /*
2    * Copyright 2024–2026 Michael Osipov
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  package net.sf.michaelo.tomcat.realm;
17  
18  import java.security.Key;
19  import java.security.Principal;
20  import java.security.SignatureException;
21  import java.util.Arrays;
22  import java.util.Base64;
23  import java.util.Collection;
24  import java.util.HashMap;
25  import java.util.HashSet;
26  import java.util.List;
27  import java.util.Map;
28  import java.util.Optional;
29  import java.util.Set;
30  import java.util.stream.Collectors;
31  
32  import javax.security.auth.Subject;
33  import javax.security.auth.kerberos.KerberosPrincipal;
34  import javax.security.auth.kerberos.KeyTab;
35  import javax.security.auth.login.LoginContext;
36  import javax.security.auth.login.LoginException;
37  
38  import com.sun.security.jgss.AuthorizationDataEntry;
39  import com.sun.security.jgss.ExtendedGSSContext;
40  import com.sun.security.jgss.InquireType;
41  
42  import net.sf.michaelo.tomcat.authenticator.SpnegoAuthenticator;
43  import net.sf.michaelo.tomcat.pac.GroupMembership;
44  import net.sf.michaelo.tomcat.pac.KerbValidationInfo;
45  import net.sf.michaelo.tomcat.pac.Pac;
46  import net.sf.michaelo.tomcat.pac.PrivateSunPacSignatureVerifier;
47  import net.sf.michaelo.tomcat.pac.asn1.AdIfRelevantAsn1Parser;
48  
49  import org.apache.catalina.authenticator.SSLAuthenticator;
50  import org.ietf.jgss.GSSContext;
51  import org.ietf.jgss.GSSCredential;
52  import org.ietf.jgss.GSSException;
53  import org.ietf.jgss.GSSName;
54  
55  /**
56   * A realm which decodes authorization data from <em>already authenticated</em> users from Active Directory via <a href=
57   * "https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-pac/166d8064-c863-41e1-9c23-edaaa5f36962">MS-PAC</a>.
58   *
59   * <p>This realm requires your JVM to provide an {@link ExtendedGSSContext} implementation. It will use
60   * {@link InquireType#KRB5_GET_AUTHZ_DATA} to extract {@code AuthorizationData} according to RFC 4120, section 5.2.6
61   * from an established security context, and use the {@link Pac} parser to extract all relevant authorization data
62   * (group SIDs), validate the PAC data server signature with the {@link PrivateSunPacSignatureVerifier} and the supplied
63   * keytab (login context) and process the data according to <a href=
64   * "https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-kile/4ad7ed1f-0bfa-4b5f-bda3-fedbc549a6c0">MS-KILE,
65   * section 3.4.5.3</a>.
66   *
67   * <h2 id="configuration">Configuration</h2>
68   *
69   * Following options can be configured:
70   *
71   * <ul>
72   *   <li>{@code loginEntryName}: the login entry identical to the one from
73   *       {@link SpnegoAuthenticator#getLoginEntryName()}.
74   *   <li>{@code prependRoleFormat}: whether the role format is prepended to the role as <code>{roleFormat}:{role}</code>
75   *       . Default is {@code false}.
76   *   <li>{@code addAdditionalAttributes}: whether the following additional attributes with their LDAP name counterparts
77   *       are added to the principal: {@code sAMAccountName}, {@code displayName}, {@code userPrincipalName} (if
78   *       available), {@code msDS-PrincipalName}. Default is {@code false}.
79   * </ul>
80   *
81   * <p><strong>Note:</strong> This realm is meant to be an alternative to the {@link ActiveDirectoryRealm} when no more
82   * additional attributes or other role formats are required beyond those provided by the PAC data and
83   * {@link SpnegoAuthenticator SPNEGO authentication} is used ({@link SSLAuthenticator X.509 authentication} is not
84   * supported). Moreover, all processing happens in memory, for that reason it is <em>orders of magnitude faster</em>
85   * than the previously mentioned one.
86   */
87  public class PacDataActiveDirectoryRealm extends ActiveDirectoryRealmBase {
88  
89  	private static final long USER_ACCOUNT_DISABLED = 0x00000001L;
90  	private static final long USER_NORMAL_ACCOUNT = 0x00000010L;
91  	private static final long USER_WORKSTATION_TRUST_ACCOUNT = 0x00000080L;
92  
93  	protected String loginEntryName;
94  	protected boolean prependRoleFormat;
95  	protected boolean addAdditionalAttributes;
96  
97  	/**
98  	 * Sets the login entry name which establishes the security context.
99  	 *
100 	 * @param loginEntryName the login entry name
101 	 */
102 	public void setLoginEntryName(String loginEntryName) {
103 		this.loginEntryName = loginEntryName;
104 	}
105 
106 	/**
107 	 * Sets whether the role format is prepended to the role.
108 	 *
109 	 * @param prependRoleFormat the prepend role format indication
110 	 */
111 	public void setPrependRoleFormat(boolean prependRoleFormat) {
112 		this.prependRoleFormat = prependRoleFormat;
113 	}
114 
115 	/**
116 	 * Sets whether the additional attributes are added to the principal.
117 	 *
118 	 * @param addAdditionalAttributes the add additional attributes indication
119 	 */
120 	public void setAddAdditionalAttributes(boolean addAdditionalAttributes) {
121 		this.addAdditionalAttributes = addAdditionalAttributes;
122 	}
123 
124 	protected Principal getPrincipal(GSSName gssName, GSSCredential gssCredential, GSSContext gssContext) {
125 		if (gssName.isAnonymous()) return new ActiveDirectoryPrincipal(gssName, Sid.ANONYMOUS_SID, gssCredential);
126 
127 		if (gssContext instanceof ExtendedGSSContext) {
128 			ExtendedGSSContext extGssContext = (ExtendedGSSContext) gssContext;
129 
130 			AuthorizationDataEntry[] adEntries = null;
131 			try {
132 				adEntries = (AuthorizationDataEntry[]) extGssContext.inquireSecContext(InquireType.KRB5_GET_AUTHZ_DATA);
133 			} catch (GSSException e) {
134 				logger.warn(sm.getString("krb5AuthzDataRealmBase.inquireSecurityContextFailed"), e);
135 			}
136 
137 			if (adEntries == null) {
138 				if (logger.isDebugEnabled())
139 					logger.debug(sm.getString("krb5AuthzDataRealmBase.noDataProvided", gssName));
140 				return null;
141 			}
142 
143 			Optional<AuthorizationDataEntry> pacDataEntry = Optional.empty();
144 			try {
145 				pacDataEntry = Arrays.stream(adEntries)
146 						.filter(adEntry -> adEntry.getType() == AdIfRelevantAsn1Parser.AD_IF_RELEVANT)
147 						.map(adEntry -> AdIfRelevantAsn1Parser.parse(adEntry.getData()))
148 						.flatMap(List::stream)
149 						.filter(adEntry -> adEntry.getType() == AdIfRelevantAsn1Parser.AD_WIN2K_PAC)
150 						.findFirst();
151 			} catch (Exception e) {
152 				String adEntriesStr = Arrays.stream(adEntries)
153 						.map(adEntry ->
154 								adEntry.getType() + " " + Base64.getEncoder().encodeToString(adEntry.getData()))
155 						.collect(Collectors.joining(",", "[", "]"));
156 				logger.warn(sm.getString("pacDataActiveDirectoryRealm.incorrectlyEncodedData", adEntriesStr), e);
157 
158 				return null;
159 			}
160 
161 			if (pacDataEntry.isPresent()) {
162 				byte[] pacData = pacDataEntry.get().getData();
163 
164 				Pac pac = null;
165 				try {
166 					pac = new Pac(pacData, new PrivateSunPacSignatureVerifier());
167 				} catch (Exception e) {
168 					logger.warn(
169 							sm.getString(
170 									"pacDataActiveDirectoryRealm.incorrectlyEncodedData",
171 									Base64.getEncoder().encodeToString(pacData)),
172 							e);
173 
174 					return null;
175 				}
176 
177 				Key[] keys = getKeys();
178 				try {
179 					pac.verifySignature(keys);
180 				} catch (SignatureException e) {
181 					logger.warn(sm.getString("pacDataActiveDirectoryRealm.signatureVerificationFailed"), e);
182 					return null;
183 				}
184 
185 				KerbValidationInfo kerbValidationInfo = pac.getKerbValidationInfo();
186 				long userAccountControl = kerbValidationInfo.getUserAccountControl();
187 
188 				if ((userAccountControl & USER_ACCOUNT_DISABLED) != 0L) {
189 					logger.warn(sm.getString("activeDirectoryRealm.userFoundButDisabled", gssName));
190 					return null;
191 				}
192 
193 				if ((userAccountControl & USER_NORMAL_ACCOUNT) == 0L
194 						&& (userAccountControl & USER_WORKSTATION_TRUST_ACCOUNT) == 0L) {
195 					logger.warn(sm.getString("activeDirectoryRealm.userFoundButNotSupported", gssName));
196 					return null;
197 				}
198 
199 				long userId = kerbValidationInfo.getUserId();
200 				Sid sid = null;
201 				if (userId == 0L) {
202 					sid = kerbValidationInfo.getExtraSids().get(0).getSid();
203 				} else {
204 					sid = kerbValidationInfo.getLogonDomainId().append(userId);
205 				}
206 				Collection<Sid> groups = new HashSet<>();
207 
208 				Sid primaryGroupSid =
209 						kerbValidationInfo.getLogonDomainId().append(kerbValidationInfo.getPrimaryGroupId());
210 				groups.add(primaryGroupSid);
211 				for (GroupMembership membership : kerbValidationInfo.getGroupIds()) {
212 					groups.add(kerbValidationInfo.getLogonDomainId().append(membership.getRelativeId()));
213 				}
214 				if (kerbValidationInfo.getExtraSids() != null) {
215 					long n = userId == 0L ? 1L : 0L;
216 					groups.addAll(kerbValidationInfo.getExtraSids().stream()
217 							.skip(n)
218 							.map(extraSid -> extraSid.getSid())
219 							.collect(Collectors.toList()));
220 				}
221 				if (kerbValidationInfo.getResourceGroupDomainSid() != null) {
222 					groups.addAll(kerbValidationInfo.getResourceGroupIds().stream()
223 							.map(resourceGroupId -> kerbValidationInfo
224 									.getResourceGroupDomainSid()
225 									.append(resourceGroupId.getRelativeId()))
226 							.collect(Collectors.toList()));
227 				}
228 
229 				Map<String, Object> additionalAttributesMap = null;
230 				if (addAdditionalAttributes) {
231 					additionalAttributesMap = new HashMap<String, Object>();
232 					additionalAttributesMap.put("sAMAccountName", kerbValidationInfo.getEffectiveName());
233 					additionalAttributesMap.put("displayName", kerbValidationInfo.getFullName());
234 					additionalAttributesMap.put(
235 							"msDS-PrincipalName",
236 							kerbValidationInfo.getLogonDomainName() + "\\" + kerbValidationInfo.getEffectiveName());
237 					if (pac.getUpnDnsInfo() != null) {
238 						additionalAttributesMap.put(
239 								"userPrincipalName", pac.getUpnDnsInfo().getUpn());
240 					}
241 				}
242 
243 				String roleFormatPrefix = prependRoleFormat ? "sid:" : "";
244 				List<String> roles = groups.stream()
245 						.map(String::valueOf)
246 						.map(group -> roleFormatPrefix + group)
247 						.collect(Collectors.toList());
248 
249 				if (logger.isTraceEnabled())
250 					logger.trace(sm.getString("activeDirectoryRealm.foundRoles", roles.size(), gssName, roles));
251 				else if (logger.isDebugEnabled())
252 					logger.debug(sm.getString("activeDirectoryRealm.foundRolesCount", roles.size(), gssName));
253 
254 				return new ActiveDirectoryPrincipal(gssName, sid, roles, gssCredential, additionalAttributesMap);
255 			} else {
256 				if (logger.isDebugEnabled())
257 					logger.debug(sm.getString("pacDataActiveDirectoryRealm.noDataProvided", gssName));
258 			}
259 		} else {
260 			logger.error(sm.getString("krb5AuthzDataRealmBase.incompatibleSecurityContextType"));
261 		}
262 
263 		return null;
264 	}
265 
266 	protected Key[] getKeys() {
267 		LoginContext lc = null;
268 		try {
269 			lc = new LoginContext(loginEntryName);
270 			lc.login();
271 			Subject subject = lc.getSubject();
272 			Set<KerberosPrincipal> principals = subject.getPrincipals(KerberosPrincipal.class);
273 			KerberosPrincipal principal = principals.iterator().next();
274 			Set<KeyTab> privateCredentials = subject.getPrivateCredentials(KeyTab.class);
275 			KeyTab keyTab = privateCredentials.iterator().next();
276 			return keyTab.getKeys(principal);
277 		} catch (LoginException | SecurityException e) {
278 			throw new IllegalStateException("Failed to load Kerberos keys for login entry '" + loginEntryName + "'", e);
279 		} finally {
280 			if (lc != null) {
281 				try {
282 					lc.logout();
283 				} catch (LoginException e) {
284 					; // Ignore
285 				}
286 			}
287 		}
288 	}
289 }