SpringSecurity認證流程


SpringSecurity配置

SecurityConfig.java

@Override
protected void configure(HttpSecurity http) throws Exception {
    // CRSF禁用,不使用session
    http.csrf().disable();
    // 基於token,所以不需要session
    http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    // 過濾請求
    http.authorizeRequests()
        .antMatchers("/login", "/captchaImage").anonymous()
        .anyRequest().authenticated();
    ....
    // 添加JWT filter
    http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}

從上面可以看到我們設置了放行 /login/captchaImage 請求,而其他請求就需要通過認證才可訪問。下面正式分析登錄認證流程

登錄認證

首先分析一下流程

1、前端填寫完表單數據后,發送請求 [post] /login,傳遞 username、password、code、uuid這四個數據

2、后端收到 [post] /login請求,來到相應的 controller 處理方法

@RestController
public class SysLoginController {

    @Autowired
    private SysLoginService loginService;

    @PostMapping("login")
    public Result login(String username, String password, String code, String uuid){
        Result result = Result.success();
        String token = loginService.login(username, password, code, uuid);
        result.put(Constants.TOKEN, token);
        return result;
    }

在這里調用 loginService 的 login方法,其中內部是具體的驗證登錄邏輯,驗證通過后返回一個token,然后回傳給前端。前端這時就可以將token數據存入本地了

3、接下來進入 loginService.login()內,進行詳細分析

@Component
public class SysLoginService {
    @Autowired private RedisCache redisCache;
    @Autowired private TokenService tokenService;
    @Autowired private AuthenticationManager authenticationManager;
    
    public String login(String username, String password, String code, String uuid) {
        String verifyKey = Constants.CAPTCHA_CODE_TAG + uuid;
        String verifyCode = redisCache.getCacheObject(verifyKey);
        ...省去驗證碼校驗邏輯,校驗失敗會拋出異常
            
        Authentication authenticate = null;
        try {
        // 認證 該方法會去調用UserDetailsServiceImpl.loadUserByUsername
            authenticate = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(username, password));
        } catch (Exception e) {
            if (e instanceof BadCredentialsException) {
                throw new UserPasswordNotMatchException();
            } else {
                throw new CustomException(e.getMessage());
            }
        }
        return tokenService.createToken((LoginUser) authenticate.getPrincipal());
    }
}

3.1 首先進行驗證碼校驗邏輯,不通過時會拋出異常

3.2 通過 AuthenticationManagerauthenticate()獲取認證信息

下面來詳細分析一下 authenticationManager.authenticate()認證過程

分析之前先來了解一下 UsernamePasswordAuthenticationToken 這個類,它擁有兩個構造方法,不同的構造方法有不同的含義,如下

// 1、只有兩個參數的構造方法表示[當前沒有認證]
public UsernamePasswordAuthenticationToken(Object principal, Object credentials)
// 2、擁有三個參數的構造方法表示[當前已經認證完畢]
public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities)

image-20210211230717227

下面開始分析認證過程

png

1)上面的AuthenticationManager調用了authenticate()方法,深入進去發現ProviderManger實現了AuthenticationMananger接口,然后我們查看authenticate()方法內部。

image-20210211221629377

2)先是遍歷所有的 AuthenticProvider,其中的supports方法,返回一個boolean值,參數是一個Class,就是根據Token的類來確定用什么Provider來處理

而源碼中的toTest類,就是我們認證傳遞的 UsernamePasswordAuthenticationToken,而他對應的provider就是AbstractUserDetailsAuthenticationProvider

AbstractUserDetailsAuthenticationProvider.java

@Override
public boolean supports(Class<?> authentication) {
    return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}

3)找到合適的Provider后,在本例中也即是AbstractUserDetailsAuthenticationProvider(抽象類),會調用provider 的authenticate 方法

image-20210211224205852

4)從下面可以看到 retrieveUser 方法返回一個 UserDetails

image-20210211224415630

5)接着深入,可以發現 DaoAuthenticationProvider 繼承了 AbstractUserDetailsAuthenticationProvider,所以DaoAuthenticationProvider 才是真正的實現類,他會調用 retrieveUser 方法,接着調用 loaderUserByUsername() 方法

image-20210211224719481

看到 loaderUserByUsername(),應該就很熟悉了,因為這就是我們自己實現 UserDetailsService 接口,自定義的認證過程

6)接着看我們自定義的 UserDetailServiceImpl

@Service("userDetailsService")
public class UserDetailServiceImpl implements UserDetailsService {
    ...
    @Autowired private ISysUserService userService;
    @Autowired private SysPermissionService permissionService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser user = userService.selectUserByUserName(username);
        if (null == user) {
            log.info("登錄用戶:{} 不存在.", username);
            throw new UsernameNotFoundException("登錄用戶:" + username + " 不存在");
        } else if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) {
            log.info("登錄用戶:{} 已被刪除.", username);
            throw new BaseException("對不起,您的賬號:" + username + " 已被刪除");
        } else if (UserStatus.DISABLE.getCode().equals(user.getStatus())) {
            log.info("登錄用戶:{} 已被停用.", username);
            throw new BaseException("對不起,您的賬號:" + username + " 已停用");
        }
        return createLoginUser(user);
    }

    public UserDetails createLoginUser(SysUser user) {
        return new LoginUser(user, permissionService.getMenuPermission(user));
    }
}

上面就是我們自己的認證邏輯。通過一個唯一標識查詢用戶,在這里就是username,當所有校驗都通過后就會調用 createLoginUser 方法,裝填用戶擁有的權限以及從數據庫中獲取的密碼,返回一個 LoginUser 對象,而這個對象實現了 UserDetails接口。(創建token的方法以及LoginUser.java可以參考文末的相關代碼)

7)然后我們回看AbstractUserDetailsAuthenticationProvider 的 authenticate 方法

image-20210211230406093

8)接着深入,可以發現createSuccessAuthentication方法創建了一個UsernamePasswordAuthenticationToken,並且他的構造方法有三個參數,這表明這個token是已近認證過后的

image-20210211230531473

4、至此認證已經結束,我再回到 loginService.login()這個我們自己寫的方法內,上面分析的8個步驟,也就是調用 authenticationManager.authenticate()的過程會返回一個 Authentication,然后就可以利用這個 Authentication 生成一個 token。

loginService.java

return tokenService.createToken((LoginUser) authenticate.getPrincipal());

5、接着回到前面第二步controller調用的 loginService.login(),這時它已近拿到了 token ,於是將其返回到前端。前端收到相應后,就可以把這個token存在本地,以后每次訪問請求時都帶上這個token 信息。

@PostMapping("login")
public AjaxResult login(String username, String password, String code, String uuid){
    AjaxResult result = AjaxResult.success();
    String token = loginService.login(username, password, code, uuid);
    result.put(Constants.TOKEN, token);
    return result;
}

請求認證

1、上面說到前端每次發送請求都帶上這個 token,但是為啥帶上這個 token,SpringSecurity就會認為此次請求已近認證通過了呢,別忘了,因為之前配置 SecurityConfig,如下

public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ...
        // 過濾請求
        http.authorizeRequests()
            .antMatchers("/login", "/captchaImage").anonymous()
            .anyRequest().authenticated();
        // 添加JWT filter,后面馬上就講
        http.addFilterBefore(authenticationTokenFilter, 
                             UsernamePasswordAuthenticationFilter.class);
    }

因為我們設置了放行 /login請求,所以才沒遭受攔截,而其他請求都是要被攔截的

2、因此我們就要自定義一個JWT filter,使用 addFilterBefore 把它添加到過濾器列表中,下面來看我們寫的 JWT過濾器

JwtAuthenticationTokenFilter.java

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private TokenService tokenService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        // 1)
        LoginUser loginUser = tokenService.getLoginUser(request);
        // 2)
        if (ObjectUtil.isNotNull(loginUser) && ObjectUtil.isNull(SecurityUtils.getAuthentication()))
        {
            // 3.1)
            tokenService.verifyToken(loginUser);
            // 3.2)
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        chain.doFilter(request, response);
    }
}

1)TokenService#getLoginUser() 是我們編寫的一個方法,可以從 request 中獲取 token,然后再解析成LoginUser,也就是我們之前編寫繼承了UserDetails的類

2)如果1)能解析成功,也就是loginUser不為空,就說明請求含帶了token認證信息,又因為這是個前后端分離項目,SecurityUtils.getAuthentication()肯定獲取不到當前請求的認證信息

public static Authentication getAuthentication() {
    return SecurityContextHolder.getContext().getAuthentication();
}

3)而沒有認證信息,這次請求勢必會被攔截下來,所以我們要手動加上這個認證信息

3.1)我們先刷新一下token,也就是在redis緩存中更新一下到期時間(刷新token的方法可以參考文末的相關代碼)

3.2)然后創建UsernamePasswordAuthenticationToken,注意這是個有三個 參數的構造方法,前面也說了,這代表已經經過認證,然后 SecurityContextHolder.getContext().setAuthentication();設置一下認證信息

這樣每次帶token的請求,都會有了認證信息,也就不會被攔截了

3、

但是為了更深刻的了解,我們接下來具體分析一下流程。

再次之前先介紹一個類 FilterSecurityInterceptor:是一個方法級的權限過濾器,基本位於過濾鏈的最底部,下面來看看源碼。

image-20210212102053438

下面來打個斷點,查看一下

image-20210212102439157

這說明來到beforeInvocation方法時我們前面編寫的jwtFilter已經被執行,認證信息已近被手動添加過了

image-20210212102758021

進入beforeInvocation()里面,由調試信息可以看到當前請求需要被認證

image-20210212103144116

接着我們進入authenticateIfRequired方法的內部

image-20210212103331881

因為我們之前jwtfilter手動添加了認證信息,所以authenticateIfRequired就直接返回了authentication,

否則的還要進行authenticationManager.authenticate();進行驗證,如果沒有之前jwtfilter手動添加認證信息,那么中途一定會拋出異常,導致此次請求失敗被攔截

至此也沒啥好講了,filterInvocation.getChain().doFilter() 調用我們的后台服務了

image-20210212104009332

相關代碼

TokenService.java

@Component
public class TokenService {

    protected static final long MILLIS_SECOND = 1000;
    
    protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND;

    private static final long MILLIS_MINUTE_20 = 20 * 60 * 1000L;

    // 令牌自定義標識
    @Value("${token.header}")
    private String header;

    // 令牌秘鑰
    @Value("${token.secret}")
    private String secret;

    // 令牌有效期(默認30分鍾)
    @Value("${token.expireTime}")
    private int expireTime;

    @Autowired
    private RedisCache redisCache;

    /**
     * 創建令牌
     *
     * @param loginUser 用戶信息
     * @return 令牌
     */
    public String createToken(LoginUser loginUser) {
        String token = IdUtil.fastUUID();
        loginUser.setToken(token);
        refreshToken(loginUser);

        Map<String, Object> claims = new HashMap<>();
        claims.put(Constants.LOGIN_TOKEN_KEY, token);
        return createToken(claims);
    }

    /**
     * 驗證令牌有效期,相差不足20分鍾,自動刷新緩存
     *
     * @param loginUser 登錄用戶
     * @return 令牌
     */
    public void verifyToken(LoginUser loginUser) {
        long expireTime = loginUser.getExpireTime();
        long currentTime = System.currentTimeMillis();
        if (expireTime - currentTime <= MILLIS_MINUTE_20) {
            refreshToken(loginUser);
        }
    }

    /**
     * 刷新令牌有效期
     *
     * @param loginUser 登錄信息
     */
    public void refreshToken(LoginUser loginUser) {
        loginUser.setLoginTime(System.currentTimeMillis());
        loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
        // 根據uuid將loginUser緩存
        String userKey = getTokenKey(loginUser.getToken());
        redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
    }

    /**
     * 獲取 token 的 redis 鍵前綴
     *
     * @param uuid
     * @return
     */
    private String getTokenKey(String uuid) {
        return Constants.LOGIN_TOKEN_TAG + uuid;
    }

    /**
     * 從數據聲明生成令牌
     *
     * @param claims 數據聲明
     * @return 令牌
     */
    private String createToken(Map<String, Object> claims) {
        return Jwts.builder().setClaims(claims).signWith(SignatureAlgorithm.HS512, secret).compact();
    }

    /**
     * 從令牌中獲取數據聲明
     *
     * @param token 令牌
     * @return 數據聲明
     */
    private Claims parseToken(String token) {
        return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
    }

    /**
     * 獲取用戶身份信息
     *
     * @return 用戶信息
     */
    public LoginUser getLoginUser(HttpServletRequest request) {
        // 獲取請求攜帶的令牌
        String token = getRespToken(request);
        if (StringUtils.isNotEmpty(token)) {
            Claims claims = parseToken(token);
            // 解析對應的權限以及用戶信息
            String uuid = (String) claims.get(Constants.LOGIN_TOKEN_KEY);
            String userKey = getTokenKey(uuid);
            return redisCache.getCacheObject(userKey);
        }
        return null;
    }

    /**
     * 獲取請求token
     *
     * @param request
     * @return token
     */
    private String getRespToken(HttpServletRequest request) {
        String token = request.getHeader(this.header);
        if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.REQ_TOKEN_PREFIX)) {
            token = token.replace(Constants.REQ_TOKEN_PREFIX, "");
        }
        return token;
    }
}

LoginUser.java

public class LoginUser implements UserDetails {
    private static final long serialVersionUID = 1821121071052157802L;

    /** 用戶唯一標識 */
    private String token;

    /** 登陸時間 */
    private Long loginTime;

    /** 過期時間 */
    private Long expireTime;
    ...
    /** 權限列表 */
    private Set<String> permissions;

    /** 用戶信息 */
    private SysUser user;
    ...
    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUserName();
    }
    ...

參考

https://www.cnblogs.com/ymstars/p/10626786.html

https://www.jianshu.com/p/d5ce890c67f7

https://gitee.com/y_project/RuoYi-Vue


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM