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