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