1.概述

Spring Security是一个高度自定义的安全框架,它利用Spring IoC和AOP的特性,为系统提供了声明式安全访问控制功能,减少了为系统安全而编写大量重复代码,使代码更加高内聚、低耦合。Spring Security作为Spring 家族的一员,与Spring MVC及其它Spring框架能很好地集成。本文将展示Spring Security整合JWT实现登陆和退出等功能,并解释一下其运行原理。

2.案例

2.1 基础概念

2.1.1 什么是认证

当访问一个系统时(应用),输入账户名、密码来登陆系统的这一过程,便叫做认证。认证的主要作用是保护系统资源、防止匿名用户恶意攻击。同时系统能够根据登录用户,分配用户权限信息,防止用户越权访问。常见的用户身份认证方式有:用户名密码输入、扫码登陆、短信登陆、面部识别登陆、指纹识别登陆等。

2.1.2 什么是授权

授权指的是根据不同的用户,系统赋予不同的权限,不同的权限对系统的数据访问和操作也不一样。举个简单例子,普通用户和系统管理员的权限一般是不一致的。登陆一个银行app,你只能看到你的账户余额,而管理员登陆,能看到他所有用户的存款余额情况(鉴于保密协议,他不能随便透露用户存款余额信息)。这种根据用户权限来控制用户访问的信息和可操作的信息,就是授权。

2.1.3 什么是会话

会话是系统为了保持与当前已登录用户状态所提供的一种机制,用户认证完成后,为了避免用户每次访问系统都要认证,将用户的信息保存于当前的会话中。JAVA目前实现会话的机制包括session方式,基于token的方式等。基于session的登陆原理可查看SSO单点登录-基于cookie的单点登录,基于token的访问方式如下图所示:

tddl集成spring springsecurity集成jwt_java


用户携带账号、密码进行登陆,认证成功智慧,服务端会生成一个token返回给客户端,客户端会存储到cookie或localStorage,每次访问时都会携带token,服务端接收到token之后都会进行解析认证,成功之后才返回请求结果。

2.2 Spring Securiy原理

Spring Security是由一系列过滤器组成,每个过滤器具备自己独特的功能。Spring Security采用了设计模式中的责任链模式,由多个过滤器组成过滤器链来完成认证和授权的功能。框架的整体结构如下所示:

tddl集成spring springsecurity集成jwt_java_02


上述SecurityFilterChain对应的就是Spring Security的过滤器。对应客户端发起的请求,在进入Controller之前,需要进过应用本身一系列过滤器,包括Spring Security过滤器链的处理(认证和授权等)。下面是请求经过过滤器链的顺序:

tddl集成spring springsecurity集成jwt_tddl集成spring_03


上述图中主要展示了核心过滤器,非核心过滤器未展示,设置@EnableWebSecurity(debug = true)打印过滤器执行流程,如下所示:

tddl集成spring springsecurity集成jwt_java_04


由上图可知,Spring Security过滤器链中共有15个过滤器,接下来分别介绍一下这些过滤器的作用:

过滤器名称

作用

WebAsyncManagerIntegrationFilter

将 Security 上下文与 Spring Web 中用于处理异步请求映射的 WebAsyncManager 进行集成

SecurityContextPersistenceFilter

在每次请求处理之前将该请求相关的安全上下文信息加载到 SecurityContextHolder 中,然后在该次请求处理完成之后,将SecurityContextHolder 中关于这次请求的信息存储到一个“仓储”中,然后将SecurityContextHolder 中的信息清除

HeaderWriterFilter

用于将头信息加入响应中

CsrfFilter

用于处理跨站请求伪造

LogoutFilter

用于处理退出登录

UsernamePasswordAuthenticationFilter

用于处理基于表单的登陆请求,从表单中获取用户名和密码。默认情况下处理来自 /login 的请求。从表单中获取用户名和密码时,默认使用的表单 name 值为 username 和 password,这两个值可以通过设置这个过滤器的 usernameParameter 和 passwordParameter 两个参数的值进行修改

DefaultLoginPageGeneratingFilter

如果没有配置登陆页面,系统初始化时就会调用这个过滤器,生成一个登陆表单页面

DefaultLogoutPageGeneratingFilter

默认生成登出页面过滤器

BasicAuthenticationFilter

检测和处理 http basic 认证

RequestCacheAwareFilter

用来处理请求的缓存

SecurityContextHolderAwareRequestFilter

主要是包装请求对象 request

AnonymousAuthenticationFilter

检测 SecurityContextHolder 中是否存在Authentication 对象,如果不存在为其提供一个匿名 Authentication

SessionManagementFilter

管理 session 的过滤器

ExceptionTranslationFilter

负责处理过滤器链中抛出的任何 AccessDeniedException 和AuthenticationException 异常

FilterSecurityInterceptor

过滤器链的出口,负责权限校验的过滤器

2.3 Spring Security默认登陆流程

默认登录流程如下图所示,当用户提交用户名和登陆密码之后,会进入UsernamePasswordAuthenticationFilter过滤器中进行认证,该方法会调用ProviderManager中authenticate方法进行认证,authenticate方法内部又会调用DaoAuthenticationProvider中loadUserByUsername方法查询用户信息,若在内存中查询到用户信息,则封装成UserDetail对象返回。

tddl集成spring springsecurity集成jwt_tddl集成spring_05

2.3 Spring Securiy整合JWT

本文将整合SpringBoot、Spring Security与JWT,编写一个用户登陆的案例。借助于Spring Security框架,可以减少大量代码的编写。当用户登陆成功之后,会返回一个token给前端服务,前端服务下次携带token来访问即可,后台服务需要定义一个JWT认证过滤器,解析token中的用户信息,判断合法性,若合法则允许访问系统资源。验证如下图所示:

tddl集成spring springsecurity集成jwt_java_06


本文案例登陆流程图如下所示:

tddl集成spring springsecurity集成jwt_java_07


1.登录流程

(1)自定义登录接口:调用ProviderManager的authenticate方法进行认证,认证通过生成jwt,同时以userId为key,存储用户信息到redis中;
(2)自定义UserDetailService:在这个实现类中查询用户信息。

2.校验

(1)定义jwt认证过滤器:解析token,获取token中的userId,利用userId从缓存中查询用户信息,若存在验证通过。

2.3.1 依赖引用

<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-jwt</artifactId>
            <version>1.0.9.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.1.3</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.24</version>
        </dependency>

2.3.2 登陆核心代码

1.UserDetailsServiceImpl
UserDetailsServiceImpl 继承Spring Security接口UserDetailsService ,主要方法包括根据userName从数据库中查询用户信息。

import com.eckey.lab.dao.SysUserDao;
import com.eckey.lab.domain.LoginUser;
import com.eckey.lab.domain.SysUser;
import com.eckey.lab.utils.RedisUtils;
import lombok.extern.slf4j.Slf4j;
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;
import org.springframework.util.StringUtils;

import javax.annotation.Resource;

@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    RedisUtils redisUtils;

    @Resource
    SysUserDao sysUserDao;
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if (StringUtils.isEmpty(username)) {
            log.error("username不能为空!");
            return null;
        }
        SysUser sysUser = sysUserDao.selectByUserName(username);
        if (sysUser == null) {
            throw new RuntimeException("用户不存在!");
        }
        return new LoginUser(sysUser);
    }
}

2.UserServiceImpl
UserServiceImp类中主要有用户登入和登出操作方法,具体如下:

import com.alibaba.fastjson.JSON;
import com.eckey.lab.dao.SysUserDao;
import com.eckey.lab.domain.LoginUser;
import com.eckey.lab.domain.SysUser;
import com.eckey.lab.service.UserService;
import com.eckey.lab.utils.JwtUtils;
import com.eckey.lab.utils.RedisUtils;
import com.eckey.lab.utils.ResultData;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import javax.annotation.Resource;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@Service
public class UserServiceImpl implements UserService {

    public static final String LOGIN_FLAG = "loginUser:";

    @Resource
    private SysUserDao sysUserDao;

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisUtils redisUtils;

    @Autowired
    private JwtUtils jwtUtils;

    @Override
    public ResultData queryByUserName(String userName) {
        if (StringUtils.isEmpty(userName)) {
            return ResultData.fail("userName不能为空!");
        }
        SysUser sysUser = sysUserDao.selectByUserName(userName);
        log.info("根据userName:{}查询到结果为:{}", userName, JSON.toJSONString(sysUser));
        return ResultData.success(sysUser);
    }

    @Override
    public ResultData login(SysUser sysUser) {
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(sysUser.getUsername(), sysUser.getPassword());
        //查询用户是否存在且密码是否合法
        Authentication authentication = authenticationManager.authenticate(authenticationToken);
        if (authentication == null) {
            return ResultData.fail("登陆失败!");
        }
        //认证通过,获取用户信息
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        Long id = loginUser.getSysUser().getId();
        if (id != null) {
            //生成jwt token返回前端,同时将用户信息存入redis
            String token = jwtUtils.generateToken(id);
            Map<String, String> maps = new HashMap<>();
            maps.put("token", token);
            redisUtils.set(LOGIN_FLAG + id, loginUser);
            return ResultData.success(maps);
        }
        return ResultData.fail("获取token失败");
    }

    @Override
    public ResultData logOut() {
        //若用户未登录执行登出操作,在TokenFilterComponent拦截器中会被拦截,认证会不通过
        //从SecurityContextHolder获取用户信息,能执行登出操作,说明该用户已登录且通过认证
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        Long id = loginUser.getSysUser().getId();
        if (id != null) {
            redisUtils.del(LOGIN_FLAG + id);
        }
        return ResultData.success();
    }
}

3.TokenFilterComponent
TokenFilterComponent是jwt token拦截器,用来获取用户请求头中的token信息,若携带token,且token合法,则允许访问,具体如下:

import com.eckey.lab.domain.LoginUser;
import com.eckey.lab.utils.JwtUtils;
import com.eckey.lab.utils.RedisUtils;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

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

@Component
public class TokenFilterComponent extends OncePerRequestFilter {

    @Autowired
    private RedisUtils redisUtils;

    @Autowired
    private JwtUtils jwtUtils;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = request.getHeader("token");
        if (StringUtils.isEmpty(token)) {
            //未获取到token信息,直接放行,SecurityContextHolder无用户信息,会被拦截器拦截
            filterChain.doFilter(request, response);
            return;
        }
        //解析jwt token,获取用户id
        Claims claimsFromToken = jwtUtils.getClaimsFromToken(token);
        Integer id = (Integer) claimsFromToken.get("USERID");
        //根据用户id查询缓存中用户信息
        LoginUser loginUser = (LoginUser) redisUtils.get("loginUser:" + id);
        if (loginUser == null) {
            throw new RuntimeException("非法用户!");
        }
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, null);
        //认证通过,将用户信息存入SecurityContextHolder
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request, response);
    }
}

4.SecurityConfig
SecurityConfig是关于Spring Security的配置类,包括密码加密方式、拦截路径、拦截方式等,这里编写一些基础配置,具体大家可根据需要自定义。

import com.eckey.lab.filter.TokenFilterComponent;
import com.eckey.lab.service.impl.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.annotation.Resource;

@EnableWebSecurity
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Resource
    private UserDetailsServiceImpl userDetailsService;

    @Autowired
    private TokenFilterComponent tokenFilterComponent;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //不通过Session获取SecurityContext
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                //登录接口允许匿名访问
                .authorizeRequests()
                .antMatchers("/login/user").anonymous()
                //除上述接口,均需要授权访问
                .anyRequest().authenticated();

        // 关闭csrf
        http.csrf().disable();
        //添加token认证过滤器
        http.addFilterBefore(tokenFilterComponent, UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 设置密码加密方式,验证密码的在这里
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    /**
     * 指定加密方式,密码需要BCryptPasswordEncoder加密方式存入数据库,否则校验不通过
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        // 使用BCrypt加密密码
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

}

一些工具类代码就不在这里展示了,具体可查看附录代码。

2.4 测试结果

2.4.1 测试数据

tddl集成spring springsecurity集成jwt_spring_08

2.4.2 访问登陆接口

tddl集成spring springsecurity集成jwt_tddl集成spring_09

2.4.3 携带有效token请求接口

tddl集成spring springsecurity集成jwt_用户信息_10


还有登出接口,登出成功之后,携带上次token请求就无法访问了,缓存用户信息数据已被清理,需要重新登陆,具体可自行测试。

3.小结

1.在Spring Security框架中,密码不会被明文存储在数据库中。默认PasswordEncoder要求数据库中的密码格式为{id}password,它会根据id去判断密码的加密方式,一般不会采用这种方式,常用的方式是利用Spring Security中的BCryptPasswordEncoder来进行密码加密;
2.在接口中通过AuthenticationManager的authenticate方式来进行用户认证,所以需要在SecurityConfig中把AuthenticationManager注入容器;
3.认证成功后生成jwt token返回响应,并且为了让下次请求过来时能准确识别用户,需要将用户信息存入redis,token中需要携带用户id信息;
4.token拦截器中需要解析token信息,若合法则放行,并将用户信息存入SecurityContextHolder,方便后面拦截器进行鉴权操作,且token拦截器需要放在UsernamePasswordAuthenticationFilter拦截器之前,便于后面拦截器可以直接获取SecurityContextHolder用户信息;
5.本文未展示token有效期的情形,下一篇将展示token指定时间有效,过期提示重新登陆。