Skip to content

Commit 27d7cbb

Browse files
Merge branch 'multi-resource-server-introspector'
2 parents 79f845a + 895584b commit 27d7cbb

File tree

14 files changed

+392
-163
lines changed

14 files changed

+392
-163
lines changed

README.md

+9-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# Spring Security Oauth2 JPA Implementation
22

33
> App-Token based OAuth2 POC built to grow with Spring Boot and ORM
4-
>
4+
5+
- [NOTICE] Test codes will be temporarily non-functional due to the introduction of the Introspector, until the next version.
6+
57
## Supporting Oauth2 Type
68
| ROPC | Authorization Code |
79
|------------------|-------------------------------------------------|
@@ -27,6 +29,8 @@ For v2, using the database tables from Spring Security 5 (only the database tabl
2729
## Overview
2830

2931
* Complete separation of the library (API) and the client for testing it
32+
* Immediate Permission (Authority) Check: Not limited to verifying the token itself, but also ensuring real-time validation of any updates to permissions in the database.
33+
* Token Introspector: Enable the ``/oauth2/introspect`` endpoint to allow multiple resource servers to verify the token's validity and permissions with the authorization server.
3034

3135
* Set up the same access & refresh token APIs on both ``/oauth2/token`` and on our controller layer such as ``/api/v1/traditional-oauth/token``, both of which function same and have `the same request & response payloads for success and errors`. (However, ``/oauth2/token`` is the standard that "spring-authorization-server" provides.)
3236
* As you are aware, the API ``/oauth2/token`` is what "spring-authorization-server" provides.
@@ -79,7 +83,7 @@ For v2, using the database tables from Spring Security 5 (only the database tabl
7983
* For v2, provide MySQL DDL, which consists of ``oauth_access_token, oauth_refresh_token and oauth_client_details``, which are tables in Security 5. As I meant to migrate current security system to Security 6 back then, I hadn't changed them to the ``oauth2_authorization`` table indicated in https://github.com/spring-projects/spring-authorization-server.
8084

8185
* Application of Spring Rest Docs
82-
86+
8387
## Dependencies
8488

8589
| Category | Dependencies |
@@ -194,6 +198,9 @@ public class CommonDataSourceConfiguration {
194198
- **Customize the verification logic for UsernamePassword and Client as desired**
195199
- ``IOauth2AuthenticationHashCheckService``
196200

201+
- **Customize OpaqueTokenIntrospector as desired (!Set this to your Resource Servers)**
202+
- ``client.config.securityimpl.introspector.CustomResourceServerTokenIntrospector``
203+
197204
## OAuth2 - ROPC
198205
* Refer to ``client/src/docs/asciidoc/api-app.adoc``
199206

client/src/main/java/com/patternknife/securityhelper/oauth2/client/config/securityimpl/guard/ResourceServerAuthorityChecker.java

+41-5
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,66 @@
11
package com.patternknife.securityhelper.oauth2.client.config.securityimpl.guard;
22

3+
import io.github.patternknife.securityhelper.oauth2.api.config.security.serivce.persistence.authorization.OAuth2AuthorizationServiceImpl;
4+
import io.github.patternknife.securityhelper.oauth2.api.config.security.serivce.userdetail.ConditionalDetailsService;
5+
import lombok.RequiredArgsConstructor;
36
import org.springframework.security.core.Authentication;
47
import org.springframework.security.core.context.SecurityContextHolder;
8+
import org.springframework.security.core.userdetails.UserDetails;
9+
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
10+
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
11+
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
12+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
13+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
14+
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication;
515
import org.springframework.stereotype.Service;
616

17+
@RequiredArgsConstructor
718
@Service
819
public class ResourceServerAuthorityChecker {
920

21+
private final OAuth2AuthorizationServiceImpl authorizationService;
22+
private final ConditionalDetailsService conditionalDetailsService;
23+
private final RegisteredClientRepository registeredClientRepository;
24+
25+
1026
public boolean hasAnyAdminRole() {
1127
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
12-
if (authentication == null || authentication.getAuthorities() == null) {
28+
if (!(authentication instanceof BearerTokenAuthentication bearerTokenAuth)) {
29+
return false;
30+
}
31+
32+
String bearerAccessToken = bearerTokenAuth.getToken().getTokenValue();
33+
34+
OAuth2Authorization oAuth2Authorization = authorizationService.findByToken(bearerAccessToken, OAuth2TokenType.ACCESS_TOKEN);
35+
if (oAuth2Authorization == null) {
1336
return false;
1437
}
15-
return authentication.getAuthorities().stream()
38+
39+
UserDetails userDetails = conditionalDetailsService.loadUserByUsername(
40+
oAuth2Authorization.getPrincipalName(),
41+
oAuth2Authorization.getAttribute("client_id")
42+
);
43+
44+
// Check for any _ADMIN roles
45+
return userDetails.getAuthorities().stream()
1646
.anyMatch(authority -> authority.getAuthority().endsWith("_ADMIN"));
1747
}
1848

1949
private boolean hasRoleOrSuperAdmin(String role) {
2050
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
21-
if (authentication == null || authentication.getAuthorities() == null) {
51+
if (authentication == null) {
2252
return false;
2353
}
2454

55+
String bearerAccessToken = ((BearerTokenAuthentication) authentication).getToken().getTokenValue();
56+
57+
OAuth2Authorization oAuth2Authorization = authorizationService.findByToken(bearerAccessToken, OAuth2TokenType.ACCESS_TOKEN);
58+
59+
UserDetails userDetails = conditionalDetailsService.loadUserByUsername(oAuth2Authorization.getPrincipalName(), oAuth2Authorization.getAttribute("client_id"));
60+
2561
// Check for SUPER_ADMIN role or the specific role
26-
return authentication.getAuthorities().stream()
27-
.anyMatch(authority -> "SUPER_ADMIN".equals(authority.getAuthority())
62+
return userDetails.getAuthorities().stream()
63+
.anyMatch(authority -> role.equals(authority.getAuthority())
2864
|| authority.getAuthority().equals(role));
2965
}
3066

client/src/main/java/com/patternknife/securityhelper/oauth2/client/config/securityimpl/guard/SecurityGuardUtil.java

+3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.patternknife.securityhelper.oauth2.client.config.securityimpl.guard.AccessTokenUserInfo;
44
import org.springframework.security.core.context.SecurityContextHolder;
5+
import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionAuthenticatedPrincipal;
56

67
public class SecurityGuardUtil {
78

@@ -10,6 +11,8 @@ public static AccessTokenUserInfo getAccessTokenUser(){
1011

1112
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
1213

14+
String userName =((OAuth2IntrospectionAuthenticatedPrincipal)principal).getAttribute("username");
15+
1316
if (principal instanceof AccessTokenUserInfo) {
1417
return ((AccessTokenUserInfo) principal);
1518
}

client/src/main/java/com/patternknife/securityhelper/oauth2/client/config/securityimpl/guard/UserCustomerOnlyImpl.java

+27-1
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,45 @@
11
package com.patternknife.securityhelper.oauth2.client.config.securityimpl.guard;
22

33
import com.patternknife.securityhelper.oauth2.client.config.response.error.exception.auth.CustomAuthGuardException;
4+
import io.github.patternknife.securityhelper.oauth2.api.config.security.serivce.persistence.authorization.OAuth2AuthorizationServiceImpl;
5+
import io.github.patternknife.securityhelper.oauth2.api.config.security.serivce.userdetail.ConditionalDetailsService;
6+
import lombok.RequiredArgsConstructor;
47
import org.aspectj.lang.ProceedingJoinPoint;
58
import org.aspectj.lang.annotation.Around;
69
import org.aspectj.lang.annotation.Aspect;
10+
import org.springframework.security.core.context.SecurityContextHolder;
11+
import org.springframework.security.core.userdetails.UserDetails;
12+
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
13+
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
14+
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
15+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
16+
import org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionAuthenticatedPrincipal;
717
import org.springframework.stereotype.Component;
818

919
@Aspect
1020
@Component
21+
@RequiredArgsConstructor
1122
public class UserCustomerOnlyImpl {
1223

24+
private final OAuth2AuthorizationServiceImpl authorizationService;
25+
private final ConditionalDetailsService conditionalDetailsService;
26+
1327
@Around("@annotation(com.patternknife.securityhelper.oauth2.client.config.securityimpl.guard.UserCustomerOnly)")
1428
public Object check(ProceedingJoinPoint joinPoint) throws Throwable {
1529

16-
AccessTokenUserInfo accessTokenUserInfo = SecurityGuardUtil.getAccessTokenUser();
30+
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
31+
32+
String userName =((OAuth2IntrospectionAuthenticatedPrincipal)principal).getUsername();
33+
String clientId =((OAuth2IntrospectionAuthenticatedPrincipal)principal).getClientId();
34+
String appToken = ((OAuth2IntrospectionAuthenticatedPrincipal)principal).getAttribute("App-Token");
35+
36+
37+
OAuth2Authorization oAuth2Authorization = authorizationService.findByUserNameAndClientIdAndAppToken(userName, clientId, appToken);
38+
39+
UserDetails userDetails = conditionalDetailsService.loadUserByUsername(userName, clientId);
40+
41+
AccessTokenUserInfo accessTokenUserInfo = ((AccessTokenUserInfo)userDetails);
42+
1743

1844
if(accessTokenUserInfo != null && (accessTokenUserInfo.getAdditionalAccessTokenUserInfo().getUserType() != AdditionalAccessTokenUserInfo.UserType.CUSTOMER)){
1945
throw new CustomAuthGuardException("ID \"" + accessTokenUserInfo.getUsername() + "\" : Not in Customer Group");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.patternknife.securityhelper.oauth2.client.config.securityimpl.introspector;
2+
3+
import io.github.patternknife.securityhelper.oauth2.api.config.security.message.ISecurityUserExceptionMessageService;
4+
import io.github.patternknife.securityhelper.oauth2.api.config.security.response.error.exception.KnifeOauth2AuthenticationException;
5+
import io.github.patternknife.securityhelper.oauth2.api.config.security.serivce.persistence.authorization.OAuth2AuthorizationServiceImpl;
6+
import io.github.patternknife.securityhelper.oauth2.api.config.security.serivce.userdetail.ConditionalDetailsService;
7+
import org.springframework.beans.factory.annotation.Value;
8+
import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector;
9+
import org.springframework.stereotype.Component;
10+
import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal;
11+
import org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector;
12+
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;
13+
14+
@Component
15+
public class CustomDefaultResourceServerTokenIntrospector implements OpaqueTokenIntrospector {
16+
17+
private final OpaqueTokenIntrospector delegate;
18+
19+
public CustomDefaultResourceServerTokenIntrospector(
20+
@Value("${security.oauth2.introspection.uri}") String introspectionUri,
21+
@Value("${security.oauth2.introspection.client-id}") String clientId,
22+
@Value("${security.oauth2.introspection.client-secret}") String clientSecret) {
23+
this.delegate = new SpringOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret);
24+
}
25+
26+
@Override
27+
public OAuth2AuthenticatedPrincipal introspect(String token) {
28+
try {
29+
return delegate.introspect(token);
30+
} catch (Exception e) {
31+
throw new KnifeOauth2AuthenticationException(e.getMessage());
32+
}
33+
}
34+
}
35+

client/src/main/java/com/patternknife/securityhelper/oauth2/client/domain/customer/api/CustomerApi.java

+3-3
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public class CustomerApi {
3939
@UserCustomerOnly
4040
@PreAuthorize("isAuthenticated()")
4141
@GetMapping("/customers/me")
42-
public CustomerResDTO.IdNameWithAccessTokenRemainingSeconds getCustomerSelf(@AuthenticationPrincipal AccessTokenUserInfo accessTokenUserInfo,
42+
public CustomerResDTO.IdNameWithAccessTokenRemainingSeconds getCustomerSelf(
4343
@RequestHeader("Authorization") String authorizationHeader) throws ResourceNotFoundException {
4444
String token = authorizationHeader.substring("Bearer ".length());
4545

@@ -58,8 +58,8 @@ public CustomerResDTO.IdNameWithAccessTokenRemainingSeconds getCustomerSelf(@Aut
5858
}
5959
}
6060

61-
return new CustomerResDTO.IdNameWithAccessTokenRemainingSeconds(customerRepository.findByIdName(accessTokenUserInfo.getUsername())
62-
.orElseThrow(() -> new ResourceNotFoundException("Couldn't find the user (username : " + accessTokenUserInfo.getUsername() + ")")), accessTokenRemainingSeconds);
61+
return new CustomerResDTO.IdNameWithAccessTokenRemainingSeconds(customerRepository.findByIdName(oAuth2Authorization.getPrincipalName())
62+
.orElseThrow(() -> new ResourceNotFoundException("Couldn't find the user (username : " + oAuth2Authorization.getPrincipalName() + ")")), accessTokenRemainingSeconds);
6363

6464
}
6565

client/src/main/resources/application.properties

+6-1
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,9 @@ logginglevel.org.springframework.security=trace
7373
io.github.patternknife.securityhelper.oauth2.no-app-token-same-access-token=true
7474

7575
spring.mvc.view.prefix=/templates/
76-
spring.mvc.view.suffix=.html
76+
spring.mvc.view.suffix=.html
77+
78+
79+
security.oauth2.introspection.uri=http://localhost:8370/oauth2/introspect
80+
security.oauth2.introspection.client-id=client_customer
81+
security.oauth2.introspection.client-secret=12345

client/src/test/java/com/patternknife/securityhelper/oauth2/client/integration/auth/CustomerIntegrationTest.java

+7
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,12 @@ public void testLoginWithInvalidCredentials_EXPOSE() throws Exception {
630630
assertEquals(userMessage, CustomSecurityUserExceptionMessage.AUTHENTICATION_WRONG_GRANT_TYPE.getMessage());
631631
}
632632

633+
/*
634+
* [IMPORTANT] To test this, as '/oauth2/introspect' has been introduced, this can't connect to itself at this point. Check 'CustomDefaultResourceServerTokenIntrospector'.
635+
*
636+
* */
637+
638+
633639
@Test
634640
public void testFetchResourceWithInvalidCredentialsAndValidCredentialsButWithNoPermission() throws Exception {
635641

@@ -719,6 +725,7 @@ public void testFetchResourceWithInvalidCredentialsAndValidCredentialsButWithNoP
719725

720726

721727

728+
722729
private static class AccessTokenMaskingPreprocessor implements OperationPreprocessor {
723730

724731
@Override
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package io.github.patternknife.securityhelper.oauth2.api.config.security.converter.auth.endpoint;
2+
3+
import java.util.HashMap;
4+
import java.util.Map;
5+
6+
import io.github.patternknife.securityhelper.oauth2.api.config.security.provider.auth.introspectionendpoint.KnifeOauth2OpaqueTokenAuthenticationProvider;
7+
import io.github.patternknife.securityhelper.oauth2.api.config.security.serivce.persistence.authorization.OAuth2AuthorizationServiceImpl;
8+
import io.github.patternknife.securityhelper.oauth2.api.config.security.serivce.userdetail.ConditionalDetailsService;
9+
import io.github.patternknife.securityhelper.oauth2.api.config.util.KnifeOAuth2EndpointUtils;
10+
import jakarta.servlet.http.HttpServletRequest;
11+
12+
import org.apache.commons.logging.Log;
13+
import org.apache.commons.logging.LogFactory;
14+
import org.springframework.security.core.Authentication;
15+
import org.springframework.security.core.context.SecurityContextHolder;
16+
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
17+
import org.springframework.security.oauth2.core.OAuth2Error;
18+
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
19+
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
20+
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
21+
import org.springframework.security.oauth2.server.authorization.OAuth2TokenIntrospection;
22+
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
23+
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenIntrospectionAuthenticationToken;
24+
import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenIntrospectionEndpointFilter;
25+
26+
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenAuthenticationConverter;
27+
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;
28+
import org.springframework.security.web.authentication.AuthenticationConverter;
29+
import org.springframework.util.Assert;
30+
import org.springframework.util.MultiValueMap;
31+
import org.springframework.util.StringUtils;
32+
33+
/**
34+
* Attempts to extract an Introspection Request from {@link HttpServletRequest} and then
35+
* converts it to an {@link OAuth2TokenIntrospectionAuthenticationToken} used for
36+
* authenticating the request.
37+
*
38+
* @author Gerardo Roza
39+
* @author Joe Grandja
40+
* @since 0.4.0
41+
* @see AuthenticationConverter
42+
* @see OAuth2TokenIntrospectionAuthenticationToken
43+
* @see OAuth2TokenIntrospectionEndpointFilter
44+
*/
45+
public final class KnifeOAuth2TokenIntrospectionAuthenticationConverter implements AuthenticationConverter {
46+
47+
/*
48+
* Now, this only takes "access_token".
49+
* */
50+
@Override
51+
public Authentication convert(HttpServletRequest request) {
52+
Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication();
53+
54+
MultiValueMap<String, String> parameters = KnifeOAuth2EndpointUtils.getFormParameters(request);
55+
56+
// token (REQUIRED)
57+
String token = parameters.getFirst(OAuth2ParameterNames.TOKEN);
58+
if (!StringUtils.hasText(token) || parameters.get(OAuth2ParameterNames.TOKEN).size() != 1) {
59+
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.TOKEN);
60+
}
61+
62+
// token_type_hint (OPTIONAL)
63+
String tokenTypeHint = parameters.getFirst(OAuth2ParameterNames.TOKEN_TYPE_HINT);
64+
if (StringUtils.hasText(tokenTypeHint) && parameters.get(OAuth2ParameterNames.TOKEN_TYPE_HINT).size() != 1) {
65+
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.TOKEN_TYPE_HINT);
66+
}
67+
68+
Map<String, Object> additionalParameters = new HashMap<>();
69+
parameters.forEach((key, value) -> {
70+
if (!key.equals(OAuth2ParameterNames.TOKEN) && !key.equals(OAuth2ParameterNames.TOKEN_TYPE_HINT)) {
71+
additionalParameters.put(key, (value.size() == 1) ? value.get(0) : value.toArray(new String[0]));
72+
}
73+
});
74+
75+
return new OAuth2TokenIntrospectionAuthenticationToken(token, clientPrincipal, tokenTypeHint,
76+
additionalParameters);
77+
}
78+
79+
private static void throwError(String errorCode, String parameterName) {
80+
OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Token Introspection Parameter: " + parameterName,
81+
"https://datatracker.ietf.org/doc/html/rfc7662#section-2.1");
82+
throw new OAuth2AuthenticationException(error);
83+
}
84+
85+
}

0 commit comments

Comments
 (0)