Spring Cloud OAuth2实战入门

1,979 阅读5分钟

知识点

在进行实战前,先说明一些本文关于微服务的知识点

  • 网关微服务

    即Spring Cloud Gateway,用户对微服务的请求几乎都要经由网关,让网关来做转发,由网关提供统一的入口,能起到内部和外部隔离,保证了后台多个服务的安全性。

  • 认证中心微服务

    用户在访问受权限保护的接口时,要向认证中心认证本人信息并申请访问授权,微服务系统中使用统一的认证中心可以让用户只需一次登录,持有令牌即可访问系统内的所有微服务,避免了不同服务还要重复登录的繁琐。

    认证中心只负责两件事:认证和鉴权,认证是指对用户登录提交的如用户名密码进行校验,验证是否为系统内的合法用户,认证通过则返回表示通过的令牌,如JWT;鉴权指用户持有令牌访问微服务接口资源时,需要判断用户所持有的令牌是否拥有访问该资源的权限。

  • 资源服务

    就是微服务系统下的各种微服务,如商城系统中的订单服务、商品服务。

    资源服务提供接口供外部访问,需要依赖认证中心做访问鉴权,当然某些不需要认证的接口例外。

微服务认证授权的一些实现方案

微服务的认证授权可以有以下几种实现方案:

  • 不需要网关服务,只搭建认证中心和资源服务

    通常不会这么做,网关在微服务系统中还是十分重要的,可以统一入口、降低客户端和服务的耦合、鉴权校验等,所以该方案一般不可取。

  • 分别搭建网关服务、认证中心和资源服务

    由网关负责转发请求,认证中心和资源服务各司其职,该方案比较主流,但缺点是服务较多,搭建和运维比较麻烦,所以对于小一点的项目来说,或许没有这个必要,可以考虑整合。

  • 网关服务与认证中心整合,资源服务独立搭建

    是一种比较实用的方案,因为很少会有庞大的系统要搭建,实际上大家都是小一点的项目,小一点的服务。

为什么要用微服务

单体应用和微服务各有各的好处,我们需要针对待解决的问题来考虑,说下优缺点对比:

单体应用的优缺点

优点:

  • 调试方便:所有功能服务都在一起,也方便测试
  • 容易部署:更新部署比较简单,打一个包更新即可

缺点:

  • 不够灵活:修改配置需要重新部署
  • 体积庞大:单体应用包含了所有代码,构建、部署时间长,时间久了也容易使项目变得难以维护
  • 依赖限制:项目使用的技术栈过时后,升级难度大
  • 服务器成本高:单体应用变得庞大之后,对服务器的配置也随之变高,这时候一般只能通过升级服务器来解决

微服务的优缺点

优点:

  • 启动快:服务分离开了,单个服务启动就快了
  • 服务职责分离:多个服务负责各自的工作,降低了复杂性,方便开发者进行拆分和管理
  • 单台服务器成本低:可以通过多个普通服务器来完成各自的服务功能
  • 服务更加可靠:搭建集群,配置主从,使整个系统比单体应用更加稳定可靠

缺点:

  • 运维难度高:微服务搭建的分布式系统,系统容错、网络延迟、分布式事务等问题需要考虑
  • 管理难度高:微服务过多,治理的难度也单体应用要高

微服务是现在后端技术发展的未来趋势或者说是主流,并且我个人认为,开发微服务还有一个好处,就是不用太担心接手的项目有多难搞,因为使用微服务的项目一般不会是多大的屎山,顶多是个小屎山,大不了重构就是。

Spring Cloud OAuth2实战

太罗嗦了,现在开始实战。

实现内容是:创建一个Maven项目,搭建两个服务,一个是认证中心服务,一个是资源服务。

认证中心提供以下接口:

  • 登录认证,返回JWT令牌
  • 验证JWT令牌有效性
  • 刷新JWT令牌

资源服务提供以下接口:

  • /a/hello:返回a hello字符串,该接口需要认证才能访问
  • /b/hello:返回b hello字符串,该接口不需要认证就能访问

步骤一:创建Maven项目

只给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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.cc</groupId>
    <artifactId>oauth2Demo</artifactId>
    <packaging>pom</packaging>
    <version>1.0-SNAPSHOT</version>
    <modules>
        <module>auth-server</module>
        <module>client-server</module>
    </modules>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.4.RELEASE</version>
        <relativePath/>
    </parent>

    <properties>
        <!--微服务版本-->
        <spring-cloud.version>Hoxton.SR5</spring-cloud.version>
        <spring-cloud-alibaba.version>2.2.0.RELEASE</spring-cloud-alibaba.version>
        <spring-cloud-oauth2.version>2.1.4.RELEASE</spring-cloud-oauth2.version>
    </properties>

    <!--dependencyManagement中声明jar包的版本号等信息,子项目再次引用该依赖时就不用显示的列出版本号-->
    <dependencyManagement>
        <dependencies>
            <!--Spring Cloud 相关依赖-->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>

            <!--Spring Cloud Alibaba 相关依赖-->
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                <version>${spring-cloud-alibaba.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>

            <!--oauth2-->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-starter-oauth2</artifactId>
                <version>${spring-cloud-oauth2.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>

我们不依赖JDBC是因为我们为了方便演示,全部数据都写在内存里面

步骤二:创建认证中心服务

pom.xml

<dependencies>
        <!--认证中心-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>

        <!--springboot启动器-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

HTTP请求访问配置

package com.cc.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.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

import java.util.ArrayList;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * 密码加密策略
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 鉴权管理器,鉴权交给网关或者资源服务,所以这里不做额外配置,但是需要初始化并注入
     */
    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

    /**
     * http请求访问配置
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                /**
                 * 三种写法:
                 * anyRequest().permitAll() // 放行所有请求
                 * anyRequest().authenticated() // 所有请求都要认证
                 * antMatchers("/**").permitAll()   //定制
                 */
                .anyRequest().permitAll()
                .and()
                // 设置跨域,这里不设置的话,跨域配置不会生效
                .cors()
                .and()
                /**
                 * 关闭跨站请求保护以及不使用session
                 * 关闭CSRF防护:使用JWT可以防止CSRF,并且客户端可能不是浏览器,CSRF保护会增加过滤器等其他组件影响性能,所以应该禁用
                 * 关闭session:使用JWT就可以不用session了,并且客户端可能不是浏览器
                 */
                .csrf()
                .disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        ;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 写入一个测试用户到内存中用于登录,密码是123456
        UserDetails userDetails = new User("admin", "$2a$10$CzJxwgp4ZAYhxs/9h6MKjeobgYivUDtthxHT3OXB2oCp02UkKgne2", new ArrayList<>());
        auth.inMemoryAuthentication()
                .withUser(userDetails).passwordEncoder(passwordEncoder());
    }
}

OAuth2服务配置

package com.cc.config.oauth2;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
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;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;

import java.util.ArrayList;
import java.util.List;

// 开启认证服务器
@EnableAuthorizationServer
@Configuration
public class OAuth2Config extends AuthorizationServerConfigurerAdapter {
    // 需要在SecurityConfig中注入Bean
    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private TokenEnhancer tokenEnhancer;

    @Autowired
    private TokenStore tokenStore;

    @Autowired
    private JwtAccessTokenConverter jwtAccessTokenConverter;

    @Autowired
    private MyUserDetailsService myUserDetailsService;

    /**
     * api接口配置:
     * 认证服务对外提供api接口,这里对api的访问权限进行配置
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        super.configure(security);
        security
                // 支持client_id及client_secret做登录认证
                .allowFormAuthenticationForClients()
                // 访问/oauth/token_key的处理,可以设置为:permitAll()或者isAuthenticated()
                .tokenKeyAccess("permitAll()")
                // 访问/oauth/check_token的处理,可以设置为:permitAll()或者isAuthenticated()
                .checkTokenAccess("permitAll()");
    }

    /**
     * 客户端配置:
     * 这里对多种用户类型进行配置,比如有系统管理员登录、PC平台用户登录、小程序用户登录等
     * 配置后的客户端才能被认证服务识别
     * 配置可以存储在内存也可以在JDBC中持久化
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        super.configure(clients);
        clients.inMemory()
                // 客户端id
                .withClient("sys_user")
                // 客户端密钥,当配置了密码加密策略后,这里的密码需要写加密后的
                .secret("$2a$10$CzJxwgp4ZAYhxs/9h6MKjeobgYivUDtthxHT3OXB2oCp02UkKgne2")
                // 认证类型,支持OAuth2的四种授权模式,可以配置多个,具体用哪个取决于调用时传的参数
                .authorizedGrantTypes("password", "refresh_token")
                // 访问令牌的过期时间
                .accessTokenValiditySeconds(3600 * 24)
                // 刷新令牌的过期时间
                .refreshTokenValiditySeconds(3600 * 24 * 7)
                // 客户端的权限范围,all即可
                .scopes("all")
        ;

    }

    /**
     * 配置授权、令牌的访问端点
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
        List<TokenEnhancer> enhancerList = new ArrayList<>();
        enhancerList.add(tokenEnhancer);
        enhancerList.add(jwtAccessTokenConverter);
        enhancerChain.setTokenEnhancers(enhancerList);

        endpoints
                // 允许的请求方式
                .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST)
                // 指定认证管理器
                .authenticationManager(authenticationManager)
                .tokenEnhancer(enhancerChain)
                .userDetailsService(myUserDetailsService)
        ;
    }
}

实现UserDetailsService接口

编写用户查询逻辑:

package com.cc.config.oauth2;

import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;

import java.util.ArrayList;

@Component
public class MyUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 这里模拟根据username从数据库查询用户
        // ...
        
        // 然后返回用户,如果查不到记得报错
        return new User("admin", "$2a$10$CzJxwgp4ZAYhxs/9h6MKjeobgYivUDtthxHT3OXB2oCp02UkKgne2", new ArrayList<>());
    }
}

可选:给JWT添加自定义信息

package com.cc.config.oauth2;

import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;

import java.util.HashMap;
import java.util.Map;

/**
 * JWT加强,添加一些自定义的信息
 *
 * @author cc
 * @date 2023-01-18 15:42
 */
public class MyTokenEnhancer implements TokenEnhancer {
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication) {
        Map<String, Object> info = new HashMap<>();

        // 通过clientId区分不一样的用户端
        String clientId = oAuth2Authentication.getOAuth2Request().getClientId();
        switch (clientId) {
            case "sys_user": {
                info.put("cc", "cc");
            }
            break;
            default:
                throw new RuntimeException("clientId不能为空");
        }
        ((DefaultOAuth2AccessToken) oAuth2AccessToken).setAdditionalInformation(info);
        return oAuth2AccessToken;
    }
}

Token令牌设置

package com.cc.config.oauth2;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;

@Configuration
public class TokenStoreConfig {
    /**
     * InMemoryTokenStore:默认采用,它可以完美的工作在单服务器上(即访问并发量 压力不大的情况下,并且它在失败的时候不会进行备份),大多数的项目都可以使用这个版本的实现来进行尝试,你可以在开发的时候使用它来进行管理,因为不会被保存到磁盘中,所以更易于调试。
     * JdbcTokenStore:这是⼀个基于JDBC的实现版本,令牌会被保存进关系型数据库。使用这个版本的实现时, 你可以在不同的服务器之间共享令牌信息,使用这个版本的时候请注意"spring-jdbc"这个依赖加入到你的 classpath当中。
     * JwtTokenStore:这个版本的全称是 JSON Web Token(JWT),它可以把令牌相关的数据进行编码(因此对于后端服务来说,它不需要进行存储,这将是一个重大优势),缺点就是这个令牌占用的空间会比较大,如果你加⼊了比较多用户凭证信息,JwtTokenStore 不会保存任何数据。
     */
    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    /**
     * JWT 加密密钥设置
     */
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey("dev");
        return converter;
    }

    /**
     * JWT加强,增加一些自定义拓展
     */
    @Bean
    public TokenEnhancer tokenEnhancer() {
        return new MyTokenEnhancer();
    }
}

测试接口

打开postman,测试以下接口:

获取token

image-20230118172713750

验证token

image-20230118173013935

刷新token

image-20230118173037688

注意这里传的是refresh_token。

测试没有问题,那我们就可以继续资源服务的开发了。

步骤二:创建资源服务

pom.xml

    <dependencies>
        <!--认证中心-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>

        <!--springboot启动器-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

资源服务配置类

package com.cc.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
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.oauth2.provider.token.RemoteTokenServices;

@Configuration
@EnableResourceServer
@EnableWebSecurity
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        // 设置当前服务的资源id
        resources.resourceId("client_demo");
        // 定义token服务对象
        RemoteTokenServices remoteTokenServices = new RemoteTokenServices();
        // 校验端点url设置,即认证中心接口地址
        remoteTokenServices.setCheckTokenEndpointUrl("http://localhost:8888/oauth/check_token");
        // 携带客户端id和客户端密码
        remoteTokenServices.setClientId("sys_user");
        remoteTokenServices.setClientSecret("123456");

        resources.tokenServices(remoteTokenServices);
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/a/**").authenticated()
                .antMatchers("/b/**").permitAll()
                .anyRequest().permitAll()
        ;
    }
}

编写接口

package com.cc.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {
    @GetMapping("/a/hello")
    public String a() {
        return "a hello";
    }

    @GetMapping("/b/hello")
    public String b() {
        return "b hello";
    }
}

测试接口

首先,不带token的去访问/a/hello接口:

image-20230118173253108

很好,报错了,现在我们携带token再来访问一次:

image-20230118173314308

最后测试/b/hello接口,不带token的,因为我们配置了这个接口不需要认证:

image-20230118173401213

都符合我们的逾期。

未完待续

今天的时间不多了,后面再说结合网关和整合网关的示例吧。