View Javadoc
1   /*
2    * Copyright 2002-2019 the original author or authors.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *      https://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  package org.springframework.security.oauth2.provider.endpoint;
17  
18  import org.springframework.security.oauth2.common.exceptions.InvalidGrantException;
19  import org.springframework.security.oauth2.common.exceptions.InvalidRequestException;
20  import org.springframework.security.oauth2.common.exceptions.OAuth2Exception;
21  import org.springframework.security.oauth2.common.exceptions.RedirectMismatchException;
22  import org.springframework.security.oauth2.provider.ClientDetails;
23  import org.springframework.util.Assert;
24  import org.springframework.util.MultiValueMap;
25  import org.springframework.util.StringUtils;
26  import org.springframework.web.util.UriComponents;
27  import org.springframework.web.util.UriComponentsBuilder;
28  
29  import java.util.Arrays;
30  import java.util.Collection;
31  import java.util.HashSet;
32  import java.util.Iterator;
33  import java.util.List;
34  import java.util.Set;
35  
36  /**
37   * Default implementation for a redirect resolver.
38   * 
39   * @author Ryan Heaton
40   * @author Dave Syer
41   */
42  public class DefaultRedirectResolver implements RedirectResolver {
43  
44  	private Collection<String> redirectGrantTypes = Arrays.asList("implicit", "authorization_code");
45  
46  	private boolean matchSubdomains = false;
47  
48  	private boolean matchPorts = true;
49  
50  	/**
51  	 * Flag to indicate that requested URIs will match if they are a subdomain of the registered value.
52  	 * 
53  	 * @param matchSubdomains the flag value to set (default true)
54  	 */
55  	public void setMatchSubdomains(boolean matchSubdomains) {
56  		this.matchSubdomains = matchSubdomains;
57  	}
58  
59  	/**
60  	 * Flag that enables/disables port matching between the requested redirect URI and the registered redirect URI(s).
61  	 *
62  	 * @param matchPorts true to enable port matching, false to disable (defaults to true)
63  	 */
64  	public void setMatchPorts(boolean matchPorts) {
65  		this.matchPorts = matchPorts;
66  	}
67  
68  	/**
69  	 * Grant types that are permitted to have a redirect uri.
70  	 * 
71  	 * @param redirectGrantTypes the redirect grant types to set
72  	 */
73  	public void setRedirectGrantTypes(Collection<String> redirectGrantTypes) {
74  		this.redirectGrantTypes = new HashSet<String>(redirectGrantTypes);
75  	}
76  
77  	public String resolveRedirect(String requestedRedirect, ClientDetails client) throws OAuth2Exception {
78  
79  		Set<String> authorizedGrantTypes = client.getAuthorizedGrantTypes();
80  		if (authorizedGrantTypes.isEmpty()) {
81  			throw new InvalidGrantException("A client must have at least one authorized grant type.");
82  		}
83  		if (!containsRedirectGrantType(authorizedGrantTypes)) {
84  			throw new InvalidGrantException(
85  					"A redirect_uri can only be used by implicit or authorization_code grant types.");
86  		}
87  
88  		Set<String> registeredRedirectUris = client.getRegisteredRedirectUri();
89  		if (registeredRedirectUris == null || registeredRedirectUris.isEmpty()) {
90  			throw new InvalidRequestException("At least one redirect_uri must be registered with the client.");
91  		}
92  		return obtainMatchingRedirect(registeredRedirectUris, requestedRedirect);
93  	}
94  
95  	/**
96  	 * @param grantTypes some grant types
97  	 * @return true if the supplied grant types includes one or more of the redirect types
98  	 */
99  	private boolean containsRedirectGrantType(Set<String> grantTypes) {
100 		for (String type : grantTypes) {
101 			if (redirectGrantTypes.contains(type)) {
102 				return true;
103 			}
104 		}
105 		return false;
106 	}
107 
108 	/**
109 	 * Whether the requested redirect URI "matches" the specified redirect URI. For a URL, this implementation tests if
110 	 * the user requested redirect starts with the registered redirect, so it would have the same host and root path if
111 	 * it is an HTTP URL. The port, userinfo, query params also matched. Request redirect uri path can include
112 	 * additional parameters which are ignored for the match
113 	 * <p>
114 	 * For other (non-URL) cases, such as for some implicit clients, the redirect_uri must be an exact match.
115 	 * 
116 	 * @param requestedRedirect The requested redirect URI.
117 	 * @param redirectUri The registered redirect URI.
118 	 * @return Whether the requested redirect URI "matches" the specified redirect URI.
119 	 */
120 	protected boolean redirectMatches(String requestedRedirect, String redirectUri) {
121 		UriComponents requestedRedirectUri = UriComponentsBuilder.fromUriString(requestedRedirect).build();
122 		UriComponents registeredRedirectUri = UriComponentsBuilder.fromUriString(redirectUri).build();
123 
124 		boolean schemeMatch = isEqual(registeredRedirectUri.getScheme(), requestedRedirectUri.getScheme());
125 		boolean userInfoMatch = isEqual(registeredRedirectUri.getUserInfo(), requestedRedirectUri.getUserInfo());
126 		boolean hostMatch = hostMatches(registeredRedirectUri.getHost(), requestedRedirectUri.getHost());
127 		boolean portMatch = matchPorts ? registeredRedirectUri.getPort() == requestedRedirectUri.getPort() : true;
128 		boolean pathMatch = isEqual(registeredRedirectUri.getPath(),
129 				StringUtils.cleanPath(requestedRedirectUri.getPath()));
130 		boolean queryParamMatch = matchQueryParams(registeredRedirectUri.getQueryParams(),
131 				requestedRedirectUri.getQueryParams());
132 
133 		return schemeMatch && userInfoMatch && hostMatch && portMatch && pathMatch && queryParamMatch;
134 	}
135 
136 
137 	/**
138 	 * Checks whether the registered redirect uri query params key and values contains match the requested set
139 	 *
140 	 * The requested redirect uri query params are allowed to contain additional params which will be retained
141 	 *
142 	 * @param registeredRedirectUriQueryParams
143 	 * @param requestedRedirectUriQueryParams
144 	 * @return whether the params match
145 	 */
146 	private boolean matchQueryParams(MultiValueMap<String, String> registeredRedirectUriQueryParams,
147 									 MultiValueMap<String, String> requestedRedirectUriQueryParams) {
148 
149 
150 		Iterator<String> iter = registeredRedirectUriQueryParams.keySet().iterator();
151 		while (iter.hasNext()) {
152 			String key = iter.next();
153 			List<String> registeredRedirectUriQueryParamsValues = registeredRedirectUriQueryParams.get(key);
154 			List<String> requestedRedirectUriQueryParamsValues = requestedRedirectUriQueryParams.get(key);
155 
156 			if (!registeredRedirectUriQueryParamsValues.equals(requestedRedirectUriQueryParamsValues)) {
157 				return false;
158 			}
159 		}
160 
161 		return true;
162 	}
163 
164 
165 
166 	/**
167 	 * Compares two strings but treats empty string or null equal
168 	 *
169 	 * @param str1
170 	 * @param str2
171 	 * @return true if strings are equal, false otherwise
172 	 */
173 	private boolean isEqual(String str1, String str2) {
174 		if (StringUtils.isEmpty(str1) && StringUtils.isEmpty(str2)) {
175 			return true;
176 		} else if (!StringUtils.isEmpty(str1)) {
177 			return str1.equals(str2);
178 		} else {
179 			return false;
180 		}
181 	}
182 
183 	/**
184 	 * Check if host matches the registered value.
185 	 * 
186 	 * @param registered the registered host. Can be null.
187 	 * @param requested the requested host. Can be null.
188 	 * @return true if they match
189 	 */
190 	protected boolean hostMatches(String registered, String requested) {
191 		if (matchSubdomains) {
192 			return isEqual(registered, requested) || (requested != null && requested.endsWith("." + registered));
193 		}
194 		return isEqual(registered, requested);
195 	}
196 
197 	/**
198 	 * Attempt to match one of the registered URIs to the that of the requested one.
199 	 * 
200 	 * @param redirectUris the set of the registered URIs to try and find a match. This cannot be null or empty.
201 	 * @param requestedRedirect the URI used as part of the request
202 	 * @return redirect uri
203 	 * @throws RedirectMismatchException if no match was found
204 	 */
205 	private String obtainMatchingRedirect(Set<String> redirectUris, String requestedRedirect) {
206 		Assert.notEmpty(redirectUris, "Redirect URIs cannot be empty");
207 
208 		if (redirectUris.size() == 1 && requestedRedirect == null) {
209 			return redirectUris.iterator().next();
210 		}
211 
212 		for (String redirectUri : redirectUris) {
213 			if (requestedRedirect != null && redirectMatches(requestedRedirect, redirectUri)) {
214 				// Initialize with the registered redirect-uri
215 				UriComponentsBuilder redirectUriBuilder = UriComponentsBuilder.fromUriString(redirectUri);
216 
217 				UriComponents requestedRedirectUri = UriComponentsBuilder.fromUriString(requestedRedirect).build();
218 
219 				if (this.matchSubdomains) {
220 					redirectUriBuilder.host(requestedRedirectUri.getHost());
221 				}
222 				if (!this.matchPorts) {
223 					redirectUriBuilder.port(requestedRedirectUri.getPort());
224 				}
225 				redirectUriBuilder.replaceQuery(requestedRedirectUri.getQuery());		// retain additional params (if any)
226 				redirectUriBuilder.fragment(null);
227 				return redirectUriBuilder.build().toUriString();
228 			}
229 		}
230 
231 		throw new RedirectMismatchException("Invalid redirect: " + requestedRedirect
232 				+ " does not match one of the registered values.");
233 	}
234 }