-
Notifications
You must be signed in to change notification settings - Fork 272
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1503 from drewwills/two-legged-oauth
feat: provide secure server-to-server access to uPortal REST APIs th…
- Loading branch information
Showing
4 changed files
with
255 additions
and
81 deletions.
There are no files selected for viewing
81 changes: 0 additions & 81 deletions
81
...tal-api/uPortal-api-rest/src/main/java/org/apereo/portal/rest/OidcUserInfoController.java
This file was deleted.
Oops, something went wrong.
40 changes: 40 additions & 0 deletions
40
uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/rest/oauth/OAuthClient.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
/** | ||
* Licensed to Apereo under one or more contributor license agreements. See the NOTICE file | ||
* distributed with this work for additional information regarding copyright ownership. Apereo | ||
* licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use | ||
* this file except in compliance with the License. You may obtain a copy of the License at the | ||
* following location: | ||
* | ||
* <p>http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* <p>Unless required by applicable law or agreed to in writing, software distributed under the | ||
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either | ||
* express or implied. See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
package org.apereo.portal.rest.oauth; | ||
|
||
/** | ||
* Objects that implement this interface work with the <code>/uPortal/api/v5-5/oauth/token</code> | ||
* endpoint defined by the {@link OidcUserInfoController} to provide access tokens to authorized | ||
* clients. Every authorized client must have a bean in the <code>ApplicationContext</code> that | ||
* contains its metadata. The {@link OidcUserInfoController} will discover and use these beans | ||
* automatically. | ||
* | ||
* @since 5.5 | ||
*/ | ||
public interface OAuthClient { | ||
|
||
/** The OAuth <code>client_id</code>. */ | ||
String getClientId(); | ||
|
||
/** The OAuth <code>client_secret</code>. */ | ||
String getClientSecret(); | ||
|
||
/** | ||
* A successful HTTP request to the <code>/uPortal/api/v5-5/oauth/token</code> URI will receive | ||
* a valid OIDC Id token. This method specifies the portal user account that the Id token will | ||
* represent. | ||
*/ | ||
String getPortalUserAccount(); | ||
} |
214 changes: 214 additions & 0 deletions
214
...i/uPortal-api-rest/src/main/java/org/apereo/portal/rest/oauth/OidcUserInfoController.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,214 @@ | ||
/** | ||
* Licensed to Apereo under one or more contributor license agreements. See the NOTICE file | ||
* distributed with this work for additional information regarding copyright ownership. Apereo | ||
* licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use | ||
* this file except in compliance with the License. You may obtain a copy of the License at the | ||
* following location: | ||
* | ||
* <p>http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* <p>Unless required by applicable law or agreed to in writing, software distributed under the | ||
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either | ||
* express or implied. See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
package org.apereo.portal.rest.oauth; | ||
|
||
import java.util.Arrays; | ||
import java.util.Collections; | ||
import java.util.HashMap; | ||
import java.util.HashSet; | ||
import java.util.List; | ||
import java.util.Map; | ||
import java.util.Set; | ||
import java.util.function.Function; | ||
import java.util.stream.Collectors; | ||
import javax.annotation.PostConstruct; | ||
import javax.servlet.http.HttpServletRequest; | ||
import org.apache.commons.lang3.StringUtils; | ||
import org.apereo.portal.security.IPerson; | ||
import org.apereo.portal.security.IPersonManager; | ||
import org.apereo.portal.security.oauth.IdTokenFactory; | ||
import org.apereo.portal.services.PersonService; | ||
import org.slf4j.Logger; | ||
import org.slf4j.LoggerFactory; | ||
import org.springframework.beans.factory.annotation.Autowired; | ||
import org.springframework.beans.factory.annotation.Value; | ||
import org.springframework.http.HttpStatus; | ||
import org.springframework.http.MediaType; | ||
import org.springframework.http.ResponseEntity; | ||
import org.springframework.web.bind.annotation.PostMapping; | ||
import org.springframework.web.bind.annotation.RequestMapping; | ||
import org.springframework.web.bind.annotation.RequestMethod; | ||
import org.springframework.web.bind.annotation.RequestParam; | ||
import org.springframework.web.bind.annotation.RestController; | ||
|
||
/** | ||
* This controller provides endpoints through which clients can obtain an ID Token in a format that | ||
* is aligned with OpenID Connect (OIDC). <i>Clients</i> in this case are normally content objects | ||
* embedded within the portal page itself (e.g. portlets, soffits, or JS bundles). | ||
* | ||
* <p>It is critical to point out that this endpoint is not a compliant OIDC Identity Provider. It | ||
* does not implement OAuth Authentication Flows of any sort. | ||
* | ||
* <p>The value of this endpoint is not (therefore) that it brings support for OIDC to uPortal, but | ||
* that other modules and services designed to work with uPortal can implement security using | ||
* standard OIDC approaches. | ||
* | ||
* @since 5.1 | ||
*/ | ||
@RestController | ||
public class OidcUserInfoController { | ||
|
||
public static final String USERINFO_ENDPOINT_URI = "/v5-1/userinfo"; | ||
public static final String USERINFO_CONTENT_TYPE = "application/jwt"; | ||
public static final String TOKEN_ENDPOINT_URI = "/v5-5/oauth/token"; | ||
|
||
@Autowired private IPersonManager personManager; | ||
|
||
@Autowired private IdTokenFactory idTokenFactory; | ||
|
||
@Autowired private List<OAuthClient> clientList; | ||
|
||
private Map<String, OAuthClient> clientMap = Collections.emptyMap(); | ||
|
||
@Autowired private PersonService personService; | ||
|
||
@Value("${org.apereo.portal.security.oauth.IdTokenFactory.timeoutSeconds:300}") | ||
private long timeoutSeconds; | ||
|
||
private final Logger logger = LoggerFactory.getLogger(getClass()); | ||
|
||
@PostConstruct | ||
public void init() { | ||
final Map<String, OAuthClient> map = | ||
clientList | ||
.stream() | ||
.collect(Collectors.toMap(OAuthClient::getClientId, Function.identity())); | ||
this.clientMap = Collections.unmodifiableMap(map); | ||
} | ||
|
||
/** Obtain an OIDC Id token for the current user. */ | ||
@RequestMapping( | ||
value = USERINFO_ENDPOINT_URI, | ||
produces = USERINFO_CONTENT_TYPE, | ||
method = {RequestMethod.GET, RequestMethod.POST}) | ||
public String userInfo( | ||
HttpServletRequest request, | ||
@RequestParam(value = "claims", required = false) String claims, | ||
@RequestParam(value = "groups", required = false) String groups) { | ||
|
||
final IPerson person = personManager.getPerson(request); | ||
return createToken(person, claims, groups); | ||
} | ||
|
||
/** | ||
* Obtain an OIDC Id token for the specified <code>client_id</code>. At least one bean of type | ||
* {@link OAuthClient} is required to use this endpoint. | ||
* | ||
* <p>This token strategy supports Spring's <code>OAuth2RestTemplate</code> for accessing | ||
* uPortal REST APIs from external systems. Use a <code>ClientCredentialsResourceDetails</code> | ||
* with <code>clientAuthenticationScheme=AuthenticationScheme.form</code>, together with a | ||
* <code>ClientCredentialsAccessTokenProvider</code>. | ||
* | ||
* @since 5.5 | ||
*/ | ||
@PostMapping(value = TOKEN_ENDPOINT_URI, produces = MediaType.APPLICATION_JSON_VALUE) | ||
public ResponseEntity oauthToken( | ||
@RequestParam(value = "client_id") String clientId, | ||
@RequestParam(value = "client_secret") String clientSecret, | ||
@RequestParam( | ||
value = "grant_type", | ||
required = false, | ||
defaultValue = "client_credentials") | ||
String grantType, | ||
@RequestParam(value = "scope", required = false, defaultValue = "/all") String scope, | ||
@RequestParam(value = "claims", required = false) String claims, | ||
@RequestParam(value = "groups", required = false) String groups) { | ||
|
||
/* | ||
* NB: Several of this method's parameters are not consumed (yet) in any way. They are | ||
* defined to match a two-legged OAuth strategy and for future use. | ||
*/ | ||
|
||
final String msg = | ||
"Processing request for OAuth access token; client_id='{}', client_secret='{}', " | ||
+ "grant_type='{}', scope='{}', claims='{}', groups='{}'"; | ||
logger.debug( | ||
msg, | ||
clientId, | ||
StringUtils.repeat("*", clientSecret.length()), | ||
grantType, | ||
scope, | ||
claims, | ||
groups); | ||
|
||
// STEP 1: identify the client | ||
final OAuthClient oAuthClient = clientMap.get(clientId); | ||
if (oAuthClient == null) { | ||
return ResponseEntity.status(HttpStatus.FORBIDDEN) | ||
.body(Collections.singletonMap("message", "client_id not found")); | ||
} | ||
|
||
logger.debug( | ||
"Selected known OAuthClient with client_id='{}' for access token request", | ||
oAuthClient.getClientId()); | ||
|
||
// STEP 2: validate the client_secret | ||
if (!oAuthClient.getClientSecret().equals(clientSecret)) { | ||
return ResponseEntity.status(HttpStatus.FORBIDDEN) | ||
.body(Collections.singletonMap("message", "authentication failed")); | ||
} | ||
|
||
// STEP 3: obtain the specified user | ||
final IPerson person = personService.getPerson(oAuthClient.getPortalUserAccount()); | ||
if (person == null) { | ||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) | ||
.body( | ||
Collections.singletonMap( | ||
"message", | ||
"portal user account not found: " | ||
+ oAuthClient.getPortalUserAccount())); | ||
} | ||
|
||
logger.debug( | ||
"Selected portal Person with username='{}' for client_id='{}'", | ||
person.getUserName(), | ||
oAuthClient.getClientId()); | ||
|
||
// STEP 4: build a standard OAuth2 access token response | ||
final String token = createToken(person, claims, groups); | ||
final Map<String, Object> rslt = new HashMap<>(); | ||
rslt.put("access_token", token); | ||
rslt.put("token_type", "bearer"); | ||
rslt.put( | ||
"expires_in", | ||
timeoutSeconds > 2 ? timeoutSeconds - 2L /* fudge factor */ : timeoutSeconds); | ||
rslt.put("scope", scope); | ||
|
||
logger.debug( | ||
"Produced the following access token for client_id='{}': {}", | ||
oAuthClient.getClientId(), | ||
rslt); | ||
|
||
return ResponseEntity.ok(rslt); | ||
} | ||
|
||
private String createToken(IPerson person, String claims, String groups) { | ||
|
||
Set<String> claimsToInclude = null; | ||
if (claims != null) { | ||
String[] tokens = claims.split("[,]"); | ||
claimsToInclude = new HashSet<>(Arrays.asList(tokens)); | ||
} | ||
|
||
Set<String> groupsToInclude = null; | ||
if (groups != null) { | ||
String[] tokens = groups.split("[,]"); | ||
groupsToInclude = new HashSet<>(Arrays.asList(tokens)); | ||
} | ||
|
||
return idTokenFactory.createUserInfo( | ||
person.getUserName(), claimsToInclude, groupsToInclude); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters