3

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

 3 years ago
source link: https://blogs.chaobei.xyz/archives/mall-pro-03
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

【手摸手,带你搭建前后端分离商城系统】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

session认证所显露的问题

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

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

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

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

banner_1591192617234.jpg

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK