iT邦幫忙

2023 iThome 鐵人賽

DAY 24
0
Mobile Development

Spring Boot+Android 30天 實戰開發 系列 第 24

【Day - 24】Spring Security 6.1.x JWT身份驗證 (下):透過Redis實作登出功能

  • 分享至 

  • xImage
  •  

6. JWT 登出功能

JWT登出功能是一個關鍵的安全性考慮,因為JWT是無狀態的,一旦簽發,就無法撤銷或註銷。然而,有幾種方法可以實現JWT登出功能,每種方法都有其優點和限制。

方法一:令牌黑名單(實際演示在第7節)

工作原理:

  1. 當使用者登出或需要註銷訪問令牌時,伺服器將該訪問令牌的唯一標識(通常是JWT的jti聲明)添加到一個令牌黑名單中。
  2. 在每次請求訪問受保護資源時,伺服器都會檢查訪問令牌是否在黑名單中。
  3. 如果訪問令牌在黑名單中,伺服器拒絕訪問,即使該令牌尚未過期。

方法二:令牌版本

工作原理:

  1. 當使用者登出或需要註銷訪問令牌時,伺服器增加訪問令牌的版本號。
  2. 客戶端必須始終提供最新版本的訪問令牌來訪問受保護的資源。
  3. 如果客戶端使用舊版本的訪問令牌,伺服器將拒絕訪問。

7. 實際演示:使用Redis實現JWT黑名單登出功能

步驟1. 搭建Redis服務:使用docker-compose

如果你本地已搭建Redis服務可以跳過這一步驟

service:
  redis:
    container_name: 'myredis'
    image: redis:7.0-alpine
    restart: always
    ports:
      - "6380:6379" # 將容器內的6379映射到本機的6380埠
    environment:
      host: localhost #Redis伺服器位址
      port: 6379 #Redis伺服器埠號
      database: 0 #Redis資料庫索引
    networks:
      - backend
  # 省略其他服務
  
networks:
  backend:

步驟2. 添加Redis依賴

首先,確保您的Spring Boot項目中包含了Redis的依賴項。可以在項目的pom.xml文件中添加以下依賴項:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

步驟3. 配置Redis

application.yml中配置Redis連接信息。這包括Redis伺服器的主機和和埠號:

  • application.yml
    這個表示基礎配置,我們將透過底下的前三行配置導入開發環境配置檔application-dev.yml,這樣的好處是當換成生產環境只要改成spring.profiles.active: prod,並準備好application-prod.yml生產環境配置即可,詳細教學可以參考此系列的第6天文章。
spring:
  profiles:
    active: dev # 導入application-dev.yml開發環境配置
---
spring:
  data:
    redis:
      host: ${conf.redis.host}  #Redis伺服器位址 (預設 localhost)
      port: ${conf.redis.port} #Redis伺服器埠號 (預設 6379)
      database: ${conf.redis.database}  #Redis資料庫索引 (預設 0)
      timeout: 4000 # 讀取超時
  • application-dev.yml
    這個表示在開發環境下的配置
conf:
  token:
    secret: "your secret key"
    expiration: 900000 #Token有效期限 (設定15分鐘過期=15*60*1000 單位:毫秒)

  redis:
    host: localhost #Redis伺服器位址
    port: 6380 #Redis伺服器埠號
    database: 0 #Redis資料庫索引

步驟4. 創建JWT黑名單服務

為了實現黑名單登出功能,我們需要在Redis中維護一個令牌黑名單。可以使用Redis的String數據結構來實現,並且該值可以根據本身的存活時間自動被Redis清除。創建一個名為JwtBlackListService,用於處理JWT黑名單等相關業務處理服務。

@Service
@RequiredArgsConstructor
public class JwtBlackListService {
    @Value("${conf.token.expiration}")
    private long jwtExpiration;

    private final RedisTemplate<String, String> redisTemplate;

    private static final String BLACKLIST_PREFIX = "jwt:blacklist:"; // 黑名單前綴

    /** 將jwt加入黑名單 */
    public void addJwtToBlackList(String jwt) {
        String key = BLACKLIST_PREFIX + jwt;
        Duration expirationDuration  = Duration.ofMillis(jwtExpiration); // 設置存活時間
        // 將jwt加入黑名單並設置存活時間,當過了存活時間就會自動被Redis自動清除
        redisTemplate.opsForValue().set(key, "true", expirationDuration); 
    }

    /** 檢查jwt是否在黑名單中 */
    public boolean isJwtInBlackList(String jwt) {
        String key = BLACKLIST_PREFIX + jwt;
        return redisTemplate.opsForValue().get(key) != null;
    }
}

步驟5. 實現登出功能

  • AuthenticationController.class
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthenticationController {
    
    private final AuthenticationService service;
    
    //** 登出接口 */
    @PostMapping("/logout")
    public ResponseEntity<StatusResponse> logout(@RequestHeader("Authorization") String token) {
        return ResponseEntity.ok(service.logout(token));
    }
    
    // 其他接口程式碼
}
  • AuthenticationService.class
@Service
@Slf4j
@AllArgsConstructor
public class AuthenticationService {
    
    private final JwtService jwtService;
    private final JwtBlackListService jwtBlackListService;    

    /** 使用者登出處理 */
    public StatusResponse logout(String token) {
        // 調用JWT黑名單服務將該token加入到黑名單中      
        jwtBlackListService.addJwtToBlackList(token.substring(7));
        // 清除Spring Security上下文
        SecurityContextHolder.clearContext();
        return StatusResponse.SUCCESS();
    }
    
    // 其他程式碼   
}

步驟6. 設置Spring Seucruity配置和JWT認證過濾器

  • JwtAuthenticationFilter.class
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
        @NonNull HttpServletRequest request,
        @NonNull HttpServletResponse response,
        @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String userEmail;
        // 以下條件為沒有攜帶Token的請求
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }
        try {
            jwt = authHeader.substring(7); //取"Bearer "後面的Token
            userEmail = jwtService.extractUsername(jwt);
            if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);
                //檢查token是否有效
                if (jwtService.isTokenValid(jwt, userDetails)) { // <---關注點
                    UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                    );
                    authToken.setDetails(
                            new WebAuthenticationDetailsSource().buildDetails(request)
                    );
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                } else {
                    sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, "Token失效,請重新申請");
                    return;
                }}
        } catch (Exception e) {
            // 異常處理的程式碼
        }
        filterChain.doFilter(request, response);
    }
}
  • JwtService.class
@Service
@Slf4j
@RequiredArgsConstructor
public class JwtService {
    // Token有效期限
    @Value("${conf.token.expiration:900000}")
    private Long EXPIRATION_TIME; //單位ms

    @Value("${conf.token.secret}")
    private String SECRET_KEY;

    private final JwtBlackListService jwtBlackListService;
    
    /**
     * 驗證Token有效性,比對JWT和UserDetails的Username(Email)是否相同
     * @return 有效為True,反之False
     */
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername()))
                && !isTokenExpired(token)
                && !isTokenInBlackList(token); // 多了這個是否在黑名單的判斷
    }

    /**
     * check if the JWT is in the blacklist
     * @return in the blacklist return true, else return false
     */
    private boolean isTokenInBlackList(String jwt) {
        return jwtBlackListService.isJwtInBlackList(jwt);
    }

    // 其他程式碼...
}
  • SecurityConfig.class
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final AuthenticationProvider authenticationProvider;

    /**
     # 用戶認證配置 #
     1. authorizeHttpRequests()方法:對所有訪問HTTP端點的HttpServletRequest進行限制
     2. anyRequest().authenticated()語句指定了對於所有請求都需要執行認證,也就是說沒有通過認證的用戶就無法訪問任何端點。
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests((authorize) -> authorize
            .requestMatchers(
                    "/error/**",
                    "/api/smarthome/auth/register",           //用戶註冊
                    "/api/smarthome/auth/login",              //用戶登入
                    "/api/smarthome/auth/password/forgot",    //忘記密碼
                    "/api/smarthome/auth/verification/check", //檢查驗證碼
                    "/v3/api-docs/**",
                    "/swagger-ui/**",
                    "/api/test/**"
                    )
                    .permitAll()
                    .anyRequest()
                    .authenticated()
                    )
            .sessionManagement((session) -> session
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authenticationProvider(authenticationProvider)
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

步驟6. Postman測試

  1. 登入取得JWT
    圖片無法顯示
  2. 登出測試是否被加入黑名單
    2.1 發送登出請求
    圖片無法顯示
    2.2 如下圖,可以看到發送完登出請求 該使用者的JWT被加入到Redis中
    圖片無法顯示
    2.3 重新發送登出請求,可以看到該JWT已經失效
    圖片無法顯示
    2.4 補充,如果想查看該JWT紀錄在Redis剩餘的存活秒數,可以使用以下Redis的指令:
    TTL "jwt:blacklist:{你要查詢的JWT}"
    
    圖片無法顯示
    2.5 當過了存活時間Redis會自動刪除該JWT,也表示該JWT已經過期,我們可以試著發送登出請求,如下圖可以看到是另一個錯誤提示。
    圖片無法顯示

上一篇
【Day - 23】Spring Security 6.1.x:實現JWT身份驗證 (中)
下一篇
【Day - 25】建立餐廳點餐應用01:專案概述和架構設計
系列文
Spring Boot+Android 30天 實戰開發 30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言