Skip to content

Commit 4020c65

Browse files
committed
Prototype to remember user consent decisions
1 parent a90d98a commit 4020c65

File tree

4 files changed

+249
-33
lines changed

4 files changed

+249
-33
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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.Optional;
22+
import java.util.Set;
23+
import java.util.function.Function;
24+
import java.util.stream.Collectors;
25+
26+
public class InMemoryUserConsentRepository implements UserConsentRepository {
27+
28+
private final Set<UserConsentRecord> userConsentRecords = new HashSet<>();
29+
30+
@Override
31+
public Set<UserConsentRecord> findBySubjectAndClientId(final String subject, final String clientId) {
32+
Assert.hasText(subject, "subject must have text");
33+
Assert.hasText(clientId, "clientId must have text");
34+
35+
return this.userConsentRecords
36+
.stream()
37+
.filter(record -> subject.equals(record.getSubject()))
38+
.filter(record -> clientId.equals(record.getClientId()))
39+
.filter(UserConsentRecord::isValid)
40+
.collect(Collectors.toSet());
41+
}
42+
43+
@Override
44+
public void saveAll(String subject, String clientId, Set<String> consentedScopes) {
45+
Assert.hasText(subject, "subject must have text");
46+
Assert.hasText(clientId, "clientId must have text");
47+
Assert.notNull(consentedScopes, "consentedScopes must not be null");
48+
49+
Function<String, UserConsentRecord> mapScopeToConsent = (scope) -> new UserConsentRecord(
50+
subject,
51+
clientId,
52+
scope);
53+
54+
// remove any older consent records
55+
this.revokeAll(subject, clientId, consentedScopes);
56+
this.userConsentRecords.addAll(consentedScopes.stream().map(mapScopeToConsent).collect(Collectors.toSet()));
57+
}
58+
59+
@Override
60+
public void revokeAll(String subject, String clientId, Set<String> revokedScopes) {
61+
Assert.hasText(subject, "subject must have text");
62+
Assert.hasText(clientId, "clientId must have text");
63+
Assert.notNull(revokedScopes, "revokedScopes must not be null");
64+
65+
revokedScopes.forEach(revokedScope -> this.revokeSingle(subject, clientId, revokedScope));
66+
}
67+
68+
private void revokeSingle(String subject, String clientId, String revokedScope) {
69+
this.userConsentRecords
70+
.stream()
71+
.filter(record -> subject.equals(record.getSubject()))
72+
.filter(record -> clientId.equals(record.getClientId()))
73+
.filter(record -> revokedScope.equals(record.getAuthorizedScope()))
74+
.findFirst()
75+
.ifPresent(this.userConsentRecords::remove);
76+
}
77+
}
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

+70-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,24 @@
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.function.Function;
73+
import java.util.stream.Collectors;
74+
7175
/**
7276
* A {@code Filter} for the OAuth 2.0 Authorization Code Grant,
7377
* which handles the processing of the OAuth 2.0 Authorization Request.
@@ -99,6 +103,7 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
99103
private final StringKeyGenerator codeGenerator = new Base64StringKeyGenerator(Base64.getUrlEncoder().withoutPadding(), 96);
100104
private final StringKeyGenerator stateGenerator = new Base64StringKeyGenerator(Base64.getUrlEncoder());
101105
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
106+
private final UserConsentRepository userConsentRepository = new InMemoryUserConsentRepository();
102107

103108
/**
104109
* Constructs an {@code OAuth2AuthorizationEndpointFilter} using the provided parameters.
@@ -198,7 +203,17 @@ private void processAuthorizationRequest(HttpServletRequest request, HttpServlet
198203
.attribute(Principal.class.getName(), principal)
199204
.attribute(OAuth2AuthorizationRequest.class.getName(), authorizationRequest);
200205

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

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

210-
UserConsentPage.displayConsent(request, response, registeredClient, authorization);
225+
UserConsentPage.displayConsent(request, response, registeredClient, authorization,
226+
scopesRequiringConsent, alreadyAuthorizedScopes);
211227
} else {
212228
Instant issuedAt = Instant.now();
213229
Instant expiresAt = issuedAt.plus(5, ChronoUnit.MINUTES); // TODO Allow configuration for authorization code time-to-live
@@ -269,15 +285,30 @@ private void processUserConsent(HttpServletRequest request, HttpServletResponse
269285
return;
270286
}
271287

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();
288+
Set<String> authorizedScopes = new HashSet<>(userConsentRequestContext.getScopes());
277289
if (userConsentRequestContext.getAuthorizationRequest().getScopes().contains(OidcScopes.OPENID)) {
278290
// openid scope is auto-approved as it does not require consent
279291
authorizedScopes.add(OidcScopes.OPENID);
280292
}
293+
294+
this.userConsentRepository.saveAll(
295+
userConsentRequestContext.getAuthorization().getPrincipalName(),
296+
userConsentRequestContext.getClientId(),
297+
authorizedScopes);
298+
299+
Set<String> deniedScopes = new HashSet<>(userConsentRequestContext.getAuthorizationRequest().getScopes());
300+
deniedScopes.removeAll(authorizedScopes);
301+
deniedScopes.remove(OidcScopes.OPENID);
302+
303+
this.userConsentRepository.revokeAll(
304+
userConsentRequestContext.getAuthorization().getPrincipalName(),
305+
userConsentRequestContext.getClientId(),
306+
deniedScopes);
307+
308+
Instant issuedAt = Instant.now();
309+
Instant expiresAt = issuedAt.plus(5, ChronoUnit.MINUTES); // TODO Allow configuration for authorization code time-to-live
310+
OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode(
311+
this.codeGenerator.generateKey(), issuedAt, expiresAt);
281312
OAuth2Authorization authorization = OAuth2Authorization.from(userConsentRequestContext.getAuthorization())
282313
.token(authorizationCode)
283314
.attributes(attrs -> {
@@ -654,9 +685,11 @@ private static class UserConsentPage {
654685
private static final String CONSENT_ACTION_CANCEL = "cancel";
655686

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

659-
String consentPage = generateConsentPage(request, registeredClient, authorization);
691+
String consentPage = generateConsentPage(request, registeredClient, authorization,
692+
scopesRequiringConsent, alreadyAuthorizedScopes);
660693
response.setContentType(TEXT_HTML_UTF8.toString());
661694
response.setContentLength(consentPage.getBytes(StandardCharsets.UTF_8).length);
662695
response.getWriter().write(consentPage);
@@ -671,12 +704,9 @@ private static boolean isConsentCancelled(HttpServletRequest request) {
671704
}
672705

673706
private static String generateConsentPage(HttpServletRequest request,
674-
RegisteredClient registeredClient, OAuth2Authorization authorization) {
707+
RegisteredClient registeredClient, OAuth2Authorization authorization,
708+
Set<String> scopesRequiringConsent, Set<String> alreadyAuthorizedScopes) {
675709

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
680710
String state = authorization.getAttribute(
681711
OAuth2ParameterNames.STATE);
682712

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

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

0 commit comments

Comments
 (0)