technology-integration(七)---使用SpringSecurity做JWT認證授權

weixin_33912246發表於2018-08-27

SpringSecurity是什麼

Spring Security是一個能夠為基於Spring的企業應用系統提供宣告式的安全訪問控制解決方案的安全框架。它提供了一組可以在Spring應用上下文中配置的Bean,充分利用了Spring IoC,DI(控制反轉Inversion of Control ,DI:Dependency Injection 依賴注入)和AOP(面向切面程式設計)功能,為應用系統提供宣告式的安全訪問控制功能,減少了為企業系統安全控制編寫大量重複程式碼的工作。(引用百度百科)

為什麼使用SpringSecurity

可以說SpringBoot的快速發展也使得了SpringSecurity的熱度往上漲,在SpringBoot以前,Shiro和SpringSecurity的框架都是主流的安全框架,但Security的配置非常臃腫,效能也相對慢一些,最大的缺點還是必須依賴於Spring框架才能開發,不過唯一值得一提的就是Security預設實現了更多功能,更是提供了oauth授權的實現。不過一切的改變還是源於SpringBoot的出現,SpringBoot整合SpringSecurity的步驟非常簡單,只需要繼承WebSecurityConfigurerAdapter這個類並實現認證方法,接著配置一下登入的uri即可完成一個簡單的使用者認證功能,可以說整個功能都不用5分鐘就能實現。我個人還是比較喜歡SpringBoot全家桶,不需要解決框架整合上的小麻煩,功能上來說也很強大。

JWT

JWT是一種用於雙方之間傳遞安全資訊的簡潔的、URL安全的表述性宣告規範。JWT作為一個開放的標準(RFC 7519),定義了一種簡潔的,自包含的方法用於通訊雙方之間以Json物件的形式安全的傳遞資訊。因為數字簽名的存在,這些資訊是可信的,JWT可以使用HMAC演算法或者是RSA的公私祕鑰對進行簽名。簡潔(Compact): 可以通過URL,POST引數或者在HTTP header傳送,因為資料量小,傳輸速度也很快 自包含(Self-contained):負載中包含了所有使用者所需要的資訊,避免了多次查詢資料庫

怎麼使用

使用JWT,我們只需要在請求的請求頭上新增如圖下類似的資料(token)。後端根據需要認證的url進行攔截,取出Hearders裡面的資料,緊接著解析出這段token的包含的資訊,判斷資訊是否正確即可。token其實就是根據資訊加密而來的一段字串,我們將需要用到的資訊放到token中,token包含的資訊儘可能的簡潔。


5551927-6b504dead1eb2fc5.png
image.png

注意:

雖然簡單的jwt認證並沒有什麼難度,但如果你沒使用過SpringSecurity,建議還是先去簡單的學習一下。


開始

  1. 編寫通過使用者id或使用者手機號碼查詢User和Role的方法
  2. 編寫Token生成工具類
  3. 繼承UserDetails介面
  4. 繼承UserDetailsService介面,實現使用者認證方法
  5. 編寫使用者賬號驗證失敗處理器與許可權不足處理器
  6. 編寫Token驗證過濾器
  7. 配置SpringSecurity Config
  8. 實現登入方法

整個流程還是相對完善的,所以步驟稍多

匯入jar

這裡需要提一下的就是,當你引入這個包的時候,SpringBoot預設會為專案所有的請求新增認證,這也是SpringSecurity的常規操作,如果你還不知道的話,趕快剎車調頭回家補課。

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

實現使用者登入方法

使用者通過手機號及密碼進行登入,我們需要先獲取使用者的身份資訊以及角色資訊

UserMapper.xml
  <resultMap id="User_Role" type="com.viu.technology.po.User">
    <id property="id" column="id" javaType="java.lang.String" jdbcType="BIGINT" />
    <result property="name" column="name" javaType="java.lang.String" jdbcType="VARCHAR" />
    <result property="phone" column="phone" javaType="java.lang.String" jdbcType="VARCHAR" />
    <result property="password" column="password" javaType="java.lang.String" jdbcType="VARCHAR" />
    <collection property="roles" ofType="com.viu.technology.po.Role">
      <id property="id" column="role_id" jdbcType="BIGINT"/>
      <result property="roleName" column="role_name" jdbcType="VARCHAR" />
    </collection>
  </resultMap>

  <select id="selUserAndRoleByPhone" parameterType="java.lang.String" resultMap="User_Role">
    SELECT u.id,u.name,u.password,u.phone,r.id as role_id ,r.role_name
    from t_user u
    LEFT JOIN t_role r on u.id=r.user_id
    where u.phone=#{phone,jdbcType=VARCHAR}
  </select>

  <select id="selUserAndRoleById" parameterType="java.lang.String" resultMap="User_Role">
    SELECT u.id,u.name,u.password,u.phone,r.id as role_id ,r.role_name
    from t_user u
    LEFT JOIN t_role r on u.id=r.user_id
    where u.id=#{id,jdbcType=VARCHAR}
  </select>
UserMapper.java
    User selUserAndRoleByPhone(String phone);

    User selUserAndRoleById(String id);
UserDao.java
    User selUserAndRoleByPhone(String phone);

    User selUserAndRoleById(String id);
UserDaoImpl.java
    public User selUserAndRoleByPhone(String phone) {
        User user = userMapper.selUserAndRoleByPhone(phone);
        return user;
    }


    public User selUserAndRoleById(String id){
        User user = userMapper.selUserAndRoleById(id);
        return user;
    }
UserService.java
    User getUserAndRoleByPhone(String phone);

    User getUserAndRoleById(String id);
UserServiceImpl.java
    public User getUserAndRoleByPhone(String phone) {
        User user = userDao.selUserAndRoleByPhone(phone);
        return user;
    }

    public User getUserAndRoleById(String id) {
        User user = userDao.selUserAndRoleById(id);
        return user;
    }

運算元據庫獲取使用者身份資訊的程式碼就到此為止了,接下來就開始編寫SpringSecurity+jwt的認證程式碼了


編寫Token生成工具類----JwtTokenUtil

工具類主要用作生成token、重新整理token以及驗證token。Token和Session一個很大的區別就是無登入狀態,我們可以利用清除session做登出的操作,但無法利用token直接做登出操作,後續會進行講解。
這個token裡的資訊比較簡單,只存放了sub和create,你可以根據自己業務需求在generateToken(UserDetails userDetails)方法裡面新增不同的資料即可,後續通過getClaimsFromToken方法獲取Claims物件,接著呼叫Claims物件的get方法獲取出對應的資料即可。

@Component
public class JwtTokenUtil{

    /**
     * 金鑰
     */
    private static final String secret = "lkhouhubkljgpihojblkjboiboihu9u";

    /**
     * 從資料宣告生成令牌
     *
     * @param claims 資料宣告
     * @return 令牌
     */
    public static String generateToken(Map<String, Object> claims) {
        //設定token的有效期為24*7小時,也就是一週
        Date expirationDate = new Date(System.currentTimeMillis() +60*60*24*7 * 1000);
        return Jwts.builder().setClaims(claims).setExpiration(expirationDate).signWith(SignatureAlgorithm.HS512, secret).compact();
    }

    /**
     * 從令牌中獲取資料宣告
     *
     * @param token 令牌
     * @return 資料宣告
     */
    public static Claims getClaimsFromToken(String token) {
        Claims claims;
        try {
            claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
        } catch (Exception e) {
            claims = null;
        }
        return claims;
    }

    /**
     * 生成令牌
     *
     * @param userDetails 使用者
     * @return 令牌
     */
    public static String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>(2);
        claims.put("sub", userDetails.getUsername());
        claims.put("created", new Date());
        return generateToken(claims);
    }

    /**
     * 從令牌中獲取使用者名稱
     *
     * @param token 令牌
     * @return 使用者名稱
     */
    public static String getUsernameFromToken(String token) {
        String username;
        try {
            Claims claims = getClaimsFromToken(token);
            username = claims.getSubject();
        } catch (Exception e) {
            username = null;
        }
        return username;
    }

    /**
     * 判斷令牌是否過期
     *
     * @param token 令牌
     * @return 是否過期
     */
    public static Boolean isTokenExpired(String token) {
        try {
            Claims claims = getClaimsFromToken(token);
            Date expiration = claims.getExpiration();
            return expiration.before(new Date());
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * 重新整理令牌
     *
     * @param token 原令牌
     * @return 新令牌
     */
    public static String refreshToken(String token) {
        String refreshedToken;
        try {
            Claims claims = getClaimsFromToken(token);
            claims.put("created", new Date());
            refreshedToken = generateToken(claims);
        } catch (Exception e) {
            refreshedToken = null;
        }
        return refreshedToken;
    }

    /**
     * 驗證令牌
     *
     * @param token       令牌
     * @param userDetails 使用者
     * @return 是否有效
     */
    public static Boolean validateToken(String token, UserDetails userDetails) {
        String username = getUsernameFromToken(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }
}

繼承UserDetails介面

UserDetails介面是SpringSecurity框架用於認證授權的一個載體,只有實現了這個介面的類才能被SpringSecurity驗證,

public class User implements UserDetails {
    private String id;

    private String name;

    private String password;

    private String phone;

    private List<Role> roles;


    public List<Role> getRoles() {
        return roles;
    }

    public void setRoles(List<Role> roles) {
        this.roles = roles;
    }

    public User(String id, String name, String password, String phone) {
        this.id = id;
        this.name = name;
        this.password = password;
        this.phone = phone;
    }

    public User() {
        super();
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    //獲取使用者角色許可權,此處從資料庫表Role中獲取
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> auths = new ArrayList<>();
        List<Role> roles = getRoles();
        if (roles!=null) {
            for (Role role : roles) {
                auths.add(new SimpleGrantedAuthority(role.getRoleName()));
            }
        }
        return auths;
    }

    //這個是UserDetails預設實現獲取密碼的方法
    @Override
    public String getPassword() {
        return password;
    }


    //這裡getUsername翻譯過來就是獲取使用者名稱的意思,但這個可以作為我們獲取使用者資訊的一個標識
    @Override
    public String getUsername() {
        return id;
    }

    //使用者賬號是否過期,暫時沒這個功能,預設返回true,即未過期
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    //使用者賬號是否鎖定
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    //使用者憑證是否過期
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    //賬號是否可用
    @Override
    public boolean isEnabled() {
        return true;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }
}

編寫登入認證方法JwtUserDetailsServiceImpl.java

該類位於com.viu.technology.service.auth包下(自行建包)
JwtUserDetailsServiceImpl實現了UserDetailsService介面,SpringSecurity會去IOC容器中尋找實現這個介面的實現類,並將該實現類作為預設的認證類。這個類主要用於獲取使用者身份資訊,並不需要我們去判斷使用者名稱和密碼是否匹配。參照UserDetails實現的getPassword和getUsername方法。

這裡之所要對username的長度進行判斷是因為,我們登入的時候用的是手機號+明文密碼進行登入,而儲存在token裡的資訊只有id。登入方法和Token認證過濾器都會呼叫loadUserByUsername方法,所以需要做一個判斷。可能會有一點疑問,既然是這樣,為什麼不直接用手機號做為token的傳遞資訊就好了呢,主要還是因為我們使用手機號查詢的情況比較少,而表的主鍵id才是經常用的。

@Service
public class JwtUserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    @Lazy
    private UserService userService;

    public JwtUserDetailsServiceImpl(){
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = null;
        if (username.length() == 32) {
            user= userService.getUserAndRoleById(username);
        } else if(username.length()==11) {
            user= userService.getUserAndRoleByPhone(username);
        }

        log.info("user:" + user);

        if (user == null) {
            throw new UsernameNotFoundException(String.format("No user found with username '%s'.", username));
        }else{
            return user;
        }
    }
}

編寫賬號密碼驗證失敗處理器EntryPointUnauthorizedHandler.java

位於com.viu.technology.handler包下,自行建立

@Component
public class EntryPointUnauthorizedHandler implements AuthenticationEntryPoint {

    private static Logger log = LoggerFactory.getLogger(EntryPointUnauthorizedHandler.class);

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        response.setHeader("Access-Control-Allow-Origin", "*");
        response.setStatus(401);
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write(JsonUtil.objectToString(Result.fail(ResultCode.USER_LOGIN_FIAL)));

    }

}

編寫賬戶許可權不足處理器RestAccessDeniedHandler.java

位於com.viu.technology.handler包下,自行建立

@Component
public class RestAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
        response.setHeader("Access-Control-Allow-Origin", "*");
        response.setStatus(403);
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write(JsonUtil.objectToString(Result.fail(ResultCode.USER_PERMISSION_DENIED)));
    }
}

編寫Token驗證過濾器JwtAuthenticationTokenFilter.java

位於com.viu.technology.filter包下,自行建立

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    private static Logger log = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");
        //該字串作為Authorization請求頭的值的字首
        String tokenHead = "tech-";
        if (authHeader != null && authHeader.startsWith(tokenHead)) {
            String authToken = authHeader.substring(tokenHead.length());
            //從token中獲取userId
            String userId = JwtTokenUtil.getUsernameFromToken(authToken);
            if (userId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                //呼叫UserDetailsService的認證方法(JwtUserDetailsServiceImpl實現類)
                UserDetails userDetails = this.userDetailsService.loadUserByUsername(userId);
                //驗證token是否正確
                if (JwtTokenUtil.validateToken(authToken, userDetails)) {
                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    //將獲取到的使用者身份資訊放到SecurityContextHolder中,這個類是為了線上程中儲存當前使用者的身份資訊
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        } else {
            log.info("沒有獲取到token");
        }
        chain.doFilter(request, response);
    }



}

配置SpringSecurity

位於com.viu.technology.config.security包下,自行建立

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)//開啟security方法級別許可權控制註解
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
    @Autowired
    private EntryPointUnauthorizedHandler entryPointUnauthorizedHandler;
    @Autowired
    private RestAccessDeniedHandler restAccessDeniedHandler;
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private SimpleUrlAuthenticationSuccessHandler successHandler;

    @Autowired
    private SimpleUrlAuthenticationFailureHandler failureHandler;

    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }


    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .authorizeRequests()
                //這裡的引數為不需要認證的uri,**代表匹配多級路徑,*代表匹配一級路徑,#代表一個字元....
                .antMatchers(
                        "/demo/**",
                        "/user/generate/token"
                ).permitAll()
                //這裡表示該路徑需要管理員角色
                .antMatchers("/auth/test").hasAnyAuthority("管理員")
                .anyRequest().authenticated()
                .and()
                .headers().cacheControl();


        //新增認證過濾
        httpSecurity.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        //新增許可權不足及驗證失敗處理器
        httpSecurity.exceptionHandling().authenticationEntryPoint(entryPointUnauthorizedHandler).accessDeniedHandler(restAccessDeniedHandler);

    }


    //這個為SpringSecurity的加密類
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

實現登入方法

UserService.java
    String login(String phone, String password);
UserServiceImpl.java

這裡需要注意一下,UsernamePasswordAuthenticationToken會自動將password進行加密之後再比對,而我們之前寫的註冊使用者方法是以明文方式存入資料庫的,並沒有加密,所以我們需要修改一下使用者註冊方法,然後重新註冊

    public String login(String phone, String password) {
        //將使用者名稱和密碼生成Token
        UsernamePasswordAuthenticationToken upToken = new UsernamePasswordAuthenticationToken(phone, password);
        //呼叫該方法時SpringSecurity會去呼叫JwtUserDetailsServiceImpl 進行驗證
        Authentication authentication = authenticationManager.authenticate(upToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);
        User userDetails = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        return JwtTokenUtil.generateToken(userDetails);
    }



    @Autowired
    PasswordEncoder passwordEncoder;

    public User registerUser(User user) {
        //在插入資料庫時將原密碼進行加密
        user.setPassword(passwordEncoder.encode(user.getPassword()));
        User userRes = userDao.insertUser(user);
        Role roleRes = roleDao.insertRole(new Role("普通群眾", user.getId()));
        List list = new ArrayList();
        list.add(roleRes);

        if (null != userRes && null != roleRes) {
            userRes.setRoles(list);
            return user;
        }
        return null;
    }
UserController.java
    @PostMapping(value = "/generate/token")
    public Result getToken(String phone, String password) throws AuthenticationException {

        String token = userService.login(phone, password);
        return Result.success(token);
    }

測試獲取token介面

5551927-5805c7aebb064694.png
獲取token

接著我們呼叫一下之前寫的註冊介面,發現沒發註冊,因為我們在SpringSecurity的配置中並沒有開放這個介面的認證,自行新增。註冊是不需要使用者身份驗證的,否則你讓人家怎麼註冊嘛。。。


5551927-1f39f9b003172063.png
image.png

測試Token是否能正常使用

UserController.java
    @GetMapping("/self/info")
    public Result getUserSelfInfo() {
       //由於通過驗證後我們會把使用者物件存到SecurityContextHolder中,所以這時候我們能通過下面這句程式碼獲取到使用者的身份資訊
        User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        return Result.success(user);
    }

接下來測試一下,如果能夠正常獲取就代表成功,記住token前面要加tech-這個幾個字串,看不順眼的話自己去改過濾器


5551927-b0e480669ed1fa07.png


溫馨提醒

你們會發現讀出來的資料和我稍微有點不一樣對吧,哈哈哈哈,肯定啊,你們沒有過濾一下敏感欄位(密碼我忘了過濾了0.0),在User類上加入@JSONField(serialize = false)註解即可,SpringBoot會將持有該註解的欄位過濾不進行輸出。


5551927-59d3703c5f2e2ac0.png
image.png


更多文章請關注該 technology-integration全面解析專題

相關文章