Skip to content

Commit 6f92c61

Browse files
joshuatcaseyJoshua Casey
authored and
Joshua Casey
committed
Prototype to remember user consent decisions
1 parent 3b09388 commit 6f92c61

File tree

4 files changed

+247
-33
lines changed

4 files changed

+247
-33
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright 2020 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.consent;
17+
18+
import org.springframework.util.Assert;
19+
20+
import java.util.HashSet;
21+
import java.util.Set;
22+
import java.util.function.Function;
23+
import java.util.stream.Collectors;
24+
25+
public class InMemoryUserConsentRepository implements UserConsentRepository {
26+
27+
private final Set<UserConsentRecord> userConsentRecords = new HashSet<>();
28+
29+
@Override
30+
public Set<UserConsentRecord> findBySubjectAndClientId(final String subject, final String clientId) {
31+
Assert.hasText(subject, "subject must have text");
32+
Assert.hasText(clientId, "clientId must have text");
33+
34+
return this.userConsentRecords
35+
.stream()
36+
.filter(record -> subject.equals(record.getSubject()))
37+
.filter(record -> clientId.equals(record.getClientId()))
38+
.filter(UserConsentRecord::isValid)
39+
.collect(Collectors.toSet());
40+
}
41+
42+
@Override
43+
public void saveAll(String subject, String clientId, Set<String> consentedScopes) {
44+
Assert.hasText(subject, "subject must have text");
45+
Assert.hasText(clientId, "clientId must have text");
46+
Assert.notNull(consentedScopes, "consentedScopes must not be null");
47+
48+
Function<String, UserConsentRecord> mapScopeToConsent = (scope) -> new UserConsentRecord(
49+
subject,
50+
clientId,
51+
scope);
52+
53+
// remove any older consent records
54+
this.revokeAll(subject, clientId, consentedScopes);
55+
this.userConsentRecords.addAll(consentedScopes.stream().map(mapScopeToConsent).collect(Collectors.toSet()));
56+
}
57+
58+
@Override
59+
public void revokeAll(String subject, String clientId, Set<String> revokedScopes) {
60+
Assert.hasText(subject, "subject must have text");
61+
Assert.hasText(clientId, "clientId must have text");
62+
Assert.notNull(revokedScopes, "revokedScopes must not be null");
63+
64+
revokedScopes.forEach(revokedScope -> this.revokeSingle(subject, clientId, revokedScope));
65+
}
66+
67+
private void revokeSingle(String subject, String clientId, String revokedScope) {
68+
this.userConsentRecords
69+
.stream()
70+
.filter(record -> subject.equals(record.getSubject()))
71+
.filter(record -> clientId.equals(record.getClientId()))
72+
.filter(record -> revokedScope.equals(record.getAuthorizedScope()))
73+
.findFirst()
74+
.ifPresent(this.userConsentRecords::remove);
75+
}
76+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
* Copyright 2020 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.consent;
17+
18+
import java.time.Duration;
19+
import java.time.Instant;
20+
import java.util.Objects;
21+
22+
public class UserConsentRecord {
23+
24+
private final String subject;
25+
private final String clientId;
26+
private final String authorizedScope;
27+
private final Instant consentGrantedTime;
28+
private final Duration lifetime;
29+
30+
public UserConsentRecord(
31+
final String subject,
32+
final String clientId,
33+
final String authorizedScope) {
34+
this.subject = subject;
35+
this.clientId = clientId;
36+
this.authorizedScope = authorizedScope;
37+
// TODO: consentGrantedTime should be passed in so that it truly reflects when the consent was granted
38+
this.consentGrantedTime = Instant.now();
39+
// TODO:
40+
this.lifetime = Duration.ofDays(7);
41+
}
42+
43+
public String getSubject() {
44+
return this.subject;
45+
}
46+
47+
public String getClientId() {
48+
return this.clientId;
49+
}
50+
51+
public String getAuthorizedScope() {
52+
return this.authorizedScope;
53+
}
54+
55+
public boolean isValid() {
56+
return Instant.now().isBefore(this.consentGrantedTime.plus(this.lifetime));
57+
}
58+
59+
@Override
60+
public boolean equals(Object o) {
61+
if (this == o) return true;
62+
if (!(o instanceof UserConsentRecord)) return false;
63+
UserConsentRecord that = (UserConsentRecord) o;
64+
return Objects.equals(subject, that.subject)
65+
&& Objects.equals(clientId, that.clientId)
66+
&& Objects.equals(authorizedScope, that.authorizedScope)
67+
&& Objects.equals(consentGrantedTime, that.consentGrantedTime)
68+
&& Objects.equals(lifetime, that.lifetime);
69+
}
70+
71+
@Override
72+
public int hashCode() {
73+
return Objects.hash(subject, clientId, authorizedScope, consentGrantedTime, lifetime);
74+
}
75+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright 2020 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.consent;
17+
18+
import java.util.Set;
19+
20+
public interface UserConsentRepository {
21+
22+
Set<UserConsentRecord> findBySubjectAndClientId(String subject, String clientId);
23+
24+
void saveAll(String subject, String clientId, Set<String> consentedScopes);
25+
26+
void revokeAll(String subject, String clientId, Set<String> revokedScopes);
27+
}

oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java

+69-33
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,6 @@
1515
*/
1616
package org.springframework.security.oauth2.server.authorization.web;
1717

18-
import java.io.IOException;
19-
import java.nio.charset.StandardCharsets;
20-
import java.security.Principal;
21-
import java.time.Instant;
22-
import java.time.temporal.ChronoUnit;
23-
import java.util.Arrays;
24-
import java.util.Base64;
25-
import java.util.Collections;
26-
import java.util.HashSet;
27-
import java.util.List;
28-
import java.util.Set;
29-
30-
import javax.servlet.FilterChain;
31-
import javax.servlet.ServletException;
32-
import javax.servlet.http.HttpServletRequest;
33-
import javax.servlet.http.HttpServletResponse;
34-
3518
import org.springframework.http.HttpMethod;
3619
import org.springframework.http.HttpStatus;
3720
import org.springframework.http.MediaType;
@@ -50,10 +33,13 @@
5033
import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
5134
import org.springframework.security.oauth2.core.oidc.OidcScopes;
5235
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
36+
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode;
5337
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
5438
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
5539
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
56-
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode;
40+
import org.springframework.security.oauth2.server.authorization.consent.InMemoryUserConsentRepository;
41+
import org.springframework.security.oauth2.server.authorization.consent.UserConsentRecord;
42+
import org.springframework.security.oauth2.server.authorization.consent.UserConsentRepository;
5743
import org.springframework.security.web.DefaultRedirectStrategy;
5844
import org.springframework.security.web.RedirectStrategy;
5945
import org.springframework.security.web.util.matcher.AndRequestMatcher;
@@ -68,6 +54,23 @@
6854
import org.springframework.web.filter.OncePerRequestFilter;
6955
import org.springframework.web.util.UriComponentsBuilder;
7056

57+
import javax.servlet.FilterChain;
58+
import javax.servlet.ServletException;
59+
import javax.servlet.http.HttpServletRequest;
60+
import javax.servlet.http.HttpServletResponse;
61+
import java.io.IOException;
62+
import java.nio.charset.StandardCharsets;
63+
import java.security.Principal;
64+
import java.time.Instant;
65+
import java.time.temporal.ChronoUnit;
66+
import java.util.Arrays;
67+
import java.util.Base64;
68+
import java.util.Collections;
69+
import java.util.HashSet;
70+
import java.util.List;
71+
import java.util.Set;
72+
import java.util.stream.Collectors;
73+
7174
/**
7275
* A {@code Filter} for the OAuth 2.0 Authorization Code Grant,
7376
* which handles the processing of the OAuth 2.0 Authorization Request.
@@ -99,6 +102,7 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
99102
private final StringKeyGenerator codeGenerator = new Base64StringKeyGenerator(Base64.getUrlEncoder().withoutPadding(), 96);
100103
private final StringKeyGenerator stateGenerator = new Base64StringKeyGenerator(Base64.getUrlEncoder());
101104
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
105+
private final UserConsentRepository userConsentRepository = new InMemoryUserConsentRepository();
102106

103107
/**
104108
* Constructs an {@code OAuth2AuthorizationEndpointFilter} using the provided parameters.
@@ -198,7 +202,17 @@ private void processAuthorizationRequest(HttpServletRequest request, HttpServlet
198202
.attribute(Principal.class.getName(), principal)
199203
.attribute(OAuth2AuthorizationRequest.class.getName(), authorizationRequest);
200204

201-
if (requireUserConsent(registeredClient, authorizationRequest)) {
205+
final Set<String> alreadyAuthorizedScopes = new HashSet<>(this.userConsentRepository.findBySubjectAndClientId(
206+
principal.getName(),
207+
registeredClient.getClientId()))
208+
.stream()
209+
.map(UserConsentRecord::getAuthorizedScope)
210+
.collect(Collectors.toSet());
211+
Set<String> scopesRequiringConsent = new HashSet<>(authorizationRequest.getScopes());
212+
scopesRequiringConsent.removeAll(alreadyAuthorizedScopes);
213+
scopesRequiringConsent.remove(OidcScopes.OPENID); // openid scope does not require consent
214+
215+
if (requireUserConsent(registeredClient, authorizationRequest) && !scopesRequiringConsent.isEmpty()) {
202216
String state = this.stateGenerator.generateKey();
203217
OAuth2Authorization authorization = builder
204218
.attribute(OAuth2ParameterNames.STATE, state)
@@ -207,7 +221,8 @@ private void processAuthorizationRequest(HttpServletRequest request, HttpServlet
207221

208222
// TODO Need to remove 'in-flight' authorization if consent step is not completed (e.g. approved or cancelled)
209223

210-
UserConsentPage.displayConsent(request, response, registeredClient, authorization);
224+
UserConsentPage.displayConsent(request, response, registeredClient, authorization,
225+
scopesRequiringConsent, alreadyAuthorizedScopes);
211226
} else {
212227
Instant issuedAt = Instant.now();
213228
Instant expiresAt = issuedAt.plus(5, ChronoUnit.MINUTES); // TODO Allow configuration for authorization code time-to-live
@@ -269,15 +284,30 @@ private void processUserConsent(HttpServletRequest request, HttpServletResponse
269284
return;
270285
}
271286

272-
Instant issuedAt = Instant.now();
273-
Instant expiresAt = issuedAt.plus(5, ChronoUnit.MINUTES); // TODO Allow configuration for authorization code time-to-live
274-
OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode(
275-
this.codeGenerator.generateKey(), issuedAt, expiresAt);
276-
Set<String> authorizedScopes = userConsentRequestContext.getScopes();
287+
Set<String> authorizedScopes = new HashSet<>(userConsentRequestContext.getScopes());
277288
if (userConsentRequestContext.getAuthorizationRequest().getScopes().contains(OidcScopes.OPENID)) {
278289
// openid scope is auto-approved as it does not require consent
279290
authorizedScopes.add(OidcScopes.OPENID);
280291
}
292+
293+
this.userConsentRepository.saveAll(
294+
userConsentRequestContext.getAuthorization().getPrincipalName(),
295+
userConsentRequestContext.getClientId(),
296+
authorizedScopes);
297+
298+
Set<String> deniedScopes = new HashSet<>(userConsentRequestContext.getAuthorizationRequest().getScopes());
299+
deniedScopes.removeAll(authorizedScopes);
300+
deniedScopes.remove(OidcScopes.OPENID);
301+
302+
this.userConsentRepository.revokeAll(
303+
userConsentRequestContext.getAuthorization().getPrincipalName(),
304+
userConsentRequestContext.getClientId(),
305+
deniedScopes);
306+
307+
Instant issuedAt = Instant.now();
308+
Instant expiresAt = issuedAt.plus(5, ChronoUnit.MINUTES); // TODO Allow configuration for authorization code time-to-live
309+
OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode(
310+
this.codeGenerator.generateKey(), issuedAt, expiresAt);
281311
OAuth2Authorization authorization = OAuth2Authorization.from(userConsentRequestContext.getAuthorization())
282312
.token(authorizationCode)
283313
.attributes(attrs -> {
@@ -654,9 +684,11 @@ private static class UserConsentPage {
654684
private static final String CONSENT_ACTION_CANCEL = "cancel";
655685

656686
private static void displayConsent(HttpServletRequest request, HttpServletResponse response,
657-
RegisteredClient registeredClient, OAuth2Authorization authorization) throws IOException {
687+
RegisteredClient registeredClient, OAuth2Authorization authorization,
688+
Set<String> scopesRequiringConsent, Set<String> alreadyAuthorizedScopes) throws IOException {
658689

659-
String consentPage = generateConsentPage(request, registeredClient, authorization);
690+
String consentPage = generateConsentPage(request, registeredClient, authorization,
691+
scopesRequiringConsent, alreadyAuthorizedScopes);
660692
response.setContentType(TEXT_HTML_UTF8.toString());
661693
response.setContentLength(consentPage.getBytes(StandardCharsets.UTF_8).length);
662694
response.getWriter().write(consentPage);
@@ -671,12 +703,9 @@ private static boolean isConsentCancelled(HttpServletRequest request) {
671703
}
672704

673705
private static String generateConsentPage(HttpServletRequest request,
674-
RegisteredClient registeredClient, OAuth2Authorization authorization) {
706+
RegisteredClient registeredClient, OAuth2Authorization authorization,
707+
Set<String> scopesRequiringConsent, Set<String> alreadyAuthorizedScopes) {
675708

676-
OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(
677-
OAuth2AuthorizationRequest.class.getName());
678-
Set<String> scopes = new HashSet<>(authorizationRequest.getScopes());
679-
scopes.remove(OidcScopes.OPENID); // openid scope does not require consent
680709
String state = authorization.getAttribute(
681710
OAuth2ParameterNames.STATE);
682711

@@ -711,7 +740,14 @@ private static String generateConsentPage(HttpServletRequest request,
711740
builder.append(" <input type=\"hidden\" name=\"client_id\" value=\"" + registeredClient.getClientId() + "\">");
712741
builder.append(" <input type=\"hidden\" name=\"state\" value=\"" + state + "\">");
713742

714-
for (String scope : scopes) {
743+
for (String scope : scopesRequiringConsent) {
744+
builder.append(" <div class=\"form-group form-check py-1\">");
745+
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + scope + "\" id=\"" + scope + "\">");
746+
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
747+
builder.append(" </div>");
748+
}
749+
750+
for (String scope : alreadyAuthorizedScopes) {
715751
builder.append(" <div class=\"form-group form-check py-1\">");
716752
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + scope + "\" id=\"" + scope + "\" checked>");
717753
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");

0 commit comments

Comments
 (0)