SpringSecurity OAuth2 (6) 自定义: ClientDetailsService, 异常处理以及一致性响应
About client-details-service
基于
ClientDetailsService
的授权服务器.
- 支持所有类型的授权. User & Client 均从数据库获取 (不再存到内存中);
- 自定义返回数据格式: 自定义
WebResponseExceptionTranslator
(异常) 以及TokenGranter
(正常);本文只摘录核心代码作说明, 完整代码请参阅: 代码仓库
☆ Authorization Server: 一致性响应
对于前后端分离的产品, 乃至现在仍在沿用的不分离的结构, 后端向前端返回统一的数据格式这一特性, 被提得越来越重要. 所以当然, 我们的授权服务器也应该给前端或是第三方应用返回统一的数据结构, 无论是正常响应还是异常响应.
约定这个结构应当形如:
{
"timestamp": "<timestamp>", // 为响应时间, 格式为 yyyy-MM-dd HH:mm:ss
"status": "<status>", // 是 HTTP 状态码, 200, 401, 403 etc.
"message": "<message>", // 是授权服务器的响应信息
"data": {} // 是信息内容. 如正常响应时, 授权服务器返回的 OAuth2AccessToken 对象的序列化值
}
异常的响应 - Exception Handling
异常响应我们期望返回如下格式:
{
"timestamp": "2020-07-01 14:26:55",
"status": 403,
"message": "客户端密码错误!",
"data": {}
}
对于异常的响应结构, 一共有两个地方可以控制:
AuthorizationServerConfiguration#configure(AuthorizationServerSecurityConfigurer)
: 授权服务器端点的 安全性 配置方法;AuthorizationServerConfiguration#configure(AuthorizationServerEndpointsConfigurer)
: 授权服务器端点的 非安全性 配置方法;
所谓的安全性是指, 请求到 TokenEndpoint
之前, 非安全性是指请求已经到了 TokenEndpoint
AuthorizationServerSecurityConfigurer
在AuthorizationServerConfigurerAdapter
的 configure(AuthorizationServerSecurityConfigurer security)
:
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
// @formatter:off
// ~ 为 client_id 和 client_secret 开启表单验证, 会启用一个名为 ClientCredentialsTokenEndpointFilter 的过滤器.
// 并会把这个过滤器放在 BasicAuthenticationFilter 之前,
// 这样如果在 ClientCredentialsTokenEndpointFilter 完成了校验 (SecurityContextHolder.getContext().getAuthentication()),
// 且请求头中即使有 Authorization: basic xxx, 也会被 BasicAuthenticationFilter 忽略.
// ref: AuthorizationServerSecurityConfigurer#clientCredentialsTokenEndpointFilter, BasicAuthenticationFilter#doFilterInternal
// ~ 如果不配置这一行, 默认就会通过 BasicAuthenticationFilter.
// security.allowFormAuthenticationForClients();
security
// ~ ExceptionTranslationFilter handling
// 在 Client Credentials Grant 和 Resource Owner Password Grant 模式下, 客户端凭证有误时会触发 authenticationEntryPoint
// -----------------------------------------------------------------------------------------------------
// ~ AuthenticationEntryPoint: called by ExceptionTranslationFilter when AuthenticationException be thrown.
.authenticationEntryPoint(authenticationEntryPoint)
// ~ AccessDeniedHandler: called by ExceptionTranslationFilter when AccessDeniedException be thrown.
.accessDeniedHandler(accessDeniedHandler)
// ~ 为 /oauth/token 端点 (TokenEndpoint) 添加自定义的过滤器
.addTokenEndpointAuthenticationFilter(new CustomClientCredentialsTokenEndpointFilter(passwordEncoder, clientDetailsService, authenticationEntryPoint))
;
// @formatter:on
}
CustomAuthenticationEntryPoint
在本专栏的之前的文章中已经介绍过, AuthenticationEntryPoint
主要用于处理 AuthenticationException
. 但是对于 Spring Security OAuth2 的授权服务器配置实现 (即在 AuthorizationServerSecurityConfigurer
配置的).
授权码模式和隐式模式下, DEBUG 跟踪发现, 该配置还承担了跳转的职责 (LoginUrlAuthenticationEntryPoint
), 即客户端首次尝试访问资源服务器上用户的受保护资源的时候, 后台抛出 AccessDeniedException
, 同时此时用户还没登陆, 所以是匿名用户, 触发 AuthenticationEntryPoint#commence
:
(参考 ExceptionTranslationFilter#doFilter
-> handleSpringSecurityException)
private void handleSpringSecurityException(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, RuntimeException exception)
throws IOException, ServletException {
if (exception instanceof AuthenticationException) {
sendStartAuthentication(request, response, chain,
(AuthenticationException) exception);
}
else if (exception instanceof AccessDeniedException) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authenticationTrustResolver.isAnonymous(authentication) || authenticationTrustResolver.isRememberMe(authentication)) {
sendStartAuthentication(
request,
response,
chain,
new InsufficientAuthenticationException(
messages.getMessage(
"ExceptionTranslationFilter.insufficientAuthentication",
"Full authentication is required to access this resource")));
}
else {
accessDeniedHandler.handle(request, response,
(AccessDeniedException) exception);
}
}
}
所以, 对于授权码模式和隐式模式, 正常流程是: 首次, 客户端请求授权服务器颁发访问令牌; 授权服务器发现用户此时还没有登陆 (后端抛出 AccessDeniedException
), 此时触发的我们自定义的 AuthenticationEntryPoint
应该承担跳转的职责. 最终, 自定义的 AuthenticationEntryPoint
实现如下:
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final RequestMatcher authorizationCodeGrantRequestMatcher = new AuthorizationCodeGrantRequestMatcher();
private final AuthenticationEntryPoint loginUrlAuthenticationEntryPoint = new LoginUrlAuthenticationEntryPoint(SecurityConfiguration.DEFAULT_LOGIN_URL);
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
log.debug("Custom AuthenticationEntryPoint triggered with exception: {}.", authException.getClass().getCanonicalName());
// 触发重定向到登陆页面
if (authorizationCodeGrantRequestMatcher.matches(request)) {
loginUrlAuthenticationEntryPoint.commence(request, response, authException);
return;
}
ResponseWrapper.forbiddenResponse(response, authException.getMessage());
}
private static class AuthorizationCodeGrantRequestMatcher implements RequestMatcher {
/**
* <ol>
* <li>授权码模式 URI</li>
* <li>隐式授权模式 URI</li>
* </ol>
*/
private static final Set<String> SUPPORT_URIS = new HashSet<>(Arrays.asList("response_type=code", "response_type=token"));
@Override
public boolean matches(HttpServletRequest request) {
if (StringUtils.equals(request.getServletPath(), AuthorizationServerConfiguration.OAUTH_AUTHORIZE_ENDPOINT)) {
final String queryString = request.getQueryString();
return SUPPORT_URIS.stream().anyMatch(supportUri -> StringUtils.indexOf(queryString, supportUri) != StringUtils.INDEX_NOT_FOUND);
}
return false;
}
}
}
CustomAccessDeniedHandler
和之前的示例一样:
依然是按照之前的实例
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
ResponseWrapper.unauthorizedResponse(response, accessDeniedException.getMessage());
}
}
CustomClientCredentialsTokenEndpointFilter
由于我们需要使用到 ClientCredentialsTokenEndpointFilter
来接受请求参数中的客户端 ID 和客户端密钥. 而默认如果使用 security.allowFormAuthenticationForClients()
会开启这个过滤器, 它本身又用到了 OAuth2AuthenticationEntryPoint
来处理认证失败的情况, 相关源代码摘录如下:
ClientCredentialsTokenEndpointFilter
public class ClientCredentialsTokenEndpointFilter extends AbstractAuthenticationProcessingFilter {
private AuthenticationEntryPoint authenticationEntryPoint = new OAuth2AuthenticationEntryPoint();
...
public ClientCredentialsTokenEndpointFilter(String path) {
super(path);
setRequiresAuthenticationRequestMatcher(new ClientCredentialsRequestMatcher(path));
// If authentication fails the type is "Form"
((OAuth2AuthenticationEntryPoint) authenticationEntryPoint).setTypeName("Form");
}
...
@Override
public void afterPropertiesSet() {
super.afterPropertiesSet();
setAuthenticationFailureHandler(new AuthenticationFailureHandler() {
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
if (exception instanceof BadCredentialsException) {
exception = new BadCredentialsException(exception.getMessage(), new BadClientCredentialsException());
}
authenticationEntryPoint.commence(request, response, exception);
}
});
setAuthenticationSuccessHandler(new AuthenticationSuccessHandler() {
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
// no-op - just allow filter chain to continue to token endpoint
}
});
}
...
AbstractAuthenticationProcessingFilter
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) hrows IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
Authentication authResult;
try {
authResult = attemptAuthentication(request, response);
if (authResult == null) {
// return immediately as subclass has indicated that it hasn't completed
// authentication
return;
}
sessionStrategy.onAuthentication(authResult, request, response);
}
catch (InternalAuthenticationServiceException failed) {
logger.error("An internal error occurred while trying to authenticate the user.", failed);
unsuccessfulAuthentication(request, response, failed);
return;
}
catch (AuthenticationException failed) {
// Authentication failed
unsuccessfulAuthentication(request, response, failed);
return;
}
// Authentication success
if (continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
successfulAuthentication(request, response, chain, authResult);
}
...
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
SecurityContextHolder.clearContext();
rememberMeServices.loginFail(request, response);
failureHandler.onAuthenticationFailure(request, response, failed);
}
...
public final void setRequiresAuthenticationRequestMatcher(
RequestMatcher requestMatcher) {
Assert.notNull(requestMatcher, "requestMatcher cannot be null");
this.requiresAuthenticationRequestMatcher = requestMatcher;
}
而期望的结果是调用我们自己写的 AuthenticationEntryPoint
. 所以, 场景上需要重写这个过滤器, 并用 AuthorizationServerSecurityConfigurer#addTokenEndpointAuthenticationFilter(javax.servlet.Filter)
注入.
下面来看看自定义的 ClientCredentialsTokenEndpointFilter
的实现方式…
可以看到, 在构造方法中我们调用了父类的方法手动设置了自定义实现的 AuthenticationEntryPoint
; 并且, 这个过滤器约定需要设置 AuthenticationManager
(ref: AbstractAuthenticationProcessingFilter#afterPropertiesSet()
); 最后, 调用父类 (ClientCredentialsTokenEndpointFilter
) 的 afterPropertiesSet()
置空实现 AbstractAuthenticationProcessingFilter
的 successHandler
, 否则你会从控制台看到, 认证成功之后又被进行了一次重定向. (因为默认, AbstractAuthenticationProcessingFilter
的 successHandler
持有的是 SavedRequestAwareAuthenticationSuccessHandler
的实例) , 场景中, 我们并不需要做这一次重定向
/**
* Description: 自定义的 {@link ClientCredentialsTokenEndpointFilter}<br>
* Details: 为了使用自定义的 {@link AuthenticationEntryPoint}, 从而自定义发生异常时的响应格式
*
* @author LiKe
* @version 1.0.0
* @date 2020-06-18 11:24
*/
@Slf4j
@Component
public class CustomClientCredentialsTokenEndpointFilter extends ClientCredentialsTokenEndpointFilter {
public CustomClientCredentialsTokenEndpointFilter(
PasswordEncoder passwordEncoder,
ClientDetailsService clientDetailsService,
AuthenticationEntryPoint authenticationEntryPoint
) {
super.setAllowOnlyPost(true);
super.setAuthenticationEntryPoint(authenticationEntryPoint);
super.setAuthenticationManager(new ClientAuthenticationManager(passwordEncoder, clientDetailsService));
this.postProcess();
}
private void postProcess() {
super.afterPropertiesSet();
}
private static class ClientAuthenticationManager implements AuthenticationManager {
private final PasswordEncoder passwordEncoder;
private final ClientDetailsService clientDetailsService;
public ClientAuthenticationManager(PasswordEncoder passwordEncoder, ClientDetailsService clientDetailsService) {
this.passwordEncoder = passwordEncoder;
this.clientDetailsService = clientDetailsService;
}
/**
* @param authentication {"authenticated":false,"authorities":[],"credentials":"client-a-p","name":"client-a","principal":"client-a"}
* @see AuthenticationManager#authenticate(Authentication)
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
log.debug("Incoming Authentication: {}", JSON.toJSONString(authentication));
final String clientId = authentication.getName();
final ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId);
if (!passwordEncoder.matches((CharSequence) authentication.getCredentials(), clientDetails.getClientSecret())) {
throw new BadCredentialsException("客户端密码错误!");
}
return new ClientAuthenticationToken(clientDetails);
}
}
private static class ClientAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal;
private final Object credentials;
public ClientAuthenticationToken(ClientDetails clientDetails) {
super(clientDetails.getAuthorities());
this.principal = clientDetails.getClientId();
this.credentials = clientDetails.getClientSecret();
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return credentials;
}
@Override
public Object getPrincipal() {
return principal;
}
}
}
通过自定义的 ClientCredentialsTokenEndpointFilter
实现在客户端凭证信息异常的时候, 能够正确的调用我们自定义的 AuthenticationEntryPoint
,
同时, 在 AuthorizationServerConfiguration
的 configure(AuthorizationServerEndpointsConfigurer endpoints)
中, 还配置了:
// ~ 自定义的 WebResponseExceptionTranslator, 默认使用 DefaultWebResponseExceptionTranslator, 在 /oauth/token 端点
// ref: TokenEndpoint
.exceptionTranslator(webResponseExceptionTranslator)
☀ Authorization Code Grant 模式下,
客户端凭证有误时会触发 AuthorizationServerEndpointsConfigurer
的 exceptionTranslator
, 会请求转发到错误的页面 (WhitelabelErrorEndpoint
, /oauth/error
), 这里需要定制 (Ref: Customize Whitelabel Error Page);
用户凭证有误时直接触发 UsernamePasswordAuthenticationProvider
的 authenticate(Authentication authentication)
方法抛出的异常显示在登陆界面;
☀ Resource Owner Password Grant 模式下,
客户端凭证有误时会触发 AuthorizationServerConfiguration
中配置的 AuthenticationEntryPoint
;
用户凭证有误时会触发 AuthorizationServerEndpointsConfigurer
的 exceptionTranslator
;
☀ Implicit Grant 模式下, 同 Authorization Code Grant
☀ Client Credentials Grant 模式下,
客户端凭证有误时会触发 AuthorizationServerSecurityConfigurer
中配置的 authenticationEntryPoint
;
AuthorizationServerEndpointConfigurer
AuthorizationServerSecurityConfigurer
主要提供 /oauth/token 端点的 安全性 配置, AuthorizationServerEndpointConfigurer
的作用就是端点本身的行为配置.
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
// @formatter:off
// 对于密码授权模式, 需要提供 AuthenticationManager 用于用户信息的认证
endpoints
.authenticationManager(authenticationManager)
// ~ 自定义的 WebResponseExceptionTranslator, 默认使用 DefaultWebResponseExceptionTranslator, 在 /oauth/token 端点
// ref: TokenEndpoint
.exceptionTranslator(webResponseExceptionTranslator)
// ~ 自定义的 TokenGranter
.tokenGranter(new CustomTokenGranter(endpoints, authenticationManager))
// ~ refresh_token required
.userDetailsService(userDetailsService)
;
// @formatter:on
}
查看 TokenEndpoint
的源代码可以看到这段代码:
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<OAuth2Exception> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) throws Exception {
logger.info("Handling error: " + e.getClass().getSimpleName() + ", " + e.getMessage());
return getExceptionTranslator().translate(e);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<OAuth2Exception> handleException(Exception e) throws Exception {
logger.info("Handling error: " + e.getClass().getSimpleName() + ", " + e.getMessage());
return getExceptionTranslator().translate(e);
}
@ExceptionHandler(ClientRegistrationException.class)
public ResponseEntity<OAuth2Exception> handleClientRegistrationException(Exception e) throws Exception {
logger.info("Handling error: " + e.getClass().getSimpleName() + ", " + e.getMessage());
return getExceptionTranslator().translate(new BadClientCredentialsException());
}
@ExceptionHandler(OAuth2Exception.class)
public ResponseEntity<OAuth2Exception> handleException(OAuth2Exception e) throws Exception {
logger.info("Handling error: " + e.getClass().getSimpleName() + ", " + e.getMessage());
return getExceptionTranslator().translate(e);
}
看到 @ExceptionHandler
注解应该很清楚这些方法的作用了. 默认情况下 getExceptionTranslator()
返回的是 DefaultWebResponseExceptionTranslator
的实例, 后者实现了唯一一个接口: WebResponseExceptionTranslator
, 该接口唯一的方法用于解析异常并返回 ResponseEntity
对象. 而默认的 WebResponseExceptionTranslator
返回的格式完全不是我们定义的格式, 所以, 需要实现 CustomWebResponseExceptionTranslator
:
CustomWebResponseExceptionTranslator
在该类中, 我们将异常 “捕获”, 并解析成我们定义的结构.
/**
* 自定义的 {@link WebResponseExceptionTranslator}<br>
* 自定义 {@link OAuth2Exception} 重写其序列化方案, 最终达到访问 /oauth/token 端点异常信息自定义的目的.<br>
* 同时需要在 {@link HttpMessageConverterConfiguration#httpMessageConvertConfigurer()} } 中排除自定义的 {@link OAuth2Exception}.
*
* @author LiKe
* @version 1.0.0
* @date 2020-06-22 15:20
* @see org.springframework.security.oauth2.provider.error.WebResponseExceptionTranslator
* @see org.springframework.security.oauth2.provider.error.DefaultWebResponseExceptionTranslator
*/
@Slf4j
@Component
public class CustomWebResponseExceptionTranslator implements WebResponseExceptionTranslator {
@Override
public ResponseEntity<OAuth2Exception> translate(Exception e) {
log.debug("Custom WebResponseExceptionTranslator triggered. Determine http status ...");
final Class<? extends Exception> exceptionClass = e.getClass();
final String exceptionMessage = e.getMessage();
// AuthenticationException -> 401
if (AuthenticationException.class.isAssignableFrom(exceptionClass)) {
return handleOAuth2Exception(exceptionMessage, HttpStatus.UNAUTHORIZED);
}
// OAuth2Exception -> 403
if (OAuth2Exception.class.isAssignableFrom(exceptionClass)) {
return handleOAuth2Exception(exceptionMessage, HttpStatus.FORBIDDEN);
}
return handleOAuth2Exception(exceptionMessage, HttpStatus.INTERNAL_SERVER_ERROR);
}
private ResponseEntity<OAuth2Exception> handleOAuth2Exception(String exceptionMessage, HttpStatus httpStatus) {
return new ResponseEntity<>(new CustomOAuth2Exception(exceptionMessage, httpStatus), httpStatus);
}
/**
* Description: 自定义 {@link OAuth2Exception}
*
* @author LiKe
* @date 2020-06-22 16:42:05
*/
@com.fasterxml.jackson.databind.annotation.JsonSerialize(using = CustomOAuth2ExceptionJackson2Serializer.class)
public static final class CustomOAuth2Exception extends OAuth2Exception {
private final HttpStatus httpStatus;
public CustomOAuth2Exception(String msg, HttpStatus httpStatus) {
super(msg);
this.httpStatus = httpStatus;
}
public HttpStatus getHttpStatus() {
return httpStatus;
}
}
// ~ Serializer
// -----------------------------------------------------------------------------------------------------------------
private static final class CustomOAuth2ExceptionJackson2Serializer extends StdSerializer<CustomOAuth2Exception> {
private static final String SERIALIZED_PLAIN_OBJECT = "{}";
protected CustomOAuth2ExceptionJackson2Serializer() {
super(CustomOAuth2Exception.class);
}
@Override
public void serialize(CustomOAuth2Exception e, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
jsonGenerator.writeStartObject();
jsonGenerator.writeObjectField(SecurityResponse.FIELD_HTTP_STATUS, e.getHttpStatus().value());
jsonGenerator.writeObjectField(SecurityResponse.FIELD_TIMESTAMP, LocalDateTime.now().format(DateTimeFormatter.ofPattern(SecurityResponse.TIME_PATTERN, Locale.CHINA)));
jsonGenerator.writeObjectField(SecurityResponse.FIELD_MESSAGE, e.getMessage());
jsonGenerator.writeObjectField(SecurityResponse.FIELD_DATA, SERIALIZED_PLAIN_OBJECT);
jsonGenerator.writeEndObject();
}
}
}
正常的响应 - TokenGranter
授权服务器无论异常与否, 都应该返回统一的响应结构, 正常响应:
{
"status": 200,
"timestamp": "2020-07-01 14:23:10",
"message": "OK",
"data": "{\"access_token\":\"c0083712-3956-4aa6-a453-292a829b9500\",\"token_type\":\"bearer\",\"refresh_token\":\"2ead9f9b-c42f-4063-82f6-24f3692d5dfa\",\"expires_in\":119,\"scope\":\"ACCESS_RESOURCE\"}"
}
需要自定义 TokenGranter
. 在授权服务器的配置中, 为 /oauth/token 端点指定令牌生成器:
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
// ...
/**
* Description: 配置 {@link AuthorizationServerEndpointsConfigurer}<br>
* Details: 配置授权服务器端点的非安全性特性, 例如 令牌存储, 自定义. 如果是密码授权, 需要在这里提供一个 {@link org.springframework.security.authentication.AuthenticationManager}
*
* @see AuthorizationServerConfigurerAdapter#configure(AuthorizationServerEndpointsConfigurer)
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
// @formatter:off
// 对于密码授权模式, 需要提供 AuthenticationManager 用于用户信息的认证
endpoints
.authenticationManager(authenticationManager)
// ~ 自定义的 WebResponseExceptionTranslator, 默认使用 DefaultWebResponseExceptionTranslator, 在 /oauth/token 端点
// ref: TokenEndpoint
.exceptionTranslator(webResponseExceptionTranslator)
// ~ 自定义的 TokenGranter
.tokenGranter(new CustomTokenGranter(endpoints, authenticationManager))
// ~ refresh_token required
.userDetailsService(userDetailsService)
;
// @formatter:on
}
// ...
}
TokenGranter
为了在正常响应下返回的令牌格式遵循我们的约定, 需要自定义 TokenGranter
. 参考了 CompositeTokenGranter
的实现方式.
/**
* 自定义的 {@link TokenGranter}<br>
* 为了自定义令牌的返回结构 (把令牌信息包装到通用结构的 data 属性内).
*
* <pre>
* {
* "status": 200,
* "timestamp": "2020-06-23 17:42:12",
* "message": "OK",
* "data": "{\"additionalInformation\":{},\"expiration\":1592905452867,\"expired\":false,\"expiresIn\":119,\"scope\":[\"ACCESS_RESOURCE\"],\"tokenType\":\"bearer\",\"value\":\"81b0d28f-f517-4521-b549-20a10aab0392\"}"
* }
* </pre>
*
* @author LiKe
* @version 1.0.0
* @date 2020-06-23 14:52
* @see org.springframework.security.oauth2.provider.endpoint.TokenEndpoint#postAccessToken(Principal, Map)
* @see org.springframework.security.oauth2.provider.endpoint.TokenEndpoint#getAccessToken(Principal, Map)
* @see CompositeTokenGranter
*/
@Slf4j
public class CustomTokenGranter implements TokenGranter {
/**
* 委托 {@link CompositeTokenGranter}
*/
private final CompositeTokenGranter delegate;
/**
* Description: 构建委托对象 {@link CompositeTokenGranter}
*
* @param configurer {@link AuthorizationServerEndpointsConfigurer}
* @param authenticationManager {@link AuthenticationManager}, grantType 为 password 时需要
* @author LiKe
* @date 2020-06-23 15:28:24
*/
public CustomTokenGranter(AuthorizationServerEndpointsConfigurer configurer, AuthenticationManager authenticationManager) {
final ClientDetailsService clientDetailsService = configurer.getClientDetailsService();
final AuthorizationServerTokenServices tokenServices = configurer.getTokenServices();
final AuthorizationCodeServices authorizationCodeServices = configurer.getAuthorizationCodeServices();
final OAuth2RequestFactory requestFactory = configurer.getOAuth2RequestFactory();
this.delegate = new CompositeTokenGranter(Arrays.asList(
new AuthorizationCodeTokenGranter(tokenServices, authorizationCodeServices, clientDetailsService, requestFactory),
new RefreshTokenGranter(tokenServices, clientDetailsService, requestFactory),
new ImplicitTokenGranter(tokenServices, clientDetailsService, requestFactory),
new ClientCredentialsTokenGranter(tokenServices, clientDetailsService, requestFactory),
new ResourceOwnerPasswordTokenGranter(authenticationManager, tokenServices, clientDetailsService, requestFactory)
));
}
@Override
public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {
log.debug("Custom TokenGranter :: grant token with type {}", grantType);
// 如果发生异常, 会触发 WebResponseExceptionTranslator
final OAuth2AccessToken oAuth2AccessToken =
Optional.ofNullable(delegate.grant(grantType, tokenRequest)).orElseThrow(() -> new UnsupportedGrantTypeException("不支持的授权类型!"));
return new CustomOAuth2AccessToken(oAuth2AccessToken);
}
/**
* 自定义 {@link CustomOAuth2AccessToken}
*/
@com.fasterxml.jackson.databind.annotation.JsonSerialize(using = CustomOAuth2AccessTokenJackson2Serializer.class)
public static final class CustomOAuth2AccessToken extends DefaultOAuth2AccessToken {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
public CustomOAuth2AccessToken(OAuth2AccessToken accessToken) {
super(accessToken);
}
/**
* Description: 序列化 {@link OAuth2AccessToken}
*
* @return 形如 { "access_token": "aa5a459e-4da6-41a6-bf67-6b8e50c7663b", "token_type": "bearer", "expires_in": 119, "scope": "read_scope" } 的字符串
* @see OAuth2AccessTokenJackson1Serializer
*/
@SneakyThrows
public String tokenSerialize() {
final LinkedHashMap<Object, Object> map = new LinkedHashMap<>(5);
map.put(OAuth2AccessToken.ACCESS_TOKEN, this.getValue());
map.put(OAuth2AccessToken.TOKEN_TYPE, this.getTokenType());
final OAuth2RefreshToken refreshToken = this.getRefreshToken();
if (Objects.nonNull(refreshToken)) {
map.put(OAuth2AccessToken.REFRESH_TOKEN, refreshToken.getValue());
}
final Date expiration = this.getExpiration();
if (Objects.nonNull(expiration)) {
map.put(OAuth2AccessToken.EXPIRES_IN, (expiration.getTime() - System.currentTimeMillis()) / 1000);
}
final Set<String> scopes = this.getScope();
if (!CollectionUtils.isEmpty(scopes)) {
final StringBuffer buffer = new StringBuffer();
scopes.stream().filter(StringUtils::isNotBlank).forEach(scope -> buffer.append(scope).append(" "));
map.put(OAuth2AccessToken.SCOPE, buffer.substring(0, buffer.length() - 1));
}
final Map<String, Object> additionalInformation = this.getAdditionalInformation();
if (!CollectionUtils.isEmpty(additionalInformation)) {
additionalInformation.forEach((key, value) -> map.put(key, additionalInformation.get(key)));
}
return OBJECT_MAPPER.writeValueAsString(map);
}
}
/**
* 自定义 {@link CustomOAuth2AccessToken} 的序列化器
*/
private static final class CustomOAuth2AccessTokenJackson2Serializer extends StdSerializer<CustomOAuth2AccessToken> {
protected CustomOAuth2AccessTokenJackson2Serializer() {
super(CustomOAuth2AccessToken.class);
}
@Override
public void serialize(CustomOAuth2AccessToken oAuth2AccessToken, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
jsonGenerator.writeStartObject();
jsonGenerator.writeObjectField(SecurityResponse.FIELD_HTTP_STATUS, HttpStatus.OK.value());
jsonGenerator.writeObjectField(SecurityResponse.FIELD_TIMESTAMP, LocalDateTime.now().format(DateTimeFormatter.ofPattern(SecurityResponse.TIME_PATTERN, Locale.CHINA)));
jsonGenerator.writeObjectField(SecurityResponse.FIELD_MESSAGE, HttpStatus.OK.getReasonPhrase());
jsonGenerator.writeObjectField(SecurityResponse.FIELD_DATA, oAuth2AccessToken.tokenSerialize());
jsonGenerator.writeEndObject();
}
}
}
HttpMessageConverterConfiguration
org.springframework.http.converter.HttpMessageConverter
: 策略接口. 对于支持的 MediaTypes (List<MediaType> getSupportedMediaTypes()
), 作为 Http Request (能读 (boolean canRead(Class<?> clazz, @Nullable MediaType mediaType)
), 就读 (T read(Class<? extends T> clazz, HttpInputMessage inputMessage)
)) 和 Http Response (能写 (boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType)
), 就写(void write(T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
)) 的转换器.
默认情况下, SpringBoot 的自动配置类会在启动时将其配置上这套默认的 HttpMessageConverter
(ref: org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport#addDefaultHttpMessageConverters
):
public class WebMvcConfigurationSupport implements ApplicationContextAware, ServletContextAware {
private static final boolean romePresent;
private static final boolean jaxb2Present;
private static final boolean jackson2Present;
private static final boolean jackson2XmlPresent;
private static final boolean jackson2SmilePresent;
private static final boolean jackson2CborPresent;
private static final boolean gsonPresent;
private static final boolean jsonbPresent;
static {
ClassLoader classLoader = WebMvcConfigurationSupport.class.getClassLoader();
romePresent = ClassUtils.isPresent("com.rometools.rome.feed.WireFeed", classLoader);
jaxb2Present = ClassUtils.isPresent("javax.xml.bind.Binder", classLoader);
jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) &&
ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader);
jackson2XmlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", classLoader);
jackson2SmilePresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.smile.SmileFactory", classLoader);
jackson2CborPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.cbor.CBORFactory", classLoader);
gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader);
jsonbPresent = ClassUtils.isPresent("javax.json.bind.Jsonb", classLoader);
}
...
/**
* Adds a set of default HttpMessageConverter instances to the given list.
* Subclasses can call this method from {@link #configureMessageConverters}.
* @param messageConverters the list to add the default message converters to
*/
protected final void addDefaultHttpMessageConverters(List<HttpMessageConverter<?>> messageConverters) {
StringHttpMessageConverter stringHttpMessageConverter = new StringHttpMessageConverter();
stringHttpMessageConverter.setWriteAcceptCharset(false); // see SPR-7316
messageConverters.add(new ByteArrayHttpMessageConverter());
messageConverters.add(stringHttpMessageConverter);
messageConverters.add(new ResourceHttpMessageConverter());
messageConverters.add(new ResourceRegionHttpMessageConverter());
try {
messageConverters.add(new SourceHttpMessageConverter<>());
}
catch (Throwable ex) {
// Ignore when no TransformerFactory implementation is available...
}
messageConverters.add(new AllEncompassingFormHttpMessageConverter());
if (romePresent) {
messageConverters.add(new AtomFeedHttpMessageConverter());
messageConverters.add(new RssChannelHttpMessageConverter());
}
if (jackson2XmlPresent) {
Jackson2ObjectMapperBuilder builder = Jackson2ObjectMapperBuilder.xml();
if (this.applicationContext != null) {
builder.applicationContext(this.applicationContext);
}
messageConverters.add(new MappingJackson2XmlHttpMessageConverter(builder.build()));
}
else if (jaxb2Present) {
messageConverters.add(new Jaxb2RootElementHttpMessageConverter());
}
if (jackson2Present) {
Jackson2ObjectMapperBuilder builder = Jackson2ObjectMapperBuilder.json();
if (this.applicationContext != null) {
builder.applicationContext(this.applicationContext);
}
messageConverters.add(new MappingJackson2HttpMessageConverter(builder.build()));
}
else if (gsonPresent) {
messageConverters.add(new GsonHttpMessageConverter());
}
else if (jsonbPresent) {
messageConverters.add(new JsonbHttpMessageConverter());
}
if (jackson2SmilePresent) {
Jackson2ObjectMapperBuilder builder = Jackson2ObjectMapperBuilder.smile();
if (this.applicationContext != null) {
builder.applicationContext(this.applicationContext);
}
messageConverters.add(new MappingJackson2SmileHttpMessageConverter(builder.build()));
}
if (jackson2CborPresent) {
Jackson2ObjectMapperBuilder builder = Jackson2ObjectMapperBuilder.cbor();
if (this.applicationContext != null) {
builder.applicationContext(this.applicationContext);
}
messageConverters.add(new MappingJackson2CborHttpMessageConverter(builder.build()));
}
}
...
}
org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport#getMessageConverters
调用了这个方法, 而用到了后者的两个方法: org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport#requestMappingHandlerAdapter
和 org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport#addDefaultHandlerExceptionResolvers
又都被 WebMvcAutoConfiguration
调用. 所以最终的, SpringBoot 的自动配置机制会确保这套默认的转换器被配置上.
首先, 我们定义一个自己实现的 HttpMessageConverter
以便稍后更多自定义控制.
/**
* 用 FastJson 作为 {@link HttpMessageConverter}
*
* @author LiKe
* @version 1.0.0
* @date 2020-05-25 15:43
*/
@Configuration
public class HttpMessageConverterConfiguration {
@Bean
public HttpMessageConverter<?> httpMessageConvertConfigurer() {
// ~ FastJsonConfig
final FastJsonConfig config = new FastJsonConfig();
config.setSerializerFeatures(
// 保留 Map 空的字段
SerializerFeature.WriteMapNullValue,
// 将 String类型的 null 转成 ""
SerializerFeature.WriteNullStringAsEmpty,
// 将 Number类型的 null 转成 0
SerializerFeature.WriteNullNumberAsZero,
// 将 List类型的 null 转成 []
SerializerFeature.WriteNullListAsEmpty,
// 将 Boolean 类型的 null 转成 false
SerializerFeature.WriteNullBooleanAsFalse,
// 避免循环引用
SerializerFeature.DisableCircularReferenceDetect
);
config.setDateFormat("yyyy-MM-dd HH:mm:ss");
// ~ FastJsonHttpMessageConverter
final FastJsonHttpMessageConverter converter = new CustomFastJsonHttpMessageConverter(
CustomWebResponseExceptionTranslator.CustomOAuth2Exception.class,
CustomTokenGranter.CustomOAuth2AccessToken.class
);
converter.setFastJsonConfig(config);
converter.setDefaultCharset(StandardCharsets.UTF_8);
// 相当于在 Controller 上的 @RequestMapping 中的 produces = "application/json"
converter.setSupportedMediaTypes(Collections.singletonList(MediaType.APPLICATION_JSON));
return converter;
}
/**
* 自定义的 {@link FastJsonHttpMessageConverter}<br>
* 默认的 {@link FastJsonHttpMessageConverter} 的 supports 方法始终返回 true, 对于某些逻辑可能不需要应用到此 Converter
*
* @author LiKe
* @date 2020-06-23 09:51:00
*/
@Slf4j
private static final class CustomFastJsonHttpMessageConverter extends FastJsonHttpMessageConverter {
/**
* 不支持的列表
*/
private final Set<Class<?>> excludes;
public CustomFastJsonHttpMessageConverter(Class<?>... clazzArr) {
this.excludes = Sets.newHashSet(Arrays.asList(clazzArr));
}
@Override
protected boolean supports(Class<?> clazz) {
final boolean supports = !excludes.contains(clazz);
log.debug("Custom FastJsonHttpMessageConverter#supports :: {} - {}", clazz.getCanonicalName(), supports);
return supports;
}
}
}
值得一提的是, 对于异常的响应 (自定义 WebResponseExceptionTranslator
中) 和正常状态的响应 (自定义 TokenGranter
), 为了返回统一的响应结构, 我们已经对自定义的 OAth2Exception
(CustomWebResponseExceptionTranslator.CustomOAuth2Exception
) 和自定义的 OAuth2AccessToken
(CustomTokenGranter.CustomOAuth2AccessToken
) 做了序列化处理, 所以在转换器中, 将他们排除, 否则直接序列化出来的 JSON 并不是我们期望的结构.
☆ Authorization Server: ClientDetailsService
在之前 Spring Security 的 Demo 中, 用户信息是通过自定义的 UserDetailsService
获取的, 同样的, 客户端信息也有对应的 ClientDetailsService
, 本文使用它从数据库中读取已经注册的客户端信息.
与 UserDetailsService
的注册方式类似, 需要在 AuthorizationServerConfigurationAdapter#configure(ClientDetailsServiceConfigurer clients)
中指定自定义的 ClientDetailsService
:
/**
* Description: 配置 {@link org.springframework.security.oauth2.provider.ClientDetailsService}
*
* @see AuthorizationServerConfigurerAdapter#configure(ClientDetailsServiceConfigurer)
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// @formatter:off
clients.withClientDetails(clientDetailsService);
// @formatter:on
}
Code
下面来看看部分核心代码.
CustomClientDetailsService
CustomClientDetailsService
: 从数据库查出客户端信息, 并组织成 ClientDetails
. 并存入缓存.
/**
* 自定义的 {@link ClientDetailsService}
*
* @author LiKe
* @version 1.0.0
* @date 2020-06-15 12:41
*/
@Slf4j
@Service
public class CustomClientDetailsService implements ClientDetailsService {
private ClientMapper clientMapper;
private RedisService redisService;
/**
* Description: 从数据库中获取已经注册过的客户端信息<br>
* Details: 该方法会在整个认证过程中被多次调用, 所以应该缓存. 缓存过期时间在 access_token 有效期的基础上加一个时间 buffer
*
* @param clientId 客户端 ID
* @see ClientDetailsService#loadClientByClientId(String)
*/
@Override
public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException {
log.debug("About to produce ClientDetails with client-id: {}", clientId);
final RedisKey cacheKey = RedisKey.builder().prefix("auth").suffix(clientId).build();
// 先从缓存中获取 ClientDto
ClientDTO clientDto = redisService.getValue(cacheKey, ClientDTO.class);
// 如果缓存中没有, 从数据库查询并置入缓存
if (Objects.isNull(clientDto)) {
clientDto = clientMapper.getClient(clientId);
if (Objects.isNull(clientDto)) {
throw new ClientRegistrationException(String.format("客户端 %s 尚未注册!", clientId));
}
// Buffer: 10s
redisService.setValue(cacheKey, clientDto, clientDto.getAccessTokenValidity() + 10);
}
return new CustomClientDetails(clientDto);
}
// ~ Autowired
// -----------------------------------------------------------------------------------------------------------------
@Autowired
public void setClientMapper(ClientMapper clientMapper) {
this.clientMapper = clientMapper;
}
@Autowired
public void setRedisService(RedisService redisService) {
this.redisService = redisService;
}
}
CustomClientDetails
ClientDetailsService
接口的唯一一个方法需要返回名为 ClientDetails
的对象. 需要将从数据库查出来的客户端信息封装成 ClientDetails
. 如下所示, 新建一个 CustomClientDetails
对象, 用于将 ClientDTO
转换成 ClientDetails
.
/**
* 自定义的 {@link ClientDetails}
*
* @author LiKe
* @version 1.0.0
* @date 2020-06-15 13:07
*/
public class CustomClientDetails implements ClientDetails {
private final ClientDTO clientDto;
public CustomClientDetails(ClientDTO clientDto) {
this.clientDto = clientDto;
}
private static Set<String> composeFrom(String raw) {
return Arrays.stream(StringUtils.split(raw, ","))
.map(StringUtils::trimToEmpty).filter(StringUtils::isNotBlank).collect(Collectors.toSet());
}
@Override
public String getClientId() {
return clientDto.getId();
}
@Override
public Set<String> getResourceIds() {
return clientDto.getResourceIds();
}
@Override
public boolean isSecretRequired() {
return true;
}
@Override
public String getClientSecret() {
return clientDto.getClientSecret();
}
@Override
public boolean isScoped() {
return true;
}
@Override
public Set<String> getScope() {
return composeFrom(clientDto.getScope());
}
@Override
public Set<String> getAuthorizedGrantTypes() {
return composeFrom(clientDto.getAuthorizedGrantType());
}
@Override
public Set<String> getRegisteredRedirectUri() {
return composeFrom(clientDto.getRedirectUri());
}
@Override
public Collection<GrantedAuthority> getAuthorities() {
return clientDto.getAuthorities().stream().map(SimpleGrantedAuthority::new).collect(Collectors.toSet());
}
@Override
public Integer getAccessTokenValiditySeconds() {
return clientDto.getAccessTokenValidity();
}
@Override
public Integer getRefreshTokenValiditySeconds() {
return clientDto.getRefreshTokenValidity();
}
@Override
public boolean isAutoApprove(String scope) {
return clientDto.isAutoApprove();
}
// -----------------------------------------------------------------------------------------------------------------
@Override
public Map<String, Object> getAdditionalInformation() {
return null;
}
}
ClientMapper
/**
* 自定义 {@link ClientDetails} 的 Mapper
*
* @author LiKe
* @version 1.0.0
* @date 2020-06-15 13:05
*/
@Repository
public interface ClientMapper {
String TABLE_NAME = "CLIENT";
/**
* Description: 通过客户端 ID 获取客户端
*
* @param clientId 客户端 ID
* @return {@link Client}
* @author LiKe
* @date 2020-06-15 13:17:53
*/
@Select("SELECT c.* FROM " + TABLE_NAME + " c WHERE c.ID = #{clientId}")
@Results({
@Result(id = true, property = "id", column = "ID"),
@Result(property = "clientSecret", column = "CLIENT_SECRET"),
@Result(property = "scope", column = "SCOPE"),
@Result(property = "authorizedGrantType", column = "AUTHORIZED_GRANT_TYPE"),
@Result(property = "redirectUri", column = "REDIRECT_URI"),
@Result(property = "accessTokenValidity", column = "ACCESS_TOKEN_VALIDITY"),
@Result(property = "refreshTokenValidity", column = "REFRESH_TOKEN_VALIDITY"),
@Result(property = "autoApprove", column = "AUTO_APPROVE"),
@Result(property = "description", column = "DESCRIPTION"),
@Result(property = "resourceIds", column = "ID", javaType = Set.class,
many = @Many(
select = "c.c.d.s.s.o.csd.as.mapper.client.map.MappingClientToResourceServerMapper.queryResourceServerIds"
)
),
@Result(property = "authorities", column = "ID", javaType = Set.class,
many = @Many(
select = "c.c.d.s.s.o.csd.as.mapper.client.map.MappingClientToClientAuthorityMapper.queryClientAuthorities"
)
)
})
ClientDTO getClient(String clientId);
}
MappingClientToResourceServerMapper
/**
* {@link MappingClientToResourceServer}Mapper
*
* @author LiKe
* @version 1.0.0
* @date 2020-06-17 11:37
*/
@Repository
@SuppressWarnings("unused")
public interface MappingClientToResourceServerMapper {
String TABLE_NAME = "MAPPING_CLIENT_TO_RESOURCE_SERVER";
/**
* Description: 查询匹配的资源服务器 ID
*
* @param clientId 客户端 ID
* @return java.util.Set<java.lang.String>
* @author LiKe
* @date 2020-06-17 11:39:08
*/
@Select("SELECT RESOURCE_SERVER_ID FROM " + TABLE_NAME + " WHERE CLIENT_ID = #{clientId}")
Set<String> queryResourceServerIds(String clientId);
}
MappingClientToClientAuthorityMapper
/**
* {@link MappingClientToClientAuthority}Mapper
*
* @author LiKe
* @version 1.0.0
* @date 2020-06-17 12:52
*/
@Repository
@SuppressWarnings("unused")
public interface MappingClientToClientAuthorityMapper {
String TABLE_NAME = "MAPPING_CLIENT_TO_CLIENT_AUTHORITY";
/**
* Description: 查询客户端职权 ID
*
* @param clientId 客户端 ID
* @return java.util.Set<java.lang.String>
* @author LiKe
* @date 2020-06-17 12:57:33
*/
@Select("SELECT ca.name " +
"FROM " + TABLE_NAME + " mctca LEFT JOIN CLIENT_AUTHORITY ca ON ca.ID = mctca.CLIENT_AUTHORITY_ID " +
"WHERE mctca.CLIENT_ID = #{clientId}")
Set<String> queryClientAuthorities(String clientId);
}
数据库表设计
☀ CLIENT
为此, 需要一张对应客户端信息的表, 取名为 CLIENT, 表结构对应 org.springframework.security.oauth2.provider.ClientDetails
, 描述了客户端详情信息, 包含: 客户端 ID, 客户端可访问的资源IDs (resource-ids)
-- ----------------------------
-- Table structure for CLIENT
-- ----------------------------
DROP TABLE IF EXISTS `CLIENT`;
CREATE TABLE `CLIENT` (
`ID` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '客户端 ID',
`CLIENT_SECRET` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '客户端 Secret (加密后)',
`SCOPE` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '客户端 Scope (英文逗号分隔)',
`AUTHORIZED_GRANT_TYPE` varchar(70) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '授权方式, 只可能是: authorization_code,implicit,refresh_token,password,client_credentials.\r\n如果是多个, 以英文逗号分隔.',
`REDIRECT_URI` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT '' COMMENT '重定向地址, 当授权方式是 authorization_code 时有效. 如果有多个, 按英文逗号分隔.',
`ACCESS_TOKEN_VALIDITY` int(0) NULL DEFAULT 120 COMMENT 'access-token 过期时间 (秒)',
`REFRESH_TOKEN_VALIDITY` int(0) NULL DEFAULT 240 COMMENT 'refresh-token 过期时间 (秒)',
`AUTO_APPROVE` tinyint(1) NULL DEFAULT 0 COMMENT '是否自动允许',
`DESCRIPTION` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '客户端描述',
PRIMARY KEY (`ID`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '客户端' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of CLIENT
-- ----------------------------
INSERT INTO `CLIENT` VALUES ('client-a', '$2a$10$W0af8zbYneYlIBlWo.pkXue6K9cQTeAfTfRvt7J3.xbjPsuDAx146', 'ACCESS_RESOURCE', 'authorization_code,password,implicit,client_credentials,refresh_token', 'callback', 120, 240, 0, 'client_secret: client-a-p');
☀ CLIENT_AUTHORITY
一个客户端往往对应了访问一簇资源的权限, 是的, 客户端本身也有访问资源权限控制的说法. 区别于 SCOPE, 客户端的职权是更细粒度的概念.
DROP TABLE IF EXISTS `CLIENT_AUTHORITY`;
CREATE TABLE `CLIENT_AUTHORITY` (
`ID` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '客户端职权 ID',
`NAME` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '职权名称',
`DESCRIPTION` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '职权描述',
PRIMARY KEY (`ID`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '客户端职权. 职权代表了一簇可访问的资源集 (RESOURCE).' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of CLIENT_AUTHORITY
-- ----------------------------
INSERT INTO `CLIENT_AUTHORITY` VALUES ('11cffc50570a4866819cee58d695b703', 'SENIOR_CLIENT', '初级');
INSERT INTO `CLIENT_AUTHORITY` VALUES ('a54ba268a6604b54b2ac26990310b3ee', 'INTERMEDIATE_CLIENT', '中级');
INSERT INTO `CLIENT_AUTHORITY` VALUES ('bade7a63483640bab2d79cd2b78fdea8', 'JUNIOR_CLIENT', '高级');
补充说明 - Client’s scope & authority
这部分将在 Spring Security OAuth2 动态权限中仔细介绍
之前查过一些资料, 往往都是说对于客户端来说, 只有 Scope 的概念, 而没有 Role (Authority) 的概念, 其实不然:
从 org.springframework.security.oauth2.provider.ClientDetails#getAuthority
的方法描述中可以看到官方的说明:
Returns the authorities that are granted to the OAuth client. Cannot return
null
.
Note that these are NOT the authorities that are granted to the user with an authorized access token.
Instead, these authorities are inherent to the client itself.
简而言之就是, 该方法返回赋予该客户端的 Authorities. 并且也说到, 这不是用户侧的权限, 而是客户端本身固有的.
并且, 在授权码模式的文章中, 我们也曾在授权服务器中给一个客户端指定权限:
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
oauthServer
.tokenKeyAccess("isAnonymous() || hasAuthority('ROLE_TRUSTED_CLIENT')")
.checkTokenAccess("hasAuthority('ROLE_TRUSTED_CLIENT')");
}
而对于资源服务器来说, ResourceServerConfigurerAdapter#configure(HttpSecurity http)
的方法注释中, 我们知道有一个处理类已经被注入, 找到使用它的类, 在 ResourceServerSecurityConfigurer
中初始化了一个 OAuth2WebSecurityExpressionHandler
, 并且在 ResourceServerSecurityConfigurer#configure(HttpSecurity http)
中, 为 HttpSecurity
注册了这个 Handler
. 相关源代码:
public final class ResourceServerSecurityConfigurer extends
SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
...
private SecurityExpressionHandler<FilterInvocation> expressionHandler = new OAuth2WebSecurityExpressionHandler();
...
@Override
public void configure(HttpSecurity http) throws Exception {
AuthenticationManager oauthAuthenticationManager = oauthAuthenticationManager(http);
resourcesServerFilter = new OAuth2AuthenticationProcessingFilter();
resourcesServerFilter.setAuthenticationEntryPoint(authenticationEntryPoint);
resourcesServerFilter.setAuthenticationManager(oauthAuthenticationManager);
if (eventPublisher != null) {
resourcesServerFilter.setAuthenticationEventPublisher(eventPublisher);
}
if (tokenExtractor != null) {
resourcesServerFilter.setTokenExtractor(tokenExtractor);
}
resourcesServerFilter = postProcess(resourcesServerFilter);
resourcesServerFilter.setStateless(stateless);
// @formatter:off
http
.authorizeRequests().expressionHandler(expressionHandler)
.and()
.addFilterBefore(resourcesServerFilter, AbstractPreAuthenticatedProcessingFilter.class)
.exceptionHandling().accessDeniedHandler(accessDeniedHandler).authenticationEntryPoint(authenticationEntryPoint);
// @formatter:on
}
...
}
再来看看 OAuth2WebSecurityExpressionHandler
的 “族谱”:
要提到这个类就不得不说一下 StandardEvaluationContext
, 当解析属性, 方法或帮助执行类型转换的时候, Spring 提供了一个用于解析这类表达式的接口: EvaluationContext
. 它有两个实现:
SimpleEvaluationContext
: 暴露了 SpEL 核心特性和配置属性, 是 SpEL 的一个子集. 为那种不需要 SpEL 全部特性的表达式.StandardEvaluationContext
: 暴露 SpEL 的全部特性.
通过下面这个简单的代码片段简述 StandardEvaluationContext
的作用:
/**
* A simple test for {@link StandardEvaluationContext}
*/
@Test
public void testStandardEvaluationContext() {
ExpressionParser expressionParser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
context.setRootObject(new Root(true));
context.setVariable("name", new Name("caplike"));
// 调用 rootObject 的方法
log.debug("{}", expressionParser.parseExpression("isRoot('caplike')").getValue(context, Boolean.class));
// 调用 name 所对应对象的 is 方法: false
log.debug("{}", expressionParser.parseExpression("#name.is('like')").getValue(context, Boolean.class));
// 调用 name 所对应对象的 is 方法: true
log.debug("{}", expressionParser.parseExpression("#name.is('caplike')").getValue(context, Boolean.class));
}
private static class Root {
private final boolean root;
public Root(Boolean root) { this.root = root; }
public boolean isRoot(String state) {
log.debug("{} :: Root#isRoot() called ...", state);
return root;
}
}
private static class Name {
private final String specifiedName;
public Name(String specifiedName) { this.specifiedName = specifiedName; }
public boolean is(String name) { return specifiedName.equals(name); }
}
控制台输出:
~ [main] DEBUG org.springframework.expression.spel.support.StandardEvaluationContextTest - caplike :: Root#isRoot() called ...
~ [main] DEBUG org.springframework.expression.spel.support.StandardEvaluationContextTest - true
~ [main] DEBUG org.springframework.expression.spel.support.StandardEvaluationContextTest - false
~ [main] DEBUG org.springframework.expression.spel.support.StandardEvaluationContextTest - true
从 OAuth2WebSecurityExpressionHandler
的继承关系可以看到其继承自 DefaultWebSecurityExpressionHandler
, 后者又继承自 AbstractSecurityExpressionHandler
. 在 OAuth2WebSecurityExpressionHandler
中我们看到:
protected StandardEvaluationContext createEvaluationContextInternal(Authentication authentication,
FilterInvocation invocation) {
StandardEvaluationContext ec = super.createEvaluationContextInternal(authentication, invocation);
ec.setVariable("oauth2", new OAuth2SecurityExpressionMethods(authentication));
return ec;
}
OAuth2SecurityExpressionMethods
: 包含了所有支持的方法签名, 如 hasScope(String)
. 调取方式为: #oauth2.hasScope('some scope')
, 同理, 再来看看 AbstractSecurityExpressionHandler
的源代码:
/**
* Invokes the internal template methods to create {@code StandardEvaluationContext}
* and {@code SecurityExpressionRoot} objects.
*
* @param authentication the current authentication object
* @param invocation the invocation (filter, method, channel)
* @return the context object for use in evaluating the expression, populated with a
* suitable root object.
*/
public final EvaluationContext createEvaluationContext(Authentication authentication, T invocation) {
SecurityExpressionOperations root = createSecurityExpressionRoot(authentication, invocation);
StandardEvaluationContext ctx = createEvaluationContextInternal(authentication, invocation);
ctx.setBeanResolver(br);
ctx.setRootObject(root);
return ctx;
}
SecurityExpressionOperations
则包含了所有 Spring Security 对于用户层级的方法签名, 如 hasRole(String)
. 由此可见, 在 configure(HttpSecurity http)
中配置 HttpSecurity
访问规则的时候可以使用 Spring Security OAuth 2.0 的支持客户端的表达式, 也可以使用 Spring Security 提供过的支持用户级别的表达式.
☀ RESOURCE
资源定义表, 表示着所有服务提供的资源的 URI 列表. 形如 /user/1 这类, 应该支持通配符.
-- ----------------------------
-- Table structure for RESOURCE
-- ----------------------------
DROP TABLE IF EXISTS `RESOURCE`;
CREATE TABLE `RESOURCE` (
`ID` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '标识资源的 ID',
`ENDPOINT` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '资源端点',
PRIMARY KEY (`ID`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '资源. 代表着形如 /user/1 的具体的资源本身.' ROW_FORMAT = Dynamic;
☀ RESOURCE_SERVER
资源服务器定义表.
-- ----------------------------
-- Table structure for RESOURCE_SERVER
-- ----------------------------
DROP TABLE IF EXISTS `RESOURCE_SERVER`;
CREATE TABLE `RESOURCE_SERVER` (
`ID` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '资源服务器 ID',
`RESOURCE_SECRET` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '资源密钥 (加密后)',
`DESCRIPTION` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '资源服务器描述',
PRIMARY KEY (`ID`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '资源服务器. 可提供客户端访问的资源服务器定义.' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of RESOURCE_SERVER
-- ----------------------------
INSERT INTO `RESOURCE_SERVER` VALUES ('resource-server', NULL, '资源服务器');
☀ USER
用户表
-- ----------------------------
-- Table structure for USER
-- ----------------------------
DROP TABLE IF EXISTS `USER`;
CREATE TABLE `USER` (
`ID` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '用户 ID',
`PASSWORD` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '用户密码',
`USERNAME` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '用户名',
PRIMARY KEY (`ID`) USING BTREE,
UNIQUE INDEX `IDX_USER_ID`(`ID`) USING BTREE COMMENT 'USER 表主键索引'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '用户表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of USER
-- ----------------------------
INSERT INTO `USER` VALUES ('34b5ecfe5f8f45cbb41871e0786d530b', '$2a$10$ED3Jr82rgI4.zIFMOh1MMuikg.vwq45P0/1oYKSNmoWmQc5DiQmBK', 'caplike');
☀ USER_AUTHORITY
用户职权表, 代表着用户的角色. 而角色是一簇可访问 RESOURCE 的集合.
-- ----------------------------
-- Table structure for USER_AUTHORITY
-- ----------------------------
DROP TABLE IF EXISTS `USER_AUTHORITY`;
CREATE TABLE `USER_AUTHORITY` (
`ID` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '用户职权 ID',
`NAME` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '职权名称',
`DESCRIPTION` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '职权描述',
PRIMARY KEY (`ID`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '用户职权. 如 ADMIN, USER etc. 职权代表了一簇可访问的资源集 (RESOURCE).' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of USER_AUTHORITY
-- ----------------------------
INSERT INTO `USER_AUTHORITY` VALUES ('e096cef2a3cf491f915dd25542b1218f', 'ADMIN', '管理员');
INSERT INTO `USER_AUTHORITY` VALUES ('e8ad77d15cc04c3097658d945714d63c', 'USER', '用户');
Mapping Table
一系列映射表. 负责维护实体间的关系.
MAPPING_CLIENT_TO_CLIENT_AUTHORITY
CLIENT -> CLIENT_AUTHORITY 的映射表. 代表着一个客户端的职权.
-- ----------------------------
-- Table structure for MAPPING_CLIENT_TO_CLIENT_AUTHORITY
-- ----------------------------
DROP TABLE IF EXISTS `MAPPING_CLIENT_TO_CLIENT_AUTHORITY`;
CREATE TABLE `MAPPING_CLIENT_TO_CLIENT_AUTHORITY` (
`CLIENT_ID` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '客户端 ID',
`CLIENT_AUTHORITY_ID` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '客户端职权 ID',
PRIMARY KEY (`CLIENT_ID`, `CLIENT_AUTHORITY_ID`) USING BTREE,
INDEX `FK_MCTCA_CLIENT_AUTHORITY_ID_CLIENT_AUTHORITY`(`CLIENT_AUTHORITY_ID`) USING BTREE,
CONSTRAINT `FK_MCTCA_CLIENT_AUTHORITY_ID_CLIENT_AUTHORITY` FOREIGN KEY (`CLIENT_AUTHORITY_ID`) REFERENCES `CLIENT_AUTHORITY` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `FK_MCTCA_CLIENT_ID_CLIENT` FOREIGN KEY (`CLIENT_ID`) REFERENCES `CLIENT` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '客户端到客户端职权的映射表.' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of MAPPING_CLIENT_TO_CLIENT_AUTHORITY
-- ----------------------------
INSERT INTO `MAPPING_CLIENT_TO_CLIENT_AUTHORITY` VALUES ('client-a', '11cffc50570a4866819cee58d695b703');
MAPPING_CLIENT_AUTHORITY_TO_RESOURCE
CLIENT_AUTHORITY -> RESOURCE 的映射表, 代表着一个职权所对应的一簇资源的访问权限.
-- ----------------------------
-- Table structure for MAPPING_CLIENT_AUTHORITY_TO_RESOURCE
-- ----------------------------
DROP TABLE IF EXISTS `MAPPING_CLIENT_AUTHORITY_TO_RESOURCE`;
CREATE TABLE `MAPPING_CLIENT_AUTHORITY_TO_RESOURCE` (
`CLIENT_AUTHORITY_ID` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '客户端职权 ID',
`RESOURCE_ID` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '标识资源的 ID',
PRIMARY KEY (`CLIENT_AUTHORITY_ID`, `RESOURCE_ID`) USING BTREE,
INDEX `FK_MCATR_RESOURCE_ID_RESOURCE`(`RESOURCE_ID`) USING BTREE,
CONSTRAINT `FK_MCATR_CLIENT_AUTHORITY_ID_CLIENT_AUTHORITY` FOREIGN KEY (`CLIENT_AUTHORITY_ID`) REFERENCES `CLIENT_AUTHORITY` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `FK_MCATR_RESOURCE_ID_RESOURCE` FOREIGN KEY (`RESOURCE_ID`) REFERENCES `RESOURCE` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Dynamic;
MAPPING_CLIENT_TO_RESOURCE_SERVER
CLIENT -> RESOURCE_SERVER 的映射表, 代表着一个客户端有权访问的资源服务器的集合.
-- ----------------------------
-- Table structure for MAPPING_CLIENT_TO_RESOURCE_SERVER
-- ----------------------------
DROP TABLE IF EXISTS `MAPPING_CLIENT_TO_RESOURCE_SERVER`;
CREATE TABLE `MAPPING_CLIENT_TO_RESOURCE_SERVER` (
`CLIENT_ID` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '客户端 ID',
`RESOURCE_SERVER_ID` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '资源服务器 ID',
PRIMARY KEY (`CLIENT_ID`, `RESOURCE_SERVER_ID`) USING BTREE,
INDEX `FK_MCTRS_RESOURCE_SERVER_ID_RESOURCE_SERVER`(`RESOURCE_SERVER_ID`) USING BTREE,
CONSTRAINT `FK_MCTRS_CLIENT_ID_CLIENT` FOREIGN KEY (`CLIENT_ID`) REFERENCES `CLIENT` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `FK_MCTRS_RESOURCE_SERVER_ID_RESOURCE_SERVER` FOREIGN KEY (`RESOURCE_SERVER_ID`) REFERENCES `RESOURCE_SERVER` (`ID`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '客户端到资源资源服务器的映射表. 标识了一个客户端可以访问的资源服务器.' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of MAPPING_CLIENT_TO_RESOURCE_SERVER
-- ----------------------------
INSERT INTO `MAPPING_CLIENT_TO_RESOURCE_SERVER` VALUES ('client-a', 'resource-server');
MAPPING_USER_TO_USER_AUTHORITY
USER -> USER_AUTHORITY 的映射标, 代表一个用户的职权.
-- ----------------------------
-- Table structure for MAPPING_USER_TO_USER_AUTHORITY
-- ----------------------------
DROP TABLE IF EXISTS `MAPPING_USER_TO_USER_AUTHORITY`;
CREATE TABLE `MAPPING_USER_TO_USER_AUTHORITY` (
`USER_ID` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '用户 ID',
`USER_AUTHORITY_ID` varbinary(32) NOT NULL COMMENT '用户职权 ID',
PRIMARY KEY (`USER_ID`, `USER_AUTHORITY_ID`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '用户和用户职权的映射表.' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of MAPPING_USER_TO_USER_AUTHORITY
-- ----------------------------
INSERT INTO `MAPPING_USER_TO_USER_AUTHORITY` VALUES ('caplike', 'e8ad77d15cc04c3097658d945714d63c');
MAPPING_USER_AUTHORITY_TO_RESOURCE
USER_AUTHORITY -> RESOURCE 的映射表, 代表着一个用户职权所对应的可访问资源的集合.
-- ----------------------------
-- Table structure for MAPPING_USER_AUTHORITY_TO_RESOURCE
-- ----------------------------
DROP TABLE IF EXISTS `MAPPING_USER_AUTHORITY_TO_RESOURCE`;
CREATE TABLE `MAPPING_USER_AUTHORITY_TO_RESOURCE` (
`USER_AUTHORITY_ID` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '用户职权 ID',
`RESOURCE_ID` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '资源 ID',
PRIMARY KEY (`USER_AUTHORITY_ID`, `RESOURCE_ID`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '用户职权和资源的映射表.' ROW_FORMAT = Dynamic;
总结
本文介绍了 SpringSecurity OAuth2 从数据库中获取并封装客户端信息, 权限套表; 几种授权模式配置式支持. 自定义异常处理, 令牌生成以及返回给前端一致性的响应结构.