1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 package org.codelibs.fess.sso.entraid;
17
18 import static org.codelibs.core.stream.StreamUtil.split;
19
20 import java.io.IOException;
21 import java.net.URI;
22 import java.net.URLEncoder;
23 import java.util.ArrayList;
24 import java.util.Arrays;
25 import java.util.Collections;
26 import java.util.HashMap;
27 import java.util.List;
28 import java.util.Locale;
29 import java.util.Map;
30 import java.util.concurrent.ExecutionException;
31 import java.util.concurrent.TimeUnit;
32 import java.util.stream.Collectors;
33
34 import org.apache.commons.lang3.StringUtils;
35 import org.apache.logging.log4j.LogManager;
36 import org.apache.logging.log4j.Logger;
37 import org.codelibs.core.lang.StringUtil;
38 import org.codelibs.core.misc.Pair;
39 import org.codelibs.core.net.UuidUtil;
40 import org.codelibs.core.stream.StreamUtil;
41 import org.codelibs.core.timer.TimeoutManager;
42 import org.codelibs.curl.Curl;
43 import org.codelibs.curl.CurlResponse;
44 import org.codelibs.fess.app.web.base.login.ActionResponseCredential;
45 import org.codelibs.fess.app.web.base.login.EntraIdCredential;
46 import org.codelibs.fess.app.web.base.login.EntraIdCredential.EntraIdUser;
47 import org.codelibs.fess.app.web.base.login.FessLoginAssist.LoginCredentialResolver;
48 import org.codelibs.fess.crawler.Constants;
49 import org.codelibs.fess.exception.SsoLoginException;
50 import org.codelibs.fess.mylasta.action.FessUserBean;
51 import org.codelibs.fess.mylasta.direction.FessConfig;
52 import org.codelibs.fess.sso.SsoAuthenticator;
53 import org.codelibs.fess.sso.SsoResponseType;
54 import org.codelibs.fess.util.ComponentUtil;
55 import org.codelibs.fess.util.DocumentUtil;
56 import org.codelibs.opensearch.runner.net.OpenSearchCurl;
57 import org.dbflute.optional.OptionalEntity;
58 import org.dbflute.optional.OptionalThing;
59 import org.lastaflute.web.login.credential.LoginCredential;
60 import org.lastaflute.web.response.ActionResponse;
61 import org.lastaflute.web.response.HtmlResponse;
62 import org.lastaflute.web.util.LaRequestUtil;
63
64 import com.google.common.cache.Cache;
65 import com.google.common.cache.CacheBuilder;
66 import com.microsoft.aad.msal4j.AuthorizationCodeParameters;
67 import com.microsoft.aad.msal4j.ConfidentialClientApplication;
68 import com.microsoft.aad.msal4j.IAuthenticationResult;
69 import com.microsoft.aad.msal4j.RefreshTokenParameters;
70 import com.microsoft.aad.msal4j.SilentParameters;
71 import com.nimbusds.jwt.JWTClaimsSet;
72 import com.nimbusds.jwt.JWTParser;
73 import com.nimbusds.oauth2.sdk.AuthorizationCode;
74 import com.nimbusds.openid.connect.sdk.AuthenticationErrorResponse;
75 import com.nimbusds.openid.connect.sdk.AuthenticationResponse;
76 import com.nimbusds.openid.connect.sdk.AuthenticationResponseParser;
77 import com.nimbusds.openid.connect.sdk.AuthenticationSuccessResponse;
78
79 import jakarta.annotation.PostConstruct;
80 import jakarta.servlet.http.HttpServletRequest;
81 import jakarta.servlet.http.HttpSession;
82
83
84
85
86
87 public class EntraIdAuthenticator implements SsoAuthenticator {
88
89 private static final Logger logger = LogManager.getLogger(EntraIdAuthenticator.class);
90
91
92
93
94 public EntraIdAuthenticator() {
95
96 }
97
98
99
100 protected static final String ENTRAID_STATE_TTL = "entraid.state.ttl";
101
102
103 protected static final String ENTRAID_AUTHORITY = "entraid.authority";
104
105
106 protected static final String ENTRAID_TENANT = "entraid.tenant";
107
108
109 protected static final String ENTRAID_CLIENT_SECRET = "entraid.client.secret";
110
111
112 protected static final String ENTRAID_CLIENT_ID = "entraid.client.id";
113
114
115 protected static final String ENTRAID_REPLY_URL = "entraid.reply.url";
116
117
118 protected static final String ENTRAID_DEFAULT_GROUPS = "entraid.default.groups";
119
120
121 protected static final String ENTRAID_DEFAULT_ROLES = "entraid.default.roles";
122
123
124
125 protected static final String AAD_STATE_TTL = "aad.state.ttl";
126
127
128 protected static final String AAD_AUTHORITY = "aad.authority";
129
130
131 protected static final String AAD_TENANT = "aad.tenant";
132
133
134 protected static final String AAD_CLIENT_SECRET = "aad.client.secret";
135
136
137 protected static final String AAD_CLIENT_ID = "aad.client.id";
138
139
140 protected static final String AAD_REPLY_URL = "aad.reply.url";
141
142
143 protected static final String AAD_DEFAULT_GROUPS = "aad.default.groups";
144
145
146 protected static final String AAD_DEFAULT_ROLES = "aad.default.roles";
147
148
149 protected static final String STATES = "entraidStates";
150
151
152 protected static final String STATE = "state";
153
154
155 protected static final String ERROR = "error";
156
157
158 protected static final String ERROR_DESCRIPTION = "error_description";
159
160
161 protected static final String ERROR_URI = "error_uri";
162
163
164 protected static final String ID_TOKEN = "id_token";
165
166
167 protected static final String CODE = "code";
168
169
170 protected long acquisitionTimeout = 30 * 1000L;
171
172
173 protected Cache<String, Pair<String[], String[]>> groupCache;
174
175
176 protected long groupCacheExpiry = 10 * 60L;
177
178
179 protected int maxGroupDepth = 10;
180
181
182 protected boolean useV2Endpoint = true;
183
184
185
186
187
188 @PostConstruct
189 public void init() {
190 if (logger.isDebugEnabled()) {
191 logger.debug("Initializing {}", this.getClass().getSimpleName());
192 }
193 ComponentUtil.getSsoManager().register(this);
194 groupCache = CacheBuilder.newBuilder().expireAfterWrite(groupCacheExpiry, TimeUnit.SECONDS).build();
195 }
196
197 @Override
198 public LoginCredential getLoginCredential() {
199 return LaRequestUtil.getOptionalRequest().map(request -> {
200 if (logger.isDebugEnabled()) {
201 logger.debug("Logging in with Entra ID Authenticator");
202 }
203 final HttpSession session = request.getSession(false);
204 if (session != null && containsAuthenticationData(request)) {
205 try {
206 return processAuthenticationData(request);
207 } catch (final Exception e) {
208 if (logger.isDebugEnabled()) {
209 logger.debug("Failed to process a login request on Entra ID.", e);
210 }
211 }
212 return null;
213 }
214
215 return new ActionResponseCredential(() -> HtmlResponse.fromRedirectPathAsIs(getAuthUrl(request)));
216 }).orElse(null);
217 }
218
219
220
221
222
223
224 protected String getAuthUrl(final HttpServletRequest request) {
225 final String state = UuidUtil.create();
226 final String nonce = UuidUtil.create();
227 storeStateInSession(request.getSession(), state, nonce);
228 final String authUrl;
229
230 if (useV2Endpoint) {
231
232 authUrl = getAuthority() + getTenant()
233 + "/oauth2/v2.0/authorize?response_type=code&scope=https://graph.microsoft.com/.default&response_mode=form_post&redirect_uri="
234 + URLEncoder.encode(getReplyUrl(request), Constants.UTF_8_CHARSET) + "&client_id=" + getClientId() + "&state=" + state
235 + "&nonce=" + nonce;
236 } else {
237
238 authUrl = getAuthority() + getTenant()
239 + "/oauth2/authorize?response_type=code&scope=directory.read.all&response_mode=form_post&redirect_uri="
240 + URLEncoder.encode(getReplyUrl(request), Constants.UTF_8_CHARSET) + "&client_id=" + getClientId()
241 + "&resource=https%3a%2f%2fgraph.microsoft.com" + "&state=" + state + "&nonce=" + nonce;
242 }
243 if (logger.isDebugEnabled()) {
244 logger.debug("redirect to: {} (using {} endpoint)", authUrl, useV2Endpoint ? "v2.0" : "v1.0");
245 }
246 return authUrl;
247
248 }
249
250
251
252
253
254
255
256 protected void storeStateInSession(final HttpSession session, final String state, final String nonce) {
257 @SuppressWarnings("unchecked")
258 Map<String, StateData> stateMap = (Map<String, StateData>) session.getAttribute(STATES);
259 if (stateMap == null) {
260 stateMap = new HashMap<>();
261 session.setAttribute(STATES, stateMap);
262 }
263 final StateData stateData = new StateData(nonce, ComponentUtil.getSystemHelper().getCurrentTimeAsLong());
264 if (logger.isDebugEnabled()) {
265 logger.debug("Storing state in session: {}", stateData);
266 }
267 stateMap.put(state, stateData);
268 }
269
270
271
272
273
274
275 protected LoginCredential processAuthenticationData(final HttpServletRequest request) {
276 final StringBuilder urlBuf = new StringBuilder(request.getRequestURL());
277 final String queryStr = request.getQueryString();
278 if (queryStr != null) {
279 urlBuf.append('?').append(queryStr);
280 }
281
282 final Map<String, List<String>> params = new HashMap<>();
283 for (final Map.Entry<String, String[]> e : request.getParameterMap().entrySet()) {
284 if (e.getValue().length > 0) {
285 params.put(e.getKey(), Arrays.asList(e.getValue()));
286 }
287 }
288 if (logger.isDebugEnabled()) {
289 logger.debug("process authentication: url: {}, params: {}", urlBuf, params);
290 }
291
292
293 final StateData stateData = validateState(request.getSession(), params.containsKey(STATE) ? params.get(STATE).get(0) : null);
294 if (logger.isDebugEnabled()) {
295 logger.debug("Loading state: {}", stateData);
296 }
297
298 final AuthenticationResponse authResponse = parseAuthenticationResponse(urlBuf.toString(), params);
299 if (authResponse instanceof final AuthenticationSuccessResponse oidcResponse) {
300 validateAuthRespMatchesCodeFlow(oidcResponse);
301 final IAuthenticationResult authData = getAccessToken(oidcResponse.getAuthorizationCode(), getReplyUrl(request));
302 validateNonce(stateData, authData);
303
304 return new EntraIdCredential(authData);
305 }
306 final AuthenticationErrorResponse oidcResponse = (AuthenticationErrorResponse) authResponse;
307 throw new SsoLoginException(String.format("Request for auth code failed: %s - %s", oidcResponse.getErrorObject().getCode(),
308 oidcResponse.getErrorObject().getDescription()));
309 }
310
311
312
313
314
315
316
317 protected AuthenticationResponse parseAuthenticationResponse(final String url, final Map<String, List<String>> params) {
318 if (logger.isDebugEnabled()) {
319 logger.debug("Parse: {} : {}", url, params);
320 }
321 try {
322 return AuthenticationResponseParser.parse(new URI(url), params);
323 } catch (final Exception e) {
324 throw new SsoLoginException("Failed to parse an authentication response.", e);
325 }
326 }
327
328
329
330
331
332
333 protected void validateNonce(final StateData stateData, final IAuthenticationResult authData) {
334 final String idToken = authData.idToken();
335 if (logger.isDebugEnabled()) {
336 logger.debug("idToken={}***", idToken.substring(0, Math.min(8, idToken.length())));
337 }
338 try {
339 final JWTClaimsSet claimsSet = JWTParser.parse(idToken).getJWTClaimsSet();
340 if (claimsSet == null) {
341 throw new SsoLoginException("could not validate nonce");
342 }
343
344 final String nonce = (String) claimsSet.getClaim("nonce");
345 if (logger.isDebugEnabled()) {
346 logger.debug("nonce={}", nonce);
347 }
348 if (StringUtils.isEmpty(nonce) || !nonce.equals(stateData.getNonce())) {
349 throw new SsoLoginException("could not validate nonce");
350 }
351 } catch (final SsoLoginException e) {
352 throw e;
353 } catch (final Exception e) {
354 throw new SsoLoginException("could not validate nonce", e);
355 }
356 }
357
358
359
360
361
362
363 public IAuthenticationResult getAccessToken(final String refreshToken) {
364 final String authority = getAuthority() + getTenant() + "/";
365 if (logger.isDebugEnabled()) {
366 logger.debug("refreshToken={}***, authority={}", refreshToken.substring(0, Math.min(8, refreshToken.length())), authority);
367 }
368 try {
369 final ConfidentialClientApplication app = ConfidentialClientApplication
370 .builder(getClientId(), com.microsoft.aad.msal4j.ClientCredentialFactory.createFromSecret(getClientSecret()))
371 .authority(authority)
372 .build();
373
374 final RefreshTokenParameters parameters =
375 RefreshTokenParameters.builder(Collections.singleton("https://graph.microsoft.com/.default"), refreshToken).build();
376
377 final IAuthenticationResult result = app.acquireToken(parameters).get(acquisitionTimeout, TimeUnit.MILLISECONDS);
378 if (result == null) {
379 throw new SsoLoginException("authentication result was null");
380 }
381 return result;
382 } catch (final Exception e) {
383 throw new SsoLoginException("Failed to get a token.", e);
384 }
385 }
386
387
388
389
390
391
392
393 protected IAuthenticationResult getAccessToken(final AuthorizationCode authorizationCode, final String currentUri) {
394 final String authority = getAuthority() + getTenant() + "/";
395 final String authCode = authorizationCode.getValue();
396 if (logger.isDebugEnabled()) {
397 logger.debug("authCode={}, authority={}, uri={}", authCode, authority, currentUri);
398 }
399 try {
400 final ConfidentialClientApplication app = ConfidentialClientApplication
401 .builder(getClientId(), com.microsoft.aad.msal4j.ClientCredentialFactory.createFromSecret(getClientSecret()))
402 .authority(authority)
403 .build();
404
405 final AuthorizationCodeParameters parameters = AuthorizationCodeParameters.builder(authCode, new URI(currentUri))
406 .scopes(Collections.singleton("https://graph.microsoft.com/.default"))
407 .build();
408
409 final IAuthenticationResult result = app.acquireToken(parameters).get(acquisitionTimeout, TimeUnit.MILLISECONDS);
410 if (result == null) {
411 throw new SsoLoginException("authentication result was null");
412 }
413 return result;
414 } catch (final Exception e) {
415 throw new SsoLoginException("Failed to get a token.", e);
416 }
417 }
418
419
420
421
422
423
424 public IAuthenticationResult refreshTokenSilently(final EntraIdCredential.EntraIdUser user) {
425 final String authority = getAuthority() + getTenant() + "/";
426 try {
427 final ConfidentialClientApplication app = ConfidentialClientApplication
428 .builder(getClientId(), com.microsoft.aad.msal4j.ClientCredentialFactory.createFromSecret(getClientSecret()))
429 .authority(authority)
430 .build();
431
432 final SilentParameters parameters = SilentParameters
433 .builder(Collections.singleton("https://graph.microsoft.com/.default"), user.getAuthenticationResult().account())
434 .build();
435
436 final IAuthenticationResult result = app.acquireTokenSilently(parameters).get(acquisitionTimeout, TimeUnit.MILLISECONDS);
437 if (logger.isDebugEnabled()) {
438 logger.debug("Silent token acquisition successful");
439 }
440 return result;
441 } catch (final Exception e) {
442 if (logger.isDebugEnabled()) {
443 logger.debug("Silent token acquisition failed: {}", e.getMessage());
444 }
445 return null;
446 }
447 }
448
449
450
451
452
453 protected void validateAuthRespMatchesCodeFlow(final AuthenticationSuccessResponse oidcResponse) {
454 if (oidcResponse.getIDToken() != null || oidcResponse.getAccessToken() != null || oidcResponse.getAuthorizationCode() == null) {
455 throw new SsoLoginException("unexpected set of artifacts received");
456 }
457 }
458
459
460
461
462
463
464
465 protected StateData validateState(final HttpSession session, final String state) {
466 if (StringUtils.isNotEmpty(state)) {
467 final StateData stateDataInSession = removeStateFromSession(session, state);
468 if (stateDataInSession != null) {
469 return stateDataInSession;
470 }
471 }
472 throw new SsoLoginException("could not validate state");
473 }
474
475
476
477
478
479
480
481 protected StateData removeStateFromSession(final HttpSession session, final String state) {
482 @SuppressWarnings("unchecked")
483 final Map<String, StateData> states = (Map<String, StateData>) session.getAttribute(STATES);
484 if (states != null) {
485 final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong();
486 states.entrySet()
487 .stream()
488 .filter(e -> (now - e.getValue().getExpiration()) / 1000L > getStateTtl())
489 .map(Map.Entry::getKey)
490 .collect(Collectors.toList())
491 .forEach(s -> {
492 if (logger.isDebugEnabled()) {
493 logger.debug("Removing old state: {}", s);
494 }
495 states.remove(s);
496 });
497 final StateData stateData = states.get(state);
498 if (stateData != null) {
499 if (logger.isDebugEnabled()) {
500 logger.debug("Restoring state from session: {}", stateData);
501 }
502 states.remove(state);
503 return stateData;
504 }
505 }
506 return null;
507 }
508
509
510
511
512
513
514 protected boolean containsAuthenticationData(final HttpServletRequest request) {
515 if (logger.isDebugEnabled()) {
516 logger.debug("HTTP Method: {}", request.getMethod());
517 }
518 if (!"POST".equalsIgnoreCase(request.getMethod())) {
519 return false;
520 }
521 final Map<String, String[]> params = request.getParameterMap();
522 if (logger.isDebugEnabled()) {
523 logger.debug("params={}", params);
524 }
525 return params.containsKey(ERROR) || params.containsKey(ID_TOKEN) || params.containsKey(CODE);
526 }
527
528
529
530
531
532
533
534 public void updateMemberOf(final EntraIdUser user) {
535 if (logger.isDebugEnabled()) {
536 logger.debug("[updateMemberOf] Starting for user: {}", user.getName());
537 }
538
539 final List<String> groupList = new ArrayList<>();
540 final List<String> roleList = new ArrayList<>();
541 final List<String> groupIdsForParentLookup = new ArrayList<>();
542
543 final List<String> defaultGroups = getDefaultGroupList();
544 final List<String> defaultRoles = getDefaultRoleList();
545 groupList.addAll(defaultGroups);
546 roleList.addAll(defaultRoles);
547
548 if (logger.isDebugEnabled()) {
549 logger.debug("[updateMemberOf] Default groups: {}, Default roles: {}", defaultGroups, defaultRoles);
550 }
551
552
553 processDirectMemberOf(user, groupList, roleList, groupIdsForParentLookup, "https://graph.microsoft.com/v1.0/me/memberOf");
554
555 if (logger.isDebugEnabled()) {
556 logger.debug("[updateMemberOf] Direct groups retrieved. Total groups: {}, Total roles: {}, Group IDs for parent lookup: {}",
557 groupList.size(), roleList.size(), groupIdsForParentLookup.size());
558 }
559
560
561 user.setGroups(groupList.stream().distinct().toArray(n -> new String[n]));
562 user.setRoles(roleList.stream().distinct().toArray(n -> new String[n]));
563
564 if (logger.isDebugEnabled()) {
565 logger.debug("[updateMemberOf] Initial groups/roles set for user: {}. Groups: {}, Roles: {}", user.getName(),
566 Arrays.toString(user.getGroupNames()), Arrays.toString(user.getRoleNames()));
567 }
568
569
570 if (!groupIdsForParentLookup.isEmpty()) {
571 if (logger.isDebugEnabled()) {
572 logger.debug("[updateMemberOf] Scheduling parent group lookup for {} group IDs: {}", groupIdsForParentLookup.size(),
573 groupIdsForParentLookup);
574 }
575 scheduleParentGroupLookup(user, new ArrayList<>(groupList), new ArrayList<>(roleList), groupIdsForParentLookup);
576 } else {
577 if (logger.isDebugEnabled()) {
578 logger.debug("[updateMemberOf] No parent group lookup needed (no group IDs to process)");
579 }
580 }
581
582 if (logger.isDebugEnabled()) {
583 logger.debug("[updateMemberOf] Completed for user: {}", user.getName());
584 }
585 }
586
587
588
589
590
591
592
593
594 protected void processMemberOf(final EntraIdUser user, final List<String> groupList, final List<String> roleList, final String url) {
595 if (logger.isDebugEnabled()) {
596 logger.debug("url={}", url);
597 }
598 try (CurlResponse response = Curl.get(url)
599 .header("Authorization", "Bearer " + user.getAuthenticationResult().accessToken())
600 .header("Accept", "application/json")
601 .execute()) {
602 final Map<String, Object> contentMap = response.getContent(OpenSearchCurl.jsonParser());
603 if (logger.isDebugEnabled()) {
604 logger.debug("response={}", contentMap);
605 }
606 if (contentMap.containsKey("value")) {
607 @SuppressWarnings("unchecked")
608 final List<Map<String, Object>> memberOfList = (List<Map<String, Object>>) contentMap.get("value");
609 final FessConfig fessConfig = ComponentUtil.getFessConfig();
610 for (final Map<String, Object> memberOf : memberOfList) {
611 if (logger.isDebugEnabled()) {
612 logger.debug("member={}", memberOf);
613 }
614 String memberType = (String) memberOf.get("@odata.type");
615 if (memberType == null) {
616 logger.warn("@odata.type is null: {}", memberOf);
617 continue;
618 }
619 memberType = memberType.toLowerCase(Locale.ENGLISH);
620 final String id = (String) memberOf.get("id");
621 if (StringUtil.isNotBlank(id)) {
622 if (memberType.contains("group")) {
623 groupList.add(id);
624 } else if (memberType.contains("role")) {
625 roleList.add(id);
626 } else {
627 if (logger.isDebugEnabled()) {
628 logger.debug("Unknown @odata.type: {}", memberOf);
629 }
630 groupList.add(id);
631 }
632 processParentGroup(user, groupList, roleList, id);
633 } else {
634 logger.warn("id is empty: {}", memberOf);
635 }
636 final String[] names = fessConfig.getEntraIdPermissionFields();
637 final boolean useDomainServices = fessConfig.isEntraIdUseDomainServices();
638 for (final String name : names) {
639 final String value = (String) memberOf.get(name);
640 if (StringUtil.isNotBlank(value)) {
641 if (logger.isDebugEnabled()) {
642 logger.debug("{} is a member of {}", name, value);
643 }
644 if (memberType.contains("group")) {
645 addGroupOrRoleName(groupList, value, useDomainServices);
646 } else if (memberType.contains("role")) {
647 addGroupOrRoleName(roleList, value, useDomainServices);
648 } else {
649 if (logger.isDebugEnabled()) {
650 logger.debug("Unknown @odata.type: {}", memberOf);
651 }
652 addGroupOrRoleName(groupList, value, useDomainServices);
653 }
654 } else if (logger.isDebugEnabled()) {
655 logger.debug("{} is empty: {}", name, memberOf);
656 }
657 }
658 }
659 final String nextLink = (String) contentMap.get("@odata.nextLink");
660 if (StringUtil.isNotBlank(nextLink)) {
661 processMemberOf(user, groupList, roleList, nextLink);
662 }
663 } else if (contentMap.containsKey("error")) {
664 logger.warn("Failed to access groups/roles: {}", contentMap);
665 }
666 } catch (final IOException e) {
667 logger.warn("Failed to access groups/roles in Entra ID.", e);
668 }
669 }
670
671
672
673
674
675
676
677 protected void addGroupOrRoleName(final List<String> list, final String value, final boolean useDomainServices) {
678 list.add(value);
679 if (useDomainServices && value.indexOf('@') >= 0) {
680 final String[] values = value.split("@");
681 if (values.length > 1) {
682 list.add(values[0]);
683 }
684 }
685 }
686
687
688
689
690
691
692
693
694
695
696
697 protected void processDirectMemberOf(final EntraIdUser user, final List<String> groupList, final List<String> roleList,
698 final List<String> groupIdsForParentLookup, final String url) {
699 if (logger.isDebugEnabled()) {
700 logger.debug("[processDirectMemberOf] Fetching direct memberships from URL: {}", url);
701 }
702 try (CurlResponse response = Curl.get(url)
703 .header("Authorization", "Bearer " + user.getAuthenticationResult().accessToken())
704 .header("Accept", "application/json")
705 .execute()) {
706 final Map<String, Object> contentMap = response.getContent(OpenSearchCurl.jsonParser());
707 if (logger.isDebugEnabled()) {
708 logger.debug("response={}", contentMap);
709 }
710 if (contentMap.containsKey("value")) {
711 @SuppressWarnings("unchecked")
712 final List<Map<String, Object>> memberOfList = (List<Map<String, Object>>) contentMap.get("value");
713 final FessConfig fessConfig = ComponentUtil.getFessConfig();
714 for (final Map<String, Object> memberOf : memberOfList) {
715 if (logger.isDebugEnabled()) {
716 logger.debug("member={}", memberOf);
717 }
718 String memberType = (String) memberOf.get("@odata.type");
719 if (memberType == null) {
720 logger.warn("@odata.type is null: {}", memberOf);
721 continue;
722 }
723 memberType = memberType.toLowerCase(Locale.ENGLISH);
724 final String id = (String) memberOf.get("id");
725 if (StringUtil.isNotBlank(id)) {
726 if (memberType.contains("group")) {
727 groupList.add(id);
728
729 groupIdsForParentLookup.add(id);
730 if (logger.isDebugEnabled()) {
731 logger.debug("[processDirectMemberOf] Added group ID: {} (will lookup parent groups later)", id);
732 }
733 } else if (memberType.contains("role")) {
734 roleList.add(id);
735 if (logger.isDebugEnabled()) {
736 logger.debug("[processDirectMemberOf] Added role ID: {}", id);
737 }
738 } else {
739 if (logger.isDebugEnabled()) {
740 logger.debug("[processDirectMemberOf] Unknown @odata.type: {}, treating as group", memberOf);
741 }
742 groupList.add(id);
743 groupIdsForParentLookup.add(id);
744 }
745 } else {
746 logger.warn("id is empty: {}", memberOf);
747 }
748 final String[] names = fessConfig.getEntraIdPermissionFields();
749 final boolean useDomainServices = fessConfig.isEntraIdUseDomainServices();
750 for (final String name : names) {
751 final String value = (String) memberOf.get(name);
752 if (StringUtil.isNotBlank(value)) {
753 if (logger.isDebugEnabled()) {
754 logger.debug("{} is a member of {}", name, value);
755 }
756 if (memberType.contains("group")) {
757 addGroupOrRoleName(groupList, value, useDomainServices);
758 } else if (memberType.contains("role")) {
759 addGroupOrRoleName(roleList, value, useDomainServices);
760 } else {
761 addGroupOrRoleName(groupList, value, useDomainServices);
762 }
763 } else if (logger.isDebugEnabled()) {
764 logger.debug("{} is empty: {}", name, memberOf);
765 }
766 }
767 }
768 final String nextLink = (String) contentMap.get("@odata.nextLink");
769 if (StringUtil.isNotBlank(nextLink)) {
770 processDirectMemberOf(user, groupList, roleList, groupIdsForParentLookup, nextLink);
771 }
772 } else if (contentMap.containsKey("error")) {
773 logger.warn("Failed to access groups/roles: {}", contentMap);
774 }
775 } catch (final IOException e) {
776 logger.warn("Failed to access groups/roles in Entra ID.", e);
777 }
778 }
779
780
781
782
783
784
785
786
787
788 protected void scheduleParentGroupLookup(final EntraIdUser user, final List<String> initialGroups, final List<String> initialRoles,
789 final List<String> groupIds) {
790 if (logger.isDebugEnabled()) {
791 logger.debug("[scheduleParentGroupLookup] Scheduling async parent group lookup for user: {}, groupIds count: {}",
792 user.getName(), groupIds.size());
793 }
794 TimeoutManager.getInstance().addTimeoutTarget(() -> {
795 if (logger.isDebugEnabled()) {
796 logger.debug("[scheduleParentGroupLookup] Async task started for user: {}", user.getName());
797 }
798 final long startTime = System.currentTimeMillis();
799 try {
800 final List<String> updatedGroups = new ArrayList<>(initialGroups);
801 final List<String> updatedRoles = new ArrayList<>(initialRoles);
802
803 if (logger.isDebugEnabled()) {
804 logger.debug("[scheduleParentGroupLookup] Processing {} group IDs for parent lookup", groupIds.size());
805 }
806
807 int processedCount = 0;
808 for (final String groupId : groupIds) {
809 if (logger.isDebugEnabled()) {
810 logger.debug("[scheduleParentGroupLookup] Processing parent groups for groupId: {} ({}/{})", groupId,
811 ++processedCount, groupIds.size());
812 }
813 processParentGroup(user, updatedGroups, updatedRoles, groupId);
814 }
815
816
817 final String[] finalGroups = updatedGroups.stream().distinct().toArray(n -> new String[n]);
818 final String[] finalRoles = updatedRoles.stream().distinct().toArray(n -> new String[n]);
819 user.setGroups(finalGroups);
820 user.setRoles(finalRoles);
821
822
823 user.resetPermissions();
824
825 final long elapsedTime = System.currentTimeMillis() - startTime;
826 if (logger.isDebugEnabled()) {
827 logger.debug(
828 "[scheduleParentGroupLookup] Async task completed for user: {}. Final groups: {}, Final roles: {}, Elapsed time: {}ms",
829 user.getName(), finalGroups.length, finalRoles.length, elapsedTime);
830 logger.debug("[scheduleParentGroupLookup] Final groups for user {}: {}", user.getName(), Arrays.toString(finalGroups));
831 logger.debug("[scheduleParentGroupLookup] Final roles for user {}: {}", user.getName(), Arrays.toString(finalRoles));
832 }
833
834
835 if (logger.isDebugEnabled()) {
836 logger.debug("[scheduleParentGroupLookup] Notifying permission change for user: {}", user.getName());
837 }
838 ComponentUtil.getActivityHelper().permissionChanged(OptionalThing.of(new FessUserBean(user)));
839 } catch (final Exception e) {
840 final long elapsedTime = System.currentTimeMillis() - startTime;
841 logger.warn("Failed to process parent groups asynchronously for user: {} after {}ms", user.getName(), elapsedTime, e);
842 }
843 }, 0, false);
844 }
845
846
847
848
849
850
851
852
853 protected void processParentGroup(final EntraIdUser user, final List<String> groupList, final List<String> roleList, final String id) {
854 processParentGroup(user, groupList, roleList, id, 0);
855 }
856
857
858
859
860
861
862
863
864
865 protected void processParentGroup(final EntraIdUser user, final List<String> groupList, final List<String> roleList, final String id,
866 final int depth) {
867 if (logger.isDebugEnabled()) {
868 logger.debug("[processParentGroup] Processing parent groups for id: {}, depth: {}/{}", id, depth, maxGroupDepth);
869 }
870 if (depth >= maxGroupDepth) {
871 if (logger.isDebugEnabled()) {
872 logger.debug("[processParentGroup] Maximum group depth {} reached for group {}", maxGroupDepth, id);
873 }
874 return;
875 }
876 final Pair<String[], String[]> groupsAndRoles = getParentGroup(user, id, depth);
877 StreamUtil.stream(groupsAndRoles.getFirst()).of(stream -> stream.forEach(groupList::add));
878 StreamUtil.stream(groupsAndRoles.getSecond()).of(stream -> stream.forEach(roleList::add));
879 if (logger.isDebugEnabled()) {
880 logger.debug("[processParentGroup] Completed for id: {}, depth: {}, added groups: {}, added roles: {}", id, depth,
881 groupsAndRoles.getFirst().length, groupsAndRoles.getSecond().length);
882 }
883 }
884
885
886
887
888
889
890
891 protected Pair<String[], String[]> getParentGroup(final EntraIdUser user, final String id) {
892 return getParentGroup(user, id, 0);
893 }
894
895
896
897
898
899
900
901
902 protected Pair<String[], String[]> getParentGroup(final EntraIdUser user, final String id, final int depth) {
903 if (logger.isDebugEnabled()) {
904 logger.debug("[getParentGroup] Getting parent groups for id: {}, depth: {}", id, depth);
905 }
906 if (depth >= maxGroupDepth) {
907 if (logger.isDebugEnabled()) {
908 logger.debug("[getParentGroup] Maximum group depth {} reached for group {}", maxGroupDepth, id);
909 }
910 return new Pair<>(StringUtil.EMPTY_STRINGS, StringUtil.EMPTY_STRINGS);
911 }
912
913 final Pair<String[], String[]> cachedResult = groupCache.getIfPresent(id);
914 if (cachedResult != null) {
915 if (logger.isDebugEnabled()) {
916 logger.debug("[getParentGroup] Cache HIT for id: {}, groups: {}, roles: {}", id, cachedResult.getFirst().length,
917 cachedResult.getSecond().length);
918 }
919 return cachedResult;
920 }
921 if (logger.isDebugEnabled()) {
922 logger.debug("[getParentGroup] Cache MISS for id: {}, fetching from API", id);
923 }
924 try {
925 return groupCache.get(id, () -> {
926 if (logger.isDebugEnabled()) {
927 logger.debug("[getParentGroup] Loading parent groups for id: {} into cache", id);
928 }
929 final List<String> groupList = new ArrayList<>();
930 final List<String> roleList = new ArrayList<>();
931 final String url = "https://graph.microsoft.com/v1.0/groups/" + id + "/getMemberGroups";
932 if (logger.isDebugEnabled()) {
933 logger.debug("[getParentGroup] Calling API: {}", url);
934 }
935 try (CurlResponse response = Curl.post(url)
936 .header("Authorization", "Bearer " + user.getAuthenticationResult().accessToken())
937 .header("Accept", "application/json")
938 .header("Content-type", "application/json")
939 .body("{\"securityEnabledOnly\":false}")
940 .execute()) {
941 final Map<String, Object> contentMap = response.getContent(OpenSearchCurl.jsonParser());
942 if (logger.isDebugEnabled()) {
943 logger.debug("[getParentGroup] Response for id {}: {}", id, contentMap);
944 }
945 if (contentMap.containsKey("value")) {
946 final String[] values = DocumentUtil.getValue(contentMap, "value", String[].class);
947 if (values != null) {
948 if (logger.isDebugEnabled()) {
949 logger.debug("[getParentGroup] Found {} parent group IDs for id: {}", values.length, id);
950 }
951 for (final String value : values) {
952 if (logger.isDebugEnabled()) {
953 logger.debug("[getParentGroup] Processing parent group id: {} for group: {}", value, id);
954 }
955 processGroup(user, groupList, roleList, value);
956 if (!groupList.contains(value) && !roleList.contains(value)) {
957 if (logger.isDebugEnabled()) {
958 logger.debug("[getParentGroup] Recursively getting parent groups for: {}", value);
959 }
960 final Pair<String[], String[]> groupsAndRoles = getParentGroup(user, value, depth + 1);
961 StreamUtil.stream(groupsAndRoles.getFirst()).of(stream1 -> stream1.forEach(groupList::add));
962 StreamUtil.stream(groupsAndRoles.getSecond()).of(stream2 -> stream2.forEach(roleList::add));
963 }
964 }
965 }
966 } else if (contentMap.containsKey("error")) {
967 @SuppressWarnings("unchecked")
968 final Map<String, Object> errorMap = (Map<String, Object>) contentMap.get("error");
969 if ("Request_ResourceNotFound".equals(errorMap.get("code"))) {
970 if (logger.isDebugEnabled()) {
971 logger.debug("[getParentGroup] Resource not found for id {}: {}", id, contentMap);
972 }
973 } else {
974 logger.warn("Failed to access parent groups for id {}: {}", id, contentMap);
975 }
976 }
977 } catch (final IOException e) {
978 logger.warn("Failed to access groups/roles in Entra ID for id: {}", id, e);
979 }
980 final Pair<String[], String[]> result = new Pair<>(groupList.stream().distinct().toArray(n1 -> new String[n1]),
981 roleList.stream().distinct().toArray(n2 -> new String[n2]));
982 if (logger.isDebugEnabled()) {
983 logger.debug("[getParentGroup] Cached result for id {}: {} groups, {} roles", id, result.getFirst().length,
984 result.getSecond().length);
985 }
986 return result;
987 });
988 } catch (final ExecutionException e) {
989 logger.warn("Failed to process group cache for id: {}", id, e);
990 return new Pair<>(StringUtil.EMPTY_STRINGS, StringUtil.EMPTY_STRINGS);
991 }
992 }
993
994
995
996
997
998
999
1000
1001 protected void processGroup(final EntraIdUser user, final List<String> groupList, final List<String> roleList, final String id) {
1002 if (logger.isDebugEnabled()) {
1003 logger.debug("[processGroup] Processing group info for id: {}", id);
1004 }
1005 final String url = "https://graph.microsoft.com/v1.0/groups/" + id;
1006 if (logger.isDebugEnabled()) {
1007 logger.debug("[processGroup] Fetching from url: {}", url);
1008 }
1009 try (CurlResponse response = Curl.get(url)
1010 .header("Authorization", "Bearer " + user.getAuthenticationResult().accessToken())
1011 .header("Accept", "application/json")
1012 .execute()) {
1013 final Map<String, Object> contentMap = response.getContent(OpenSearchCurl.jsonParser());
1014 if (logger.isDebugEnabled()) {
1015 logger.debug("[processGroup] Response for id {}: {}", id, contentMap);
1016 }
1017 groupList.add(id);
1018 if (contentMap.containsKey("error")) {
1019 logger.warn("Failed to access group info: {}", contentMap);
1020 } else {
1021 final FessConfig fessConfig = ComponentUtil.getFessConfig();
1022 final String[] names = fessConfig.getEntraIdPermissionFields();
1023 final int initialSize = groupList.size();
1024 for (final String name : names) {
1025 final String value = (String) contentMap.get(name);
1026 if (StringUtil.isNotBlank(value)) {
1027 groupList.add(value);
1028 if (logger.isDebugEnabled()) {
1029 logger.debug("[processGroup] Added {} value: {} for group id: {}", name, value, id);
1030 }
1031 } else if (logger.isDebugEnabled()) {
1032 logger.debug("[processGroup] {} is empty for group id: {}", name, id);
1033 }
1034 }
1035 if (logger.isDebugEnabled()) {
1036 logger.debug("[processGroup] Completed for id: {}, added {} entries", id, groupList.size() - initialSize);
1037 }
1038 }
1039 } catch (final IOException e) {
1040 logger.warn("Failed to access groups/roles in Entra ID for id: {}", id, e);
1041 }
1042 }
1043
1044
1045
1046
1047
1048
1049 protected List<String> getDefaultGroupList() {
1050 String value = ComponentUtil.getFessConfig().getSystemProperty(ENTRAID_DEFAULT_GROUPS);
1051 if (StringUtil.isBlank(value)) {
1052 value = ComponentUtil.getFessConfig().getSystemProperty(AAD_DEFAULT_GROUPS);
1053 }
1054 if (StringUtil.isBlank(value)) {
1055 return Collections.emptyList();
1056 }
1057 return split(value, ",").get(stream -> stream.filter(StringUtil::isNotBlank).map(String::trim).collect(Collectors.toList()));
1058 }
1059
1060
1061
1062
1063
1064
1065 protected List<String> getDefaultRoleList() {
1066 String value = ComponentUtil.getFessConfig().getSystemProperty(ENTRAID_DEFAULT_ROLES);
1067 if (StringUtil.isBlank(value)) {
1068 value = ComponentUtil.getFessConfig().getSystemProperty(AAD_DEFAULT_ROLES);
1069 }
1070 if (StringUtil.isBlank(value)) {
1071 return Collections.emptyList();
1072 }
1073 return split(value, ",").get(stream -> stream.filter(StringUtil::isNotBlank).map(String::trim).collect(Collectors.toList()));
1074 }
1075
1076
1077
1078
1079 protected static class StateData {
1080 private final String nonce;
1081 private final long expiration;
1082
1083
1084
1085
1086
1087
1088 public StateData(final String nonce, final long expiration) {
1089 this.nonce = nonce;
1090 this.expiration = expiration;
1091 }
1092
1093
1094
1095
1096
1097 public String getNonce() {
1098 return nonce;
1099 }
1100
1101
1102
1103
1104
1105 public long getExpiration() {
1106 return expiration;
1107 }
1108
1109 @Override
1110 public String toString() {
1111 return "StateData [nonce=" + nonce + ", expiration=" + expiration + "]";
1112 }
1113 }
1114
1115
1116
1117
1118
1119
1120 protected String getClientId() {
1121 String value = ComponentUtil.getFessConfig().getSystemProperty(ENTRAID_CLIENT_ID);
1122 if (StringUtil.isBlank(value)) {
1123 value = ComponentUtil.getFessConfig().getSystemProperty(AAD_CLIENT_ID, StringUtil.EMPTY);
1124 }
1125 return value;
1126 }
1127
1128
1129
1130
1131
1132
1133 protected String getClientSecret() {
1134 String value = ComponentUtil.getFessConfig().getSystemProperty(ENTRAID_CLIENT_SECRET);
1135 if (StringUtil.isBlank(value)) {
1136 value = ComponentUtil.getFessConfig().getSystemProperty(AAD_CLIENT_SECRET, StringUtil.EMPTY);
1137 }
1138 return value;
1139 }
1140
1141
1142
1143
1144
1145
1146 protected String getTenant() {
1147 String value = ComponentUtil.getFessConfig().getSystemProperty(ENTRAID_TENANT);
1148 if (StringUtil.isBlank(value)) {
1149 value = ComponentUtil.getFessConfig().getSystemProperty(AAD_TENANT, StringUtil.EMPTY);
1150 }
1151 return value;
1152 }
1153
1154
1155
1156
1157
1158
1159 protected String getAuthority() {
1160 String value = ComponentUtil.getFessConfig().getSystemProperty(ENTRAID_AUTHORITY);
1161 if (StringUtil.isBlank(value)) {
1162 value = ComponentUtil.getFessConfig().getSystemProperty(AAD_AUTHORITY, "https://login.microsoftonline.com/");
1163 }
1164 return value;
1165 }
1166
1167
1168
1169
1170
1171
1172 protected long getStateTtl() {
1173 String value = ComponentUtil.getFessConfig().getSystemProperty(ENTRAID_STATE_TTL);
1174 if (StringUtil.isBlank(value)) {
1175 value = ComponentUtil.getFessConfig().getSystemProperty(AAD_STATE_TTL, "3600");
1176 }
1177 return Long.parseLong(value);
1178 }
1179
1180
1181
1182
1183
1184
1185
1186 protected String getReplyUrl(final HttpServletRequest request) {
1187 String value = ComponentUtil.getFessConfig().getSystemProperty(ENTRAID_REPLY_URL);
1188 if (StringUtil.isBlank(value)) {
1189 value = ComponentUtil.getFessConfig().getSystemProperty(AAD_REPLY_URL, StringUtil.EMPTY);
1190 }
1191 if (StringUtil.isNotBlank(value)) {
1192 return value;
1193 }
1194 return request.getRequestURL().toString();
1195 }
1196
1197 @Override
1198 public void resolveCredential(final LoginCredentialResolver resolver) {
1199 resolver.resolve(EntraIdCredential.class, credential -> OptionalEntity.of(credential.getUser()));
1200 }
1201
1202
1203
1204
1205
1206 public void setAcquisitionTimeout(final long acquisitionTimeout) {
1207 this.acquisitionTimeout = acquisitionTimeout;
1208 }
1209
1210
1211
1212
1213
1214 public void setGroupCacheExpiry(final long groupCacheExpiry) {
1215 this.groupCacheExpiry = groupCacheExpiry;
1216 }
1217
1218
1219
1220
1221
1222 public void setMaxGroupDepth(final int maxGroupDepth) {
1223 this.maxGroupDepth = maxGroupDepth;
1224 }
1225
1226 @Override
1227 public ActionResponse getResponse(final SsoResponseType responseType) {
1228 return null;
1229 }
1230
1231 @Override
1232 public String logout(final FessUserBean user) {
1233 return null;
1234 }
1235
1236
1237
1238
1239
1240 public void setUseV2Endpoint(final boolean useV2Endpoint) {
1241 this.useV2Endpoint = useV2Endpoint;
1242 }
1243 }