/*******************************************************************************
* Cloud Foundry
* Copyright (c) [2009-2016] Pivotal Software, Inc. All Rights Reserved.
* <p>
* This product is licensed to you under the Apache License, Version 2.0 (the "License").
* You may not use this product except in compliance with the License.
* <p>
* This product includes a number of subcomponents with
* separate copyright notices and license terms. Your use of these
* subcomponents is subject to the terms and conditions of the
* subcomponent's license, as noted in the LICENSE file.
*******************************************************************************/
package org.cloudfoundry.identity.uaa.provider.oauth;
import com.fasterxml.jackson.core.type.TypeReference;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.cloudfoundry.identity.uaa.authentication.UaaAuthentication;
import org.cloudfoundry.identity.uaa.authentication.manager.ExternalGroupAuthorizationEvent;
import org.cloudfoundry.identity.uaa.authentication.manager.ExternalLoginAuthenticationManager;
import org.cloudfoundry.identity.uaa.authentication.manager.InvitedUserAuthenticatedEvent;
import org.cloudfoundry.identity.uaa.oauth.KeyInfo;
import org.cloudfoundry.identity.uaa.oauth.jwk.JsonWebKey;
import org.cloudfoundry.identity.uaa.oauth.jwk.JsonWebKeyHelper;
import org.cloudfoundry.identity.uaa.oauth.jwk.JsonWebKeySet;
import org.cloudfoundry.identity.uaa.oauth.jwt.ChainedSignatureVerifier;
import org.cloudfoundry.identity.uaa.oauth.jwt.Jwt;
import org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants;
import org.cloudfoundry.identity.uaa.provider.AbstractXOAuthIdentityProviderDefinition;
import org.cloudfoundry.identity.uaa.provider.IdentityProvider;
import org.cloudfoundry.identity.uaa.provider.IdentityProviderProvisioning;
import org.cloudfoundry.identity.uaa.provider.OIDCIdentityProviderDefinition;
import org.cloudfoundry.identity.uaa.provider.RawXOAuthIdentityProviderDefinition;
import org.cloudfoundry.identity.uaa.user.UaaUser;
import org.cloudfoundry.identity.uaa.user.UaaUserPrototype;
import org.cloudfoundry.identity.uaa.util.JsonUtils;
import org.cloudfoundry.identity.uaa.util.LinkedMaskingMultiValueMap;
import org.cloudfoundry.identity.uaa.util.RestTemplateFactory;
import org.cloudfoundry.identity.uaa.util.TokenValidation;
import org.cloudfoundry.identity.uaa.util.UaaStringUtils;
import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.common.exceptions.InvalidTokenException;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import static java.util.Collections.emptyList;
import static java.util.Optional.ofNullable;
import static org.cloudfoundry.identity.uaa.oauth.jwk.JsonWebKey.KeyType.MAC;
import static org.cloudfoundry.identity.uaa.oauth.jwk.JsonWebKey.KeyType.RSA;
import static org.cloudfoundry.identity.uaa.oauth.token.CompositeAccessToken.ID_TOKEN;
import static org.cloudfoundry.identity.uaa.provider.ExternalIdentityProviderDefinition.GROUP_ATTRIBUTE_NAME;
import static org.cloudfoundry.identity.uaa.provider.ExternalIdentityProviderDefinition.USER_NAME_ATTRIBUTE_NAME;
import static org.cloudfoundry.identity.uaa.util.TokenValidation.validate;
import static org.cloudfoundry.identity.uaa.util.UaaHttpRequestUtils.isAcceptedInvitationAuthentication;
public class XOAuthAuthenticationManager extends ExternalLoginAuthenticationManager<XOAuthAuthenticationManager.AuthenticationData> {
public static Log logger = LogFactory.getLog(XOAuthAuthenticationManager.class);
private final RestTemplateFactory restTemplateFactory;
public XOAuthAuthenticationManager(IdentityProviderProvisioning providerProvisioning, RestTemplateFactory restTemplateFactory) {
super(providerProvisioning);
this.restTemplateFactory = restTemplateFactory;
}
@Override
protected AuthenticationData getExternalAuthenticationDetails(Authentication authentication) {
XOAuthCodeToken codeToken = (XOAuthCodeToken) authentication;
setOrigin(codeToken.getOrigin());
IdentityProvider provider = getProviderProvisioning().retrieveByOrigin(getOrigin(), IdentityZoneHolder.get().getId());
if (provider != null && provider.getConfig() instanceof AbstractXOAuthIdentityProviderDefinition) {
AuthenticationData authenticationData = new AuthenticationData();
AbstractXOAuthIdentityProviderDefinition config = (AbstractXOAuthIdentityProviderDefinition) provider.getConfig();
Map<String, Object> claims = getClaimsFromToken(codeToken, config);
if (claims == null) {
return null;
}
authenticationData.setClaims(claims);
Map<String, Object> attributeMappings = config.getAttributeMappings();
String userNameAttributePrefix = (String) attributeMappings.get(USER_NAME_ATTRIBUTE_NAME);
String username;
if (StringUtils.hasText(userNameAttributePrefix)) {
username = (String) claims.get(userNameAttributePrefix);
logger.debug(String.format("Extracted username for claim: %s and username is: %s", userNameAttributePrefix, username));
} else {
String preferredUsername = "preferred_username";
username = (String) claims.get(preferredUsername);
logger.debug(String.format("Extracted username for claim: %s and username is: %s", preferredUsername, username));
}
authenticationData.setUsername(username);
Collection<String> groupWhiteList = config.getExternalGroupsWhitelist();
authenticationData.setAuthorities(extractXOAuthUserAuthorities(attributeMappings, claims, groupWhiteList));
ofNullable(attributeMappings).ifPresent(map -> authenticationData.setAttributeMappings(new HashMap<>(map)));
return authenticationData;
}
logger.debug("No identity provider found for origin:"+getOrigin()+" and zone:"+IdentityZoneHolder.get().getId());
return null;
}
@Override
protected void populateAuthenticationAttributes(UaaAuthentication authentication, Authentication request, AuthenticationData authenticationData) {
Map<String, Object> claims = authenticationData.getClaims();
if (claims != null) {
if (claims.get("amr") != null) {
if (authentication.getAuthenticationMethods()==null) {
authentication.setAuthenticationMethods(new HashSet<>((Collection<String>) claims.get("amr")));
} else {
authentication.getAuthenticationMethods().addAll((Collection<String>) claims.get("amr"));
}
}
Object acr = claims.get(ClaimConstants.ACR);
if (acr != null) {
if (acr instanceof Map) {
Map<String, Object> acrMap = (Map) acr;
Object values = acrMap.get("values");
if (values instanceof Collection) {
authentication.setAuthContextClassRef(new HashSet<>((Collection) values));
} else if (values instanceof String[]) {
authentication.setAuthContextClassRef(new HashSet<>(Arrays.asList((String[]) values)));
} else {
logger.debug(String.format("Unrecognized ACR claim[%s] for user_id: %s", values, authentication.getPrincipal().getId()));
}
} else if (acr instanceof String) {
authentication.setAuthContextClassRef(new HashSet(Arrays.asList((String) acr)));
} else {
logger.debug(String.format("Unrecognized ACR claim[%s] for user_id: %s", acr, authentication.getPrincipal().getId()));
}
}
MultiValueMap<String, String> userAttributes = new LinkedMultiValueMap<>();
logger.debug("Mapping XOauth custom attributes");
for (Map.Entry<String, Object> entry : authenticationData.getAttributeMappings().entrySet()) {
if (entry.getKey().startsWith(USER_ATTRIBUTE_PREFIX) && entry.getValue() != null) {
String key = entry.getKey().substring(USER_ATTRIBUTE_PREFIX.length());
Object values = claims.get(entry.getValue());
if (values != null) {
logger.debug(String.format("Mapped XOauth attribute %s to %s", key, values));
if (values instanceof List) {
List list = (List)values;
List<String> strings = (List<String>) list.stream()
.map(object -> Objects.toString(object, null))
.collect(Collectors.toList());
userAttributes.put(key, strings);
} else if (values instanceof String) {
userAttributes.put(key, Arrays.asList((String) values));
} else {
userAttributes.put(key, Arrays.asList(values.toString()));
}
}
}
}
authentication.setUserAttributes(userAttributes);
authentication.setExternalGroups(
ofNullable(
authenticationData.getAuthorities()
)
.orElse(emptyList())
.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet())
);
}
super.populateAuthenticationAttributes(authentication, request, authenticationData);
}
@Override
protected List<String> getExternalUserAuthorities(UserDetails request) {
return super.getExternalUserAuthorities(request);
}
@Override
protected UaaUser getUser(Authentication request, AuthenticationData authenticationData) {
if (authenticationData != null) {
Map<String, Object> claims = authenticationData.getClaims();
String username = authenticationData.getUsername();
String email = (String) claims.get("email");
if (email == null) {
email = generateEmailIfNull(username);
}
logger.debug(String.format("Returning user data for username:%s, email:%s", username, email));
return new UaaUser(
new UaaUserPrototype()
.withEmail(email)
.withGivenName((String) claims.get("given_name"))
.withFamilyName((String) claims.get("family_name"))
.withPhoneNumber((String) claims.get("phone_number"))
.withModified(new Date())
.withUsername(username)
.withPassword("")
.withAuthorities(authenticationData.getAuthorities())
.withCreated(new Date())
.withOrigin(getOrigin())
.withExternalId(null)
.withVerified(true)
.withZoneId(IdentityZoneHolder.get().getId())
.withSalt(null)
.withPasswordLastModified(null));
}
logger.debug("Authenticate data is missing, unable to return user");
return null;
}
protected List<? extends GrantedAuthority> extractXOAuthUserAuthorities(Map<String, Object> attributeMappings, Map<String, Object> claims, Collection<String> groupWhiteList) {
List<String> groupNames = new LinkedList<>();
if (attributeMappings.get(GROUP_ATTRIBUTE_NAME) instanceof String) {
groupNames.add((String) attributeMappings.get(GROUP_ATTRIBUTE_NAME));
} else if (attributeMappings.get(GROUP_ATTRIBUTE_NAME) instanceof Collection) {
groupNames.addAll((Collection) attributeMappings.get(GROUP_ATTRIBUTE_NAME));
}
logger.debug("Extracting XOauth group names:"+groupNames);
Set<String> scopes = new HashSet<>();
for (String g : groupNames) {
Object roles = claims.get(g);
if (roles instanceof String) {
scopes.addAll(Arrays.asList(((String) roles).split(",")));
} else if (roles instanceof Collection) {
scopes.addAll((Collection<? extends String>) roles);
}
}
logger.debug("Filtering XOauth scopes:"+scopes);
scopes = UaaStringUtils.retainAllMatches(scopes, groupWhiteList);
logger.debug("Filtered XOauth scopes:"+scopes);
List<XOAuthUserAuthority> authorities = new ArrayList<>();
for (String scope : scopes) {
authorities.add(new XOAuthUserAuthority(scope));
}
return authorities;
}
@Override
protected UaaUser userAuthenticated(Authentication request, UaaUser userFromRequest, UaaUser userFromDb) {
boolean userModified = false;
boolean is_invitation_acceptance = isAcceptedInvitationAuthentication();
String email = userFromRequest.getEmail();
logger.debug("XOAUTH user authenticated:"+email);
if (is_invitation_acceptance) {
String invitedUserId = (String) RequestContextHolder.currentRequestAttributes().getAttribute("user_id", RequestAttributes.SCOPE_SESSION);
logger.debug("XOAUTH user accepted invitation, user_id:"+invitedUserId);
userFromDb = getUserDatabase().retrieveUserById(invitedUserId);
if (email != null) {
if (!email.equalsIgnoreCase(userFromDb.getEmail())) {
throw new BadCredentialsException("OAuth User email mismatch. Authenticated email doesn't match invited email.");
}
}
publish(new InvitedUserAuthenticatedEvent(userFromDb));
userFromDb = getUserDatabase().retrieveUserById(invitedUserId);
}
//we must check and see if the email address has changed between authentications
if (request.getPrincipal() != null) {
if (haveUserAttributesChanged(userFromDb, userFromRequest)) {
logger.debug("User attributed have changed, updating them.");
userFromDb = userFromDb.modifyAttributes(email, userFromRequest.getGivenName(), userFromRequest.getFamilyName(), userFromRequest.getPhoneNumber()).modifyUsername(userFromRequest.getUsername());
userModified = true;
}
}
ExternalGroupAuthorizationEvent event = new ExternalGroupAuthorizationEvent(userFromDb, userModified, userFromRequest.getAuthorities(), true);
publish(event);
return getUserDatabase().retrieveUserById(userFromDb.getId());
}
@Override
protected boolean isAddNewShadowUser() {
if (!super.isAddNewShadowUser()) {
return false;
}
IdentityProvider<AbstractXOAuthIdentityProviderDefinition> provider = getProviderProvisioning().retrieveByOrigin(getOrigin(), IdentityZoneHolder.get().getId());
return provider.getConfig().isAddShadowUserOnLogin();
}
public RestTemplate getRestTemplate(AbstractXOAuthIdentityProviderDefinition config) {
return restTemplateFactory.getRestTemplate(config.isSkipSslValidation());
}
private String getResponseType(AbstractXOAuthIdentityProviderDefinition config) {
if (RawXOAuthIdentityProviderDefinition.class.isAssignableFrom(config.getClass())) {
return "token";
} else if (OIDCIdentityProviderDefinition.class.isAssignableFrom(config.getClass())) {
return "id_token";
} else {
throw new IllegalArgumentException("Unknown type for provider.");
}
}
protected Map<String, Object> getClaimsFromToken(XOAuthCodeToken codeToken, AbstractXOAuthIdentityProviderDefinition config) {
String idToken = getTokenFromCode(codeToken, config);
return getClaimsFromToken(idToken, config);
}
protected Map<String, Object> getClaimsFromToken(String idToken, AbstractXOAuthIdentityProviderDefinition config) {
logger.debug("Extracting claims from id_token");
if (idToken == null) {
logger.debug("id_token is null, no claims returned.");
return null;
}
JsonWebKeySet tokenKey = getTokenKeyFromOAuth(config);
logger.debug("Validating id_token");
TokenValidation validation = validate(idToken)
.checkSignature(new ChainedSignatureVerifier(tokenKey))
.checkIssuer((StringUtils.isEmpty(config.getIssuer()) ? config.getTokenUrl().toString() : config.getIssuer()))
.checkAudience(config.getRelyingPartyId())
.checkExpiry()
.throwIfInvalid();
logger.debug("Decoding id_token");
Jwt decodeIdToken = validation.getJwt();
logger.debug("Deserializing id_token claims");
return JsonUtils.readValue(decodeIdToken.getClaims(), new TypeReference<Map<String, Object>>() {});
}
private JsonWebKeySet<JsonWebKey> getTokenKeyFromOAuth(AbstractXOAuthIdentityProviderDefinition config) {
String tokenKey = config.getTokenKey();
if (StringUtils.hasText(tokenKey)) {
Map<String, Object> p = new HashMap<>();
p.put("value", tokenKey);
p.put("kty", KeyInfo.isAssymetricKey(tokenKey) ? RSA.name() : MAC.name());
logger.debug("Key configured, returning.");
return new JsonWebKeySet<>(Arrays.asList(new JsonWebKey(p)));
}
URL tokenKeyUrl = config.getTokenKeyUrl();
if (tokenKeyUrl == null || !StringUtils.hasText(tokenKeyUrl.toString())) {
return new JsonWebKeySet<>(Collections.emptyList());
}
MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
headers.add("Authorization", getClientAuthHeader(config));
headers.add("Accept", "application/json");
HttpEntity tokenKeyRequest = new HttpEntity<>(null, headers);
logger.debug("Fetching token keys from:"+tokenKeyUrl);
ResponseEntity<String> responseEntity = getRestTemplate(config).exchange(tokenKeyUrl.toString(), HttpMethod.GET, tokenKeyRequest, String.class);
logger.debug("Token key response:"+responseEntity.getStatusCode());
if (responseEntity.getStatusCode() == HttpStatus.OK) {
return JsonWebKeyHelper.deserialize(responseEntity.getBody());
} else {
throw new InvalidTokenException("Unable to fetch verification keys, status:" + responseEntity.getStatusCode());
}
}
private String getTokenFromCode(XOAuthCodeToken codeToken, AbstractXOAuthIdentityProviderDefinition config) {
if (StringUtils.hasText(codeToken.getIdToken()) && "id_token".equals(getResponseType(config))) {
logger.debug("XOauthCodeToken contains id_token, not exchanging code.");
return codeToken.getIdToken();
}
MultiValueMap<String, String> body = new LinkedMaskingMultiValueMap<>("code");
body.add("grant_type", "authorization_code");
body.add("response_type", getResponseType(config));
body.add("code", codeToken.getCode());
body.add("redirect_uri", codeToken.getRedirectUrl());
HttpHeaders headers = new HttpHeaders();
String clientAuthHeader = getClientAuthHeader(config);
headers.add("Authorization", clientAuthHeader);
headers.add("Accept", "application/json");
URI requestUri;
HttpEntity requestEntity = new HttpEntity<>(body, headers);
try {
requestUri = config.getTokenUrl().toURI();
} catch (URISyntaxException e) {
logger.error("Invalid URI configured:"+config.getTokenUrl(), e);
return null;
}
try {
logger.debug(String.format("Performing token exchange with url:%s and request:%s", requestUri, body));
// A configuration that skips SSL/TLS validation requires clobbering the rest template request factory
// setup by the bean initializer.
ResponseEntity<Map<String, String>> responseEntity =
getRestTemplate(config)
.exchange(requestUri,
HttpMethod.POST,
requestEntity,
new ParameterizedTypeReference<Map<String, String>>() {
}
);
logger.debug(String.format("Request completed with status:%s", responseEntity.getStatusCode()));
return responseEntity.getBody().get(ID_TOKEN);
} catch (HttpServerErrorException | HttpClientErrorException ex) {
throw ex;
}
}
private String getClientAuthHeader(AbstractXOAuthIdentityProviderDefinition config) {
String clientAuth = new String(Base64.encodeBase64((config.getRelyingPartyId() + ":" + config.getRelyingPartySecret()).getBytes()));
return "Basic " + clientAuth;
}
protected static class AuthenticationData {
private Map<String, Object> claims;
private String username;
private List<? extends GrantedAuthority> authorities;
private Map<String, Object> attributeMappings;
public Map<String, Object> getAttributeMappings() {
return attributeMappings;
}
public void setAttributeMappings(Map<String, Object> attributeMappings) {
this.attributeMappings = attributeMappings;
}
public void setClaims(Map<String, Object> claims) {
this.claims = claims;
}
public Map<String, Object> getClaims() {
return claims;
}
public void setUsername(String username) {
this.username = username;
}
public String getUsername() {
return username;
}
public List<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
public void setAuthorities(List<? extends GrantedAuthority> authorities) {
this.authorities = authorities;
}
}
}