SpringBoot集成jwt实现登录验证

1,500 阅读7分钟

在项目中,用户的登录校验最常见的业务。

本文模拟一个业务场景:注册会员,登录网站,获取订单信息

说明:

  1. 访问订单信息时,用户需要登录过才可以访问;
  2. 用户前往注册登录或主页无需登录;

注册

用户注册需必填信息有用户名,密码,以及手机验证码,因此:

  1. 注册时要获取手机验证码;
  2. 验证码有60有效
  3. 验证码存储在redis中
  4. 验证后将用户信息入库

验证码的相关配置

# 自定义redis键值
redis:
  key:
    prefix:
      otpCode: "member:otpCode:" #动态校验码前缀
    expire:
      otpCode: 60 #动态校验码过期时间
  • redis序列化配置(代码略)
  • redis属性配置对应类(代码略)

获取验证码

@Override
public String getOtpCode(String phone) {
    // 1 当前用户是否注册
    List<Member> members = memberService.getByPhone(phone);
    if (!members.isEmpty()) {
        throw new CustomException(ResultCodeEnum.EXISTED_USER);
    }
    // 2 60s验证码唯一且有效
    String key = redisKeyPrefixConfig.getPrefix().getOtpCode() + phone;
    if (redisTemplate.hasKey(key)) {
        //throw new CustomException(ResultCodeEnum.OPTCODE_EFFECTIVE);
        log.info("验证码未过期:phone=[{}],optCode=[{}]");
        return (String) redisTemplate.opsForValue().get(key);
    }
    // 3 返回6位随机数字验证码
    Random random = new Random();
    StringBuffer optCode = new StringBuffer();
    final int optSize = 6;
    for (int i = 0; i < optSize; i++) {
        optCode.append(random.nextInt(10));
    }
    log.info("新生成验证码:phone=[{}],optCode=[{}]", phone, optCode);
    // 1min过期
    long expire = redisKeyPrefixConfig.getExpire().getOtpCode();
    redisTemplate.opsForValue().set(key, optCode.toString(), expire, TimeUnit.SECONDS);

    return optCode.toString();
}
  • 控制器
@GetMapping("/optCode")
public ResponseEntity<R> getOptCode(@RequestParam String phone) {
    String optCode = ssoService.getOtpCode(phone);
    return R.ok().data("optCode", optCode).buildResponseEntity();
}

注册

用户验证码通过后,需要将用户信息注册到数据中。考虑到安全性,用户的密码不可以明文储存在数据中,也不建议可揭秘,登录验验证时只需对比加密结果一直即可

  • 引入spring-security依赖,里面包含hash盐加密工具
<!--spring security-->
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-core</artifactId>
    <version>RELEASE</version>
</dependency>
  • 注册加密类
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}
  • 注册入库
@Override
public boolean register(RegisterQo registerQo) {
    // 1. 校验验证码
    String key = redisKeyPrefixConfig.getPrefix().getOtpCode() + registerQo.getPhone();
    String redisOptCode = (String) redisTemplate.opsForValue().get(key);
    if (StringUtils.isEmpty(redisOptCode) || !redisOptCode.equals(registerQo.getOtpCode())) {
        throw new CustomException(ResultCodeEnum.UNMATCH_OPTCODE);
    }
    // 2. 验证码正确使用后删除
    redisTemplate.delete(key);
    // 3. 数据库插入记录,密码使用加密方式
    Member member = new Member().setStatus(1).setMemberLevelId(4L);
    BeanUtils.copyProperties(registerQo, member);
    String password = passwordEncoder.encode(registerQo.getPassword());
    member.setPassword(password);

    return memberService.save(member);
}
  • 控制器
@GetMapping("/optCode")
public ResponseEntity<R> getOptCode(@RequestParam String phone) {
    String optCode = ssoService.getOtpCode(phone);
    return R.ok().data("optCode", optCode).buildResponseEntity();
}

@PostMapping("/register")
public ResponseEntity<R> register(@Valid @RequestBody RegisterQo registerQo) {
    ssoService.register(registerQo);
    return R.ok().message("注册成功").buildResponseEntity();
}

测试

登录

单机session

  1. 校验用户登录信息;
  2. 登录后将用户记录存入session会话,下次访问时浏览器携带cookie即可;

验证

为简化代码,建议直接使用validator类,在进入控制器时直接校验,校验错误可通过aop或全局异常的方式捕获错误信息

  • 注册实体类
@Data
public class RegisterQo {
    @NotBlank(message = "用户名不能为空")
    @Length(min = 4,max = 20,message = "用户名长度必须在4-20字符之间")
    private String username;

    @NotBlank(message = "密码不能为空")
    @Length(min = 4,max = 20,message = "密码长度必须在4-20字符之间")
    private String password;

    @NotBlank(message = "手机号不能为空")
    @Length(min = 11,max = 11,message = "电话必须是11个字符")
    @Pattern(regexp = "^1[3|4|5|6|7|8|9][0-9]\\d{8}$",message = "电话号码格式不正确")
    private String phone;

    @NotBlank(message = "验证码不能为空")
    @Length(min = 6,max = 6,message = "验证码必须6个字符")
    private String otpCode;
}
  • 异常捕获,为方便查看错误信息,这里直接将errors信息返回
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<R> error(MethodArgumentNotValidException e) {
    log.error(ExceptionUtils.getMessage(e));
    List<ObjectError> allErrors = e.getBindingResult().getAllErrors();
    String description = allErrors.stream().map(x -> x.getDefaultMessage()).reduce((x, y) -> x + "," + y).orElseGet(PARAM_ERROR::getMessage);
    return new ResponseEntity<>(R.setResult(PARAM_ERROR).data("description",description), BAD_REQUEST);
}
  • 进入mvc控制器后,校验用户名对应的密码
@Override
public Member login(String username, String password) {
    // 1. 获取数据库用户信息
    List<Member> members = memberService.getByUsername(username);
    // 2. 密码加密匹配
    if (CollectionUtils.isEmpty(members) || (!passwordEncoder.matches(password, members.get(0).getPassword()))) {
        throw new CustomException(ResultCodeEnum.UNMATCH_UNAME_PWD);
    }
    // 3. 返回用户信息
    Member member = members.get(0);
    return member;
}

记录session

校验通过后,将用户的信息记录到session中

  • 获取serlvet中的request类
public HttpServletRequest getRequest() {
    return ((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest();
}
  • 记录session,返回
@GetMapping("/login")
public ResponseEntity<R> sessionLogin(String username, String password) {
    Member member = ssoService.login(username, password);
    // 登录记录session
    getHttpSession().setAttribute("member", member);
    // TODO 返回member的vo类
    return R.ok().message("登录成功").data("username", member.getUsername()).buildResponseEntity();
}

访问拦截

  • 访问url时,首先进入前置拦截器,在这里判断session
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    log.info("进入前置拦截器...");
    // session校验
    if (ObjectUtils.isEmpty(request.getSession().getAttribute("member"))) {
        printUnauthorized(response);
        return false;
    }
    return true;
}

/**
 * 该部分没有进入到mvc,无法使用自定义异常,需要从response返回
 *
 * @param response
 * @throws Exception
 */
protected void printUnauthorized(HttpServletResponse response) throws Exception {
    response.setHeader("Content-Type", "application/json");
    response.setCharacterEncoding("UTF-8");
    response.setStatus(401);
    String result = new ObjectMapper().writeValueAsString(R.setResult(ResultCodeEnum.UNAUTHORIZED));
    response.getWriter().print(result);
}
  • 除指定url,其他访问都需要登录的session信息
@Configuration
public class InterceptorConfiguration implements WebMvcConfigurer {

    @Autowired
    private NoAuthUrlProperties noAuthUrlProperties;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authInterceptorHandler())
                .addPathPatterns("/**")
                .excludePathPatterns(new ArrayList<>(noAuthUrlProperties.getShouldSkipUrls()));
    }

    @Bean
    public AuthHandlerInterceptor authInterceptorHandler() {
        return new AuthHandlerInterceptor();
    }
}

模拟获取订单

若用户没有登录,没有记录session,那么将无法访问(若以登录,清楚cookie,或重启服务)

  • 获取订单
    @GetMapping
    public ResponseEntity<R> getOrder() {
        log.info("下订单...");
        // session会话信息
        Member member = (Member) getHttpSession().getAttribute("member");
        return R.ok().message("测试订单调用").data("userId",member.getId()).buildResponseEntity();
    }
  • 测试

分布式session

上面的方案,在部署单台服务器时是可行的,但是如果部署了两台服务器

时,那么很可能出现这种情况:用户登录成功后,访问订单时一开始可以访问,刷新后又不能访问了。

这是由于session信息是存储在服务器端的,也就是在jvm中,以concurrentMap形式存储。

用户登录时,session在记录在A服务器中,B服务器并没有该session,导致代理转发到B服务器时会没有访问权限

要解决分布式环境下的问题,有两种思路:

  1. 实时同步:当某一台服务器中session发生变化,其他服务器也会收到通知,实时变化
  2. session共享:将session信息从服务器中剥离,作为一个单独的服务库存储,所有服务器操作session时,只需要访问这个数据服务

相比较:第一种方案虽然可行,但是维护成本大,需要引入通信组件,且增大了服务器大压力。第二中相对简单,这里使用redis缓存作为存储

改动

  • 引入spring session依赖
<dependency>
   <groupId>org.springframework.session</groupId>
   <artifactId>spring-session-data-redis</artifactId>
</dependency>
  • 修改yml
spring:
	session:
    store-type: redis
  • 启用redis session,这里设置过期时间1h
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)

测试

  • 修改nginx.conf,使用负载均衡策略
upstream mall-member {
    server localhost:9110 weight=3;
    server localhost:9111 weight=3;
}

server {
	listen 3000;
	server_name localhost;

	location / {
        proxy_pass http://mall-member;
        root   html;
        index  index.html index.htm;
	}
}	
  • 登录访问

JWT版本

使用redis共享session虽然很好的解决了同步问题,但是有三个明显缺点:1.随着用户规模扩大,数据库成本加大;2.session只适合存储少量登录现象,不适合携带更多的类似头像权限等信息;3.cookie不能跨域

简介

JWT(JSON Web Token),用户会话信息存储在客户端浏览器,它定义了一种紧凑且自包含的方式,用于在各方之间以JSON对象进行安全传输信息。这些信息可以通过对称/非对称方式进行签名,防止信息被串改。且它本身就是json格式,对前后端交互友好。

缺点:

  1. jwt在有效期内是可以使用的,可多个token并存,修改密码依然可用。一旦泄漏,存在风险
  2. 加密后内容较长,占用带宽

结构

JWT包含三个部分:Header.Payload.Signature,内容以.分割

  • Header: 采用Base64加密,描述 JWT 的元数据,主要包含了签名算法和令牌(token)灵性

  • Payload: 用来存放实际需要传递的数据,可自定义。JWT 规定了7个官方字段,供选用:

    • iss (issuer):签发人
    • exp (expiration time):过期时间
    • sub (subject):主题
    • aud (audience):受众
    • nbf (Not Before):生效时间
    • iat (Issued At):签发时间
    • jti (JWT ID):编号

    JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分

  • Signature:signature 部分是使用密钥(secret)对前两部分的签名,防止数据篡改。默认采用HS256加密算法

    HMACSHA256(
      base64UrlEncode(header) + "." +
      base64UrlEncode(payload),
      secret)
    

使用方式

客户端收到服务器返回的 JWT,可以储存在 Cookie 或 localStorage。客户端每次与服务器通信,都要带上这个 JWT。也可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求的头信息Authorization字段里面。

Authorization: Bearer <token>

代码整合

  • 引入jjwt
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
</dependency>
  • 自定义jwt配置
#jwt配置
jwt:
  header: Authorization #JWT存储的请求头
  secret: mall-member-secret #JWT加解密使用的密钥
  expire: 604800 #JWT的超期限时间(60*60*24)
  prefix: Bearer #JWT负载中拿到开头
  • jwt加密解密方法(代码略)
  • 修改前置拦截器
public final static String GLOBAL_JWT_MEMBER_INFO="jwt:member:info";

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    log.info("进入前置拦截器...");
    String authorization = request.getHeader(jwtProperties.getHeader());
    // 1.校验token
    if (StringUtils.isEmpty(authorization)||!authorization.startsWith(jwtProperties.getPrefix())) {
        printUnauthorized(response);
        return false;
    }
    // 2. 去除Bearer前缀
    String token = authorization.substring(jwtProperties.getPrefix().length());
    // 3. 解析
    Claims claims = jwtKit.parse(token);
    if (claims == null) {
        printUnauthorized(response);
        return false;
    }
    request.setAttribute(GLOBAL_JWT_MEMBER_INFO,claims);
    return true;
}
  • 登录控制器
@GetMapping("/login")
public ResponseEntity<R> jwtLogin(String username, String password) {
    Member member = ssoService.login(username, password);
    // 返回jwt
    HashMap<String, String> map = new HashMap<>(2);
    map.put("token", jwtKit.generate(member));
    map.put("header", jwtProperties.getPrefix());
    return R.ok().message("登录成功").data("jwt", map).buildResponseEntity();
}

详细过程,可参考源代码:github.com/chetwhy/clo…