vlambda博客
学习文章列表

【手摸手,带你搭建前后端分离商城系统】03 整合Spring Security token 实现方案,完成主业务登录

【手摸手,带你搭建前后端分离商城系统】03 整合Spring Security token 实现方案,完成主业务登录

上节里面,我们已经将基本的前端 VUE + Element UI 整合到了一起。并且通过 axios 发送请求到后端API。

解决跨域问题后、成功从后端获取到数据。

本小结,将和大家一起搭建 Spring-Security + token 的方式先完成登录。权限将在后面讲解。

引入

在之前,我们的 API 都是一种裸奔的方式。谁都可以访问,肯定是不安全的。所以我们要引入安全校验框架。

传统 session 方案

传统session 的方式是,通过一个 拦截器 拦截所有的请求,若 cookie 当中存储的 session id 在服务端过期后、则要求前端重新登录,进而获取一个新的session

session 与 cookie 区别

因为HTTP 是一种无状态的协议。所以服务端不知道这个 请求是谁发过来的,有好多人访问服务器,但是对于服务器来说,这些人我都不认识。就需要一种东西来给每个人加一个 ID

「session(会话)」 是一种客户端发起请求后, 服务端用来识别用户的东西,可以保存一些用户的基本信息。比如ID什么的

「cookie」 是一种客户端浏览器用来记录和保存信息的东西。简单理解,如图所示。

image-20201015110133157

当然,默认的cookie 里面总会包含一串 JSESSIONID

image-20201015110702977

session认证所显露的问题

「Session」: 每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。

「扩展性」: 用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。

「CSRF」: 因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。

JWT

https://jwt.io/

肯定是原有的session认证的方式存在弊端、我们就需要采取一种新的方式来进行验证。JWT

JWT token 由三部分构成:

  • 头部(header)
  • 载荷(playload)
  • 签证(signature)

具体的内容可以参考:https://www.jianshu.com/p/576dbf44b2ae

头部 header

头部一般包含加密算法和类型。例如

{
  "alg""HS256",// 加密算法
  "typ""JWT" // 声明类型
}
负荷 playload

负载可以理解为存放信息的位置,例如:

{
   "iss":"mall-pro"// 签发者
   "sub":"admin"// 面向的用户
   "iat"1602737566890,//签发时间
   "exp"1602739566890//过期时间,必须大于签发时间
}
签证(signature)

签证一般是头部和负荷组成内容的,一旦头部和负荷内容被篡改,验签的时候也将无法通过。

//secret为加密算法的密钥
String signature = HMACSHA512(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)

我们来参考一个生成的 JWT 实例

注意,我这里使用回车、一般三部分都是通过标点进行分割的。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

实现原理

  1. 用户调用登录接口后、验证用户名和密码。验证成功后、颁发给其 token
  2. 前台获得 token 后,将其存放到本地、每次的请求都将这个 token 携带到请求头里面。
  3. 后台收到请求后、验证请求头里面的 Authorization 是否正确、从而判断是否可以调用这个接口。
  4. 通过解析 token  将账号信息存入 userDetail 让其顺利调用接口信息、并可以在接口中获得当前登录人的账号信息。

Spring Security

安全框架,我们这里考虑使用 Spring-Security ,使用全家桶系列,一般大家都会想到apache shiro 等权限框架、都是可以的。我们这里介绍如何加入 Spring-Security

引入到 mall-security 并且添加一个配置文件。

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

添加一个登陆接口

我们首先从登陆接口开始,一个最基本的 controller 接受参数。当然,用户名和密码肯定是不能为空的,校验完后交给 service

    @ApiOperation("用户登录接口")
    @RequestMapping("login")
    public CommonResult login(@RequestBody @Valid @ApiParam("用户名密码") UmsAdminLoginParam param) {

        UmsAdminTokenBO tokenBO = umsAdminService.umsAdminLogin(param);
        return CommonResult.success(tokenBO);
    }

具体的内容无非是:查询数据库、是否存在、密码是否正确。正确就构造一个 token 返回给前端。这里主要说一些重要的点。

断言与全局异常处理

断言可以理解为:若当前行不符合判断条件、则抛出异常。或者直接使用断言来抛出一个异常。比如账号不存在,直接抛出一个异常即可。

全局异常处理:全局异常处理,在全局统一拦截异常信息,并通过{code=500,message="error message"} 的方式返回给前端做出提示即可。

Springboot 对于全局异常的处理、简直是简单的不得了~

@RestControllerAdvice
@Slf4j
public class GlobalExControllerHandler {

    /**
     * <p>全局异常拦截器,拦截自定义ApiException
     * <p>author: <a href='mailto:[email protected]'>MRC</a>
     *
     * @param e 自定义异常
     * @return xyz.chaobei.common.api.CommonResult
     * @since 2020/10/20
     **/

    @ExceptionHandler(value = ApiException.class)
    public CommonResult exceptionHandler(ApiException e
{
        log.info("系统异常拦截器:异常信息:" + e.getMessage());
        if (Objects.nonNull(e.getErrorCode())) {
            return CommonResult.failed(e.getErrorCode());
        }
        return CommonResult.failed(e.getMessage());
    }
}

直接通过 return 的方式,就好像我们在 controller 里面给前端返回json 一样简单。

断言则是,判断某一条件是否成立、如果不成立则抛出异常的一种更加简单的方式。就不用每次都写throw new xxxException

简而言之就是:一种非常优美的方式抛异常(偷懒的)

public class Asserts {
    /**
     * <p>断言抛出一个异常
     * <p>author: <a href='mailto:[email protected]'>MRC</a>
     *
     * @param message 提示语
     * @return void
     * @since 2020/10/15
     **/

    public static void fail(String message) {
        throw new ApiException(message);
    }

    public static void fail(IErrorCode iErrorCode) {
        throw new ApiException(iErrorCode);
    }
}

Spring Security UserDetails

Spring UserDetails 作为一个接口、规定了一些需要的参数方法。我们必须要用自己的逻辑实现这个方法。并将username password 等重要信息通过其定义的方法进行返回。也是作为一种桥接、将我们的用户名、密码等信息交付给 SpringSecurity


public class UmsAdminUserDetails implements UserDetails {

    private final UmsAdminModel adminModel;

    public UmsAdminUserDetails(UmsAdminModel adminModel) {
        this.adminModel = adminModel;
    }
    // 省略,具体请查看源码
}    

JWT 签发服务

JWT 又称作JsonWebToken ,我们需要一个依赖来生成token/登录后需要将这个 token 返回给前端,让前端保存,而后所有的请求都需要带上这个 token 然后我们服务端就知道是哪个用户在请求了。

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
</dependency>

生成token

我在上面的内容里面已经介绍了。我们的token 必须要包含:

  • sub 签发给谁
  • iat 过期时间戳
  • iss 谁签发的
    /**
     * 功能描述: 通过负载生成token
     *
     * @Param: claims 负载
     * @Return: java.lang.String
     * @Author: MRC
     * @Date: 2020/10/21 0:17
     */

    private String buildToken(Map<String, Object> claims) {
        return Jwts.builder()
                .setClaims(claims)
                .setExpiration(generateExpirationDate())
                .signWith(SignatureAlgorithm.HS512, jwtConfig.getSecret())
                .compact();
    }

通过builder() 构造器、设置其负载内容、并且指定 过期时间setExpiration ,以及加入秘钥进行加密 signWith

token 检验

token 检验包含:当前token 是否有效(能顺利从token取出我们的sub)、以及检验其是否过期 无效等。

    /**
     * <p>从toKen中获取负载信息
     * <p>author: <a href='mailto:[email protected]'>MRC</a>
     *
     * @param token 获取的token
     * @return io.jsonwebtoken.Claims
     * @since 2020/10/22
     **/

    private Claims getClaimsFromToken(String token) {
        Claims claims = null;
        try {
            claims = Jwts.parser()
                    .setSigningKey(jwtConfig.getSecret())
                    .parseClaimsJws(token)
                    .getBody();
        } catch (Exception e) {
            log.info("JWT格式验证失败:{}", token);
        }
        return claims;
    }

该方法描述了如何从一个token 里面取出我们所需要的 Claims 信息。并且可以从负载里面取出 sub 以及 exp 等信息。我简要介绍一个。其他的详细内容请查看源码。

    /**
     * <p>首先获取token当中的负载、而后从负载中取出sub
     * <p>author: <a href='mailto:[email protected]'>MRC</a>
     *
     * @param token 被校验的token
     * @return java.lang.String
     * @since 2020/10/22
     **/

    public String getUserNameFromToken(String token) {
        String username;
        try {
            Claims claims = getClaimsFromToken(token);
            username = claims.getSubject();
        } catch (Exception e) {
            username = null;
        }
        return username;
    }

「如果你的token被篡改了,那么验证的时候肯定会报错、所以要捕获一下异常。返回空即可。」

login service

写到这里,我们login 控制器的service 已经可以全部写下去了。登录成功,通过tokenService 返回一个token ,然后封装返回给前端即可。

    @Override
    public UmsAdminTokenBO umsAdminLogin(UmsAdminLoginParam param) {
        
        // 通过用户名获取userDetail
        UserDetails userDetails = this.findUserDetailByUserName(param.getUsername());
        // 基本校验用户名和密码
        if (!passwordEncoder.matches(param.getPassword(), userDetails.getPassword())) {
            Asserts.fail("用户名密码错误");
        }
        // 这里暂时不开启权限,后面再修改
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null);
        // 将构建的用户信息加入spring security context 上下文
        SecurityContextHolder.getContext().setAuthentication(authentication);

        String token = defaultTokenServer.generateToken(userDetails);

        return UmsAdminTokenBO.builder().token(token).tokenHeader(jwtConfig.getTokenHeader()).build();
    }

Security Config

接下来。就是配置一个全局的Security Config

public class SecurityConfig extends WebSecurityConfigurerAdapter {}

主要还是需要重写configure() 方法。获取一个 registry 实例。将我们的拦截信息加入到里面。

  • 配置开放的路径
  • 配置需要验证的路径。
  • 添加一个JWT默认过滤器,在 SpringSecurity 处理之前,将token 进行校验后加入到 context 上下文里面。
@Override
    protected void configure(HttpSecurity http) throws Exception {

        ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = http.authorizeRequests();

        // 添加开放的路径
        for (String url : urlsConfig.getUrls()) {
            registry.antMatchers(url).permitAll();
        }
        // 允许跨域预请求
        registry.antMatchers(HttpMethod.OPTIONS).permitAll();

        // 所有的请求都需要身份认证
        registry.and()
                .authorizeRequests()
                .anyRequest().authenticated()
                // 关闭csrf 不使用session
                .and()
                .csrf()
                .disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                // 自定义权限拒绝
                .and()
                .exceptionHandling()
                .accessDeniedHandler(this.customerAccessDenied())
                .authenticationEntryPoint(this.customerAuthentication())
                // 添加权限拦截器和JWT拦截器,注意,是before
                .and()
                .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }

自定义过滤器

@Slf4j
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private JwtConfig jwtConfig;

    @Autowired
    private DefaultTokenServer defaultTokenServer;

    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * <p> token 过滤器逻辑
     * 1、token 必须存在
     * 2、toKen 必须正确,未过期。
     * 3、若上下文不存在。则往上下文放一个userDetail
     * <p>author: <a href='mailto:[email protected]'>MRC</a>
     *
     * @param request     请求
     * @param response    响应
     * @param filterChain 过滤器
     * @return void
     * @since 2020/10/22
     **/

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

        String token = request.getHeader(jwtConfig.getTokenHeader());

        log.info("doFilterInternal request url={}", request.getRequestURL());
        log.info("doFilterInternal request token={}", token);

        // 请求携带token/则检验这个token是否正确和是否过期
        if (!StringUtils.isEmpty(token)) {
            // 携带的用户名信息
            String username = defaultTokenServer.getUserNameFromToken(token);
            log.info("request token username={}", username);

            if (StringUtils.isEmpty(username)) {
                filterChain.doFilter(request, response);
            }
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            //校验token是否有效
            if (defaultTokenServer.isTokenExpired(token)) {
                filterChain.doFilter(request, response);
            }
            //检查当前上下文是否存在用户信息,若没有则添加
            if (SecurityContextHolder.getContext().getAuthentication() == null) {
                log.info("doFilterInternal getContext = null");
    
                // 将用户信息添加到上下文。说明这个request 是通过的。
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                log.info("doFilterInternal user:{}", username);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        // 通过拦截器
        filterChain.doFilter(request, response);
    }
}

其实我们这里去掉session 以后,我们的客户端对于前端的请求标识、只能通过携带token的方式。

然后我们每一个请求首先会进入JwtAuthenticationTokenFilter 也就是我们上面写的这个。

检查当前请求有没有携带token 要是带了 token 那就检查它,检查成功就从数据库查出来这个人。把这个人注入到我们的SpringSecurity Context 里面。

SpringSecurity 的其他过滤器看到上下文有东西在,就放行~说明是登录后的。

要是没带、或者验证错误~。那上下文也就没有这个用户的信息了。所以这个请求只能返回403

密码问题

这里使用的是:PasswordEncoder 接口实现类下的 BCryptPasswordEncoder ,当然,你肯定要在使用之前要用@Bean

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

未来使用的时候、直接注入一个就行了。

  • matches 校验
  • encode 加密

至于是怎么加密的。当然还得研究一下~

实际测试

在未登录之前,我们访问一个接口~

{
    "code"401,
    "data""Full authentication is required to access this resource",
    "message""暂未登录或token已经过期"
}

首先使用用户名和密码进行登录,我们加入一条数据。admin,123456

INSERT INTO `mall-pro`.`ums_admin`(`id``username``password``icon``lock``email``nick_name``note``create_time``login_time``status`VALUES (1'admin''$2a$10$08arRlZRspTqMBK1N8NqW.9CQq7KWffa47MGelgJMuPK/uXtKX3O6''#e'1'[email protected]''管理员''测试''2020-10-22 16:14:33''2020-10-22 16:14:36'1);

请求登录接口/auth/login ,验证用户名和密码后、返回信息如下:

{
    "code"200,
    "message""操作成功",
    "data": {
        "tokenHeader""Authorization",
        "token""eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImlzcyI6Im1hbGwtcHJvIiwiZXhwIjoxNjAzNTAzNjU3LCJpYXQiOjE2MDM0MTcyNTc4MzJ9.5bX2gajbRebS9MyII3OlBKD4xc5uTgelvFprT8SHvBq_MnFa--CSn3ntkGteITt5lLRbAyxyzC8u8KZ1ZCdYjg"
    }
}

将登录后,将指定头和token带入请求头进行请求,成功请求到数据~

小结

已经好久没更新这一篇文章了。希望我的读者你们不要怪我,实在是太忙了。白天要上班,偶尔摸鱼写一写,代码调试完、而后我再整理这篇文章。现在已经是凌晨00:26 。加油吧~ 我努力更新完这个系列。

源码地址

https://gitee.com/mrc1999/mall-pro

欢迎关注