最近接手一个需求,在已有的登录系统下,为第三方平台提供一个登录认证功能。这里涉及的协议是OAuth2,关于该协议的具体内容不是本文讲述的主要内容,具体可以参考如下链接:
Oauth2协议相关:
http://www.ruanyifeng.com/blog/2014/05/oauth_2_0.html
https://github.com/jeansfish/RFC6749.zh-cn/blob/master/SUMMARY.md
Spring Security Oauth2框架相关:
https://docs.spring.io/spring-security-oauth2-boot/docs/current-SNAPSHOT/reference/htmlsingle/#oauth2-boot-authorization-server-authentication-manager

本系列分为两部分,第一部分介绍如何搭建一个认证授权的服务器,第二部分将从Spring Security Oauth2框架源码出发,简单分析其工作流程及原理,最后分享几个我在完成这个项目过程中踩过的坑。

Part One : 快速搭建认证授权服务器

技术选型:Spring Boot 、Spring Security Oauth2、thymeleaf、Mybatis…
这里简单阐述下为什么选择Spring Security Oauth2框架:Oauth2实际上是一个关于授权(authorization)的开放网络标准,它仅仅是定义了一些列的规范、认证的交互流程,而不涉及任何具体的实现细节。因此若想要基于Oauth2协议实现认证授权功能,你可能会面临这些问题:授权码如何生成?令牌如何存储?令牌时效如何设定等等,诸如这些问题,若手动实现Oauth2协议,你可能会被淹没在大量的细节实现上,而无暇顾及核心的业务需求。Spring Security Oauth2本身已经提供Oauth2协议的实现,且依赖Spring、Spring Security的强大后台,能够更好的兼容整合项目。

  1. 引入主要的依赖:
 <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.0.3.RELEASE</version>
        <relativePath/>
    </parent>
     <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
     <dependency>
            <groupId>org.springframework.security.oauth</groupId>
            <artifactId>spring-security-oauth2</artifactId>
            <version>2.3.3.RELEASE</version>
      </dependency>
   <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.thymeleaf.extras</groupId>
            <artifactId>thymeleaf-extras-springsecurity4</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

2、常规Spring Security配置

@Configuration
@EnableWebSecurity
//  启用方法级别的权限认证
@EnableGlobalMethodSecurity(prePostEnabled = true)
//  启用Session共享,部署两个实例,共享信息存储在Redis中
@EnableRedisHttpSession
public class SecurityConfig extends WebSecurityConfigurerAdapter {

   //自定义的登录认证Provider,后续会讲到
    @Autowired
    private SelfAuthenticationProvider authenticationProvider;
    //自定义的登出成功处理器,后续会讲到
    @Autowired
    private LogoutSuccessHandler logoutSuccessHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/login/**", "/healthCheck/**").permitAll()
                .anyRequest().authenticated()   // 其他地址的访问均需验证权限
                .and()
                //  定义当需要用户登录时候,转到的登录页面
                .formLogin()
                .loginPage("/login")
                .successHandler(authenticationSuccessHandler())
                .failureHandler(authenticationFailureHandler())
                .and()
                .logout()
                .logoutUrl("/oauth/logout")
                .logoutSuccessHandler(logoutSuccessHandler)
        .and()
        .csrf()
        .disable();
    }

    /**
     * 登录成功处理器:定义认证通过后的一些操作
     * @return
     */
    AuthenticationSuccessHandler authenticationSuccessHandler() {
        return new SelfAuthenticationSuccessHandler();
    }
   /**
   * 登录失败处理器:定义认证失败后的一些操作
   */
    AuthenticationFailureHandler authenticationFailureHandler() {
        return new SelfAuthenticationFailureHandler();
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/assets/**");
    }

    /**
     * 使用自定义的authenticationProvider
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //使用自定义的AuthenticationProvider
        auth.authenticationProvider(authenticationProvider);
    }

    /**
     * 加密器
     * 为client_secret加密
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

3、OAuth2的认证服务器配置

@Configuration
//该注解表示启用认证服务器
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

   //客户端信息,这里我配置到application.yml文件中,最后将客户端信息存储到内存中
    @Value("${client.detail.client_id}")
    private String clientId;
    @Value("${client.detail.client_secret}")
    private String clientSecret;
    @Value("${client.detail.scopes}")
    private String scopes;
    @Value("${client.detail.authorized_grant_types}")
    private String authorizedGrantTypes;
    @Value("${client.detail.redirect_uris}")
    private String redirectUris;

    @Resource
    private DataSource dataSource;
    @Autowired
    RedisConnectionFactory redisConnectionFactory;

    /**
     * 配置授权服务器的安全,意味着实际上是/oauth/token端点。
     * /oauth/authorize端点也应该是安全的
     * 默认的设置覆盖到了绝大多数需求,所以一般情况下你不需要做任何事情。
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        super.configure(security);
    }

    /**
     * 配置ClientDetailsService
     * 注意,除非你在下面的configure(AuthorizationServerEndpointsConfigurer)中指定了一个AuthenticationManager,否则密码授权方式不可用。
     * 至少配置一个client,否则服务器将不会启动。
     * 两种方式:JDBC、InMemory
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        //方式一:客户端信息存储在数据库
//        clients.jdbc(dataSource);

        //方式二:客户端信息存储到内存中
//        clients.inMemory()
//                .withClient(clientId)
//                .secret(clientSecret)
//                .scopes(scopes)
//                .authorizedGrantTypes(authorizedGrantTypes)
//                .redirectUris(redirectUris.split(","));

        clients.withClientDetails(clientDetailsService);


    }

      @Bean
     //Spring Security Oauth源码中有懒加载一个clientDetailsService,安全起见加上@Primary注解
      @Primary
    public ClientDetailsService clientDetailsService() throws Exception {
        //存储到内存中
        InMemoryClientDetailsServiceBuilder inMemoryClientDetailsServiceBuilder = new InMemoryClientDetailsServiceBuilder();
        inMemoryClientDetailsServiceBuilder
                .withClient(clientId)
                .secret(clientSecret)
                .scopes(scopes)
                .authorizedGrantTypes(authorizedGrantTypes)
                .redirectUris(redirectUris.split(","));
        return inMemoryClientDetailsServiceBuilder.build();

        //存储到数据库
//        return new JdbcClientDetailsService(dataSource);
    }

    /**
     * 该方法是用来配置Authorization Server endpoints的一些非安全特性的,比如token存储、token自定义、授权类型等等的
     * 默认情况下,你不需要做任何事情,除非你需要密码授权,那么在这种情况下你需要提供一个AuthenticationManager
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        //token使用Redis存储;授权码使用数据库存储
        endpoints.tokenStore(redisTokenStore())
                .authorizationCodeServices(authorizationCodeServices())
                .requestFactory(new DefaultOAuth2RequestFactory(clientDetailsService()));
    }

    /**
     * token存储到Redis中
     * @return
     */
    @Bean
    public RedisTokenStore redisTokenStore() {
        return new RedisTokenStore(redisConnectionFactory);
    }

    /***
     * 授权码存储到数据库中
     * @return
     */
    protected AuthorizationCodeServices authorizationCodeServices() {
         return new JdbcAuthorizationCodeServices(dataSource);
    }

4、OAuth2的资源服务器配置

@Configuration
//该注解表示启用资源服务器
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

  //这里配置,所有匹配/user/**的请求,都受资源服务器的保护,即必须通过access_token换取资源信息
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.requestMatchers().antMatchers("/user/**")
                .and()
                .authorizeRequests()
                .anyRequest().authenticated();
    }
}

5、自定义用户授权页面和错误提示页面

@Configuration
@Import(AuthorizationServerEndpointsConfiguration.class)
public class EndpointConfiguration {

//authorizationEndpoint是源码提供的一个bean,这里覆盖其两个属性
    @Autowired
    private AuthorizationEndpoint authorizationEndpoint;

    @PostConstruct
    public void init() {
        //用户授权页面
        authorizationEndpoint.setUserApprovalPage("forward:/oauth/user_approval");
        //错误页面
        authorizationEndpoint.setErrorPage("forward:/oauth/globalError");
    }
}

注意,对authorizationEndpoint的操作不要放到AuthorizationServerConfig配置类中,我之前看别人的技术博客,是写在一起的,但通过我的实践发现,写在一起会偶现一些问题,通过源码发现,若AuthorizationServerConfig配置类中注入authorizationEndpoint,则配置类会与框架的配置类AuthorizationServerEndpointsConfiguration存在循环依赖。

6、页面跳转控制器

@Controller
@SessionAttributes("authorizationRequest")
public class PageController {

    private static final String DEFAULT_GLOBAL_ERROR = "未知错误,请联系技术客服!";
    private static final Integer DEFAULT_ERROR_FLAG = 201;

    /**
     * 登录页面跳转
     * @return
     */
    @RequestMapping("/login")
    public String login() {
        return "login_new";
    }

    /**
     * 用户授权页面跳转
     * @return
     */
    @RequestMapping("/oauth/user_approval")
    public String userApproval() {
        return "authorization";
    }

    /**
     * 全局异常提示页面跳转
     * @param request
     * @param model
     * @return
     */
    @RequestMapping("/oauth/globalError")
    public String handleGlobalError(HttpServletRequest request, Map<String, Object> model) {
        Object error = request.getAttribute("error");
        String errorSummary = DEFAULT_GLOBAL_ERROR;
        if (error instanceof OAuth2Exception) {
            OAuth2Exception oauthError = (OAuth2Exception) error;
            errorSummary = HtmlUtils.htmlEscape(oauthError.getSummary());
        }
        model.put("errorSummary", errorSummary);
        return "error_original";
    }

}

7、受保护的资源控制器

@RestController
@RequestMapping(value = "/user")
public class UserController {

    /**
     * 受资源服务器保护,即必须通过access_token获取
     * @return
     */
    @RequestMapping(value = "/loginUser")
    public LoginUser getLoginUser() {
        //从上下文中获取登录用户的信息
        OAuth2Authentication authentication = (OAuth2Authentication) SecurityContextHolder.getContext().getAuthentication();

        SelfUserDetail userDetail = (SelfUserDetail) authentication.getUserAuthentication().getPrincipal();
        return new LoginUser(userDetail.getUsername(), userDetail.getOrgCode());
    }
}

上文资源服务器控制类对/user/**的请求进行保护,即UserController的url必须通过access_token换取。这里受保护的资源为当前登录用户的基本信息

Part Two:流程分析&源码解析

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐