001/* 002 * Copyright 2023 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.oauth; 017 018import java.io.IOException; 019import java.net.URI; 020import java.time.ZonedDateTime; 021import java.util.HashSet; 022import java.util.Map; 023import java.util.Optional; 024import java.util.Set; 025 026import org.apache.avalon.framework.configuration.Configurable; 027import org.apache.avalon.framework.configuration.Configuration; 028import org.apache.avalon.framework.configuration.ConfigurationException; 029import org.apache.avalon.framework.context.Context; 030import org.apache.avalon.framework.context.ContextException; 031import org.apache.avalon.framework.context.Contextualizable; 032import org.apache.avalon.framework.service.ServiceException; 033import org.apache.avalon.framework.service.ServiceManager; 034import org.apache.avalon.framework.service.Serviceable; 035import org.apache.cocoon.ProcessingException; 036import org.apache.cocoon.components.ContextHelper; 037import org.apache.cocoon.environment.Redirector; 038import org.apache.cocoon.environment.Request; 039import org.apache.cocoon.environment.Session; 040import org.apache.commons.lang3.StringUtils; 041import org.apache.commons.text.StringTokenizer; 042 043import org.ametys.core.util.DateUtils; 044import org.ametys.core.util.SessionAttributeProvider; 045import org.ametys.runtime.authentication.AccessDeniedException; 046import org.ametys.runtime.config.Config; 047import org.ametys.runtime.plugin.component.AbstractLogEnabled; 048import org.ametys.workspaces.extrausermgt.authentication.oauth.OAuthCallbackAction; 049 050import com.nimbusds.oauth2.sdk.AccessTokenResponse; 051import com.nimbusds.oauth2.sdk.AuthorizationGrant; 052import com.nimbusds.oauth2.sdk.AuthorizationRequest; 053import com.nimbusds.oauth2.sdk.AuthorizationRequest.Builder; 054import com.nimbusds.oauth2.sdk.ErrorObject; 055import com.nimbusds.oauth2.sdk.ParseException; 056import com.nimbusds.oauth2.sdk.RefreshTokenGrant; 057import com.nimbusds.oauth2.sdk.ResponseType; 058import com.nimbusds.oauth2.sdk.Scope; 059import com.nimbusds.oauth2.sdk.TokenRequest; 060import com.nimbusds.oauth2.sdk.TokenResponse; 061import com.nimbusds.oauth2.sdk.auth.ClientAuthentication; 062import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; 063import com.nimbusds.oauth2.sdk.auth.Secret; 064import com.nimbusds.oauth2.sdk.http.HTTPResponse; 065import com.nimbusds.oauth2.sdk.id.ClientID; 066import com.nimbusds.oauth2.sdk.id.State; 067import com.nimbusds.oauth2.sdk.token.AccessToken; 068import com.nimbusds.oauth2.sdk.token.RefreshToken; 069import com.nimbusds.oauth2.sdk.token.Tokens; 070 071import net.minidev.json.JSONObject; 072import net.minidev.json.JSONValue; 073 074/** 075 * OAuth provider definition to interact with a Nextcloud server. 076 * 077 * This could be a default OAuth provider definition except for the configuration 078 * part and the handling of the user id provided by Nextcloud as a custom parameter 079 * in the token response. 080 */ 081public class DefaultOauthProvider extends AbstractLogEnabled implements OAuthProvider, Configurable, Serviceable, Contextualizable 082{ 083 /** OAuth state session attribute */ 084 public static final String OAUTH_STATE_SESSION_ATTRIBUTE = "oauth.state"; 085 /** OAuth redirect URI to use after a successful token request */ 086 public static final String OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE = "oauth.redirect.uri"; 087 /** Oauth access token session attribute */ 088 public static final String OAUTH_ACCESS_TOKEN_SESSION_ATTRIBUTE = "oauth.access.token"; 089 /** Oauth access token expiration date session attribute */ 090 public static final String OAUTH_ACCESS_TOKEN_EXPIRATION_DATE_SESSION_ATTRIBUTE = "oauth.access.token.expiration.date"; 091 /** Oauth refresh token session attribute */ 092 public static final String OAUTH_REFRESH_TOKEN_SESSION_ATTRIBUTE = "oauth.refresh.token"; 093 /** Oauth custom parameter session attribute */ 094 public static final String OAUTH_CUSTOM_PARAMETER = "oauth.custom.parameter"; 095 private static final String __OAUTH_AUTHORIZATION_CALLBACK = "/_extra-user-management/oauth-callback"; 096 097 /** The provider id */ 098 protected String _id; 099 /** the oauth client id */ 100 protected ClientID _clientID; 101 /** the authentication to use when requesting a token */ 102 protected ClientAuthentication _auth; 103 /** the authorization endpoint URI */ 104 protected URI _authorizationEnpoint; 105 /** the token endpoint URI */ 106 protected URI _tokenEndpointURI; 107 /** the scope for the token */ 108 protected Scope _scope; 109 /** the list of custom parameters returned with the token that must be stored for later use*/ 110 protected Set<String> _customParameters; 111 private SessionAttributeProvider _sessionAttributeProvider; 112 113 private Set<State> _knownState = new HashSet<>(); 114 private Context _context; 115 116 public void contextualize(Context context) throws ContextException 117 { 118 _context = context; 119 } 120 121 public void service(ServiceManager manager) throws ServiceException 122 { 123 _sessionAttributeProvider = (SessionAttributeProvider) manager.lookup(SessionAttributeProvider.ROLE); 124 } 125 126 public void configure(Configuration configuration) throws ConfigurationException 127 { 128 // Provider id 129 _id = configuration.getAttribute("id"); 130 131 // authentication 132 _clientID = new ClientID(_getConfigValue(configuration.getChild("clientId"))); 133 _auth = new ClientSecretBasic(_clientID, new Secret(_getConfigValue(configuration.getChild("secret")))); 134 135 // endpoints 136 String baseURL = _getConfigValue(configuration.getChild("baseURL")); 137 if (StringUtils.isNotEmpty(baseURL)) 138 { 139 _authorizationEnpoint = URI.create(baseURL + _getConfigValue(configuration.getChild("authorizationEndpointPath"))); 140 _tokenEndpointURI = URI.create(baseURL + _getConfigValue(configuration.getChild("tokenEndpointPath"))); 141 } 142 else 143 { 144 _authorizationEnpoint = URI.create(_getConfigValue(configuration.getChild("authorizationEndpointURI"))); 145 _tokenEndpointURI = URI.create(_getConfigValue(configuration.getChild("tokenEndpointURI"))); 146 } 147 148 // scope 149 _scope = new Scope(); 150 String scopes = _getConfigValue(configuration.getChild("scopes")); 151 for (String scope : StringTokenizer.getCSVInstance(scopes).getTokenList()) 152 { 153 _scope.add(scope); 154 } 155 156 // token response custom parameters 157 String customParams = _getConfigValue(configuration.getChild("customParams")); 158 _customParameters = new HashSet<>(StringTokenizer.getCSVInstance(customParams).getTokenList()); 159 } 160 161 /** 162 * Get the value of a configuration element either by retrieving the associated value in 163 * the application config or directly the configuration element value 164 * @param cfg a configuration element. Can not be {@code null} 165 * @return the value or null if the value is not present 166 */ 167 protected String _getConfigValue(Configuration cfg) 168 { 169 if (cfg.getAttributeAsBoolean("config", false)) 170 { 171 // get the config value associated or null if it doesn't exist 172 return Config.getInstance().getValue(cfg.getValue("")); 173 } 174 else 175 { 176 return cfg.getValue(null); 177 } 178 } 179 180 public ClientID getClientID() 181 { 182 return _clientID; 183 } 184 185 public URI getAuthorizationEndpointURI() 186 { 187 return _authorizationEnpoint; 188 } 189 190 public URI getTokenEndpointURI() 191 { 192 return _tokenEndpointURI; 193 } 194 195 public String getId() 196 { 197 return _id; 198 } 199 200 public ClientAuthentication getClientAuthentication() 201 { 202 return _auth; 203 } 204 205 public Scope getScope() 206 { 207 return _scope; 208 } 209 210 public Set<String> getCustomParametersName() 211 { 212 return _customParameters; 213 } 214 215 public boolean isKnownState(State state) 216 { 217 // actually removes it, a state should not be used multiple times 218 return _knownState.remove(state); 219 } 220 221 public Optional<AccessToken> getStoredAccessToken() 222 { 223 // Get stored attribute 224 Optional<AccessToken> accessToken = _sessionAttributeProvider.getSessionAttribute(OAUTH_ACCESS_TOKEN_SESSION_ATTRIBUTE + "$" + _id) 225 // try to parse it or ignore it 226 .map(s -> { 227 try 228 { 229 return AccessToken.parse((JSONObject) JSONValue.parseWithException((String) s)); 230 } 231 catch (ParseException | net.minidev.json.parser.ParseException e) 232 { 233 getLogger().warn("Failed to parse the stored access token for provider {}. The token is ignored", _id, e); 234 return null; 235 } 236 }); 237 238 if (accessToken.isEmpty()) 239 { 240 return accessToken; 241 } 242 243 // Check if the token is still valid 244 Optional<ZonedDateTime> expirationDate = _sessionAttributeProvider.getSessionAttribute(OAUTH_ACCESS_TOKEN_EXPIRATION_DATE_SESSION_ATTRIBUTE + "$" + _id) 245 .map(str -> DateUtils.parseZonedDateTime((String) str)); 246 247 if (expirationDate.isEmpty()) 248 { 249 // There is a stored token but no expiration date. This should never happens. Ignore all. 250 getLogger().warn("A token is stored in session for provider {} but the token has no expiration date or an invalid one. The token is ignored.", _id); 251 return Optional.empty(); 252 } 253 254 if (ZonedDateTime.now().isBefore(expirationDate.get())) 255 { 256 return accessToken; 257 } 258 259 // The token is expired. We try to get a new one silently with the refresh token 260 return _refreshToken(); 261 } 262 263 private Optional<AccessToken> _refreshToken() 264 { 265 Optional<Object> tokenAttribute = _sessionAttributeProvider.getSessionAttribute(OAUTH_REFRESH_TOKEN_SESSION_ATTRIBUTE + "$" + _id); 266 Optional<RefreshToken> refreshToken = tokenAttribute.map(str -> { 267 try 268 { 269 return RefreshToken.parse((JSONObject) JSONValue.parseWithException((String) str)); 270 } 271 catch (ParseException | net.minidev.json.parser.ParseException e) 272 { 273 getLogger().warn("Failed to parse the stored refresh token for provider {}. The token is ignored", _id, e); 274 return null; 275 } 276 }); 277 278 if (refreshToken.isPresent()) 279 { 280 AuthorizationGrant refreshTokenGrant = new RefreshTokenGrant(refreshToken.get()); 281 282 try 283 { 284 return Optional.of(requestAccessToken(refreshTokenGrant)); 285 } 286 catch (AccessDeniedException | IOException e) 287 { 288 getLogger().warn("Failed to refresh access token for provider {}", _id, e); 289 return Optional.empty(); 290 } 291 } 292 return Optional.empty(); 293 } 294 295 public <T> Optional<T> getStoredCustomParameter(String parameter) 296 { 297 Optional<Object> sessionAttribute = _sessionAttributeProvider.getSessionAttribute(OAUTH_CUSTOM_PARAMETER + "$" + _id + "#" + parameter); 298 return sessionAttribute.map(str -> (T) JSONValue.parse((String) str)); 299 } 300 301 public Optional<AccessToken> getAccessToken(Redirector redirector) throws ProcessingException, IOException 302 { 303 Optional<AccessToken> accessToken = getStoredAccessToken(); 304 305 if (accessToken.isPresent()) 306 { 307 return accessToken; 308 } 309 310 AuthorizationRequest authRequest = buildAuthorizationCodeRequest(); 311 if (!redirector.hasRedirected()) 312 { 313 redirector.redirect(false, authRequest.toURI().toString()); 314 } 315 return Optional.empty(); 316 } 317 318 /** 319 * Build an authorization request based on the provide information. 320 * 321 * The request will use a {@code ResponseType#CODE} for the response type. 322 * 323 * @return an authorization request 324 */ 325 protected AuthorizationRequest buildAuthorizationCodeRequest() 326 { 327 // Save the link between state and provider to retrieve provider during callback 328 State state = _generateState(); 329 Builder authorizationRequestBuilder = new AuthorizationRequest.Builder(ResponseType.CODE, _clientID) 330 .endpointURI(_authorizationEnpoint) 331 .redirectionURI(_getRedirectUri()) 332 .state(state); 333 if (!_scope.isEmpty()) 334 { 335 authorizationRequestBuilder.scope(_scope); 336 } 337 return authorizationRequestBuilder.build(); 338 } 339 340 /** 341 * Generate a state for the given provider. 342 * The state will be stored in the provider to be able to retrieve the provider responding 343 * to a authorize request being processed in {@link OAuthCallbackAction}. 344 * We also store the state in session to unsure that the response is linked to the current session. 345 * 346 * @return the newly generated state 347 */ 348 protected State _generateState() 349 { 350 State state = new State(); 351 while (!_knownState.add(state)) 352 { 353 // if the state already existed, generate a new one 354 state = new State(); 355 } 356 Request request = ContextHelper.getRequest(_context); 357 Session session = request.getSession(); 358 // only one state is required by session as you can't go through multiple authorization process at the same time 359 session.setAttribute(OAUTH_STATE_SESSION_ATTRIBUTE, state); 360 return state; 361 } 362 363 private URI _getRedirectUri() 364 { 365 // Before returning the standard redirect URI we store the current 366 // request URI in session. This way, the standard redirect will be 367 // able to redirect to the current request making it transparent to 368 // the user. 369 Request request = ContextHelper.getRequest(_context); 370 _storeCurrentRequestUriInSession(request); 371 return _buildAbsoluteURI(request, __OAUTH_AUTHORIZATION_CALLBACK); 372 } 373 374 private void _storeCurrentRequestUriInSession(Request request) 375 { 376 // creation of the actual redirect URI (The one we actually want to go back to) 377 StringBuilder actualRedirectUri = new StringBuilder(request.getRequestURI()); 378 String queryString = request.getQueryString(); 379 if (StringUtils.isNotEmpty(queryString)) 380 { 381 actualRedirectUri.append("?"); 382 actualRedirectUri.append(queryString); 383 } 384 385 // saving the actualRedirectUri to enable its use in "OIDCCallbackAction" 386 Session session = request.getSession(true); 387 session.setAttribute(OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE, actualRedirectUri.toString()); 388 } 389 390 private URI _buildAbsoluteURI(Request request, String path) 391 { 392 StringBuilder uriBuilder = new StringBuilder() 393 .append(request.getScheme()) 394 .append("://") 395 .append(request.getServerName()); 396 397 if (request.isSecure()) 398 { 399 if (request.getServerPort() != 443) 400 { 401 uriBuilder.append(":"); 402 uriBuilder.append(request.getServerPort()); 403 } 404 } 405 else 406 { 407 if (request.getServerPort() != 80) 408 { 409 uriBuilder.append(":"); 410 uriBuilder.append(request.getServerPort()); 411 } 412 } 413 414 uriBuilder.append(request.getContextPath()); 415 uriBuilder.append(path); 416 417 return URI.create(uriBuilder.toString()); 418 } 419 420 public AccessToken requestAccessToken(AuthorizationGrant authorizationGrant) throws IOException 421 { 422 TokenRequest tokenRequest = new TokenRequest(getTokenEndpointURI(), getClientAuthentication(), authorizationGrant, getScope()); 423 424 ZonedDateTime requestDate = ZonedDateTime.now(); 425 HTTPResponse httpResponse = tokenRequest.toHTTPRequest().send(); 426 return _getAccessTokenFromAuthorizationServerResponse(httpResponse, requestDate); 427 } 428 429 /** 430 * Parse the token response to get the token and store it before returning the newly acquired token 431 * @param httpResponse the response 432 * @param requestDate the date of the request 433 * @return the new token 434 * @throws AccessDeniedException if the response doesn't indicate success 435 */ 436 protected AccessToken _getAccessTokenFromAuthorizationServerResponse(HTTPResponse httpResponse, ZonedDateTime requestDate) 437 { 438 TokenResponse tokenResponse; 439 try 440 { 441 tokenResponse = TokenResponse.parse(httpResponse); 442 } 443 catch (ParseException e) 444 { 445 getLogger().error("Token response is invalid. Access will be denied", e); 446 throw new AccessDeniedException("Oauth authorization request failed with invalid response. The response was not parseable"); 447 } 448 if (tokenResponse.indicatesSuccess()) 449 { 450 AccessTokenResponse successResponse = tokenResponse.toSuccessResponse(); 451 452 return storeTokens(successResponse, requestDate); 453 } 454 else 455 { 456 ErrorObject errorObject = tokenResponse.toErrorResponse().getErrorObject(); 457 throw new AccessDeniedException("Oauth authorization request failed with http status '" + errorObject.getHTTPStatusCode() 458 + "', code '" + errorObject.getCode() 459 + "' and description '" + errorObject.getDescription() + "'."); 460 } 461 } 462 463 /** 464 * Store the tokens information for later uses. 465 * @param response the tokens returned by the successful token request 466 * @param requestDate the request date to compute the expiration date 467 * @return the access token 468 */ 469 protected AccessToken storeTokens(AccessTokenResponse response, ZonedDateTime requestDate) 470 { 471 Request request = ContextHelper.getRequest(_context); 472 Session session = request.getSession(); 473 474 Tokens tokens = response.getTokens(); 475 476 477 AccessToken accessToken = tokens.getAccessToken(); 478 session.setAttribute(OAUTH_ACCESS_TOKEN_SESSION_ATTRIBUTE + "$" + _id, accessToken.toJSONObject().toJSONString()); 479 session.setAttribute(OAUTH_REFRESH_TOKEN_SESSION_ATTRIBUTE + "$" + _id, tokens.getRefreshToken().toJSONObject().toJSONString()); 480 if (accessToken.getLifetime() != 0) 481 { 482 session.setAttribute(OAUTH_ACCESS_TOKEN_EXPIRATION_DATE_SESSION_ATTRIBUTE + "$" + _id, DateUtils.zonedDateTimeToString(requestDate.plusSeconds(accessToken.getLifetime()))); 483 } 484 485 // Store custom parameters if any 486 487 Map<String, Object> customParameters = response.getCustomParameters(); 488 for (String paramName : getCustomParametersName()) 489 { 490 Object param = customParameters.get(paramName); 491 // Session attribute must be stored as string for possible serialization 492 session.setAttribute(OAUTH_CUSTOM_PARAMETER + "$" + _id + "#" + paramName, JSONValue.toJSONString(param)); 493 } 494 495 return accessToken; 496 } 497}