|
| 1 | +/* |
| 2 | + * Copyright 2020-2023 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.server.authorization.authentication; |
| 17 | + |
| 18 | +import java.security.Principal; |
| 19 | +import java.util.Collections; |
| 20 | +import java.util.HashSet; |
| 21 | +import java.util.Set; |
| 22 | +import java.util.function.Consumer; |
| 23 | + |
| 24 | +import org.apache.commons.logging.Log; |
| 25 | +import org.apache.commons.logging.LogFactory; |
| 26 | + |
| 27 | +import org.springframework.security.authentication.AuthenticationProvider; |
| 28 | +import org.springframework.security.core.Authentication; |
| 29 | +import org.springframework.security.core.AuthenticationException; |
| 30 | +import org.springframework.security.core.GrantedAuthority; |
| 31 | +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; |
| 32 | +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; |
| 33 | +import org.springframework.security.oauth2.core.OAuth2DeviceCode; |
| 34 | +import org.springframework.security.oauth2.core.OAuth2Error; |
| 35 | +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; |
| 36 | +import org.springframework.security.oauth2.core.OAuth2UserCode; |
| 37 | +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; |
| 38 | +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; |
| 39 | +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; |
| 40 | +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent; |
| 41 | +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; |
| 42 | +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; |
| 43 | +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; |
| 44 | +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; |
| 45 | +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; |
| 46 | +import org.springframework.util.Assert; |
| 47 | + |
| 48 | +/** |
| 49 | + * An {@link AuthenticationProvider} implementation for the OAuth 2.0 Authorization Consent |
| 50 | + * used in the Device Authorization Grant. |
| 51 | + * |
| 52 | + * @author Steve Riesenberg |
| 53 | + * @since 1.1 |
| 54 | + * @see OAuth2DeviceAuthorizationConsentAuthenticationToken |
| 55 | + * @see OAuth2AuthorizationConsent |
| 56 | + * @see OAuth2DeviceAuthorizationRequestAuthenticationProvider |
| 57 | + * @see OAuth2DeviceVerificationAuthenticationProvider |
| 58 | + * @see OAuth2DeviceCodeAuthenticationProvider |
| 59 | + * @see RegisteredClientRepository |
| 60 | + * @see OAuth2AuthorizationService |
| 61 | + * @see OAuth2AuthorizationConsentService |
| 62 | + */ |
| 63 | +public final class OAuth2DeviceAuthorizationConsentAuthenticationProvider implements AuthenticationProvider { |
| 64 | + |
| 65 | + private static final String DEFAULT_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1"; |
| 66 | + private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE); |
| 67 | + |
| 68 | + private final Log logger = LogFactory.getLog(getClass()); |
| 69 | + private final RegisteredClientRepository registeredClientRepository; |
| 70 | + private final OAuth2AuthorizationService authorizationService; |
| 71 | + private final OAuth2AuthorizationConsentService authorizationConsentService; |
| 72 | + private Consumer<OAuth2AuthorizationConsentAuthenticationContext> authorizationConsentCustomizer; |
| 73 | + |
| 74 | + /** |
| 75 | + * Constructs an {@code OAuth2DeviceAuthorizationConsentAuthenticationProvider} using the provided parameters. |
| 76 | + * |
| 77 | + * @param registeredClientRepository the repository of registered clients |
| 78 | + * @param authorizationService the authorization service |
| 79 | + * @param authorizationConsentService the authorization consent service |
| 80 | + */ |
| 81 | + public OAuth2DeviceAuthorizationConsentAuthenticationProvider( |
| 82 | + RegisteredClientRepository registeredClientRepository, |
| 83 | + OAuth2AuthorizationService authorizationService, |
| 84 | + OAuth2AuthorizationConsentService authorizationConsentService) { |
| 85 | + Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null"); |
| 86 | + Assert.notNull(authorizationService, "authorizationService cannot be null"); |
| 87 | + Assert.notNull(authorizationConsentService, "authorizationConsentService cannot be null"); |
| 88 | + this.registeredClientRepository = registeredClientRepository; |
| 89 | + this.authorizationService = authorizationService; |
| 90 | + this.authorizationConsentService = authorizationConsentService; |
| 91 | + } |
| 92 | + |
| 93 | + @Override |
| 94 | + public Authentication authenticate(Authentication authentication) throws AuthenticationException { |
| 95 | + OAuth2DeviceAuthorizationConsentAuthenticationToken deviceAuthorizationConsentAuthentication = |
| 96 | + (OAuth2DeviceAuthorizationConsentAuthenticationToken) authentication; |
| 97 | + |
| 98 | + OAuth2Authorization authorization = this.authorizationService.findByToken( |
| 99 | + deviceAuthorizationConsentAuthentication.getState(), STATE_TOKEN_TYPE); |
| 100 | + if (authorization == null) { |
| 101 | + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE); |
| 102 | + } |
| 103 | + |
| 104 | + if (this.logger.isTraceEnabled()) { |
| 105 | + this.logger.trace("Retrieved authorization with device authorization consent state"); |
| 106 | + } |
| 107 | + |
| 108 | + Authentication principal = (Authentication) deviceAuthorizationConsentAuthentication.getPrincipal(); |
| 109 | + |
| 110 | + RegisteredClient registeredClient = this.registeredClientRepository.findByClientId( |
| 111 | + deviceAuthorizationConsentAuthentication.getClientId()); |
| 112 | + if (registeredClient == null || !registeredClient.getId().equals(authorization.getRegisteredClientId())) { |
| 113 | + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID); |
| 114 | + } |
| 115 | + |
| 116 | + if (this.logger.isTraceEnabled()) { |
| 117 | + this.logger.trace("Retrieved registered client"); |
| 118 | + } |
| 119 | + |
| 120 | + OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute( |
| 121 | + OAuth2AuthorizationRequest.class.getName()); |
| 122 | + Set<String> requestedScopes = authorizationRequest.getScopes(); |
| 123 | + Set<String> authorizedScopes = deviceAuthorizationConsentAuthentication.getScopes() != null ? |
| 124 | + new HashSet<>(deviceAuthorizationConsentAuthentication.getScopes()) : |
| 125 | + new HashSet<>(); |
| 126 | + if (!requestedScopes.containsAll(authorizedScopes)) { |
| 127 | + throwError(OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE); |
| 128 | + } |
| 129 | + |
| 130 | + if (this.logger.isTraceEnabled()) { |
| 131 | + this.logger.trace("Validated device authorization consent request parameters"); |
| 132 | + } |
| 133 | + |
| 134 | + OAuth2AuthorizationConsent currentAuthorizationConsent = this.authorizationConsentService.findById( |
| 135 | + authorization.getRegisteredClientId(), principal.getName()); |
| 136 | + Set<String> currentAuthorizedScopes = currentAuthorizationConsent != null ? |
| 137 | + currentAuthorizationConsent.getScopes() : Collections.emptySet(); |
| 138 | + |
| 139 | + if (!currentAuthorizedScopes.isEmpty()) { |
| 140 | + for (String requestedScope : requestedScopes) { |
| 141 | + if (currentAuthorizedScopes.contains(requestedScope)) { |
| 142 | + authorizedScopes.add(requestedScope); |
| 143 | + } |
| 144 | + } |
| 145 | + } |
| 146 | + |
| 147 | + OAuth2AuthorizationConsent.Builder authorizationConsentBuilder; |
| 148 | + if (currentAuthorizationConsent != null) { |
| 149 | + if (this.logger.isTraceEnabled()) { |
| 150 | + this.logger.trace("Retrieved existing authorization consent"); |
| 151 | + } |
| 152 | + authorizationConsentBuilder = OAuth2AuthorizationConsent.from(currentAuthorizationConsent); |
| 153 | + } else { |
| 154 | + authorizationConsentBuilder = OAuth2AuthorizationConsent.withId( |
| 155 | + authorization.getRegisteredClientId(), principal.getName()); |
| 156 | + } |
| 157 | + authorizedScopes.forEach(authorizationConsentBuilder::scope); |
| 158 | + |
| 159 | + if (this.authorizationConsentCustomizer != null) { |
| 160 | + // @formatter:off |
| 161 | + OAuth2AuthorizationConsentAuthenticationContext authorizationConsentAuthenticationContext = |
| 162 | + OAuth2AuthorizationConsentAuthenticationContext.with(deviceAuthorizationConsentAuthentication) |
| 163 | + .authorizationConsent(authorizationConsentBuilder) |
| 164 | + .registeredClient(registeredClient) |
| 165 | + .authorization(authorization) |
| 166 | + .authorizationRequest(authorizationRequest) |
| 167 | + .build(); |
| 168 | + // @formatter:on |
| 169 | + this.authorizationConsentCustomizer.accept(authorizationConsentAuthenticationContext); |
| 170 | + if (this.logger.isTraceEnabled()) { |
| 171 | + this.logger.trace("Customized authorization consent"); |
| 172 | + } |
| 173 | + } |
| 174 | + |
| 175 | + Set<GrantedAuthority> authorities = new HashSet<>(); |
| 176 | + authorizationConsentBuilder.authorities(authorities::addAll); |
| 177 | + |
| 178 | + OAuth2Authorization.Token<OAuth2DeviceCode> deviceCodeToken = authorization.getToken(OAuth2DeviceCode.class); |
| 179 | + OAuth2Authorization.Token<OAuth2UserCode> userCodeToken = authorization.getToken(OAuth2UserCode.class); |
| 180 | + |
| 181 | + if (authorities.isEmpty()) { |
| 182 | + // Authorization consent denied (or revoked) |
| 183 | + if (currentAuthorizationConsent != null) { |
| 184 | + this.authorizationConsentService.remove(currentAuthorizationConsent); |
| 185 | + if (this.logger.isTraceEnabled()) { |
| 186 | + this.logger.trace("Revoked authorization consent"); |
| 187 | + } |
| 188 | + } |
| 189 | + authorization = OAuth2Authorization.from(authorization) |
| 190 | + .token(deviceCodeToken.getToken(), metadata -> |
| 191 | + metadata.put(OAuth2Authorization.Token.ACCESS_DENIED_METADATA_NAME, true)) |
| 192 | + .token(userCodeToken.getToken(), metadata -> |
| 193 | + metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true)) |
| 194 | + .build(); |
| 195 | + this.authorizationService.save(authorization); |
| 196 | + if (this.logger.isTraceEnabled()) { |
| 197 | + this.logger.trace("Invalidated device code and user code because authorization consent was denied"); |
| 198 | + } |
| 199 | + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.ACCESS_DENIED); |
| 200 | + } |
| 201 | + |
| 202 | + OAuth2AuthorizationConsent authorizationConsent = authorizationConsentBuilder.build(); |
| 203 | + if (!authorizationConsent.equals(currentAuthorizationConsent)) { |
| 204 | + this.authorizationConsentService.save(authorizationConsent); |
| 205 | + if (this.logger.isTraceEnabled()) { |
| 206 | + this.logger.trace("Saved authorization consent"); |
| 207 | + } |
| 208 | + } |
| 209 | + |
| 210 | + OAuth2Authorization updatedAuthorization = OAuth2Authorization.from(authorization) |
| 211 | + .principalName(principal.getName()) |
| 212 | + .authorizedScopes(authorizedScopes) |
| 213 | + .token(deviceCodeToken.getToken(), metadata -> metadata |
| 214 | + .put(OAuth2Authorization.Token.ACCESS_GRANTED_METADATA_NAME, true)) |
| 215 | + .token(userCodeToken.getToken(), metadata -> metadata |
| 216 | + .put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true)) |
| 217 | + .attribute(Principal.class.getName(), principal) |
| 218 | + .attributes(attrs -> attrs.remove(OAuth2ParameterNames.STATE)) |
| 219 | + .build(); |
| 220 | + this.authorizationService.save(updatedAuthorization); |
| 221 | + |
| 222 | + if (this.logger.isTraceEnabled()) { |
| 223 | + this.logger.trace("Saved authorization with authorized scopes"); |
| 224 | + // This log is kept separate for consistency with other providers |
| 225 | + this.logger.trace("Authenticated authorization consent request"); |
| 226 | + } |
| 227 | + |
| 228 | + return new OAuth2DeviceVerificationAuthenticationToken(registeredClient.getClientId(), principal, |
| 229 | + deviceAuthorizationConsentAuthentication.getUserCode()); |
| 230 | + } |
| 231 | + |
| 232 | + @Override |
| 233 | + public boolean supports(Class<?> authentication) { |
| 234 | + return OAuth2DeviceAuthorizationConsentAuthenticationToken.class.isAssignableFrom(authentication); |
| 235 | + } |
| 236 | + |
| 237 | + /** |
| 238 | + * Sets the {@code Consumer} providing access to the {@link OAuth2AuthorizationConsentAuthenticationContext} |
| 239 | + * containing an {@link OAuth2AuthorizationConsent.Builder} and additional context information. |
| 240 | + * |
| 241 | + * <p> |
| 242 | + * The following context attributes are available: |
| 243 | + * <ul> |
| 244 | + * <li>The {@link OAuth2AuthorizationConsent.Builder} used to build the authorization consent |
| 245 | + * prior to {@link OAuth2AuthorizationConsentService#save(OAuth2AuthorizationConsent)}.</li> |
| 246 | + * <li>The {@link Authentication} of type |
| 247 | + * {@link OAuth2DeviceAuthorizationConsentAuthenticationToken}.</li> |
| 248 | + * <li>The {@link RegisteredClient} associated with the authorization request.</li> |
| 249 | + * <li>The {@link OAuth2Authorization} associated with the state token presented in the |
| 250 | + * authorization consent request.</li> |
| 251 | + * <li>The {@link OAuth2AuthorizationRequest} associated with the authorization consent request.</li> |
| 252 | + * </ul> |
| 253 | + * |
| 254 | + * @param authorizationConsentCustomizer the {@code Consumer} providing access to the |
| 255 | + * {@link OAuth2AuthorizationConsentAuthenticationContext} containing an {@link OAuth2AuthorizationConsent.Builder} |
| 256 | + */ |
| 257 | + public void setAuthorizationConsentCustomizer(Consumer<OAuth2AuthorizationConsentAuthenticationContext> authorizationConsentCustomizer) { |
| 258 | + Assert.notNull(authorizationConsentCustomizer, "authorizationConsentCustomizer cannot be null"); |
| 259 | + this.authorizationConsentCustomizer = authorizationConsentCustomizer; |
| 260 | + } |
| 261 | + |
| 262 | + private static void throwError(String errorCode, String parameterName) { |
| 263 | + OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, DEFAULT_ERROR_URI); |
| 264 | + throw new OAuth2AuthorizationException(error); |
| 265 | + } |
| 266 | + |
| 267 | +} |
0 commit comments