001/* 002 * Copyright 2022 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.plugins.extrausermgt.authentication.oidc; 017 018import java.io.IOException; 019import java.net.URI; 020import java.net.URISyntaxException; 021import java.net.URL; 022import java.util.Date; 023import java.util.List; 024import java.util.Map; 025import java.util.stream.Collectors; 026import java.util.stream.Stream; 027 028import org.apache.avalon.framework.context.Context; 029import org.apache.avalon.framework.context.ContextException; 030import org.apache.avalon.framework.context.Contextualizable; 031import org.apache.avalon.framework.service.ServiceException; 032import org.apache.avalon.framework.service.ServiceManager; 033import org.apache.avalon.framework.service.Serviceable; 034import org.apache.cocoon.ProcessingException; 035import org.apache.cocoon.components.ContextHelper; 036import org.apache.cocoon.environment.Redirector; 037import org.apache.cocoon.environment.Request; 038import org.apache.cocoon.environment.Session; 039import org.apache.commons.lang3.StringUtils; 040 041import org.ametys.core.authentication.AbstractCredentialProvider; 042import org.ametys.core.authentication.AuthenticateAction; 043import org.ametys.core.authentication.BlockingCredentialProvider; 044import org.ametys.core.user.User; 045import org.ametys.core.user.UserIdentity; 046import org.ametys.core.user.directory.NotUniqueUserException; 047import org.ametys.core.user.directory.UserDirectory; 048import org.ametys.core.user.population.UserPopulation; 049import org.ametys.plugins.extrausermgt.authentication.oidc.endofauthenticationprocess.EndOfAuthenticationProcess; 050import org.ametys.runtime.authentication.AccessDeniedException; 051 052import com.nimbusds.jose.JOSEException; 053import com.nimbusds.jose.JWSAlgorithm; 054import com.nimbusds.jose.proc.BadJOSEException; 055import com.nimbusds.jwt.JWT; 056import com.nimbusds.oauth2.sdk.AuthorizationCode; 057import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant; 058import com.nimbusds.oauth2.sdk.AuthorizationGrant; 059import com.nimbusds.oauth2.sdk.ParseException; 060import com.nimbusds.oauth2.sdk.RefreshTokenGrant; 061import com.nimbusds.oauth2.sdk.ResponseType; 062import com.nimbusds.oauth2.sdk.Scope; 063import com.nimbusds.oauth2.sdk.SerializeException; 064import com.nimbusds.oauth2.sdk.TokenErrorResponse; 065import com.nimbusds.oauth2.sdk.TokenRequest; 066import com.nimbusds.oauth2.sdk.TokenResponse; 067import com.nimbusds.oauth2.sdk.auth.ClientAuthentication; 068import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; 069import com.nimbusds.oauth2.sdk.auth.Secret; 070import com.nimbusds.oauth2.sdk.http.HTTPResponse; 071import com.nimbusds.oauth2.sdk.id.ClientID; 072import com.nimbusds.oauth2.sdk.id.Issuer; 073import com.nimbusds.oauth2.sdk.id.State; 074import com.nimbusds.oauth2.sdk.token.AccessToken; 075import com.nimbusds.oauth2.sdk.token.RefreshToken; 076import com.nimbusds.openid.connect.sdk.AuthenticationRequest; 077import com.nimbusds.openid.connect.sdk.OIDCTokenResponse; 078import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser; 079import com.nimbusds.openid.connect.sdk.UserInfoRequest; 080import com.nimbusds.openid.connect.sdk.UserInfoResponse; 081import com.nimbusds.openid.connect.sdk.claims.IDTokenClaimsSet; 082import com.nimbusds.openid.connect.sdk.claims.UserInfo; 083import com.nimbusds.openid.connect.sdk.token.OIDCTokens; 084import com.nimbusds.openid.connect.sdk.validators.IDTokenValidator; 085 086/** 087 * Sign in (through Google, facebook...) using the OpenId Connect (OIDC) protocol. 088 */ 089public abstract class AbstractOIDCCredentialProvider extends AbstractCredentialProvider implements BlockingCredentialProvider, Contextualizable, Serviceable 090{ 091 /** Session attribute for OIDC */ 092 public static final String REDIRECT_URI_SESSION_ATTRIBUTE = "oidc_actualRedirectUri"; 093 /** Session attribute for OIDC*/ 094 public static final String TOKEN_SESSION_ATTRIBUTE = "oidc_token"; 095 /** Session date attribute for OIDC*/ 096 public static final String EXPDATE_SESSION_ATTRIBUTE = "oidc_expirationDate"; 097 /** Session attribute for OIDC*/ 098 public static final String REFRESH_TOKEN_SESSION_ATTRIBUTE = "oidc_refreshToken"; 099 /** Session attribute for OIDC*/ 100 public static final String STATE_SESSION_ATTRIBUTE = "oidc_state"; 101 102 /** Scope for the authentication request */ 103 protected Scope _scope; 104 105 /** URI for the authentication request */ 106 protected URI _authUri; 107 108 /** URI for the token request */ 109 protected URI _tokenEndpointUri; 110 111 /** URI for the user info request */ 112 protected URI _userInfoEndpoint; 113 114 /** jwk URL for the validation of the token */ 115 protected URL _jwkSetURL; 116 117 /** Issuer for the validation of the token */ 118 protected Issuer _iss; 119 120 /** Ametys context */ 121 protected Context _context; 122 /** Client ID */ 123 protected ClientID _clientID; 124 /** Client secret */ 125 protected Secret _clientSecret; 126 127 private EndOfAuthenticationProcess _endOfAuthenticationProcess; 128 129 public void contextualize(Context context) throws ContextException 130 { 131 _context = context; 132 } 133 134 public void service(ServiceManager manager) throws ServiceException 135 { 136 _endOfAuthenticationProcess = (EndOfAuthenticationProcess) manager.lookup(EndOfAuthenticationProcess.ROLE); 137 } 138 139 @Override 140 public void init(String id, String cpModelId, Map<String, Object> paramValues, String label) throws Exception 141 { 142 super.init(id, cpModelId, paramValues, label); 143 _clientID = new ClientID(paramValues.get("authentication.oidc.idclient").toString()); 144 _clientSecret = new Secret(paramValues.get("authentication.oidc.clientsecret").toString()); 145 146 initUrisScope(); 147 } 148 149 /** 150 * get the client authentication info for the token end point 151 * @return the client authentication 152 */ 153 protected ClientAuthentication getClientAuthentication() 154 { 155 return new ClientSecretBasic(_clientID, _clientSecret); 156 } 157 158 public boolean blockingGrantAnonymousRequest() 159 { 160 return false; 161 } 162 163 public boolean blockingIsStillConnected(UserIdentity userIdentity, Redirector redirector) throws Exception 164 { 165 Request request = ContextHelper.getRequest(_context); 166 Session session = request.getSession(true); 167 168 Date expDat = (Date) session.getAttribute(EXPDATE_SESSION_ATTRIBUTE); 169 if (new Date().before(expDat)) 170 { 171 return true; 172 } 173 174 RefreshToken refreshToken = (RefreshToken) session.getAttribute(REFRESH_TOKEN_SESSION_ATTRIBUTE); 175 AuthorizationGrant refreshTokenGrant = new RefreshTokenGrant(refreshToken); 176 177 // The credentials to authenticate the client at the token endpoint 178 ClientAuthentication clientAuth = getClientAuthentication(); 179 180 // Make the token request 181 OIDCTokens tokens = requestToken(clientAuth, refreshTokenGrant); 182 183 // idToken to validate the token 184 JWT idToken = tokens.getIDToken(); 185 // accessToken to be able to access the user info 186 AccessToken accessToken = tokens.getAccessToken(); 187 IDTokenClaimsSet claims = validateIdToken(idToken); 188 session.setAttribute(EXPDATE_SESSION_ATTRIBUTE, claims.getExpirationTime()); 189 session.setAttribute(TOKEN_SESSION_ATTRIBUTE, accessToken); 190 191 return true; 192 } 193 194 public UserIdentity blockingGetUserIdentity(Redirector redirector) throws Exception 195 { 196 Request request = ContextHelper.getRequest(_context); 197 Session session = request.getSession(true); 198 199 URI redirectUri = _buildRedirectUri(); 200 201 getLogger().debug("OIDCCredentialProvider callback URI: {}", redirectUri); 202 203 String code = request.getParameter("code"); 204 // if the code is null, then this is the first time the user sign-in 205 // if no state are stored in session, then the code belongs to a previous session. Restart 206 if (code == null || session.getAttribute(STATE_SESSION_ATTRIBUTE) == null) 207 { 208 signIn(redirector, redirectUri, session); 209 return null; 210 } 211 212 // we got an authorization code 213 // but first, check the state to prevent CSRF attacks 214 checkState(); 215 AuthorizationCode authCode = new AuthorizationCode(code); 216 // get the tokens (id token and access token) 217 OIDCTokens tokens = requestToken(authCode, redirectUri); 218 219 // idToken to validate the token 220 JWT idToken = tokens.getIDToken(); 221 // accessToken to be able to access the user info 222 AccessToken accessToken = tokens.getAccessToken(); 223 RefreshToken refreshToken = tokens.getRefreshToken(); 224 225 session.setAttribute(REFRESH_TOKEN_SESSION_ATTRIBUTE, refreshToken); 226 227 // validate id token 228 IDTokenClaimsSet claims = validateIdToken(idToken); 229 230 // set expirationTime 231 claims.getExpirationTime(); 232 session.setAttribute(EXPDATE_SESSION_ATTRIBUTE, claims.getExpirationTime()); 233 234 235 UserInfo userInfo = getUserInfo(accessToken); 236 237 // then the user is finally logged in 238 return getUserIdentity(userInfo, request, redirector); 239 } 240 241 public void blockingUserNotAllowed(Redirector redirector) throws Exception 242 { 243 // Nothing to do. 244 } 245 246 public void blockingUserAllowed(UserIdentity userIdentity, Redirector redirector) throws Exception 247 { 248 Request request = ContextHelper.getRequest(_context); 249 Session session = request.getSession(true); 250 String redirectUri = (String) session.getAttribute(AbstractOIDCCredentialProvider.REDIRECT_URI_SESSION_ATTRIBUTE); 251 redirector.redirect(true, redirectUri); 252 } 253 254 public boolean requiresNewWindow() 255 { 256 return true; 257 } 258 259 private UserPopulation _getPopulation(Request request) 260 { 261 @SuppressWarnings("unchecked") 262 List<UserPopulation> userPopulations = (List<UserPopulation>) request.getAttribute(AuthenticateAction.REQUEST_ATTRIBUTE_AVAILABLE_USER_POPULATIONS_LIST); 263 264 // If the list has only one element 265 if (userPopulations.size() == 1) 266 { 267 return userPopulations.get(0); 268 } 269 270 // In this list a population was maybe chosen? 271 final String chosenUserPopulationId = (String) request.getAttribute(AuthenticateAction.REQUEST_ATTRIBUTE_USER_POPULATION_ID); 272 if (StringUtils.isNotBlank(chosenUserPopulationId)) 273 { 274 return userPopulations.stream() 275 .filter(userPopulation -> StringUtils.equals(userPopulation.getId(), chosenUserPopulationId)) 276 .findFirst() 277 .get(); 278 } 279 280 // Cannot work here... 281 throw new IllegalStateException("The " + this.getClass().getName() + " does not work when population is not known"); 282 } 283 284 /** 285 * Initialize the URIs 286 * @throws AccessDeniedException If an error occurs 287 */ 288 protected abstract void initUrisScope() throws AccessDeniedException; 289 290 /** 291 * Builds the redirect URI and the actual redirect URI 292 * @return The redirect <code>URI</code> and saves the actual redirect <code>URI</code> 293 * @throws URISyntaxException If an error occurs 294 */ 295 private URI _buildRedirectUri() throws URISyntaxException 296 { 297 Request request = ContextHelper.getRequest(_context); 298 299 // creation of the actual redirect URI (The one we actually want to go back to) 300 StringBuilder actualRedirectUri = new StringBuilder(request.getRequestURI()); 301 String queryString = request.getQueryString(); 302 if (queryString != null) 303 { 304 // remove any existing code or state from the request param if any exist, they are outdated 305 queryString = Stream.of(StringUtils.split(queryString, "&")) 306 .filter(str -> !StringUtils.startsWithAny(str, "code=", "state=")) 307 .collect(Collectors.joining("&")); 308 if (StringUtils.isNotEmpty(queryString)) 309 { 310 actualRedirectUri.append("?"); 311 actualRedirectUri.append(queryString); 312 } 313 } 314 315 // saving the actualRedirectUri to enable its use in "OIDCCallbackAction" 316 Session session = request.getSession(true); 317 session.setAttribute(REDIRECT_URI_SESSION_ATTRIBUTE, actualRedirectUri.toString()); 318 319 // creation of redirect URI (the issuer (google, facebook, etc.) is going to redirect to) 320 return buildAbsoluteURI(request, "/_extra-user-management/oidc-callback"); 321 } 322 323 /** 324 * Computes the callback uri 325 * @param request the current request 326 * @param path the callback path 327 * @return the callback uri 328 */ 329 protected URI buildAbsoluteURI(Request request, String path) 330 { 331 StringBuilder uriBuilder = new StringBuilder() 332 .append(request.getScheme()) 333 .append("://") 334 .append(request.getServerName()); 335 336 if (request.isSecure()) 337 { 338 if (request.getServerPort() != 443) 339 { 340 uriBuilder.append(":"); 341 uriBuilder.append(request.getServerPort()); 342 } 343 } 344 else 345 { 346 if (request.getServerPort() != 80) 347 { 348 uriBuilder.append(":"); 349 uriBuilder.append(request.getServerPort()); 350 } 351 } 352 353 uriBuilder.append(request.getContextPath()); 354 uriBuilder.append(path); 355 356 return URI.create(uriBuilder.toString()); 357 } 358 359 /** 360 * Sign the user in by sending an authentication request to the issuer 361 * @param redirector The redirector 362 * @param redirectUri The redirect URI 363 * @param session The current session 364 * @throws ProcessingException If an error occurs 365 * @throws IOException If an error occurs 366 */ 367 protected void signIn(Redirector redirector, URI redirectUri, Session session) throws ProcessingException, IOException 368 { 369 // sign-in request: redirect the client through the actual authentication process 370 371 // creation of the state used to secure the process 372 State state = new State(); 373 session.setAttribute(STATE_SESSION_ATTRIBUTE, state); 374 375 // compose the request 376 AuthenticationRequest authenticationRequest = new AuthenticationRequest(_authUri, new ResponseType(ResponseType.Value.CODE), _scope, _clientID, redirectUri, state, null); 377 378 String authReqURI = authenticationRequest.toURI().toString(); 379 authReqURI += "&access_type=offline"; 380 381 redirector.redirect(false, authReqURI); 382 } 383 384 /** 385 * Checks the State parameter of the request to prevent CSRF attacks 386 * @throws AccessDeniedException If an error occurs 387 */ 388 protected void checkState() throws AccessDeniedException 389 { 390 Request request = ContextHelper.getRequest(_context); 391 Session session = request.getSession(true); 392 String storedState = session.getAttribute(STATE_SESSION_ATTRIBUTE).toString(); 393 String stateRequest = request.getParameter("state"); 394 395 if (!storedState.equals(stateRequest)) 396 { 397 getLogger().error("OIDC state mismatch. Method checkState of AbstractOIDCCredentialProvider"); 398 throw new AccessDeniedException("OIDC state mismatch"); 399 } 400 401 session.setAttribute(STATE_SESSION_ATTRIBUTE, null); 402 } 403 404 /** 405 * Request the tokens (ID token and Access token) 406 * @param authCode The authorization code from the authentication request 407 * @param redirectUri The redirect URI 408 * @return The <code>OIDCTokens</code> that contains the access token and the id token 409 * @throws AccessDeniedException If an error occurs 410 */ 411 protected OIDCTokens requestToken(AuthorizationCode authCode, URI redirectUri) throws AccessDeniedException 412 { 413 // token request: checking if the user is known 414 TokenRequest tokenReq = new TokenRequest(_tokenEndpointUri, getClientAuthentication(), new AuthorizationCodeGrant(authCode, redirectUri)); 415 // sending request 416 HTTPResponse tokenHTTPResp = null; 417 try 418 { 419 tokenHTTPResp = tokenReq.toHTTPRequest().send(); 420 } 421 catch (SerializeException | IOException e) 422 { 423 getLogger().error("OIDC token request failed ", e); 424 throw new AccessDeniedException("OIDC token request failed"); 425 } 426 427 // cast the HTTPResponse to TokenResponse 428 TokenResponse tokenResponse = null; 429 try 430 { 431 tokenResponse = OIDCTokenResponseParser.parse(tokenHTTPResp); 432 } 433 catch (ParseException e) 434 { 435 getLogger().error("OIDC token request result invalid ", e); 436 throw new AccessDeniedException("OIDC token request result invalid"); 437 } 438 439 if (tokenResponse instanceof TokenErrorResponse) 440 { 441 getLogger().error("OIDC token request invalid token response instance of TokenErrorResponse in method requestToken from AbstractOIDCCredentialProvider"); 442 throw new AccessDeniedException("OIDC token request result invalid"); 443 } 444 445 // get the tokens 446 OIDCTokenResponse accessTokenResponse = (OIDCTokenResponse) tokenResponse; 447 448 return accessTokenResponse.getOIDCTokens(); 449 } 450 451 /** 452 * Request the tokens using a refresh token 453 * @param clientAuth The client authentication 454 * @param refreshTokenGrant The refreshtokenGrant 455 * @return The <code>OIDCTokens</code> that contains the access token and the id token 456 * @throws AccessDeniedException If an error occurs 457 * @throws URISyntaxException If an error occurs 458 */ 459 protected OIDCTokens requestToken(ClientAuthentication clientAuth, AuthorizationGrant refreshTokenGrant) throws AccessDeniedException, URISyntaxException 460 { 461 // token request: checking if the user is known 462 TokenRequest tokenReq = new TokenRequest(_tokenEndpointUri, clientAuth, refreshTokenGrant); 463 // sending request 464 465 HTTPResponse tokenHTTPResp = null; 466 try 467 { 468 tokenHTTPResp = tokenReq.toHTTPRequest().send(); 469 } 470 catch (SerializeException | IOException e) 471 { 472 getLogger().error("OIDC token request failed ", e); 473 throw new AccessDeniedException("OIDC token request failed"); 474 } 475 476 // cast the HTTPResponse to TokenResponse 477 TokenResponse tokenResponse = null; 478 try 479 { 480 tokenResponse = OIDCTokenResponseParser.parse(tokenHTTPResp); 481 } 482 catch (ParseException e) 483 { 484 getLogger().error("OIDC token request result invalid ", e); 485 throw new AccessDeniedException("OIDC token request result invalid"); 486 } 487 488 if (tokenResponse instanceof TokenErrorResponse) 489 { 490 getLogger().error("OIDC token request result invalid: tokenResponse instance of TokenErrorResponse in method requestToken from AbstractOIDCCredentialProvider"); 491 throw new AccessDeniedException("OIDC token request result invalid"); 492 } 493 494 // get the tokens 495 OIDCTokenResponse accessTokenResponse = (OIDCTokenResponse) tokenResponse; 496 497 return accessTokenResponse.getOIDCTokens(); 498 } 499 500 /** 501 * Validate the id token from the token request 502 * @param idToken The id token from the token request 503 * @return The <code>IDTokenClaimsSet</code> that contains information on the connection such as the expiration time 504 * @throws AccessDeniedException If an error occurs 505 */ 506 protected IDTokenClaimsSet validateIdToken(JWT idToken) throws AccessDeniedException 507 { 508 JWSAlgorithm jwsAlg = JWSAlgorithm.RS256; 509 // create validator for signed ID tokens 510 IDTokenValidator validator = new IDTokenValidator(_iss, _clientID, jwsAlg, _jwkSetURL); 511 IDTokenClaimsSet claims; 512 513 try 514 { 515 claims = validator.validate(idToken, null); 516 } 517 catch (BadJOSEException e) 518 { 519 getLogger().error("OIDC invalid : issuer, clientId, jwsAlg or jwkSetURL", e); 520 throw new AccessDeniedException("OIDC invalid signature issuer, clientId, jwsAlg or jwkSetURL"); 521 } 522 catch (JOSEException e) 523 { 524 getLogger().error("OIDC error while validating token", e); 525 throw new AccessDeniedException("OIDC error while validating token"); 526 } 527 528 return claims; 529 } 530 531 /** 532 * Request the userInfo using the user info end point and an access token 533 * @param accessToken the access token to retrieve the user info 534 * @return a representation of the user info from the scope requested with the token 535 * @throws IOException if an error occurred while contacting the end point 536 * @throws ParseException if an error occurred while parsing the end point answer 537 */ 538 protected UserInfo getUserInfo(AccessToken accessToken) throws IOException, ParseException 539 { 540 HTTPResponse httpResponse = new UserInfoRequest(_userInfoEndpoint, accessToken).toHTTPRequest().send(); 541 UserInfoResponse userInfoResponse = UserInfoResponse.parse(httpResponse); 542 543 if (userInfoResponse.indicatesSuccess()) 544 { 545 return userInfoResponse.toSuccessResponse().getUserInfo(); 546 } 547 else 548 { 549 String error = userInfoResponse.toErrorResponse().getErrorObject().toJSONObject().toJSONString(); 550 getLogger().error("Failed to retrieve the user info. The server indicate the following error :\n" + error); 551 throw new AccessDeniedException("Failed to retrieve the user info. The server indicate the following error :\n" + error); 552 } 553 } 554 555 /** 556 * Compute a user identity based on the user info 557 * @param userInfo the user info 558 * @param request the original request 559 * @param redirector the redirector to use if need be 560 * @return the identified user info or null if no matching user were found 561 * @throws NotUniqueUserException if multiple user matched 562 */ 563 protected UserIdentity getUserIdentity(UserInfo userInfo, Request request, Redirector redirector) throws NotUniqueUserException 564 { 565 // get the user email 566 String login = userInfo.getEmailAddress(); 567 if (login == null) 568 { 569 getLogger().error("Email not found, connection canceled "); 570 throw new AccessDeniedException("Email not found, connection canceled"); 571 } 572 573 // create a UserIdentity from the email 574 UserPopulation userPopulation = _getPopulation(request); 575 UserIdentity user = _getUserIdentity(login, userPopulation); 576 577 // If we found a UserIdentity, we return it 578 if (user != null) 579 { 580 return user; 581 } 582 583 // If not, we are going to pre-sign-up the user with its email, firstname and lastname 584 String firstName = userInfo.getGivenName(); 585 String lastName = userInfo.getFamilyName(); 586 if (firstName == null || lastName == null) 587 { 588 getLogger().info("The fields could not be pre-filled"); 589 } 590 591 // We call the temporarySignup method from the endOfSignupProcess, which will do nothing if it is in the CMS and temporary sign the user up if it is the site 592 _endOfAuthenticationProcess.unexistingUser(login, firstName, lastName, userPopulation, redirector, request); 593 594 return null; 595 } 596 597 private UserIdentity _getUserIdentity(String login, UserPopulation userPopulation) throws NotUniqueUserException 598 { 599 User user = null; 600 601 for (UserDirectory userDirectory : userPopulation.getUserDirectories()) 602 { 603 user = userDirectory.getUser(login); 604 605 if (user == null) 606 { 607 // Try to get user by email 608 user = userDirectory.getUserByEmail(login); 609 } 610 611 if (user != null) 612 { 613 return user.getIdentity(); 614 } 615 } 616 617 return null; 618 } 619}