Spring Security + jwt 实现安全控制

权限系统是每个系统必不可少的一部分,我们可以自己实现根据自己的需求采用不同的技术方案。最近在我们的管理后台尚使用了Spring Security + JWT实现了后台的权限系统,包括用户登录,角色分配,鉴权与授权。

理解权限框架本质

有哪些技术方案?
业内通用的做法有Shiro,Spring Security,还有很多公司自己实现的基于url拦截的权限框架。从个人使用体验上来说,有好用的轮子就应该选择用经过很多人验证过的轮子。而不是自己沉迷于简单的增删改,时间应该花在研究security的原理,代码组织架构上,因为我也见过几个项目自己手写的权限框架,并没有用的很流畅,反而总是在一些url匹配不够通用上问题频出。
那么权限框架的本质是什么?
对,就是匹配逻辑。举个简单例子,网站用户A拥有权限标识:"user_add","coupon_delete","coupon_all",接收到request请求后,判断此请求需要的权限标识是否匹配。权限标识可以是:menu_url,menu_code,role_code等等,我们可以选择系统中变动频率小的变量来做角色标识。因为这个权限标识只能硬编码或者ant风格匹配在目标资源上。举个例子:假如你的系统角色固定,那就用角色code作权限标识,若是菜单基本固定,就用菜单url做标识。后面会具体讲到

用户登录的逻辑和jwt

用户到底是怎么登录的?
这个问题对于初级工程师来说会很迷惑,曾经也经历过。所以简单说明下。在一般的web软件开发中,开发者不需要关注会话这件事情,因为tomcat容器自动帮我们管理的会话session,他的流程是这样的,用户访问服务,服务端生成session会话,并且把sessionId回写到浏览期的cookie中,浏览器后面的每次请求就会携带上这个sessionId。服务端就能标识这个用户了,至于登陆鉴权的逻辑都是基于你能唯一标识当前的用户来做的。通用的做法是,用户成功登陆后,服务端会把用户信息存放在sessionId标识的session中。随着用户体量增多,在分布式的环境下一般的做法是session共享,或者采用redis接替tomcat管理session会话的方案。
为什么要用jwt?
全程是json web token,关于jwt是什么,可以参考阮一峰的文章:JSON Web Token 入门教程。使用了jwt后,我们完全把登陆信息存放在客户端,每次认证都是由客户端带着鉴权参数过来。具体的逻辑是服务端生成token,包含token有效期,存放的鉴权信息等,下发给客户端。客户端自放在本地。服务端就可以提供无状态的服务了,非常方便扩展。

实际案例

导入依赖


 
        org.springframework.boot
        spring-boot-starter-parent
        2.1.2.RELEASE
         
    

        
            org.springframework.boot
            spring-boot-starter-security
        

配置security

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

    /**
     * 读取忽略的配置文件
     */
    @Autowired
    private FilterIgnorePropertiesConfig filterIgnorePropertiesConfig;

    /**
     * 未携带token的异常处理
     */
    @Autowired
    private JwtAuthenticationEntryPoint unauthorizedHandler;

    /**
     * 业务的用户密码验证
     */
    @Autowired
    private JwtUserDetailsService jwtUserDetailsService;

    /**
     * 自定义基于JWT的安全过滤器
     */
    @Autowired
    private JwtAuthorizationTokenFilter authenticationTokenFilter;

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
// 配置基于数据库的用户密码查询  密码使用security自带的BCryptEncoder(结合了随机盐和加密算法)
        auth.userDetailsService(jwtUserDetailsService)
                .passwordEncoder(passwordEncoder());
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.headers().frameOptions().disable();

        // 【1】授权异常及不创建会话(不使用session)
        http.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        //允许不登录访问的接口
        ExpressionUrlAuthorizationConfigurer.ExpressionInterceptUrlRegistry registry = http.authorizeRequests();

        // 【2】 从配置文件读取url
        registry.antMatchers(HttpMethod.OPTIONS, "/**").anonymous();
        filterIgnorePropertiesConfig.getUrls().forEach(url -> registry.antMatchers(url).permitAll());

        //需要登录才允许访问
        filterIgnorePropertiesConfig.getAuthenticates().forEach(url -> registry.antMatchers(url).authenticated());

        //其它的严格控制权限,必须权限拥有的菜单中对应的api_url才允许访问 【3】 权限控制
        //registry.anyRequest().access("@permissionService.hasPermission(request,authentication)");
        registry.anyRequest().authenticated();

        // 把token拦截器配置在security 用户名和密码拦截器之前  【4】 从token解析的逻辑
        http.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        // AuthenticationTokenFilter will ignore the below paths
        web.ignoring()
                .antMatchers(
                        HttpMethod.GET,
                        "/*.html",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js"
                );
    }
}
处理配置文件
@Data
@Configuration
@RefreshScope
@ConditionalOnExpression("!'${ignore}'.isEmpty()") 
@ConfigurationProperties(prefix = "ignore")
public class FilterIgnorePropertiesConfig {

    private List urls = new ArrayList<>();

    private List authenticates = new ArrayList<>();

}

application.yml

ignore:
  urls:
  - /auth/**
  - /act/**
  - /druid/*
  - /*/user/login

anonymous:都支持访问
permitAll():不登陆也能访问
authenticated():登陆就能访问
access():严格控制权限

token拦截器

拦截器主要做了这么几件事:

1.从请求头里面获取token
2.解析token里面存放的用户信息
3.用户信息不为空,且当前请求SecurityContextHolder(默认的实现是ThreadLocal)中的用户信息为空,就设置进去。
3.1用redis标记了token是否是用户手动过期掉的,因为token本身存放了过期时间 无法修改。
3.2根据3中简要的用户信息查询全部用户信息,包括角色,菜单等。如果你足够信任token,也可以省略这里查询数据库。

@Slf4j
@Component
public class JwtAuthorizationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private FilterIgnorePropertiesConfig filterIgnorePropertiesConfig;

    private OrRequestMatcher orRequestMatcher;

    @Autowired
    private UserDetailsService userDetailsService;

    private final JwtTokenUtil jwtTokenUtil;

    private final String tokenHeader;

    private int expiration;

    @Autowired
    private RedisManager redisManager;

    @PostConstruct
    public void init() {
// 初始化忽略的url不走过此滤器
        List matchers = filterIgnorePropertiesConfig.getUrls().stream()
                .map(url -> new AntPathRequestMatcher(url))
                .collect(Collectors.toList());
        orRequestMatcher = new OrRequestMatcher(matchers);
    }

    public JwtAuthorizationTokenFilter(JwtTokenUtil jwtTokenUtil, @Value("${jwt.header}") String tokenHeader, @Value("${jwt.expiration}") Long expire) {
        this.jwtTokenUtil = jwtTokenUtil;
        this.tokenHeader = tokenHeader;
        this.expiration = (int) (expire / 1000);
    }

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

        String requestURI = request.getRequestURI();
        log.debug("processing authentication for '{}'", requestURI);
        final String requestHeader = request.getHeader(this.tokenHeader);

        JwtUser jwtUser = null;
        String authToken = null;
        if (requestHeader != null && requestHeader.startsWith("Bearer ")) {
            authToken = requestHeader.substring(7);
            try {
                jwtUser = jwtTokenUtil.getJwtUserFromToken(authToken);
            } catch (ExpiredJwtException e) {
                // token 过期
                throw new AccountExpiredException("登陆状态已过期");
            } catch (MalformedJwtException e) {
                log.info("解析前端传过来的Authentication错误,但不影响业务逻辑!token:{}", requestHeader);
            } catch (Exception e) {
                log.info("JwtAuthorizationTokenFilter处理异常!{}", e.getMessage());
            }
        }
        log.debug("checking authentication for user '{}'", jwtUser);

        //生成jwt的token的过期时间是一天,而这里控制实际过期时间是两个小时(application.yml配置的过期时间)
        if (jwtUser != null && jwtUser.getUsername() != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            if (redisManager.exists(CacheAdminConstant.USER_AUTHORITY_NOT_EXPIRED + authToken)) {
                redisManager.expire(CacheAdminConstant.USER_AUTHORITY_NOT_EXPIRED + authToken, expiration);
            } else {
                throw new AccountExpiredException("登录信息已经过期或已经退出登录,请重新登录!");
            }

            UserDetails user = userDetailsService.loadUserByUsername(jwtUser.getUsername());
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            log.debug("authorizated user '{}', setting security context", user.getUsername());
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

    /**
     * 可以重写
     * @param request
     * @return 返回为true时,则不过滤即不会执行doFilterInternal
     * @throws ServletException
     */
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        return orRequestMatcher.matches(request);
    }
}
从持久层查询用户

1.把用户的权限标识封装到GrantedAuthority对象,这是security封装的权限顶级接口。
2.检验菜单权限的时候就会通过这里封装的权限标识来比对。
3.关于权限标识的选取上文有提到,尽量选择不容易变动的变量(角色Code|菜单Code|菜单path)。
4.这个对象就是放在线程变量的用户对象,serurity的注解也会从这里取出权限标识来比对

@Primary
@Service
@Transactional(propagation = Propagation.SUPPORTS, readOnly = true, rollbackFor = Exception.class)
public class JwtUserDetailsService implements UserDetailsService {

    @Autowired
    private SysUserService sysUserService;

    @Override
    public UserDetails loadUserByUsername(String username){

        // 根据登陆的用户名查询用户相关的信息
        UserEntity user = sysUserService.loadUserByUsername(username);

        if (user == null) {
            throw new UsernameNotFoundException("该账户不存在,请联系管理员添加");
        } else {
            return create(user);
        }
    }

    public UserDetails create(UserEntity user) {
        JwtUser jwtUser = new JwtUser();
        BeanUtils.copyProperties(user, jwtUser);

        Set roleCodeList = new HashSet<>();
//        roleCodeList.addAll(user.getRoleIdList().stream().map(String::valueOf).collect(Collectors.toList()));
// 选取菜单permission作为权限标识
        roleCodeList.addAll(user.getPermissionList().stream().filter(StringUtils::isNotEmpty).collect(Collectors.toSet()));
        Collection authorities = AuthorityUtils.createAuthorityList(roleCodeList.toArray(new String[0]));
        jwtUser.setAuthorities(authorities);

        return jwtUser;
    }

}
用户登陆的流程

上面的部分是用户带着token来访问授权接口,或者不带token访问公用接口。那么token是怎么生成的呢?我们需要暴露公开的登陆接口,校验用户信息状态等。成功通过校验后,把部分用户信息封装在token里面下发给客户端。
这是一个基于的jjwt的jwtToken工具类:

@Component
@Slf4j
public class JwtTokenUtil {

    private transient Clock clock = DefaultClock.INSTANCE;

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private Long expiration;

    @Value("${jwt.header}")
    private String tokenHeader;

    @Autowired
    private RedisManager redisManager;

    private ObjectMapper mapper = new ObjectMapper();

    public JwtUser getJwtUserFromToken(String token) throws Exception {
        String subject = getClaimFromToken(token, Claims::getSubject);
        Map subjectMap = mapper.readValue(subject, Map.class);

        // 在token中存储了用户ID 用户名  用户状态
        JwtUser jwtUser = new JwtUser();
        jwtUser.setUserId(Long.valueOf(subjectMap.get("userId").toString()));
        jwtUser.setUsername((String) subjectMap.get("username"));
        jwtUser.setState((Integer) subjectMap.get("state"));

        return jwtUser;
    }

    public Date getIssuedAtDateFromToken(String token) {
        return getClaimFromToken(token, Claims::getIssuedAt);
    }

    public Date getExpirationDateFromToken(String token) {
        return getClaimFromToken(token, Claims::getExpiration);
    }

    public  T getClaimFromToken(String token, Function claimsResolver) {
        final Claims claims = getAllClaimsFromToken(token);
        return claimsResolver.apply(claims);
    }

    private Claims getAllClaimsFromToken(String token) {
        return Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
    }

    private Boolean isTokenExpired(String token) {
        final Date expirationDate = getExpirationDateFromToken(token);
        return expirationDate.before(clock.now());
    }

    private Boolean isCreatedBeforeLastPasswordReset(Date created, Date lastPasswordReset) {
        return (lastPasswordReset != null && created.before(lastPasswordReset));
    }

    private Boolean ignoreTokenExpiration(String token) {
        // here you specify tokens, for that the expiration is ignored
        return false;
    }

    // 登陆校验成功后调用这个接口生成token下发
    public String generateToken(UserDetails userDetails) {
        Map claims = new HashMap<>();

        try {
            String subject = mapper.writeValueAsString(userDetails);
            log.info("generateToken subject:{}", subject);
            String token = doGenerateToken(claims, subject);
            redisManager.set(CacheAdminConstant.USER_AUTHORITY_NOT_EXPIRED + token, "1", (int) (expiration / 1000));
            return token;
        } catch (JsonProcessingException e) {
            throw new IllegalArgumentException("Cannot format json", e);
        }
    }

    private String doGenerateToken(Map claims, String subject) {
        final Date createdDate = clock.now();
        final Date expirationDate = calculateExpirationDate(createdDate);

        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(createdDate)
                .setExpiration(expirationDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    public Boolean canTokenBeRefreshed(String token, Date lastPasswordReset) {
        final Date created = getIssuedAtDateFromToken(token);
        return !isCreatedBeforeLastPasswordReset(created, lastPasswordReset)
                && (!isTokenExpired(token) || ignoreTokenExpiration(token));
    }

    public String refreshToken(String token) {
        final Date createdDate = clock.now();
        final Date expirationDate = calculateExpirationDate(createdDate);

        final Claims claims = getAllClaimsFromToken(token);
        claims.setIssuedAt(createdDate);
        claims.setExpiration(expirationDate);

        return Jwts.builder()
                .setClaims(claims)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    public Boolean validateToken(String token, UserDetails userDetails) throws Exception {
        JwtUser user = (JwtUser) userDetails;
        final JwtUser jwtUser = getJwtUserFromToken(token);
        return (
                jwtUser.getUsername().equals(user.getUsername())
                        && !isTokenExpired(token));
    }

    private Date calculateExpirationDate(Date createdDate) {
        //过期时间1天
        return new Date(createdDate.getTime() + 1000 * 60 * 60 * 24);
    }
}
jwt token刷新机制

我们回顾下token机制相比传统的session机制带来的好处,服务无状态,服务端不用存储用户的session,用户数过多也不会占用资源,方便服务水平拓展...,token也有一个缺点就是由于token的有效期是保存在客户端的,当用户主动退出,或者服务端要踢出用户的时候很难做到。refresh token可以实现这种场景,并且能实现用户无感知登陆。访问资源的称之为access token,客户端访问所有的资源都需要带上,它的有效期比较短。refresh token是用来刷新access token,它的有效期是比较长的。接下来回顾一下整个会话管理流程:

  • 客户端使用用户名和密码认证
  • 服务端校验用户名和密码,下发access_token(2小时有效)和refresh_token(7天有效)
  • 客户端带着access_token访问需要认证的资源,access_token有效,返回资源。
  • access_token过期,返回和客户端约定的响应码,客户端带着refresh_token刷新access_token.
  • refresh_token 有效,正常返回,refresh_token过期走重新登陆流程。
  • 客户端使用新的 access_token 访问需要认证的接口


    会话管理流程

将生成的refresh_token以及过期时间存储在服务端的数据库中,只有在申请新的access_token时才会验证。同时我们也能实现在服务端踢出用户,只需要禁用|删除refresh_token,用户在刷新access_token时就会重新去登陆。(时间精度的控制取决于access_token的有效期)

接口权限控制

当我们完成了用户登陆-token下发-请求拦截认证的流程后,当request到达Controller层,SecurityContextHolder已经存储了用户的常用信息(用户名,权限标识等等),所以在Controller层可以直接使用注解来鉴权。

@PreAuthorize("hasAuthority('test_menu_code')")
    @PostMapping("/getUserInfo")
    public ResponseResult getUserInfo() {
        return new ResponseResult(getUser());
    }

至此,完成了整个权限控制。代码只是列出了关键的部分,没有达到运行的流程,需要有一定基础的程序员来根据自己的业务定制。只是提供了一个企业级权限控制的实现方案。

你可能感兴趣的:(Spring Security + jwt 实现安全控制)