SpringSecurity从入门到放弃之JWT认证登陆(一)

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的访问方式如下图所示:
在这里插入图片描述
用户携带账号、密码进行登陆,认证成功智慧,服务端会生成一个token返回给客户端,客户端会存储到cookie或localStorage,每次访问时都会携带token,服务端接收到token之后都会进行解析认证,成功之后才返回请求结果。

2.2 Spring Securiy原理

Spring Security是由一系列过滤器组成,每个过滤器具备自己独特的功能。Spring Security采用了设计模式中的责任链模式,由多个过滤器组成过滤器链来完成认证和授权的功能。框架的整体结构如下所示:
在这里插入图片描述
上述SecurityFilterChain对应的就是Spring Security的过滤器。对应客户端发起的请求,在进入Controller之前,需要进过应用本身一系列过滤器,包括Spring Security过滤器链的处理(认证和授权等)。下面是请求经过过滤器链的顺序:

在这里插入图片描述
上述图中主要展示了核心过滤器,非核心过滤器未展示,设置@EnableWebSecurity(debug = true)打印过滤器执行流程,如下所示:
在这里插入图片描述
由上图可知,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对象返回。
在这里插入图片描述

2.3 Spring Securiy整合JWT

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

在这里插入图片描述

2.4.2 访问登陆接口

在这里插入图片描述

2.4.3 携带有效token请求接口

在这里插入图片描述
还有登出接口,登出成功之后,携带上次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指定时间有效,过期提示重新登陆。

4.参考文献

1.https://juejin.cn/post/6861394327159963655
2.https://juejin.cn/post/6844903861237317640
3.https://www.bilibili.com/video/BV1mm4y1X7Hc

5.附录

https://gitee.com/Marinc/springboot-demos/tree/master/spring-security

  • 0
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值