1. 程式人生 > 其它 >SpringSecurity+JWT登入認證

SpringSecurity+JWT登入認證

1. 簡要

​ 之前學習的認證方式,在伺服器驗證通過後,會在當前對話session中儲存資料,然後向客戶端返回一個session_id存在客戶端Cookie中,但是這種模式會存在問題就是擴充套件性不好,對於單機還好,如果是伺服器叢集,就需要實現session共享,保證每臺伺服器都能讀取session。

​ 而今天要學的JWT(JSON Web Token)是目前比較留校的一種跨域認證方案,在前後端分離專案中應用的比較多。具體JWT的概念可以學習阮一峰大神的這篇文章:JSON Web Token 入門教程 - 阮一峰

​ 俺今天是要使用SpringSecurity實現JWT的認證,前後端分離,使用JSON互動。

2. 設計思路

首先是登入認證:

  1. 前端POST請求,將使用者名稱和密碼以JSON的形式傳送請求/jwt/login
  2. 自定義一個JWTAuthenticationFilter,進行提取request中的引數,封裝為一個UsernampasswordAuthenticationTokenAuthenticationManager進行認證
  3. AuthenticationManager從自定義的Service中查詢使用者資訊,判斷賬號密碼是否正確
  4. 認證成功則生成JWT Token給客戶端
  5. 認證失敗則返回錯誤

然後是請求時的Token校驗:

​ 因為使用JWT,所以不需要用到session,每一個請求都是需要攜帶Token,伺服器根據Token進行驗證,因此這裡我們還需要一個自定義的Filter,用來攔截任意請求

  1. 一個請求發起
  2. 被自定義的Filter攔截,進行token驗證
  3. 將驗證成功的token生成Authentication物件存入SecurityContext上下文,表示驗證完成
  4. 後續再進行許可權的驗證

3. 環境搭建

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.1</version>
    </dependency>
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.2.2</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>5.1.48</version>
        <scope>runtime</scope>
    </dependency>
    <!-- 引入jaxb-api包 -->
    <dependency>
        <groupId>javax.xml.bind</groupId>
        <artifactId>jaxb-api</artifactId>
        <version>2.3.0</version>
    </dependency>

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

這裡需要注意的一個包是:jaxb-api

匯入這個包是因為使用JJWT這個包時會報一個異常:

java.lang.ClassNotFoundException: javax.xml.bind.DatatypeConverter at java.base/jdk.internal.loader

匯入這個包就可以解決了。

Springboot配置mysql和mybatis

spring:
  datasource:
    username: root
    password: root
    url: jdbc:mysql://localhost:3306/security
    driver-class-name: com.mysql.jdbc.Driver

mybatis:
  mapper-locations:
  - classpath:mapper/*.xml
  type-aliases-package: com.zzy.jwt.pojo

4. 原始碼實現

  1. 自定義JWTAuthenticationFilter

    public class JWTAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    
        /**
         * 通過構造器注入攔截的Url,請求方法沒有限制
         * @param antPathRequestUrl url
         */
        public JWTAuthenticationFilter(String antPathRequestUrl) {
            super(new AntPathRequestMatcher(antPathRequestUrl, "POST"));
        }
    
        @Override
        public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
            // 從JSON中讀取請求引數
            User user = new ObjectMapper().readValue(request.getInputStream(), User.class);
    
            // 獲取使用者名稱和密碼
            String username = user.getUsername();
            String password = user.getPassword();
    
            // 構造Token,使用UsernamepasswordAuthenticationToken
            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
            // 設定一些客戶端IP等資訊
            setDetails(request, token);
            // 交給AuthenticationManager進行認證
            return this.getAuthenticationManager().authenticate(token);
        }
    
        protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
            authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
        }
    
        @Override
        protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
            ObjectMapper objectMapper = new ObjectMapper();
            Response myResponse = new Response();
            // 設定header
            response.setHeader("Content-Type", "application/json;charset=utf-8");
            User user = (User) authResult.getPrincipal();
            // 生成Token返回
            String token = JWTUtil.generate(user);
            myResponse.setStatusCode(HttpStatus.OK.value());
            myResponse.setMsg("登入成功!");
            myResponse.setData("Bearer " + token);
    
            response.setStatus(HttpStatus.OK.value());
            response.getWriter().write(objectMapper.writeValueAsString(myResponse));
        }
    
    
        /**
         * 重寫認證失敗後的處理方法
         * @param request
         * @param response
         * @param failed
         * @throws IOException
         * @throws ServletException
         */
        protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                                  AuthenticationException failed) throws IOException, ServletException {
            ObjectMapper objectMapper = new ObjectMapper();
            Response myResponse = new Response();
            myResponse.setStatusCode(HttpStatus.UNAUTHORIZED.value());
            if (failed instanceof LockedException) {
                myResponse.setMsg("賬號被鎖定!");
            } else if (failed instanceof CredentialsExpiredException) {
                myResponse.setMsg("使用者密碼過期!");
            } else if (failed instanceof AccountExpiredException) {
                myResponse.setMsg("使用者賬號過期!");
            } else if (failed instanceof DisabledException) {
                myResponse.setMsg("使用者賬號被禁用!");
            } else if (failed instanceof BadCredentialsException) {
                myResponse.setMsg("使用者賬號或密碼錯誤!");
            }
    
            response.setContentType("application/json;charset=utf-8");
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.getWriter().write(objectMapper.writeValueAsString(myResponse));
        }
    

    ​ 自定義登入認證過濾器,這次攔截的url由構造器進行設定,重寫attemptAuthentication方法邏輯與之前的自定義認證一樣,都是從請求中獲取使用者名稱和密碼等引數,封裝為一個Token,我這裡直接使用了官方的UsernamepasswordAuthenticationToken,然後交給AuthenticationManager去認證。

    ​ 這裡的認證成功與失敗沒有使用自定義類的形式實現,而是以重寫successfulAuthentication方法和unsuccessfulAuthentication方法實現,認證成功後生成一個JWT Token返回,這裡直接將user物件作為JWT的playload儲存,實際中應以實際情況寫入的。認證失敗則返回JSON字串錯誤。

    ​ 需要與之前的驗證碼認證區分開的是,這裡前後端是以JSON傳遞的,所以request接收到的是一個json字串的資料,返回時也是json資料,相同的地方是Provider還是呼叫UserDetailsService的loadUserByUsername方法去驗證使用者。

    ​ 在這裡也是自定義了一個Response類,用來構造返回資訊,主要就是設定狀態,返回資訊,以及資料。

  2. 定義UserDetail類和UserDetailsService

    UserDetail類:

    @Data
    @JsonIgnoreProperties(ignoreUnknown = true)
    public class User implements UserDetails, Serializable {
    
        private static final long serialVersionUID = -1384206000980290583L;
    
        private int id;
        private String username;
        private String password;
        private String email;
        private boolean enabled;
    
        @Override
        public String toString() {
            return "User{" +
                    "id=" + id +
                    ", username='" + username + '\'' +
                    ", password='" + password + '\'' +
                    ", email='" + email + '\'' +
                    '}';
        }
    
        @Override
        @JsonDeserialize(using = CustomAuthorityDeserializer.class)
        public Collection<? extends GrantedAuthority> getAuthorities() {
            List<SimpleGrantedAuthority> authorities = new ArrayList<>();
            authorities.add(new SimpleGrantedAuthority("ROLE_ROOT"));
            return authorities;
        }
    
        @Override
        public boolean isAccountNonExpired() {
            return true;
        }
    
        @Override
        public boolean isAccountNonLocked() {
            return true;
        }
    
        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }
    
        @Override
        public boolean isEnabled() {
            return enabled;
        }
    }
    

    User類沒有太多變動,和之前一樣,因為還沒涉及到授權部分,這裡暫時先寫死許可權位ROLE_ROOT.

    有兩個錯誤可能會出現:

    1. com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "accountNonExpired" (class com.zzy.jwt.pojo.User), not marked as ignorable

      這是因為我們user類中一些欄位在資料庫中是沒有的,需要在類上加上一個註解:@JsonIgnoreProperties(ignoreUnknown = true)

      解決方案來源於這個部落格:jackson json轉bean忽略沒有的欄位 not marked as ignorable

    2. com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of org.springframework.security.core.GrantedAuthority

      這是因為我們在構造JWT Token時候是將整個user物件寫入的,user類中getAuthorities方法導致的,需要在getAuthorities方法上加上一個註解@JsonDeserialize(using = CustomAuthorityDeserializer.class)並定義一個類:CustomAuthorityDeserializer

      具體解決方式可以參考這個博文:Cannot construct instance of org.springframework.security.core.GrantedAuthority的錯誤解決

    UserDetailsService:

    @Service
    public class UserService implements UserDetailsService {
    
        @Autowired
        private UserMapper userMapper;
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            User user = userMapper.findUserByUsername(username);
            if (user == null) {
                throw new UsernameNotFoundException("User: " + username + " not exist!");
            }
    
            return user;
        }
    }
    

    Provider通過呼叫UserService的方法loadUserByUsername去認證使用者,這裡的方法裡通過使用者名稱查詢資料庫。

    當然這個UserService是我們自定義的實現UserDetailsService的,在後面我們的Security配置中要設定為這個Service

    資料庫操作相關的比較簡單,具體再看gitee上的原始碼吧,哈哈。

  3. JWT工具類

    public class JWTUtil {
        // 金鑰
        private final static String secretKey = "Lucas";
    
        // 過期時間設定為5min
        private final static Duration expiration = Duration.ofMinutes(10);
    
        /**
         * 生成Token
         * @param user
         * @return
         */
        public static String generate(User user) {
            // 過期時間
            Date expiryDate = new Date(System.currentTimeMillis() + expiration.toMillis());
            try {
                return Jwts.builder()
                    .setSubject(new ObjectMapper().writeValueAsString(user))       // 將username放進JWT
                    .setIssuedAt(new Date())    // 設定jwt簽發時間
                    .setExpiration(expiryDate)  // 設定jwt過期時間
                    .signWith(SignatureAlgorithm.HS512, secretKey)  // 設定加密演算法和金鑰
                    .compact();
            } catch (Exception e) {
                return null;
            }
        }
    
        /**
         * 解析Token
         * @param token
         * @return
         */
        public static Claims parse(String token) {
            try {
                return Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(token)
                    .getBody();
    
            } catch (Exception e) {
                return null;
            }
        }
    
    }
    

    這裡只是簡單的構造,一個生成Token的方法,一個解析Token的方法,金鑰設定為字串"Lucas", 過期時間設定為10分鐘。

    更加細緻的工具方法暫未去實現,先湊合著用著。

    其實,到了這裡我們就差將我們自定義的Filter和Provider新增到SpringSecurity的主配置中,就實現了認證了,但我們這次用的是JWT方式,伺服器端並沒有記錄登入使用者的資訊,因此我們需要多一個過濾器,用來攔截請求,驗證Token的,如果請求攜帶了Token,並且token裡的資訊是正確的,我們才將Authentication設定已認證,讓SpringSecurity過濾器鏈去做後續的鑑權啥的。

  4. 自定義Token攔截器

    SpringSecurity預設的基礎配置中沒有提供對Bearer Authentication處理的過濾器,但是提供了處理Basic Authentication的過濾器BasicAuthenticationFilter,我們還是可以模仿它實現自己的Filter

    public class JWTAuthenticationRequestFilter extends OncePerRequestFilter {
    
        private UserDetailsService userDetailsService;
    
        public JWTAuthenticationRequestFilter(UserDetailsService userDetailsService) {
            this.userDetailsService = userDetailsService;
        }
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
            // 校驗Token
            Authentication authentication = getAuthentication(request);
            if (authentication == null) {
                filterChain.doFilter(request, response);
                return;
            }
            // 認證成功, 儲存到SecurityContext上下文中
            SecurityContextHolder.getContext().setAuthentication(authentication);
            filterChain.doFilter(request, response);
        }
    
        private Authentication getAuthentication(HttpServletRequest request) throws JsonProcessingException {
            // 判斷是否有Token,拿到Token
            String header = request.getHeader("Authorization");
            if (header == null || !header.startsWith("Bearer ")) {
                return null;
            }
            String token = header.split(" ")[1];
            System.out.println("token = " + token);
            // 通過Token解析
            Claims claims = JWTUtil.parse(token);
            if (claims == null) {
                return null;
            }
            User user = (User) new ObjectMapper().readValue(claims.getSubject(), User.class);
    
            // 開始驗證
            // 通過使用者名稱查詢資料庫,確認是否存在該使用者
            UserDetails userDetails = null;
            try {
                userDetails = userDetailsService.loadUserByUsername(user.getUsername());
            } catch (UsernameNotFoundException e) {
                return null;
            }
    
            // 校驗Token的密碼與資料庫中使用者密碼是否一致
            if (!userDetails.getPassword().equals(user.getPassword())) {
                return null;
            }
            // 構造Authentication物件
            return new UsernamePasswordAuthenticationToken(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities());
        }
    }
    

    簡單說明下這裡的邏輯:

    1. 對於任意的請求,都會被該過濾器攔截,首先會判斷是否攜帶了token,這裡設定是請求頭Authentication中放置Token
    2. 接著對Token進行解析,拿出我們存放進去的user類物件
    3. 呼叫userService呼叫loadUserByUsername去查詢資料庫,確定是否存在該使用者
    4. 校驗資料庫中的使用者密碼是否與Token中的使用者密碼資訊一致,如果一致則構造一個可信的Authentication物件,這裡是UsernamepasswordAuthenticationToken;如果不一致,則返回null
    5. 若Token校驗不通過,則放行,SpringSecurity後續的過濾器會處理掉他的。
    6. 若Token校驗成功,則將校驗物件Authentication放入SpringContext上下文中
  5. 自定義SpringSecurity中的異常

    前面一些處理報異常時並沒有返回到客戶端,我們還需要自定義SpringSecurity的全域性異常處理,返回JSON資料給到客戶端。

    具體的操作可以學習小胖哥這篇博文:Spring Security 實戰乾貨:自定義異常處理

    許可權相關異常處理:

    public class SimpleAccessDeniedHandler implements AccessDeniedHandler {
    
        @Override
        public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
            Response myResponse = new Response();
            myResponse.setStatusCode(HttpServletResponse.SC_FORBIDDEN);
            myResponse.setMsg("沒有許可權!");
    
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.setCharacterEncoding("utf-8");
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    
            response.getWriter().write(new ObjectMapper().writeValueAsString(myResponse));
        }
    }
    

    認證相關異常處理:

    public class SimpleAuthenticationEntryPoint implements AuthenticationEntryPoint {
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
            Response myResponse = new Response();
            myResponse.setStatusCode(HttpServletResponse.SC_UNAUTHORIZED);
            myResponse.setMsg(authException.getMessage());
    
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setCharacterEncoding("utf-8");
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    
            response.getWriter().write(new ObjectMapper().writeValueAsString(myResponse));
        }
    }
    
  6. 配置

    我們還是自定義一個配置類繼承 SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>,配置我們自定義的Filter

    @Component
    public class JWTLoginConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    
        @Autowired
        private UserService userService;
    
        @Override
        public void configure(HttpSecurity http) throws Exception {
            // 配置Filter
            JWTAuthenticationFilter jwtAuthenticationFilter = new JWTAuthenticationFilter("/jwt/login");
            JWTAuthenticationRequestFilter jwtAuthenticationRequestFilter = new JWTAuthenticationRequestFilter(userService);
    
            // 配置AuthenticationManager
            jwtAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
    
            // 新增Filter
            http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
            http.addFilterBefore(jwtAuthenticationRequestFilter, UsernamePasswordAuthenticationFilter.class);
        }
    }
    

    注意的點:

    1. 這裡雖說是一個配置類,但是最後是要併入SpringSecurity的主配置的,這裡用的註解是@Component,能讓我們在主配置類中進行注入。
    2. 自定義的認證Filter一定要設定AuthenticationManager,否則找不到對應的Provider來處理。

    主配置類:

    @Configuration
    public class MySecurityConfig extends WebSecurityConfigurerAdapter {
    
        @Autowired
        private JWTLoginConfig jwtLoginConfig;
    
        @Autowired
        private UserService userService;
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        @Override
        public void configure(AuthenticationManagerBuilder builder) throws Exception {
            builder.userDetailsService(userService).passwordEncoder(passwordEncoder());
        }
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.authorizeRequests()
                .antMatchers("/hello")
                .hasRole("ROOT")
                .anyRequest()
                .authenticated()
                .and()
                .apply(jwtLoginConfig)
                .and()
                .csrf()
                .disable();
    
            // 自定義異常處理
            http.exceptionHandling().accessDeniedHandler(new SimpleAccessDeniedHandler()).authenticationEntryPoint(new SimpleAuthenticationEntryPoint());
            // 前後端分離是STATELESS,session使用該策略
            http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        }
    }
    

    這裡我們自定義了UserDetailService方法,因此需要配置上builder.userDetailsService(userService).passwordEncoder(passwordEncoder());,密碼編碼器不可少。

    然後自定義異常的話就得使用http.exceptionHandling().accessDeniedHandler(new SimpleAccessDeniedHandler()).authenticationEntryPoint(new SimpleAuthenticationEntryPoint());將我們自定義的類傳入

    最後是將session策略設定為了STATELESS.

5. 測試

我簡單寫了一個介面/hello

@RestController
public class UserController {

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

通過Postman進行測試驗證:

  1. 訪問/jwt/login登入

    返回了一個Token,拿著這個Token去做請求

  2. 訪問/hello

    在發起請求時,在header部分新增一個key為Authentication,value為登入請求時返回的Token,才能正常訪問,獲得回覆。

  3. 當Token過期時,再去訪問/hello

    返回了401的錯誤,這是咱們自定義的異常處理器SimpleAuthenticationEntryPoint返回了資訊

  4. 我們在定義User類時預設寫死了許可權為ROLE_ROOT,那當我們將/hello的訪問許可權修改為ROLE_USER時去訪問

    http.authorizeRequests().antMatchers("/hello").hasRole("USER")
    

    返回了403錯誤,這是自定義異常處理器SimpleAccessDeniedHandler返回的資訊

6. 原始碼地址

歡迎訪問 Lucas-張 / SpringSecurity