一、开篇

本篇是《Spring OAuth2 开发指南》系列文章的第二篇,通过代码实例具体介绍 OAuth2 明码模式的开发细节。网络上对于 OAuth2 开发的代码示范非常多而且芜杂,基本上都是官网手册的摘录搬运,或者过多地受制于框架自身如 Spring Security,束缚太多,不足系统性,容易造成同学们云里雾里,以至于生吞活剥。

自己主张在开发落地过程中,既不能齐全本人造轮子,也不应齐全依赖轮子,应该从实质登程,在理清技术原理和细节的条件下,抉择适宜的办法。从这个准则登程,本文将依据“明码模式的典型架构档次和次要流程”(见《Spring OAuth2 开发指南(一)》)中形容的流程节点,展现其代码实现。另外,文章的要点在于后半局部,提出了资源服务器端鉴权/权限管制,和受权服务器端鉴权/权限管制两种实现办法。

须要留神的是 password 模式因为 OAuth2.1 不举荐应用所以只提供旧的组件代码版本,具体请参见 https://datatracker.ietf.org/...

二、 演示案例

咱们持续用相册预览零碎(PAPS,Photo Album Preview System)作为演示案例。

PAPS 是一个社交平台的子系统,与 IBCS 相似,采纳 RESTful API 对外交互,次要性能是容许用户预览本人的相册,以下是 PAPS 演示我的项目的必要服务:

服务名 | 类别 | 形容 | 技术选型

  • photo-service外部服务资源服务器角色,相册预览服务Spring Boot 开发的 RESTful 服务
    idp外部服务受权服务器角色,具体指负责认证、受权和鉴权Spring Boot 开发
    demo-h5内部利用demo 利用的前端应用 Postman 代替

为此,咱们将搭建两个工程项目:photo-service 和 idp,客户端用 Postman 代替。

三、 工程构造

接下来演示两个工程项目的框架代码,这部分代码蕴含工程的框架结构、Spring Security 和 OAuth2 的根底配置,尽量采纳最精简的形式书写。其余我的项目能够 copy 这部分代码作为根底模板应用。

photo-service 相册服务

  • 根底工程构造
src/main    java        com.example.demophoto            config                oauth2                    CheckTokenAuthentication.java                    CheckTokenFilter.java                    CustomPermissionEvaluator.java                    CustomRemoteTokenServices.java                    ResourceServerConfigurer.java            service                PermisionEvaluatingService.java            web                PhotoController.java            DemoPhotoApplication.java    resources        applicaton.yaml
  • pom.xml
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">    <modelVersion>4.0.0</modelVersion>    <parent>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-parent</artifactId>        <version>2.3.4.RELEASE</version>        <relativePath/> <!-- lookup parent from repository -->    </parent>    <groupId>com.example</groupId>    <artifactId>oauth2-demo-1a-photo-service</artifactId>    <version>0.0.1-SNAPSHOT</version>    <name>oauth2-demo-1a-photo-service</name>    <description>oauth2-demo-1a-photo-service</description>    <properties>        <java.version>1.8</java.version>    </properties>    <dependencies>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter</artifactId>        </dependency>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-web</artifactId>        </dependency>        <!-- https://mvnrepository.com/artifact/org.springframework.security.oauth.boot/spring-security-oauth2-autoconfigure -->        <dependency>            <groupId>org.springframework.security.oauth.boot</groupId>            <artifactId>spring-security-oauth2-autoconfigure</artifactId>            <version>2.1.2.RELEASE</version>        </dependency>    </dependencies>    <build>        <plugins>            <plugin>                <groupId>org.springframework.boot</groupId>                <artifactId>spring-boot-maven-plugin</artifactId>            </plugin>        </plugins>    </build></project>
  • applicaton.yaml
server:  port: 8010security:  oauth2:    client:      clientId: client2      clientSecret: client2p    resource:      tokenInfoUri: http://127.0.0.1:8000/oauth/check_token
  • ResourceServerConfigurer.java
package com.example.demophoto.config.oauth2;import org.springframework.context.annotation.Configuration;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;@Configuration@EnableResourceServerpublic class ResourceServerConfigurer extends ResourceServerConfigurerAdapter {    /**     * spring-security-oauth2 组件一般性配置     *     * @param resources     */    @Override    public void configure(ResourceServerSecurityConfigurer resources) {        resources.resourceId("demo-1");    }    /**     * spring-security-oauth2 组件一般性配置     *     * @param http     * @throws Exception     */    @Override    public void configure(HttpSecurity http) throws Exception {        http.authorizeRequests()                .anyRequest().authenticated();    }}

idp 受权服务

  • 根底工程构造
src/main    java        com.example.demoidp            config                oauth2                    AuthorizationServerConfigurer.java                    CheckTokenInterceptor.java                    WebSecurityConfig.java            service                业务逻辑,如鉴权逻辑            DemoIdpApplication.java    resources        applicaton.yaml
  • pom.xml
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">    <modelVersion>4.0.0</modelVersion>    <parent>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-parent</artifactId>        <version>2.3.4.RELEASE</version>        <relativePath/> <!-- lookup parent from repository -->    </parent>    <groupId>com.example</groupId>    <artifactId>oauth2-demo-1a-idp</artifactId>    <version>0.0.1-SNAPSHOT</version>    <name>oauth2-demo-1a-idp</name>    <description>oauth2-demo-1a-idp</description>    <properties>        <java.version>1.8</java.version>    </properties>    <dependencies>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter</artifactId>        </dependency>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-web</artifactId>        </dependency>        <!-- https://mvnrepository.com/artifact/org.springframework.security.oauth/spring-security-oauth2 -->        <dependency>            <groupId>org.springframework.security.oauth</groupId>            <artifactId>spring-security-oauth2</artifactId>            <version>2.3.8.RELEASE</version>        </dependency>    </dependencies>    <build>        <plugins>            <plugin>                <groupId>org.springframework.boot</groupId>                <artifactId>spring-boot-maven-plugin</artifactId>            </plugin>        </plugins>    </build></project>
  • applicaton.yaml
server:  port: 8000
  • AuthorizationServerConfigurer.java
package com.example.demoidp.config.oauth2;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.authentication.AuthenticationManager;import org.springframework.security.crypto.factory.PasswordEncoderFactories;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;@Configuration@EnableAuthorizationServerpublic class AuthorizationServerConfigurer extends AuthorizationServerConfigurerAdapter {    private AuthenticationManager authenticationManager;    /**     * spring-security-oauth2 组件一般性配置     *     * @param authenticationManager     */    @Autowired    public AuthorizationServerConfigurer(AuthenticationManager authenticationManager) {        this.authenticationManager = authenticationManager;    }    /**     * 配置明码加密办法     */    @Bean    PasswordEncoder passwordEncoder() {        return PasswordEncoderFactories.createDelegatingPasswordEncoder();    }    /**     * spring-security-oauth2 组件一般性配置     *     * @param endpoints     */    @Override    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {        endpoints.authenticationManager(authenticationManager);    }    /**     * spring-security-oauth2 组件一般性配置     *     * @param security     */    @Override    public void configure(AuthorizationServerSecurityConfigurer security) {        security                // /oauth/check_token 申请放行                .checkTokenAccess("permitAll()")                .passwordEncoder(passwordEncoder());    }}
  • WebSecurityConfig.java
package com.example.demoidp.config.oauth2;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.authentication.AuthenticationManager;import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;@Configuration@EnableWebSecuritypublic class WebSecurityConfig extends WebSecurityConfigurerAdapter {    /**     * spring-security-oauth2 组件一般性配置     *     * @return AuthenticationManager     * @throws Exception     */    @Bean    @Override    public AuthenticationManager authenticationManagerBean() throws Exception {        return super.authenticationManagerBean();    }}

四、 代码实现

如图所示,是明码模式的最精简架构档次和次要流程。上面咱们逐渐实现该流程:

一)第一阶段:认证受权阶段

1)用户代理(demo-h5)将用户输出的用户名和明码,发送给客户端(demo-service)

此步骤咱们应用 Postman 执行,这里不开展介绍。

2)客户端(demo-service)将用户输出的用户名和明码,连同 client_id + client_secret (由 idp 调配)一起发送到 idp 以申请令牌,如果 idp 约定了 scope 则还须要带上 scope 参数

此步骤咱们应用 Postman 执行,这里不开展介绍。须要留神的是,Postman 在这里依然是一个 client 角色,client_id 代表的是它本人。申请的 URL 为:

POST http://127.0.0.1:8000/oauth/token
3)idp 首先验证 client_id + client_secret 的合法性,再查看 scope 是否无误,最初验证用户名和明码是否正确,正确则生成 token。这一步也叫“认证”

为了实现这个步骤,咱们在 idp 工程的 AuthorizationServerConfigurer 类中退出以下代码:

  • 首先是 client_id + client_secret + scope 的校验
@Configuration@EnableAuthorizationServerpublic class AuthorizationServerConfigurer extends AuthorizationServerConfigurerAdapter {    ...    /**     * 3. [明码模式的典型架构档次和次要流程] 中的第 3 步:     *    idp 首先验证 client_id + client_secret 的合法性,再查看 scope 是否无误     *     *    PS: 这里为演示不便,就地创立了账号,生产环境应自行替换成数据库查问等形式     */    private class MockJDBCClientDetailsService implements ClientDetailsService {        @Override        public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException {            /**             * GrantedAuthority 与 hasAuthority() 关联             */            Set<GrantedAuthority> authorities = new HashSet<>();            authorities.add(new SimpleGrantedAuthority("READ"));            authorities.add(new SimpleGrantedAuthority("WRITE"));                BaseClientDetails details1 = new BaseClientDetails();            details1.setClientId("client1");            details1.setClientSecret(passwordEncoder().encode("client1p"));            details1.setAuthorizedGrantTypes(Arrays.asList("password"));            details1.setScope(Arrays.asList("resource:write", "resource:read"));            details1.setResourceIds(Arrays.asList("demo-1"));            details1.setAuthorities(authorities);                BaseClientDetails details2 = new BaseClientDetails();            details2.setClientId("client2");            details2.setClientSecret(passwordEncoder().encode("client2p"));            details2.setAuthorizedGrantTypes(Arrays.asList("client_credentials"));            details2.setScope(Arrays.asList("resource:write", "resource:read"));            details2.setResourceIds(Arrays.asList("demo-1"));            details2.setAuthorities(authorities);                BaseClientDetails details3 = new BaseClientDetails();            details3.setClientId("client3");            details3.setClientSecret(passwordEncoder().encode("client3p"));            details3.setAuthorizedGrantTypes(Arrays.asList("password"));            details3.setScope(Arrays.asList("resource:write", "resource:read"));            details3.setResourceIds(Arrays.asList("demo-1"));            details3.setAuthorities(authorities);                Map<String, ClientDetails> clients = new HashMap<>();            clients.put("client1", details1);            clients.put("client2", details2);            clients.put("client3", details3);                if (!clients.containsKey(clientId)) {                throw new ClientRegistrationException("Client not found");            }                return clients.get(clientId);        }    }        /**     * spring-security-oauth2 组件一般性配置     * 配置自定义 ClientDetails     *     * @param clients     * @throws Exception     */    @Override    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {        clients.withClientDetails(new MockJDBCClientDetailsService());    }        ...}
  • 而后是用户名和明码的校验
@Configuration@EnableWebSecuritypublic class WebSecurityConfig extends WebSecurityConfigurerAdapter {    /**     * 3. [明码模式的典型架构档次和次要流程] 中的第 3 步:     *    验证用户名和明码是否正确,正确则生成 token     *     *    PS: 这里为演示不便,就地创立了账号,生产环境应自行替换成数据库查问等形式     */    private class MockJDBCUserDeatilsService implements UserDetailsService {        @Override        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {            Map<String, String> users = new HashMap<>();            users.put("user1", "pwd1");            users.put("user2", "pwd2");            if (!users.containsKey(username)) {                throw new UsernameNotFoundException("User not found");            }            return User.withDefaultPasswordEncoder()                    .username(username)                    .password(users.get(username))                    .roles("USER")                    .build();        }    }    @Bean    @Override    public UserDetailsService userDetailsService() {        return new MockJDBCUserDeatilsService();    }}

当 client_id + client_secret + scope,以及用户名和明码都校验通过后,spring-security-oauth2 会调用适合的 tokenServices 生成 token。有趣味的同学能够自行查阅源代码追踪整个过程,这里介绍源码追踪的入口办法:

咱们晓得 demo-h5 客户端(Postman)首先向 http://127.0.0.1:8000/oauth/t... 发动申请,因而咱们找到 spring-security-oauth2 组件源码中的 /oauth/token 端点,具体门路为:

org.springframework.security.oauth2.provider.endpoint.TokenEndpoint.postAccessToken()
4)idp 返回认证后果给客户端,认证通过返回 token,认证失败返回 401。如果认证胜利则此步骤也叫“受权”

这一步 spring-security-oauth2 曾经为咱们解决好了,不须要额定解决。想要追踪源码过程的同学,可参考上一步骤介绍的入口办法。

5)客户端收到 token 后进行暂存,并创立对应的 session

这个步骤通过 Postman 演示(间接复制返回的 token 字符串即可),这里不开展介绍。

6)客户端颁发 cookie 给用户代理/浏览器

这个步骤通过 Postman 演示,这里不开展介绍。

二)第二阶段:受权后申请资源阶段

7)用户通过用户代理(demo-h5)拜访“我的相册”页面,用户代理携带 cookie 向客户端(demo—service)发动申请

此步骤应用 Postman 执行,不开展叙述。

8)客户端通过 session 找到对应的 token,携带此 token 向资源服务器(photo-service)发动申请

此步骤应用 Postman 执行,咱们将第 5) 步获取的 token 作为 Bearer Token,向 photo-service 发动申请,申请的 URL 为:

GET http://127.0.0.1:8010/api/photo该申请只须要携带 token 即可,不须要其余参数
9)资源服务器(photo-service)向 idp 申请验证 token 有效性

在介绍如何解决申请前,咱们先在 photo-service 工程中新增相干代码:

  • PhotoController.java
package com.example.demophoto.web;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;@RestController@RequestMapping("/api/")public class PhotoController {    @GetMapping("/photo")    public String fetchPhoto() {        return "GET photo";    }}

此外,还有几个要害配置:

  1. ResourceServerConfigurerAdapter.configure(HttpSecurity http) 办法配置了 http.authorizeRequests().anyRequest().authenticated() 使得所有申请都要先鉴权;
  2. application.yaml 中配置了 client_id、client_secret 和 resource.tokenInfoUri,当资源服务承受到申请时,会携带 token 向 tokenInfoUri 指定的地址发动鉴权申请。

默认状况下,当 demo-h5 向 photo-service 发动资源拜访的申请时,photo-service 会将获取的 token 发到 idp 进行校验,在这个过程中 spring-security-oauth2 不会对 scope 做任何解决。咱们晓得 scope 是用来束缚 client 的权限范畴的,因而 scope 权限查看(也视为鉴权的工作之一)这个工作须要本人编码实现。

通常来说,scope 权限查看的业务逻辑能够灵便设定,甚至能够疏忽它。本文介绍两种 scope 查看的实现办法:

  1. 资源服务器端查看;
  2. 受权服务器端查看。

接下来的第 10) 步将拆分成两种形式,别离对此进行介绍。

10)【形式一:资源服务器端 scope 查看】 idp 校验 token 有效性,资源服务器校验 scope

idp 校验 token 有效性,通过则返回 client 相干信息(蕴含 scope )给 photo-service,photo-service 再依据 scope 判断客户端(demo-h5)是否有权限调用此 API,如通过查看则持续下一步骤,否则返回 403 谬误给 demo-h5。这一步也叫“鉴权”

咱们在 photo-service 工程中增加以下代码:

  • ResourceServerConfigurer.java
@Configuration@EnableResourceServerpublic class ResourceServerConfigurer extends ResourceServerConfigurerAdapter {    ...        @Override    public void configure(HttpSecurity http) throws Exception {        http.authorizeRequests()                .antMatchers("/api/photo/**").access("#oauth2.hasScope('resource:read')")                .antMatchers("/api/photo2/**").access("#oauth2.hasScope('resource:read')")                .antMatchers("/api/photo3/**").access("#oauth2.hasScope('resource:write')")                .anyRequest().authenticated();    }        ...}

通过 access("#oauth2.hasScope('resource:write')") 办法能够实现资源服务器端的 scope 查看。其次要流程为:

  1. photo-service 收到客户端申请后,将获取到的 token 发往 idp 校验;
  2. idp 校验通过后,将 clientDetails 信息返回给 photo-service,其中就包含 scope 参数;
  3. photo-service 拿到 scope 后,依据 access("#oauth2.hasScope('resource:write')") 判断该申请是否在 scope 范畴内。
10)【形式二:idp 端 scope 查看】 idp 校验 token + scope 有效性

idp 校验 token 有效性,再依据 scope 判断客户端(demo-h5)是否有权限调用此 API,最初返回校验后果给资源服务器。因为 spring-security-oauth2 自身没有解决 scope 查看,且默认状况下,photo-service 向 idp 申请 token 鉴权时,并未携带任何其余申请信息,因而 idp 无奈晓得本次申请的细节,因而无奈执行 socpe 查看。

所以重点有两个:一是 photo-service 向 idp 申请 token 鉴权的同时如何携带申请的细节(比方拜访的是什么资源?申请的是哪个API?);二是如何拦挡 token 鉴权过程使得 scope 校验失败是返回 403 谬误?

当然实现这个目标,有很多办法,本文采纳了比拟直观的办法:利用 Filter。

咱们在 photo-service 工程中增加以下代码:

  • ResourceServerConfigurer.java
package com.example.demophoto.config.oauth2;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.autoconfigure.security.oauth2.resource.ResourceServerProperties;import org.springframework.context.annotation.Configuration;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;@Configuration@EnableResourceServerpublic class ResourceServerConfigurer extends ResourceServerConfigurerAdapter {    private final ResourceServerProperties resource;    @Autowired    protected ResourceServerConfigurer(ResourceServerProperties resource) {        this.resource = resource;    }    /**     * 自定义 RemoteTokenServices 以取代资源服务器默认应用的     * RemoteTokenServices 向 IDP 发动 /oauth/check_token 鉴权申请     *     * @return     */    public CustomRemoteTokenServices customRemoteTokenServices() {        CustomRemoteTokenServices services = new CustomRemoteTokenServices();        services.setCheckTokenEndpointUrl(this.resource.getTokenInfoUri());        services.setClientId(this.resource.getClientId());        services.setClientSecret(this.resource.getClientSecret());        return services;    }    @Override    public void configure(ResourceServerSecurityConfigurer resources) {        resources.resourceId("demo-1")                .tokenServices(customRemoteTokenServices());    }    @Override    public void configure(HttpSecurity http) throws Exception {        http.addFilterBefore(new CheckTokenFilter(), AbstractPreAuthenticatedProcessingFilter.class);        http.authorizeRequests()                .antMatchers("/api/photo/**").access("#oauth2.hasScope('resource:read')")                .antMatchers("/api/photo2/**").access("#oauth2.hasScope('resource:read')")                .antMatchers("/api/photo3/**").access("#oauth2.hasScope('resource:write')")                .anyRequest().authenticated();    }}
  • CheckTokenFilter.java
package com.example.demophoto.config.oauth2;import org.springframework.security.core.context.SecurityContext;import org.springframework.security.core.context.SecurityContextHolder;import javax.servlet.*;import javax.servlet.http.HttpServletRequest;import java.io.IOException;import java.util.HashMap;import java.util.Map;/** * 在向 IDP 发动 /oauth/check_token 申请前,将申请细节存储到 SecurityContext 中, * 以便 CustomRemoteTokenServices.loadAuthentication() 能够获取到该申请细节 */public class CheckTokenFilter implements Filter {    @Override    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException,            ServletException {        HttpServletRequest request = (HttpServletRequest) req;        String uri = request.getRequestURI();        String method = request.getMethod();        /**         * 仅解决 /api/**         */        if (!uri.startsWith("/api/")) {            chain.doFilter(req, res);            return;        }        SecurityContext sc = SecurityContextHolder.getContext();        CheckTokenAuthentication authentication = (CheckTokenAuthentication) sc.getAuthentication();        if (authentication == null) {            authentication = new CheckTokenAuthentication(null);        }        /**         * 将用户代理或其余服务申请拜访本资源服务器的细节(此处为 HTTP-Method + URI)         * 存储到 SecurityContext 的 authentication 对象中         */        Map<String, Object> details = new HashMap<>();        details.put("uri", uri);        details.put("method", method);        authentication.setDetails(details);        sc.setAuthentication(authentication);        chain.doFilter(req, res);    }}
  • CustomRemoteTokenServices.java
package com.example.demophoto.config.oauth2;import org.apache.commons.logging.Log;import org.apache.commons.logging.LogFactory;import org.springframework.http.*;import org.springframework.http.client.ClientHttpResponse;import org.springframework.security.access.AccessDeniedException;import org.springframework.security.core.Authentication;import org.springframework.security.core.AuthenticationException;import org.springframework.security.core.context.SecurityContextHolder;import org.springframework.security.crypto.codec.Base64;import org.springframework.security.oauth2.client.resource.OAuth2AccessDeniedException;import org.springframework.security.oauth2.common.OAuth2AccessToken;import org.springframework.security.oauth2.common.exceptions.InvalidTokenException;import org.springframework.security.oauth2.provider.OAuth2Authentication;import org.springframework.security.oauth2.provider.token.AccessTokenConverter;import org.springframework.security.oauth2.provider.token.DefaultAccessTokenConverter;import org.springframework.security.oauth2.provider.token.ResourceServerTokenServices;import org.springframework.util.LinkedMultiValueMap;import org.springframework.util.MultiValueMap;import org.springframework.web.client.DefaultResponseErrorHandler;import org.springframework.web.client.RestOperations;import org.springframework.web.client.RestTemplate;import java.io.IOException;import java.io.UnsupportedEncodingException;import java.util.HashMap;import java.util.Map;/** * 以 RemoteTokenServices 为模板 * 基本思路是在向 IDP 发动 /oauth/check_token 的申请中, * 增加用户代理或其余服务申请拜访本资源服务器的 API 的细节, * 以便 IDP 能够判断该用户代理或其余服务(即 client)是否能够调用此 API * <p> * (PS:也能够由 IDP 返回 ClientDetails 给资源服务,由资源服务解决放行逻辑) */public class CustomRemoteTokenServices implements ResourceServerTokenServices {    protected final Log logger = LogFactory.getLog(getClass());    private RestOperations restTemplate;    private String checkTokenEndpointUrl;    private String clientId;    private String clientSecret;    private String tokenName = "token";    /**     * 与 IDP 约定的存储 API 申请细节的参数     */    private String reqPayload = "payload";    private AccessTokenConverter tokenConverter = new DefaultAccessTokenConverter();    public CustomRemoteTokenServices() {        restTemplate = new RestTemplate();        ((RestTemplate) restTemplate).setErrorHandler(new DefaultResponseErrorHandler() {            @Override            // Ignore 400            public void handleError(ClientHttpResponse response) throws IOException {                Integer statusCode = response.getRawStatusCode();                if (statusCode != 400) {                    if (statusCode == 401 || statusCode == 403) {                        HttpStatus status = HttpStatus.resolve(statusCode);                        throw new AccessDeniedException(status.toString());                    }                    super.handleError(response);                }            }        });    }    public void setRestTemplate(RestOperations restTemplate) {        this.restTemplate = restTemplate;    }    public void setCheckTokenEndpointUrl(String checkTokenEndpointUrl) {        this.checkTokenEndpointUrl = checkTokenEndpointUrl;    }    public void setClientId(String clientId) {        this.clientId = clientId;    }    public void setClientSecret(String clientSecret) {        this.clientSecret = clientSecret;    }    public void setAccessTokenConverter(AccessTokenConverter accessTokenConverter) {        this.tokenConverter = accessTokenConverter;    }    public void setTokenName(String tokenName) {        this.tokenName = tokenName;    }    /**     * 当应用自定义的 tokenServices 替换默认的 tokenServices 后,     * 原来流程中的第 9 步就变成由该办法执行。     *     * 9. [明码模式的典型架构档次和次要流程] 中的第 9 步:     * 资源服务器(photo-service)向 idp 申请验证 token 有效性     *     * @param accessToken     * @return     * @throws AuthenticationException     * @throws InvalidTokenException     */    @Override    public OAuth2Authentication loadAuthentication(String accessToken) throws AuthenticationException, InvalidTokenException {        Map<String, Object> authDetails = new HashMap<>();        /**         * 获得在 CheckTokenFilter 过滤器中置入的 API 申请细节         */        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();        if (authentication != null) {            authDetails = (Map<String, Object>) authentication.getDetails();        }        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();        formData.add(tokenName, accessToken);        if (!authDetails.isEmpty()) {            formData.add(reqPayload, authDetails.get("method") + " " + authDetails.get("uri"));        }        HttpHeaders headers = new HttpHeaders();        headers.set("Authorization", getAuthorizationHeader(clientId, clientSecret));        Map<String, Object> map = postForMap(checkTokenEndpointUrl, formData, headers);        /**         * 11. [明码模式的典型架构档次和次要流程] 中的第 11 步:         *     如果 token 校验失败则返回 401 给客户端,如果 scope 查看不通过则返回 403         */        if (map.containsKey("error")) {            if (logger.isDebugEnabled()) {                logger.debug("check_token returned error: " + map.get("error"));            }            if (map.containsKey("status")) {                if ("403".equals(map.get("status").toString())) {                    throw new OAuth2AccessDeniedException(map.get("error").toString());                }            }            throw new InvalidTokenException(accessToken);        }        // gh-838        if (map.containsKey("active") && !"true".equals(String.valueOf(map.get("active")))) {            logger.debug("check_token returned active attribute: " + map.get("active"));            throw new InvalidTokenException(accessToken);        }        return tokenConverter.extractAuthentication(map);    }    @Override    public OAuth2AccessToken readAccessToken(String accessToken) {        throw new UnsupportedOperationException("Not supported: read access token");    }    private String getAuthorizationHeader(String clientId, String clientSecret) {        if (clientId == null || clientSecret == null) {            logger.warn("Null Client ID or Client Secret detected. Endpoint that requires authentication will reject request with 401 error.");        }        String creds = String.format("%s:%s", clientId, clientSecret);        try {            return "Basic " + new String(Base64.encode(creds.getBytes("UTF-8")));        } catch (UnsupportedEncodingException e) {            throw new IllegalStateException("Could not convert String");        }    }    private Map<String, Object> postForMap(String path, MultiValueMap<String, String> formData, HttpHeaders headers) {        if (headers.getContentType() == null) {            headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);        }        @SuppressWarnings("rawtypes")        Map<String, Object> result = new HashMap<>();        try {            Map map = restTemplate.exchange(path, HttpMethod.POST,                    new HttpEntity<MultiValueMap<String, String>>(formData, headers), Map.class).getBody();                    result = map;        }        catch (Exception e) {            logger.error(e.getMessage());        }        return result;    }}
  • CheckTokenAuthentication.java
package com.example.demophoto.config.oauth2;import org.springframework.security.authentication.AbstractAuthenticationToken;import org.springframework.security.core.GrantedAuthority;import java.util.Collection;public class CheckTokenAuthentication extends AbstractAuthenticationToken {    /**     * Creates a token with the supplied array of authorities.     *     * @param authorities the collection of <tt>GrantedAuthority</tt>s for the principal     *                    represented by this authentication object.     */    public CheckTokenAuthentication(Collection<? extends GrantedAuthority> authorities) {        super(authorities);    }    @Override    public Object getCredentials() {        return null;    }    @Override    public Object getPrincipal() {        return null;    }}

接着在 idp 工程中增加以下代码:

  • AuthorizationServerConfigurer.java
@Configuration@EnableAuthorizationServerpublic class AuthorizationServerConfigurer extends AuthorizationServerConfigurerAdapter {    ...        @Override    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {        endpoints.authenticationManager(authenticationManager)                // 通过插入 interceptor 来实现自定义的鉴权办法                .addInterceptor(new CheckTokenInterceptor(endpoints.getTokenStore()));    }        ...}
  • CheckTokenInterceptor.java
package com.example.demoidp.config.oauth2;import org.springframework.security.access.AccessDeniedException;import org.springframework.security.oauth2.provider.OAuth2Authentication;import org.springframework.security.oauth2.provider.OAuth2Request;import org.springframework.security.oauth2.provider.token.TokenStore;import org.springframework.util.StringUtils;import org.springframework.web.servlet.HandlerInterceptor;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.util.HashMap;import java.util.Map;/** * /oauth/check_token 校验 token 申请拦截器 */public class CheckTokenInterceptor implements HandlerInterceptor {    private String TOKEN_NAME = "token";    private final String TOKEN_INFO_URI = "/oauth/check_token";    private TokenStore tokenStore;    public CheckTokenInterceptor(TokenStore tokenStore) {        this.tokenStore = tokenStore;    }    // for test only    private final Map<String, String> clientScopes = new HashMap<String, String>() {        {            put("client1[resource:read]", "GET /api/photo");            put("client1[resource:write]", "POST /api/photo");            put("client2[resource:read]", "GET /api/photo2");            put("client2[resource:write]", "POST /api/photo2");            put("client3[resource:read]", "GET /api/photo3");            put("client3[resource:write]", "POST /api/photo3");        }    };    /**     * 10. [明码模式的典型架构档次和次要流程] 中的第 10 步:     *     idp 校验 token 有效性和 scope 权限     * <p>     * 即 IDP 依据 scope 判断客户端(demo-service)     * 是否有权限调用此 API,最初返回校验后果给资源服务器     */    @Override    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {        String uri = request.getRequestURI();        /**         * 仅拦挡 /oauth/check_token         */        if (!TOKEN_INFO_URI.equals(uri)) {            return true;        }        /**         * payload 是 IDP 和资源服务器角色约定的传参格局         * 即 client 申请拜访资源服务器的 API 的细节         * 可要求必须携带 payload         *         * 此局部可依据业务逻辑自行处理         */        String paylad = request.getParameter("payload");        if (StringUtils.isEmpty(paylad)) {            throw new AccessDeniedException("insufficient_payload");        }        if ("GET /error".equals(paylad)) {            return true;        }        /**         * 10. [明码模式的典型架构档次和次要流程] 中的第 10 步:         * 【形式二:idp 端 scope 查看】 idp 校验 token + scope 有效性         *          * 依据 token 查得 clientId,再依据 scope 查看该 client 是否有权限调用此 API         * 此局部可依据业务逻辑自行处理,比方从数据库中查问 client、API 和 scope 的关系         */        String token = request.getParameter(TOKEN_NAME);        OAuth2Authentication oAuth2Authentication = tokenStore.readAuthentication(token);        OAuth2Request oAuth2Request = oAuth2Authentication.getOAuth2Request();        String scopeKey = oAuth2Request.getClientId() + oAuth2Request.getScope();        if (clientScopes.containsKey(scopeKey)) {            if (!clientScopes.get(scopeKey).equals(paylad)) {                throw new AccessDeniedException("insufficient_scope");            }        }        return true;    }}

idp 端的 scope 查看实现起来略微麻烦点,其次要思路是:

  1. 在 photo-service 向 idp 发动 /oauth/check_oauth 鉴权申请前,增加过滤器,将客户端的申请细节保留到某个全局对象中;
  2. 替换 photo-service 默认的 tokenServices,在向 idp 发动 /oauth/check_oauth 鉴权申请的过程中,将申请细节附加到申请中;
  3. idp 在 AuthorizationServerEndpointsConfigurer 中增加自定义 Interceptor,在每次 check token 前先执行 自定义 Interceptor;
  4. idp 在自定义 Interceptor 中取出申请细节,依据申请细节和 clientDetails 信息(scope),执行 scope 查看。

以上办法,尽管实现麻烦,然而定制性和灵活性很强,不受框架束缚,能够适应各种简单的业务逻辑。

11)资源服务器依据 idp 测验后果(true/false 或其余等效伎俩)决定是否返回用户相册数据给客户端。如果 token 校验失败则返回 401 给客户端,如果 scope 查看不通过则返回 403。这一步也叫“权限管制”

与鉴权工作中的 scope 范畴查看相似,实现权限管制的办法也有两种:

  1. 受权服务器端的权限管制,属于集中式权限管制;
  2. 资源服务器端的权限管制,属于分散型权限管制。

其中,受权服务器端的权限管制比较简单,在 idp 工程的 CheckTokenInterceptor.preHandle() 办法中增加权限管制的业务代码即可:

  • CheckTokenInterceptor.java
public class CheckTokenInterceptor implements HandlerInterceptor {    @Override    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {        ...        /**         * 11. [明码模式的典型架构档次和次要流程] 中的第 11 步:         *  受权服务器短的权限管制,即集中式权限管制         *         * 实现更细粒度的权限管制,从某种程度上来说,这个过程也能够称作鉴权         */        // 受权服务器端鉴权/权限管制业务的逻辑        return true;    }}

最初来看资源服务器端的权限管制。咱们应用 spring-secutity 提供的规范办法来实现:

  1. 资源服务器端 PreAuthorize hasRole/hasAuthority
  2. 资源服务器端 PreAuthorize 自定义实现 hasPermission
以上说法在某种程度上也能够了解为鉴权。

首先,咱们增加或批改 photo-service 工程的相干代码:

  • PhotoController.java
package com.example.demophoto.web;import org.springframework.security.access.prepost.PreAuthorize;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;/** * 1、 权限管制的两种类型:资源服务端权限管制、受权服务器端权限管制 * 2、 权限管制的三种办法: *      A、 资源服务器端 PreAuthorize hasRole/hasAuthority *      B、 资源服务器端 HttpSecurity access 自定义实现 hasPermission *      D、 受权服务器端 HandlerInterceptor *     以上说法在某种程度上也能够了解为鉴权。 */@RestController@RequestMapping("/api/")public class PhotoController {    @GetMapping("/photo")    @PreAuthorize("hasRole('USER') and hasAuthority('WRITE')")    public String fetchPhoto() {        return "GET photo";    }    @GetMapping("/photo2")    public String fetchPhoto2() {        return "GET photo 2";    }    @GetMapping("/photo3")    @PreAuthorize("hasPermission('PhotoController', 'read')")    public String fetchPhoto3() {        return "GET photo 3";    }}
  • ResourceServerConfigurer.java
@Configuration@EnableResourceServerpublic class ResourceServerConfigurer extends ResourceServerConfigurerAdapter {    ...    /**     * 旧版本的 spring-security-oauth2 还须要将执行 resources.expressionHandler(oAuth2WebSecurityExpressionHandler)      * 以注入自定义的 expressionHandler,以后及当前版本不须要了     *      * @return     */    @Bean    public OAuth2WebSecurityExpressionHandler oAuth2WebSecurityExpressionHandler() {        OAuth2WebSecurityExpressionHandler oAuth2WebSecurityExpressionHandler = new OAuth2WebSecurityExpressionHandler();        // 在新版本的 spring-security-oauth2 中,这行代码能够不必,        // 框架会主动注入 customPermissionEvaluator 替换默认的 DenyAllPermissionEvaluator        // oAuth2WebSecurityExpressionHandler.setPermissionEvaluator(customPermissionEvaluator);        return oAuth2WebSecurityExpressionHandler;    }        ...}
  • CustomPermissionEvaluator.java
package com.example.demophoto.config.oauth2;import com.example.demophoto.service.PermisionEvaluatingService;import org.springframework.security.access.PermissionEvaluator;import org.springframework.security.core.Authentication;import org.springframework.stereotype.Component;import java.io.Serializable;@Componentpublic class CustomPermissionEvaluator implements PermissionEvaluator {    private PermisionEvaluatingService permisionEvaluatingService = new PermisionEvaluatingService();    @Override    public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {        return permisionEvaluatingService.hasPermission(authentication, targetDomainObject, permission);    }    @Override    public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {        return permisionEvaluatingService.hasPermission(authentication, targetId, targetType, permission);    }}
  • PermisionEvaluatingService.java
package com.example.demophoto.service;import org.springframework.security.core.Authentication;import java.io.Serializable;public class PermisionEvaluatingService {    public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {        // 业务逻辑        return true;    }    public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {        // 业务逻辑        return true;    }}
  • DemoPhotoApplication.java
@SpringBootApplication@EnableGlobalMethodSecurity(prePostEnabled = true)  // 开启 hasRole/hasAuthority/hasPermission 反对public class DemoPhotoApplication {    ...}

通过以上配置,当客户端向 photo-service 发动 GET /api/photo3 申请时,将会进入 CustomPermissionEvaluator.hasPermission() 办法进行判断,因而能够实现非常灵活的资源服务器端权限管制。