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 @SuppressWarnings("unchecked") 296 public <T> Optional<T> getStoredCustomParameter(String parameter) 297 { 298 Optional<Object> sessionAttribute = _sessionAttributeProvider.getSessionAttribute(OAUTH_CUSTOM_PARAMETER + "$" + _id + "#" + parameter); 299 return sessionAttribute.map(str -> (T) JSONValue.parse((String) str)); 300 } 301 302 public Optional<AccessToken> getAccessToken(Redirector redirector) throws ProcessingException, IOException 303 { 304 Optional<AccessToken> accessToken = getStoredAccessToken(); 305 306 if (accessToken.isPresent()) 307 { 308 return accessToken; 309 } 310 311 AuthorizationRequest authRequest = buildAuthorizationCodeRequest(); 312 if (!redirector.hasRedirected()) 313 { 314 redirector.redirect(false, authRequest.toURI().toString()); 315 } 316 return Optional.empty(); 317 } 318 319 /** 320 * Build an authorization request based on the provide information. 321 * 322 * The request will use a {@code ResponseType#CODE} for the response type. 323 * 324 * @return an authorization request 325 */ 326 protected AuthorizationRequest buildAuthorizationCodeRequest() 327 { 328 // Save the link between state and provider to retrieve provider during callback 329 State state = _generateState(); 330 Builder authorizationRequestBuilder = new AuthorizationRequest.Builder(ResponseType.CODE, _clientID) 331 .endpointURI(_authorizationEnpoint) 332 .redirectionURI(_getRedirectUri()) 333 .state(state); 334 if (!_scope.isEmpty()) 335 { 336 authorizationRequestBuilder.scope(_scope); 337 } 338 return authorizationRequestBuilder.build(); 339 } 340 341 /** 342 * Generate a state for the given provider. 343 * The state will be stored in the provider to be able to retrieve the provider responding 344 * to a authorize request being processed in {@link OAuthCallbackAction}. 345 * We also store the state in session to unsure that the response is linked to the current session. 346 * 347 * @return the newly generated state 348 */ 349 protected State _generateState() 350 { 351 State state = new State(); 352 while (!_knownState.add(state)) 353 { 354 // if the state already existed, generate a new one 355 state = new State(); 356 } 357 Request request = ContextHelper.getRequest(_context); 358 Session session = request.getSession(); 359 // only one state is required by session as you can't go through multiple authorization process at the same time 360 session.setAttribute(OAUTH_STATE_SESSION_ATTRIBUTE, state); 361 return state; 362 } 363 364 private URI _getRedirectUri() 365 { 366 // Before returning the standard redirect URI we store the current 367 // request URI in session. This way, the standard redirect will be 368 // able to redirect to the current request making it transparent to 369 // the user. 370 Request request = ContextHelper.getRequest(_context); 371 _storeCurrentRequestUriInSession(request); 372 return _buildAbsoluteURI(request, __OAUTH_AUTHORIZATION_CALLBACK); 373 } 374 375 private void _storeCurrentRequestUriInSession(Request request) 376 { 377 // creation of the actual redirect URI (The one we actually want to go back to) 378 StringBuilder actualRedirectUri = new StringBuilder(request.getRequestURI()); 379 String queryString = request.getQueryString(); 380 if (StringUtils.isNotEmpty(queryString)) 381 { 382 actualRedirectUri.append("?"); 383 actualRedirectUri.append(queryString); 384 } 385 386 // saving the actualRedirectUri to enable its use in "OIDCCallbackAction" 387 Session session = request.getSession(true); 388 session.setAttribute(OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE, actualRedirectUri.toString()); 389 } 390 391 private URI _buildAbsoluteURI(Request request, String path) 392 { 393 StringBuilder uriBuilder = new StringBuilder() 394 .append(request.getScheme()) 395 .append("://") 396 .append(request.getServerName()); 397 398 if (request.isSecure()) 399 { 400 if (request.getServerPort() != 443) 401 { 402 uriBuilder.append(":"); 403 uriBuilder.append(request.getServerPort()); 404 } 405 } 406 else 407 { 408 if (request.getServerPort() != 80) 409 { 410 uriBuilder.append(":"); 411 uriBuilder.append(request.getServerPort()); 412 } 413 } 414 415 uriBuilder.append(request.getContextPath()); 416 uriBuilder.append(path); 417 418 return URI.create(uriBuilder.toString()); 419 } 420 421 public AccessToken requestAccessToken(AuthorizationGrant authorizationGrant) throws IOException 422 { 423 TokenRequest tokenRequest = new TokenRequest(getTokenEndpointURI(), getClientAuthentication(), authorizationGrant, getScope()); 424 425 ZonedDateTime requestDate = ZonedDateTime.now(); 426 HTTPResponse httpResponse = tokenRequest.toHTTPRequest().send(); 427 return _getAccessTokenFromAuthorizationServerResponse(httpResponse, requestDate); 428 } 429 430 /** 431 * Parse the token response to get the token and store it before returning the newly acquired token 432 * @param httpResponse the response 433 * @param requestDate the date of the request 434 * @return the new token 435 * @throws AccessDeniedException if the response doesn't indicate success 436 */ 437 protected AccessToken _getAccessTokenFromAuthorizationServerResponse(HTTPResponse httpResponse, ZonedDateTime requestDate) 438 { 439 TokenResponse tokenResponse; 440 try 441 { 442 tokenResponse = TokenResponse.parse(httpResponse); 443 } 444 catch (ParseException e) 445 { 446 getLogger().error("Token response is invalid. Access will be denied", e); 447 throw new AccessDeniedException("Oauth authorization request failed with invalid response. The response was not parseable"); 448 } 449 if (tokenResponse.indicatesSuccess()) 450 { 451 AccessTokenResponse successResponse = tokenResponse.toSuccessResponse(); 452 453 return storeTokens(successResponse, requestDate); 454 } 455 else 456 { 457 ErrorObject errorObject = tokenResponse.toErrorResponse().getErrorObject(); 458 throw new AccessDeniedException("Oauth authorization request failed with http status '" + errorObject.getHTTPStatusCode() 459 + "', code '" + errorObject.getCode() 460 + "' and description '" + errorObject.getDescription() + "'."); 461 } 462 } 463 464 /** 465 * Store the tokens information for later uses. 466 * @param response the tokens returned by the successful token request 467 * @param requestDate the request date to compute the expiration date 468 * @return the access token 469 */ 470 protected AccessToken storeTokens(AccessTokenResponse response, ZonedDateTime requestDate) 471 { 472 Request request = ContextHelper.getRequest(_context); 473 Session session = request.getSession(); 474 475 Tokens tokens = response.getTokens(); 476 477 478 AccessToken accessToken = tokens.getAccessToken(); 479 session.setAttribute(OAUTH_ACCESS_TOKEN_SESSION_ATTRIBUTE + "$" + _id, accessToken.toJSONObject().toJSONString()); 480 session.setAttribute(OAUTH_REFRESH_TOKEN_SESSION_ATTRIBUTE + "$" + _id, tokens.getRefreshToken().toJSONObject().toJSONString()); 481 if (accessToken.getLifetime() != 0) 482 { 483 session.setAttribute(OAUTH_ACCESS_TOKEN_EXPIRATION_DATE_SESSION_ATTRIBUTE + "$" + _id, DateUtils.zonedDateTimeToString(requestDate.plusSeconds(accessToken.getLifetime()))); 484 } 485 486 // Store custom parameters if any 487 488 Map<String, Object> customParameters = response.getCustomParameters(); 489 for (String paramName : getCustomParametersName()) 490 { 491 Object param = customParameters.get(paramName); 492 // Session attribute must be stored as string for possible serialization 493 session.setAttribute(OAUTH_CUSTOM_PARAMETER + "$" + _id + "#" + paramName, JSONValue.toJSONString(param)); 494 } 495 496 return accessToken; 497 } 498}