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 private URI _buildAbsoluteURI(Request request, String path) 324 { 325 StringBuilder uriBuilder = new StringBuilder() 326 .append(request.getScheme()) 327 .append("://") 328 .append(request.getServerName()); 329 330 if (request.isSecure()) 331 { 332 if (request.getServerPort() != 443) 333 { 334 uriBuilder.append(":"); 335 uriBuilder.append(request.getServerPort()); 336 } 337 } 338 else 339 { 340 if (request.getServerPort() != 80) 341 { 342 uriBuilder.append(":"); 343 uriBuilder.append(request.getServerPort()); 344 } 345 } 346 347 uriBuilder.append(request.getContextPath()); 348 uriBuilder.append(path); 349 350 return URI.create(uriBuilder.toString()); 351 } 352 353 /** 354 * Sign the user in by sending an authentication request to the issuer 355 * @param redirector The redirector 356 * @param redirectUri The redirect URI 357 * @param session The current session 358 * @throws ProcessingException If an error occurs 359 * @throws IOException If an error occurs 360 */ 361 protected void signIn(Redirector redirector, URI redirectUri, Session session) throws ProcessingException, IOException 362 { 363 // sign-in request: redirect the client through the actual authentication process 364 365 // creation of the state used to secure the process 366 State state = new State(); 367 session.setAttribute(STATE_SESSION_ATTRIBUTE, state); 368 369 // compose the request 370 AuthenticationRequest authenticationRequest = new AuthenticationRequest(_authUri, new ResponseType(ResponseType.Value.CODE), _scope, _clientID, redirectUri, state, null); 371 372 String authReqURI = authenticationRequest.toURI().toString(); 373 authReqURI += "&access_type=offline"; 374 375 redirector.redirect(false, authReqURI); 376 } 377 378 /** 379 * Checks the State parameter of the request to prevent CSRF attacks 380 * @throws AccessDeniedException If an error occurs 381 */ 382 protected void checkState() throws AccessDeniedException 383 { 384 Request request = ContextHelper.getRequest(_context); 385 Session session = request.getSession(true); 386 String storedState = session.getAttribute(STATE_SESSION_ATTRIBUTE).toString(); 387 String stateRequest = request.getParameter("state"); 388 389 if (!storedState.equals(stateRequest)) 390 { 391 getLogger().error("OIDC state mismatch. Method checkState of AbstractOIDCCredentialProvider"); 392 throw new AccessDeniedException("OIDC state mismatch"); 393 } 394 395 session.setAttribute(STATE_SESSION_ATTRIBUTE, null); 396 } 397 398 /** 399 * Request the tokens (ID token and Access token) 400 * @param authCode The authorization code from the authentication request 401 * @param redirectUri The redirect URI 402 * @return The <code>OIDCTokens</code> that contains the access token and the id token 403 * @throws AccessDeniedException If an error occurs 404 */ 405 protected OIDCTokens requestToken(AuthorizationCode authCode, URI redirectUri) throws AccessDeniedException 406 { 407 // token request: checking if the user is known 408 TokenRequest tokenReq = new TokenRequest(_tokenEndpointUri, getClientAuthentication(), new AuthorizationCodeGrant(authCode, redirectUri)); 409 // sending request 410 HTTPResponse tokenHTTPResp = null; 411 try 412 { 413 tokenHTTPResp = tokenReq.toHTTPRequest().send(); 414 } 415 catch (SerializeException | IOException e) 416 { 417 getLogger().error("OIDC token request failed ", e); 418 throw new AccessDeniedException("OIDC token request failed"); 419 } 420 421 // cast the HTTPResponse to TokenResponse 422 TokenResponse tokenResponse = null; 423 try 424 { 425 tokenResponse = OIDCTokenResponseParser.parse(tokenHTTPResp); 426 } 427 catch (ParseException e) 428 { 429 getLogger().error("OIDC token request result invalid ", e); 430 throw new AccessDeniedException("OIDC token request result invalid"); 431 } 432 433 if (tokenResponse instanceof TokenErrorResponse) 434 { 435 getLogger().error("OIDC token request invalid token response instance of TokenErrorResponse in method requestToken from AbstractOIDCCredentialProvider"); 436 throw new AccessDeniedException("OIDC token request result invalid"); 437 } 438 439 // get the tokens 440 OIDCTokenResponse accessTokenResponse = (OIDCTokenResponse) tokenResponse; 441 442 return accessTokenResponse.getOIDCTokens(); 443 } 444 445 /** 446 * Request the tokens using a refresh token 447 * @param clientAuth The client authentication 448 * @param refreshTokenGrant The refreshtokenGrant 449 * @return The <code>OIDCTokens</code> that contains the access token and the id token 450 * @throws AccessDeniedException If an error occurs 451 * @throws URISyntaxException If an error occurs 452 */ 453 protected OIDCTokens requestToken(ClientAuthentication clientAuth, AuthorizationGrant refreshTokenGrant) throws AccessDeniedException, URISyntaxException 454 { 455 // token request: checking if the user is known 456 TokenRequest tokenReq = new TokenRequest(_tokenEndpointUri, clientAuth, refreshTokenGrant); 457 // sending request 458 459 HTTPResponse tokenHTTPResp = null; 460 try 461 { 462 tokenHTTPResp = tokenReq.toHTTPRequest().send(); 463 } 464 catch (SerializeException | IOException e) 465 { 466 getLogger().error("OIDC token request failed ", e); 467 throw new AccessDeniedException("OIDC token request failed"); 468 } 469 470 // cast the HTTPResponse to TokenResponse 471 TokenResponse tokenResponse = null; 472 try 473 { 474 tokenResponse = OIDCTokenResponseParser.parse(tokenHTTPResp); 475 } 476 catch (ParseException e) 477 { 478 getLogger().error("OIDC token request result invalid ", e); 479 throw new AccessDeniedException("OIDC token request result invalid"); 480 } 481 482 if (tokenResponse instanceof TokenErrorResponse) 483 { 484 getLogger().error("OIDC token request result invalid: tokenResponse instance of TokenErrorResponse in method requestToken from AbstractOIDCCredentialProvider"); 485 throw new AccessDeniedException("OIDC token request result invalid"); 486 } 487 488 // get the tokens 489 OIDCTokenResponse accessTokenResponse = (OIDCTokenResponse) tokenResponse; 490 491 return accessTokenResponse.getOIDCTokens(); 492 } 493 494 /** 495 * Validate the id token from the token request 496 * @param idToken The id token from the token request 497 * @return The <code>IDTokenClaimsSet</code> that contains information on the connection such as the expiration time 498 * @throws AccessDeniedException If an error occurs 499 */ 500 protected IDTokenClaimsSet validateIdToken(JWT idToken) throws AccessDeniedException 501 { 502 JWSAlgorithm jwsAlg = JWSAlgorithm.RS256; 503 // create validator for signed ID tokens 504 IDTokenValidator validator = new IDTokenValidator(_iss, _clientID, jwsAlg, _jwkSetURL); 505 IDTokenClaimsSet claims; 506 507 try 508 { 509 claims = validator.validate(idToken, null); 510 } 511 catch (BadJOSEException e) 512 { 513 getLogger().error("OIDC invalid : issuer, clientId, jwsAlg or jwkSetURL", e); 514 throw new AccessDeniedException("OIDC invalid signature issuer, clientId, jwsAlg or jwkSetURL"); 515 } 516 catch (JOSEException e) 517 { 518 getLogger().error("OIDC error while validating token", e); 519 throw new AccessDeniedException("OIDC error while validating token"); 520 } 521 522 return claims; 523 } 524 525 /** 526 * Request the userInfo using the user info end point and an access token 527 * @param accessToken the access token to retrieve the user info 528 * @return a representation of the user info from the scope requested with the token 529 * @throws IOException if an error occurred while contacting the end point 530 * @throws ParseException if an error occurred while parsing the end point answer 531 */ 532 protected UserInfo getUserInfo(AccessToken accessToken) throws IOException, ParseException 533 { 534 HTTPResponse httpResponse = new UserInfoRequest(_userInfoEndpoint, accessToken).toHTTPRequest().send(); 535 UserInfoResponse userInfoResponse = UserInfoResponse.parse(httpResponse); 536 537 if (userInfoResponse.indicatesSuccess()) 538 { 539 return userInfoResponse.toSuccessResponse().getUserInfo(); 540 } 541 else 542 { 543 String error = userInfoResponse.toErrorResponse().getErrorObject().toJSONObject().toJSONString(); 544 getLogger().error("Failed to retrieve the user info. The server indicate the following error :\n" + error); 545 throw new AccessDeniedException("Failed to retrieve the user info. The server indicate the following error :\n" + error); 546 } 547 } 548 549 /** 550 * Compute a user identity based on the user info 551 * @param userInfo the user info 552 * @param request the original request 553 * @param redirector the redirector to use if need be 554 * @return the identified user info or null if no matching user were found 555 * @throws NotUniqueUserException if multiple user matched 556 */ 557 protected UserIdentity getUserIdentity(UserInfo userInfo, Request request, Redirector redirector) throws NotUniqueUserException 558 { 559 // get the user email 560 String login = userInfo.getEmailAddress(); 561 if (login != null) 562 { 563 getLogger().error("Email not found, connection canceled "); 564 throw new AccessDeniedException("Email not found, connection canceled"); 565 } 566 567 // create a UserIdentity from the email 568 UserPopulation userPopulation = _getPopulation(request); 569 UserIdentity user = _getUserIdentity(login, userPopulation); 570 571 // If we found a UserIdentity, we return it 572 if (user != null) 573 { 574 return user; 575 } 576 577 // If not, we are going to pre-sign-up the user with its email, firstname and lastname 578 String firstName = userInfo.getGivenName(); 579 String lastName = userInfo.getFamilyName(); 580 if (firstName == null || lastName == null) 581 { 582 getLogger().info("The fields could not be pre-filled"); 583 } 584 585 // 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 586 _endOfAuthenticationProcess.unexistingUser(login, firstName, lastName, userPopulation, redirector, request); 587 588 return null; 589 } 590 591 private UserIdentity _getUserIdentity(String login, UserPopulation userPopulation) throws NotUniqueUserException 592 { 593 User user = null; 594 595 for (UserDirectory userDirectory : userPopulation.getUserDirectories()) 596 { 597 user = userDirectory.getUser(login); 598 599 if (user == null) 600 { 601 // Try to get user by email 602 user = userDirectory.getUserByEmail(login); 603 } 604 605 if (user != null) 606 { 607 return user.getIdentity(); 608 } 609 } 610 611 return null; 612 } 613}