Spring Security OAuth2.0認證授權四:分佈式系統認證授權

Spring Security OAuth2.0認證授權系列文章

Spring Security OAuth2.0認證授權一:框架搭建和認證測試
Spring Security OAuth2.0認證授權二:搭建資源服務
Spring Security OAuth2.0認證授權三:使用JWT令牌

前面幾篇文章講解了如何從頭開始搭建認證服務和資源服務,從頒發普通令牌到頒發jwt令牌,最終完成了jwt令牌的頒發和校驗。本篇文章將會講解分佈式環境下如何進行認證和授權。

一、設計思路

分佈式授權圖.png

一般來說,一個典型的分佈式系統架構如上圖所示,這裡進行一個簡單的設計,來完成分佈式系統下的認證和授權。

整體設計思路是使用OAuth2.0頒發令牌,使用JWT對令牌簽名並頒發JWT令牌給客戶端。既然決定使用JWT令牌了,則不需要再調用認證服務器對令牌進行驗證了,因為JWT本身就包含了所需要的信息,而且只要驗簽成功,則可認為令牌可信任且有效。

如上所述,則可以如此設計:

  1. 用戶請求登陸之後認證服務頒發令牌給用戶,瀏覽器將令牌儲存下來。
  2. 瀏覽器請求資源的的時候攜帶着令牌,網關攔截請求對令牌驗證,驗證的方法很簡單,不請求認證服務而是直接使用密鑰(對稱或非對稱)驗簽,只要驗證成功則將jwt payload中的信息解析成明文放到請求頭中轉發請求到資源服務。
  3. 資源服務拿到明文信息,根據明文信息中的權限信息驗證是否有權限訪問該資源,有權限則返回資源信息,無權限則返回401。

綜上,整體思路就是網關認證,資源服務鑒權。

典型的微服務架構下會有註冊中心、網關等服務,接下來會依次介紹和搭建相關服務。

二、註冊中心搭建

為了方便程序本地調試方便,這裡使用eureka server作為服務註冊中心,使用起來也非常簡單

1.添加maven依賴

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
    </dependency>

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

2.新建啟動類

@SpringBootApplication
@EnableEurekaServer
public class RegisterServer {
    public static void main(String[] args) {
        SpringApplication.run(RegisterServer.class,args);
    }
}

3.新建配置文件

spring:
  application:
    name: register-server

server:
  port: 8765 #啟動端口

eureka:
  server:
    enable-self-preservation: false    #關閉服務器自我保護,客戶端心跳檢測15分鐘內錯誤達到80%服務會保護,導致別人還認為是好用的服務
    eviction-interval-timer-in-ms: 10000 #清理間隔(單位毫秒,默認是60*1000)5秒將客戶端剔除的服務在服務註冊列表中剔除#
    shouldUseReadOnlyResponseCache: true #eureka是CAP理論種基於AP策略,為了保證強一致性關閉此切換CP 默認不關閉 false關閉
  client:
    register-with-eureka: false  #false:不作為一個客戶端註冊到註冊中心
    fetch-registry: false      #為true時,可以啟動,但報異常:Cannot execute request on any known server
    instance-info-replication-interval-seconds: 10
    serviceUrl:
      defaultZone: //localhost:${server.port}/eureka/
  instance:
    hostname: ${spring.cloud.client.ip-address}
    prefer-ip-address: true
    instance-id: ${spring.application.name}:${spring.cloud.client.ip-address}:${spring.application.instance_id:${server.port}}

然後啟動啟動類,訪問瀏覽器,//127.0.0.1:8765,出現如下頁面即表示已經成功

eureka server.png

二、網關搭建

這裡選用spring cloud gateway作為網關(不是zuul)

1.添加maven依賴

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>
    <!--gateway 依賴 -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway</artifactId>
        <version>2.2.5.RELEASE</version>
    </dependency>
    <!--actuator 依賴 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
    <!-- jwt依賴 -->
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-jwt</artifactId>
    </dependency>
</dependencies>

2.新建啟動類

@SpringBootApplication
public class GatewayServer {

    public static void main(String[] args) {
        SpringApplication.run(GatewayServer.class, args);
    }
}

3.新建配置文件

server:
  port: 8761
spring:
  cloud:
    gateway:
      routes:
        - id: resource_server
          uri: "lb://resource-server"
          predicates:
            - Path=/r**
  application:
    name: gateway-server

eureka:
  client:
    service-url:
      defaultZone: //127.0.0.1:8765/eureka
  instance:
    prefer-ip-address: true
    instance-id: ${spring.application.name}:${spring.cloud.client.ip‐address}:${spring.application.instance_id:${server.port}}

如此,一個網關就已經搭建好了,但是還不具備我們想要的認證功能。

4.添加token全局過濾器

知識點有以下幾點:

  • 全局過濾器要實現GlobalFilter接口
  • 為了實現token過濾器最先被調用,要實現Order接口並將優先級調到最大
  • 使用JwtHelper工具類對jwt驗簽,簽名的key必須和認證中心中配置的key保持一致
  • 驗簽成功後將jwt中payload明文信息放到token-info的header值中傳遞給目標服務

實現代碼如下:

@Component
@Slf4j
public class TokenFilter implements GlobalFilter, Ordered {
    private static final String BEAR_HEADER = "Bearer ";
    /**
     * 該值要和auth-server中配置的簽名相同
     *
     * com.kdyzm.spring.security.auth.center.config.TokenConfig#SIGNING_KEY
     */
    private static final String SIGNING_KEY = "auth123";

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String token = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
        //如果沒有token,則直接返回401
        if(StringUtils.isEmpty(token)){
            return unAuthorized(exchange);
        }
        //驗簽並獲取PayLoad
        String payLoad;
        try {
            Jwt jwt = JwtHelper.decodeAndVerify(token.replace(BEAR_HEADER,""), new MacSigner(SIGNING_KEY));
            payLoad = jwt.getClaims();
        } catch (Exception e) {
            log.error("驗簽失敗",e);
            return unAuthorized(exchange);
        }
        //將PayLoad數據放到header
        ServerHttpRequest.Builder builder = exchange.getRequest().mutate();
        builder.header("token-info", payLoad).build();
        //繼續執行
        return chain.filter(exchange.mutate().request(builder.build()).build());
    }

    private Mono<Void> unAuthorized(ServerWebExchange exchange){
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        return exchange.getResponse().setComplete();
    }

    /**
     * 將該過濾器的優先級設置為最高,因為只要認證不通過,就不能做任何事情
     *
     * @return
     */
    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

三、資源服務修改

原來資源服務已經集成了OAuth2.0、Spring Security、JWT等組件,根據現在的設計方案,需要刪除OAuth2.0和JWT組件,只留下Spring Security組件。

1.移除OAuth2.0、JWT組件

這裡要刪除maven依賴,同時將相關配置刪除

第一步,刪除maven依賴,直接將以下兩個依賴移除就好

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-jwt</artifactId>
</dependency>

第二步,刪除相關配置

將ResouceServerConfig、TokenConfig兩個類直接刪除 即可。

2.添加過濾器

這裡需要使用過濾器做,首先寫一個過濾器,實現OncePerRequestFilter接口,該過濾器的作用就是獲取網關傳過來的token-info明文數據,封裝成JwtTokenInfo對象,並將該相關信息添加到SpringSecurity上下文以備之後的鑒權使用。

代碼實現如下:

@Component
@Slf4j
public class AuthFilterCustom extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        String tokenInfo=request.getHeader("token-info");
        if(StringUtils.isEmpty(tokenInfo)){
            log.info("未找到token信息");
            filterChain.doFilter(request,response);
            return;
        }
        JwtTokenInfo jwtTokenInfo = objectMapper.readValue(tokenInfo, JwtTokenInfo.class);
        log.info("tokenInfo={}",objectMapper.writeValueAsString(jwtTokenInfo));
        List<String> authorities1 = jwtTokenInfo.getAuthorities();
        String[] authorities=new String[authorities1.size()];
        authorities1.toArray(authorities);
        //將用戶信息和權限填充 到用戶身份token對象中
        UsernamePasswordAuthenticationToken authenticationToken
                = new UsernamePasswordAuthenticationToken(jwtTokenInfo.getUser_name(),null, AuthorityUtils.createAuthorityList(authorities));
        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        //將authenticationToken填充到安全上下文
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request,response);
    }
}

3.將過濾器註冊到過濾器鏈

修改WebSecurityConfig類,使用如下方法註冊過濾器:

.addFilterAfter(authFilterCustom, BasicAuthenticationFilter.class)//添加過濾器

同時,一定要關閉session功能,否則會出現上下文緩存問題

.sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);//禁用session

完整代碼如下:

    @Autowired
    private AuthFilterCustom authFilterCustom;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf()
                .disable()
                .authorizeRequests()
//                .antMatchers("/r/r1").hasAuthority("p2")
//                .antMatchers("/r/r2").hasAuthority("p2")
                .antMatchers("/**").authenticated()//所有的請求必須認證通過
                .anyRequest().permitAll()//其它所有請求都可以隨意訪問
                .and()
                .addFilterAfter(authFilterCustom, BasicAuthenticationFilter.class)//添加過濾器
        .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);//禁用session

    }

四、其他注意事項

認證服務auth-server以及資源服務resource-server、網關服務gateway-server都要集成eureka client組件

五、測試

測試前需要將各個服務依次啟動起來:

第一步,獲取token

這裡使用password模式直接獲取token,POST請求如下接口:

//127.0.0.1:30000/oauth/token?client_id=c1&client_secret=secret&grant_type=password&username=zhangsan&password=123

即可獲取token。

第二步,訪問資源

通過網關請求資源服務的r1接口,GET請求如下接口:

//127.0.0.1:8761/r1

需要帶上Header,key為Authorization,value格式如下:

Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX25hbWUiOiJ6aGFuZ3NhbiIsInNjb3BlIjpbIlJPTEVfQURNSU4iLCJST0xFX1VTRVIiLCJST0xFX0FQSSJdLCJleHAiOjE2MTAzNzI5MzUsImF1dGhvcml0aWVzIjpbInAxIiwicDIiXSwianRpIjoiOWQzMzRmZGMtOTcwZC00YmJkLWI2MmMtZDU4MDZkNTgzM2YwIiwiY2xpZW50X2lkIjoiYzEifQ.gZraRNeX-o_jKiH7XQgg3TlUQBpxUcXa2-qR_Treu8U

如果相應結果如下,則表示測試通過

訪問資源r1

否則,會返回401狀態碼。

六、項目源代碼

項目源代碼://gitee.com/kdyzm/spring-security-oauth-study/tree/v5.0.0

我的博客原文地址://blog.kdyzm.cn/post/30