JWT+SpringSecurity實現基於Token的單點登錄(二):認證和授權

前言

本章是基於上一章“JWT+SpringSecurity實現基於Token的單點登錄(一):前期準備”的基礎上進行開發的,如果前期準備還沒有做好的,可點擊鏈接至上一章。

代碼地址:gitee

一、JWT工具類

這裏我們使用jjwt來構建我們的Token。首先導入jjwt的依賴包。

        <!--JWT-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.0</version>
        </dependency>

接着構建JwtTokenUtils工具類。

package com.shiep.jwtauth.utils;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import java.util.Date;
import java.util.HashMap;
import java.util.List;

/**
 * @author: 倪明輝
 * @date: 2019/3/6 15:11
 * @description: JWT工具類
 * JWT是由三段組成的,分別是header(頭)、payload(負載)和signature(簽名)
 * 其中header中放{
 *   "alg": "HS512",
 *   "typ": "JWT"
 * } 表明使用的加密算法,和token的類型==>默認是JWT
 *
 */
public class JwtTokenUtils {

    public static final String TOKEN_HEADER = "Authorization";
    public static final String TOKEN_PREFIX = "Bearer ";

    //密鑰,用於signature(簽名)部分解密
    private static final String PRIMARY_KEY = "jwtsecretdemo";
    //簽發者
    private static final String ISS = "Gent.Ni";
    // 添加角色的key
    private static final String ROLE_CLAIMS = "role";

    // 過期時間是3600秒,既是1個小時
    private static final long EXPIRATION = 3600L;

    // 選擇了記住我之後的過期時間爲7天
    private static final long EXPIRATION_REMEMBER = 604800L;

    /**
     * description: 創建Token
     *
     * @param username
     * @param isRememberMe
     * @return java.lang.String
     */
    public static String createToken(String username, List<String> roles, boolean isRememberMe) {
        long expiration = isRememberMe ? EXPIRATION_REMEMBER : EXPIRATION;
        HashMap<String, Object> map = new HashMap<>();
        map.put(ROLE_CLAIMS, roles);
        return Jwts.builder()
                //採用HS512算法對JWT進行的簽名,PRIMARY_KEY是我們的密鑰
                .signWith(SignatureAlgorithm.HS512, PRIMARY_KEY)
                //設置角色名
                .setClaims(map)
                //設置發證人
                .setIssuer(ISS)
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
                .compact();
    }

    /**
     * description: 從token中獲取用戶名
     *
     * @param token
     * @return java.lang.String
     */
    public static String getUsername(String token){
        return getTokenBody(token).getSubject();
    }

    // 獲取用戶角色
    public static List<String> getUserRole(String token){
        return (List<String>) getTokenBody(token).get(ROLE_CLAIMS);
    }

    /**
     * description: 判斷Token是否過期
     *
     * @param token
     * @return boolean
     */
    public static boolean isExpiration(String token){
        return getTokenBody(token).getExpiration().before(new Date());
    }

    /**
     * description: 獲取
     *
     * @param token
     * @return io.jsonwebtoken.Claims
     */
    private static Claims getTokenBody(String token){
        return Jwts.parser()
                .setSigningKey(PRIMARY_KEY)
                .parseClaimsJws(token)
                .getBody();
    }
}
JwtTokenUtils類的createToken方法,傳入參數UserName爲用戶名,roles是該用戶的角色列表,isRememberMe代表是否記住我,從而選擇Token的過期時間。getUsername和getUserRole方法分別用來讀取Token中的用戶名和該用戶的角色列表。isExpiration方法用來判斷該Token是否過期。

二、實現UserDetails,封裝用戶信息

package com.shiep.jwtauth.entity;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.HashSet;
import java.util.List;

/**
 * @author: 倪明輝
 * @date: 2019/3/6 15:27
 * @description: 實現UserDetails,封裝用戶信息,用於驗證身份
 */
public class JwtAuthUser implements UserDetails {
    private Integer id;
    private String userName;
    private String password;
    private List<String> roles;
    private Collection<? extends GrantedAuthority> authorities;

    /**
     * description: 通過FXUser來創建JwtAuthUser
     *
     * @param user
     * @return
     */
    public JwtAuthUser(FXUser user){
        this.id=user.getId();
        this.userName=user.getName();
        this.password=user.getPassword();
        this.roles=user.getRoles();
    }

    /**
     * description: 鑑權最重要方法,通過此方法來返回用戶權限
     * 
     * @param  
     * @return java.util.Collection<? extends org.springframework.security.core.GrantedAuthority>
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new HashSet<>();
        if (roles!=null) {
            for (String role : roles) {
                authorities.add(new SimpleGrantedAuthority(role));
            }
        }
        System.out.println("authorities:"+authorities);
        return authorities;
    }

    @Override
    public String getPassword() {
        return this.password;
    }

    @Override
    public String getUsername() {
        return this.userName;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public String toString() {
        return "JwtAuthUser{" +
                "id=" + id +
                ", username='" + userName + '\'' +
                ", password='" + password + '\'' +
                ", authorities=" + roles +
                '}';
    }
}
JwtAuthUser類實現了UserDetails,從而封裝了用戶信息,用於認證和鑑權。

三、實現UserDetailsService,從數據庫加載用戶信息(UserDetails)

package com.shiep.jwtauth.service.impl;

import com.shiep.jwtauth.entity.FXUser;
import com.shiep.jwtauth.entity.JwtAuthUser;
import com.shiep.jwtauth.repository.FXUserRepository;
import org.springframework.beans.factory.annotation.Autowired;
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.Service;

/**
 * @author: 倪明輝
 * @date: 2019/3/6 16:26
 * @description: 實現UserDetailsService,從數據庫中加載用戶信息==》用戶名、密碼及角色名
 *
 *  Spring Security中進行身份驗證的是AuthenticationManager接口,ProviderManager是它的一個默認實現,但它並不用來處理身份認證,
 *  而是委託給配置好的AuthenticationProvider,每個AuthenticationProvider會輪流檢查身份認證。檢查後或者返回Authentication對象或者拋出異常。
 *
 *  驗證身份就是加載響應的UserDetails,看看是否和用戶輸入的賬號、密碼、權限等信息匹配。
 *  此步驟由實現AuthenticationProvider的DaoAuthenticationProvider(它利用UserDetailsService驗證用戶名、密碼和授權)處理。
 *  包含 GrantedAuthority 的 UserDetails對象在構建 Authentication對象時填入數據。
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private FXUserRepository userRepository;

    /**
     * description: 通過用戶名從數據庫中讀取該用戶賬戶信息及權限信息
     *
     * @param userName 用戶名
     * @return org.springframework.security.core.userdetails.UserDetails
     */
    @Override
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
        FXUser user = userRepository.findByName(userName);
        if(user==null){
            // 實際當用戶不存在時,應該頁面顯示錯誤信息,並跳轉到登錄界面
            throw new UsernameNotFoundException("該用戶不存在!");
        }
        user.setRoles(userRepository.getRolesByUserName(userName));
        System.out.println("UserDetailsServiceImpl==>loadUserByUsername:"+user.toString());
        return new JwtAuthUser(user);
    }
}
UserDetailsServiceImpl實現了UserDetailsService,只有一個方法loadUserByUsername,通過查詢用戶名從數據庫中加載用戶信息(UserDetails),這裏是JwtAuthUser。

爲了更清晰理解SpringSecurity的認證鑑權原理,下面講解下SpringSecurity中一些核心類。

  • AuthenticationManager, 用戶認證的管理類,所有的認證請求(比如login)都會通過提交一個token給AuthenticationManagerauthenticate()方法來實現。當然事情肯定不是它來做,具體校驗動作會由AuthenticationManager將請求轉發給具體的實現類來做。根據實現反饋的結果再調用具體的Handler來給用戶以反饋。
  • AuthenticationProvider, 認證的具體實現類,一個provider是一種認證方式的實現,比如提交的用戶名密碼我是通過和DB中查出的user記錄做比對實現的,那就有一個DaoProvider;如果我是通過CAS請求單點登錄系統實現,那就有一個CASProvider
    前面講了AuthenticationManager只是一個代理接口,真正的認證就是由AuthenticationProvider來做的。一個AuthenticationManager可以包含多個Provider,每個provider通過實現一個support方法來表示自己支持那種Token的認證。AuthenticationManager默認的實現類是ProviderManager
  • UserDetailService, 用戶認證通過Provider來做,所以Provider需要拿到系統已經保存的認證信息,獲取用戶信息的接口spring-security抽象成UserDetailService
  • AuthenticationToken, 所有提交給AuthenticationManager的認證請求都會被封裝成一個Token的實現,比如最容易理解的UsernamePasswordAuthenticationToken
  • SecurityContext,當用戶通過認證之後,就會爲這個用戶生成一個唯一的SecurityContext,裏面包含用戶的認證信息Authentication。通過SecurityContext我們可以獲取到用戶的標識Principle和授權信息GrantedAuthrity。在系統的任何地方只要通過SecurityHolder.getSecruityContext()就可以獲取到SecurityContext

                                              

這裏先大概瞭解下,下面我們接着Coding。

四、配置登錄校驗攔截器

在配置登錄校驗攔截器前,我們一般先創建一個Model類,用來接收用戶登錄信息。

package com.shiep.jwtauth.model;

import lombok.Data;

/**
 * @author: 倪明輝
 * @date: 2019/3/6 16:18
 * @description: 封裝了用戶登錄時的信息
 */
@Data
public class LoginUser {
    private String username;
    private String password;
    private Boolean rememberMe;
}

 LoginUser類中有三個字段,用戶名、密碼、是否記住我。實際開發時還能加入驗證碼等。

配置好了LoginUser類後,我們接着來看看如何配置過濾器。

package com.shiep.jwtauth.filter;

import com.alibaba.fastjson.JSON;
import com.shiep.jwtauth.common.ResultEnum;
import com.shiep.jwtauth.common.ResultVO;
import com.shiep.jwtauth.entity.JwtAuthUser;
import com.shiep.jwtauth.model.LoginUser;
import com.shiep.jwtauth.utils.JwtTokenUtils;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

/**
 * @author: 倪明輝
 * @date: 2019/3/6 16:12
 * @description: 進行用戶賬號的驗證==>認證功能
 *
 */
public class JwtLoginAuthFilter extends UsernamePasswordAuthenticationFilter {

    private AuthenticationManager authenticationManager;

    private ThreadLocal<Boolean> rememberMe = new ThreadLocal<>();

    public JwtLoginAuthFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
        // 設置該過濾器地址
        super.setFilterProcessesUrl("/auth/login");
    }

    /**
     * description: 登錄驗證
     *
     * @param request
     * @param response
     * @return org.springframework.security.core.Authentication
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws AuthenticationException {
        LoginUser loginUser = new LoginUser();
        loginUser.setUsername(request.getParameter("username"));
        loginUser.setPassword(request.getParameter("password"));
        loginUser.setRememberMe(Boolean.parseBoolean(request.getParameter("rememberMe")));
        System.out.println(loginUser.toString());
        rememberMe.set(loginUser.getRememberMe());
        return authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(loginUser.getUsername(), loginUser.getPassword(), new ArrayList<>())
        );

    }

    /**
     * description: 登錄驗證成功後調用,驗證成功後將生成Token,並重定向到用戶主頁home
     * 與AuthenticationSuccessHandler作用相同
     *
     * @param request
     * @param response
     * @param chain
     * @param authResult
     * @return void
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authResult) throws IOException, ServletException {

        // 查看源代碼會發現調用getPrincipal()方法會返回一個實現了`UserDetails`接口的對象,這裏是JwtAuthUser
        JwtAuthUser jwtUser = (JwtAuthUser) authResult.getPrincipal();
        System.out.println("JwtAuthUser:" + jwtUser.toString());
        boolean isRemember = rememberMe.get();
        List<String> roles = new ArrayList<>();
        Collection<? extends GrantedAuthority> authorities = jwtUser.getAuthorities();
        for (GrantedAuthority authority : authorities){
            roles.add(authority.getAuthority());
        }
        System.out.println("roles:"+roles);
        String token = JwtTokenUtils.createToken(jwtUser.getUsername(), roles,isRemember);
        System.out.println("token:"+token);
        // 重定向無法設置header,這裏設置header只能設置到/auth/login界面的header
        //response.setHeader("token", JwtTokenUtils.TOKEN_PREFIX + token);

        // 登錄成功重定向到home界面
        // 這裏先採用參數傳遞
        response.sendRedirect("/home?token="+token);
    }

    /**
     * description: 登錄驗證失敗後調用,這裏直接Json返回,實際上可以重定向到錯誤界面等
     * 與AuthenticationFailureHandler作用相同
     *
     * @param request
     * @param response
     * @param failed
     * @return void
     */
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
        response.getWriter().write(JSON.toJSONString(ResultVO.result(ResultEnum.USER_LOGIN_FAILED,false)));
    }
}
JwtLoginAuthFilter實現了UsernamePasswordAuthenticationFilter,該攔截器的attemptAuthentication方法,通過request.getParameter方法來得到前端傳來的登錄參數,並構建LoginUser對象。PS:從代碼看LoginUser是不是有點多餘?我們是不是可以直接得到參數進行校驗?嗯……是可以
的,但是原本正確的思路是通過從輸入流中直接構建登錄對象的,代碼如下:
LoginUser loginUser = new ObjectMapper().readValue(request.getInputStream(), LoginUser.class);   
但是LoginUser的Boolean rememberMe字段,老是無法與前端數據進行匹對(將Boolean改成String也報錯,錯誤信息如下),因此這裏先採用這種方式讀取數據吧。ps:有知道如何解決的小夥伴,麻煩告訴我下,感激不盡~
com.fasterxml.jackson.databind.exc.MismatchedInputException: No content to map due to end-of-input

我們接着看attemptAuthentication方法,該方法從登錄界面讀取到數據後,通過authenticationManager.authenticate方法,讓SpringSecurity去進行驗證,不需要自己查數據庫對用戶名和密碼進行配對。

attemptAuthentication方法進行校驗後,有兩種結果:成功或失敗。當驗證成功時將調用successfulAuthentication方法,失敗調用unsuccessfulAuthentication方法。

successfulAuthentication方法中首先通過Authentication.getPrincipal()方法來得到當前用戶的信息(UserDetails),接着通過用戶名、角色列表和是否記住我來構建Token。原本應該Token將放在response的header中的,但是設置header只能設置在當前頁面的response中?(有知道如何設置重定向後頁面的header的小夥伴,麻煩告訴我下,感激不盡~(/ □ \))至於爲什麼要頁面重定向先賣個關子,我們先往下看。

unsuccessfulAuthentication方法是用戶認證失敗後返回信息,這裏用到了兩個工具類,下面我們先來看看他們。

五、response狀態碼

package com.shiep.jwtauth.common;

import lombok.Getter;

/**
 * @author: 倪明輝
 * @date: 2019/3/7 17:13
 * @description: JWT認證==》認證結果的枚舉類
 */
@Getter
public enum ResultEnum {

    /**
     * description: 認證結果狀態碼及信息
     *
     * @param null
     * @return
     */
    SUCCESS(101,"成功"),
    FAILURE(102,"失敗"),
    USER_NEED_AUTHORITIES(201,"用戶未登錄"),
    USER_LOGIN_FAILED(202,"用戶賬號或密碼錯誤"),
    USER_LOGIN_SUCCESS(203,"用戶登錄成功"),
    USER_NO_ACCESS(204,"用戶無權訪問"),
    USER_LOGOUT_SUCCESS(205,"用戶登出成功"),
    TOKEN_IS_BLACKLIST(206,"此token爲黑名單"),
    LOGIN_IS_OVERDUE(207,"登錄已失效"),
    ;

    private Integer code;

    private String message;

    ResultEnum(Integer code, String message) {
        this.code = code;
        this.message = message;
    }

    /**
     * description: 通過code返回message
     *
     * @param code
     * @return com.shiep.jwtauth.common.ResultEnum
     */
    public static ResultEnum parse(int code){
        ResultEnum[] values = values();
        for (ResultEnum value : values) {
            if(value.getCode() == code){
                return value;
            }
        }
        throw  new RuntimeException("Unknown code of ResultEnum");
    }
}
ResultEnum封裝一些常用的狀態碼。
package com.shiep.jwtauth.common;

import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;

/**
 * @author: 倪明輝
 * @date: 2019/3/7 17:23
 * @description: response返回結果集
 */
public class ResultVO implements Serializable {
    private static final long serialVersionUID = -5359028332240046810L;

    /**
     * description: 返回響應信息
     *
     * @param respCode
     * @param success
     * @return java.util.Map<java.lang.String,java.lang.Object>
     */
    public static Map<String, Object> result(ResultEnum respCode, Boolean success) {
        Map<String, Object> map = new HashMap<String, Object>();
        map.put("code", respCode.getCode());
        map.put("message", respCode.getMessage());
        map.put("data", null);
        map.put("success",success);
        return map;
    }

    /**
     * description: 返回響應信息及Token
     *
     * @param respCode
     * @param jwtToken
     * @param success
     * @return java.util.Map<java.lang.String,java.lang.Object>
     */
    public final static Map<String, Object> result(ResultEnum respCode, String jwtToken, Boolean success) {
        Map<String, Object> map = new HashMap<String, Object>();
        map.put("jwtToken",jwtToken);
        map.put("code", respCode.getCode());
        map.put("message", respCode.getMessage());
        map.put("data", null);
        map.put("success",success);
        return map;
    }
}
ResultVO用於返回結果集。

六、BasicAuthenticationFilter過濾器

package com.shiep.jwtauth.filter;

import com.shiep.jwtauth.utils.JwtTokenUtils;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;

/**
 * @author: 倪明輝
 * @date: 2019/3/6 16:20
 * @description: 對所有請求進行過濾
 * BasicAuthenticationFilter繼承於OncePerRequestFilter==》確保在一次請求只通過一次filter,而不需要重複執行。
 */
public class JwtPreAuthFilter extends BasicAuthenticationFilter {

    public JwtPreAuthFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    /**
     * description: 從request的header部分讀取Token
     *
     * @param request
     * @param response
     * @param chain
     * @return void
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws IOException, ServletException {
        System.out.println("BasicAuthenticationFilters");
        String tokenHeader = request.getHeader(JwtTokenUtils.TOKEN_HEADER);
        System.out.println("tokenHeader:"+tokenHeader);
        // 如果請求頭中沒有Authorization信息則直接放行了
        if (tokenHeader == null || !tokenHeader.startsWith(JwtTokenUtils.TOKEN_PREFIX)) {
            chain.doFilter(request, response);
            return;
        }
        // 如果請求頭中有token,則進行解析,並且設置認證信息
        SecurityContextHolder.getContext().setAuthentication(getAuthentication(tokenHeader));
        super.doFilterInternal(request, response, chain);
    }

    /**
     * description: 讀取Token信息,創建UsernamePasswordAuthenticationToken對象
     *
     * @param tokenHeader
     * @return org.springframework.security.authentication.UsernamePasswordAuthenticationToken
     */
    private UsernamePasswordAuthenticationToken getAuthentication(String tokenHeader) {
        //解析Token時將“Bearer ”前綴去掉
        String token = tokenHeader.replace(JwtTokenUtils.TOKEN_PREFIX, "");
        String username = JwtTokenUtils.getUsername(token);
        List<String> roles = JwtTokenUtils.getUserRole(token);
        Collection<GrantedAuthority> authorities = new HashSet<>();
        if (roles!=null) {
            for (String role : roles) {
                authorities.add(new SimpleGrantedAuthority(role));
            }
        }
        if (username != null){
            return new UsernamePasswordAuthenticationToken(username, null, authorities);
        }
        return null;
    }
}

在JwtLoginAuthFilter中我們對登錄的用戶進行認證,認證成功時將生成Token給用戶前端,之後前端保存該Token在Cookie或session中,當用戶訪問服務器時,只需攜帶該Token即可訪問服務。而Token的認證鑑權工作就是由本例中的JwtPreAuthFilter來實現了。

JwtPreAuthFilter繼承了BasicAuthenticationFilter,該過濾器是繼承於OncePerRequestFilter,用於確保在一次請求只通過一次filter,而不需要重複執行。簡單的說,就是用戶的每次請求都將經過該過濾器。下面我們詳細看看該過濾器中的代碼。

doFilterInternal方法從request的header中查看是否帶有Token,如果沒有則放行,如果有則進行Token解析(調用getAuthentication方法),並設置認證信息。

 七、配置Handler

package com.shiep.jwtauth.handler;

import com.alibaba.fastjson.JSON;
import com.shiep.jwtauth.common.ResultEnum;
import com.shiep.jwtauth.common.ResultVO;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author: 倪明輝
 * @date: 2019/3/8 9:44
 * @description: 用戶登出成功時返回給前端的數據
 */
public class FxLogoutSuccessHandler implements LogoutSuccessHandler {
    @Override
    public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
        httpServletResponse.getWriter().write(JSON.toJSONString(ResultVO.result(ResultEnum.USER_LOGOUT_SUCCESS,true)));
    }

}
package com.shiep.jwtauth.handler;

import com.alibaba.fastjson.JSON;
import com.shiep.jwtauth.common.ResultEnum;
import com.shiep.jwtauth.common.ResultVO;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author: 倪明輝
 * @date: 2019/3/7 15:18
 * @description: 用戶未登錄時返回給前端的數據
 */
public class UnAuthorizedEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {

        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
//        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
//        String reason = "統一處理,原因:"+authException.getMessage();
//        response.getWriter().write(new ObjectMapper().writeValueAsString(reason));
        response.getWriter().write(JSON.toJSONString(ResultVO.result(ResultEnum.USER_NEED_AUTHORITIES,false)));
    }
}
FxLogoutSuccessHandler是用戶成功登出時調用的,而UnAuthorizedEntryPoint是用戶未登錄時調用的。Spring中handle還有許多,不一一列舉了。

八、配置SpringSecurity

到這裏基本操作都寫好啦,現在就需要我們將這些辛苦寫好的“組件”組合到一起發揮作用了,那就需要配置SpringSecurity了。

package com.shiep.jwtauth.config;

import com.shiep.jwtauth.filter.JwtLoginAuthFilter;
import com.shiep.jwtauth.filter.JwtPreAuthFilter;
import com.shiep.jwtauth.handler.FxLogoutSuccessHandler;
import com.shiep.jwtauth.handler.UnAuthorizedEntryPoint;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

/**
 * @author: 倪明輝
 * @date: 2019/3/6 16:24
 * @description:
 */
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    // 因爲UserDetailsService的實現類實在太多啦,這裏設置一下我們要注入的實現類
    @Qualifier("userDetailsServiceImpl")
    private UserDetailsService userDetailsService;

    // 加密器
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }

    /**
     * description: 加載userDetailsService,用於從數據庫中取用戶信息
     * 
     * @param auth 
     * @return void
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
    }

    /**
     * description: http細節
     * 
     * @param http 
     * @return void
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 開啓跨域資源共享
        http.cors()
                .and()
                // 關閉csrf
                .csrf().disable()
                // 關閉session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .httpBasic().authenticationEntryPoint(new UnAuthorizedEntryPoint())
                .and()
                .authorizeRequests()
                // 需要角色爲ADMIN才能刪除該資源
                .antMatchers(HttpMethod.DELETE,"/tasks/**").hasAnyRole("ADMIN")
                // 測試用資源,需要驗證了的用戶才能訪問
                .antMatchers("/tasks/**").authenticated()
                // 其他都放行了
                .anyRequest().permitAll()
                .and()
                .formLogin()
                .loginPage("/login")
                .permitAll()
                //.successHandler(new Fx)
                .and()
                .logout()//默認註銷行爲爲logout
                .logoutSuccessHandler(new FxLogoutSuccessHandler())
                .and()
                // 添加到過濾鏈中
                // 先是UsernamePasswordAuthenticationFilter用於login校驗
                .addFilter(new JwtLoginAuthFilter(authenticationManager()))
                // 再通過OncePerRequestFilter,對其他請求過濾
                .addFilter(new JwtPreAuthFilter(authenticationManager()));
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", new CorsConfiguration().applyPermitDefaultValues());
        return source;
    }
}
configure(HttpSecurity http)方法是重點,具體在代碼註釋裏面已經解釋清楚了。下面我們來配置下Controller和視圖。

注意:配置SpringSecurity,需要從細粒度到粗粒度。不然細粒度將不起作用。

九、thymeleaf視圖

loginPage.html==》登錄界面

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
    <title>Spring Security Example </title>
    <script>
        function changeValue(){
            var check = document.getElementById("rememberMe");
            if(check.checked == true){
                document.getElementById("rememberMe").value = true;
            }else{
                document.getElementById("rememberMe").value = false;
            }
        }
    </script>
</head>
<body>
<div th:if="${param.error}">
    Invalid username and password.
</div>
<div th:if="${param.logout}">
    You have been logged out.
</div>
<form th:action="@{/auth/login}" method="post">
    <div><label> User Name : <input type="text" name="username"/> </label></div>
    <div><label> Password: <input type="password" name="password"/> </label></div>
    <div><label> RememberMe: <input type="checkbox" name="rememberMe" id="rememberMe" onclick="changeValue()"/></label></div>
    <div><input type="submit" value="Sign In"/></div>
</form>
</body>
</html>

 loginPage頁面,定義了username、password和rememberMe來得到用戶登錄信息(看前面的登錄認證過濾器),接着提交到“/auth/login”路徑,該路徑就是前面JwtLoginAuthFilter中配置的路徑,提交到該過濾器中進行登錄驗證。

homePage.html==》用戶登錄驗證成功後進入的用戶主頁

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Home</title>
    <script src="jquery-3.1.1.min.js" type="text/javascript"></script>
    <script src="getToken.js" type="text/javascript"></script>
</head>
<body>
    <script>
        var token=$.getUrlParam("token");
        window.onload = function () {
            $("#token").val(token);
        }
        function deleteTask() {
            $.ajax({
                type : 'delete',
                url : '/tasks/1',
                contentType : 'application/json;charset=UTF-8',
                dataType : 'text',
                beforeSend: function(request) {
                    request.setRequestHeader("Authorization","Bearer "+token);
                },
                success : function(data,textStatus,jqXHR){
                    alert("response:"+data);
                },
                error: function (err) {
                    alert("ajax錯誤碼:" + err.status);
                }
            });
        }
    </script>
    <h1>Login success</h1>
    <textarea id="token" rows="5" cols="50"></textarea>
    <input type="button" value="admin角色才能刪除" onclick="deleteTask()"/>
</body>
</html>

homePage.html中導入了jquery和自己寫的一個getToken.js,用於從URL中得到參數。而按鈕的點擊事件調用deleteTask()方法,用於發送delete請求到“/tasks”,該路徑需要“ROLE_ADMIN”權限。

/*獲取到Url裏面的參數*/
(function ($) {
    $.getUrlParam = function (name) {
        var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
        var r = window.location.search.substr(1).match(reg);
        if (r != null) return unescape(r[2]); return null;
    }
})(jQuery);

十、Controller控制層

package com.shiep.jwtauth.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

/**
 * @author: 倪明輝
 * @date: 2019/3/8 10:31
 * @description:
 */
@Controller
public class LoginController {
    @GetMapping("/login")
    public String toLoginPage(){
        return "loginPage";
    }

    @GetMapping("/home")
    public String toHomePage(){
        return "homePage";
    }
}
LoginController中定義了“/login”路徑指向loginPage.html頁面,“/home”路徑指向homePage.html頁面。
package com.shiep.jwtauth.controller;

import org.springframework.web.bind.annotation.*;

/**
 * @author: 倪明輝
 * @date: 2019/3/6 16:35
 * @description:
 */
@RestController
@RequestMapping(path = "/tasks",produces = "application/json;charset=gbk")
public class TaskController {

    @GetMapping
    public String listTasks(){
        return "任務列表";
    }

    @PostMapping
    public String newTasks(){
        return "創建了一個新的任務";
    }

    @PutMapping("/{taskId}")
    public String updateTasks(@PathVariable("taskId")Integer id){
        return "更新了一下id爲:"+id+"的任務";
    }

    @DeleteMapping("/{taskId}")
    public String deleteTasks(@PathVariable("taskId")Integer id){
        return "刪除了id爲:"+id+"的任務";
    }
}
TaskController就是對應SpringSecurity配置類中的需要權限才能訪問的路徑。

好了,終於開發完成了,接下來我們運行項目來測試下吧。

十一、運行結果測試

首先,我們在數據庫中插入兩個用戶,通過上一章的方法進行插入用戶。

用戶名:wang,密碼:123(數據庫中的密碼是經過加密後的),該用戶具有的權限(ROLE_USER、ROLE_ADMIN)

用戶名:li,密碼:123(數據庫中的密碼是經過加密後的),該用戶具有的權限(ROLE_USER)

接着使用瀏覽器訪問http://localhost:8080/tasks,即get方式。因爲“/tasks/**”路徑是需要權限認證的,但是我們此時未驗證(在header中設置Token),因此會跳轉到http://localhost:8080/login界面。

我們在這個界面使用wang用戶登錄。頁面從“/login”跳轉到“/auth/login”路徑進行認證,認證成功重定向到“/home”頁面,並通過參數形式攜帶Token到前臺。現在我們看看程序的控制檯輸出:

可以看到認證成功了,Token已經生成。下面是home界面。

當我們點擊按鈕時:

可以看到權限認證成功,已經執行方法。

接着,我們重新以li用戶登錄試試,步驟同上。

發現發生403錯誤,這是用戶無權限。

十二、後記

以上關於使用JWT+SpringSecurity實現基於Token的單點登錄之認證和授權部分已經完成。但是除了上面提到的,還有一些問題:因爲JWT的最大缺點是服務器不保存會話狀態,所以在使用期間不可能取消令牌或更改令牌的權限。也就是說,一旦JWT簽發,在有效期內將會一直有效。在實際情況下,用戶登出時,應該使本次的Token失效。這是其中一個問題。另外關於JWT,我們還能繼續跟Redis進行集成,將其緩存到Redis中。最後,跟SpringCloud進行繼承,作爲Zuul網關的認證入口。這些我們將在下一章繼續開發。

Spring Security 認證流程詳解

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章