关于java:JWT-实现登录认证-Token-自动续期方案

4次阅读

共计 6940 个字符,预计需要花费 18 分钟才能阅读完成。

JWT 实现登录认证 + Token 主动续期计划,这才是正确的应用姿态!
我的项目中根本都有用户治理模块,而用户治理模块会波及到加密及认证流程。

明天就来讲讲认证性能的技术选型及实现。技术上没啥难度当然也没啥挑战,然而对一个原先没写过认证性能的人来说也是一种锤炼吧

技术选型
要实现认证性能,很容易就会想到 JWT 或者 session,然而两者有啥区别?各自的优缺点?应该 Pick 谁?夺命三连

区别
基于 session 和基于 JWT 的形式的次要区别就是用户的状态保留的地位,session 是保留在服务端的,而 JWT 是保留在客户端的。

认证流程
基于 session 的认证流程

用户在浏览器中输出用户名和明码,服务器通过明码校验后生成一个 session 并保留到数据库
服务器为用户生成一个 sessionId,并将具备 sesssionId 的 cookie 搁置在用户浏览器中,在后续的申请中都将带有这个 cookie 信息进行拜访
服务器获取 cookie,通过获取 cookie 中的 sessionId 查找数据库判断以后申请是否无效
基于 JWT 的认证流程

用户在浏览器中输出用户名和明码,服务器通过明码校验后生成一个 token 并保留到数据库
前端获取到 token,存储到 cookie 或者 local storage 中,在后续的申请中都将带有这个 token 信息进行拜访
服务器获取 token 值,通过查找数据库判断以后 token 是否无效
优缺点
JWT 保留在客户端,在分布式环境下不须要做额定工作。而 session 因为保留在服务端,分布式环境下须要实现多机数据共享
session 个别须要联合 Cookie 实现认证,所以须要浏览器反对 cookie,因而挪动端无奈应用 session 认证计划
安全性

JWT 的 payload 应用的是 base64 编码的,因而在 JWT 中不能存储敏感数据。而 session 的信息是存在服务端的,相对来说更平安
如果在 JWT 中存储了敏感信息,能够解码进去十分的不平安

性能

通过编码之后 JWT 将十分长,cookie 的限度大小个别是 4k,cookie 很可能放不下,所以 JWT 个别放在 local storage 外面。并且用户在零碎中的每一次 http 申请都会把 JWT 携带在 Header 外面,HTTP 申请的 Header 可能比 Body 还要大。而 sessionId 只是很短的一个字符串,因而应用 JWT 的 HTTP 申请比应用 session 的开销大得多
一次性

无状态是 JWT 的特点,但也导致了这个问题,JWT 是一次性的。想批改外面的内容,就必须签发一个新的 JWT

无奈废除 一旦签发一个 JWT,在到期之前就会始终无效,无奈中途废除。若想废除,一种罕用的解决伎俩是联合 redis。
续签 如果应用 JWT 做会话治理,传统的 cookie 续签计划个别都是框架自带的,session 有效期 30 分钟,30 分钟内如果有拜访,有效期被刷新至 30 分钟。一样的情理,要扭转 JWT 的无效工夫,就要签发新的 JWT。最简略的一种形式是每次申请刷新 JWT,即每个 HTTP 申请都返回一个新的 JWT。这个办法不仅暴力不优雅,而且每次申请都要做 JWT 的加密解密,会带来性能问题。另一种办法是在 redis 中独自为每个 JWT 设置过期工夫,每次拜访时刷新 JWT 的过期工夫
抉择 JWT 或 session
我投 JWT 一票,JWT 有很多毛病,然而在分布式环境下不须要像 session 一样额定实现多机数据共享,尽管 seesion 的多机数据共享能够通过粘性 session、session 共享、session 复制、长久化 session、terracoa 实现 seesion 复制等多种成熟的计划来解决这个问题。

然而 JWT 不须要额定的工作,应用 JWT 不香吗?且 JWT 一次性的毛病能够联合 redis 进行补救。扬长补短,因而在理论我的项目中抉择的是应用 JWT 来进行认证。

性能实现
JWT 所需依赖

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.10.3</version>
</dependency>

JWT 工具类

public class JWTUtil {private static final Logger logger = LoggerFactory.getLogger(JWTUtil.class);

    // 私钥
    private static final String TOKEN_SECRET = "123456";

    /**
     * 生成 token,自定义过期工夫 毫秒
     *
     * @param userTokenDTO
     * @return
     */
    public static String generateToken(UserTokenDTO userTokenDTO) {
        try {
            // 私钥和加密算法
            Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET);
            // 设置头部信息
            Map<String, Object> header = new HashMap<>(2);
            header.put("Type", "Jwt");
            header.put("alg", "HS256");

            return JWT.create()
                    .withHeader(header)
                    .withClaim("token", JSONObject.toJSONString(userTokenDTO))
                    //.withExpiresAt(date)
                    .sign(algorithm);
        } catch (Exception e) {logger.error("generate token occur error, error is:{}", e);
            return null;
        }
    }

    /**
     * 测验 token 是否正确
     *
     * @param token
     * @return
     */
    public static UserTokenDTO parseToken(String token) {Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET);
        JWTVerifier verifier = JWT.require(algorithm).build();
        DecodedJWT jwt = verifier.verify(token);
        String tokenInfo = jwt.getClaim("token").asString();
        return JSON.parseObject(tokenInfo, UserTokenDTO.class);
    }
}

阐明:

生成的 token 中不带有过期工夫,token 的过期工夫由 redis 进行治理
UserTokenDTO 中不带有敏感信息,如 password 字段不会呈现在 token 中
Redis 工具类

public final class RedisServiceImpl implements RedisService {
    /**
     * 过期时长
     */
    private final Long DURATION = 1 * 24 * 60 * 60 * 1000L;

    @Resource
    private RedisTemplate redisTemplate;

    private ValueOperations<String, String> valueOperations;

    @PostConstruct
    public void init() {RedisSerializer redisSerializer = new StringRedisSerializer();
        redisTemplate.setKeySerializer(redisSerializer);
        redisTemplate.setValueSerializer(redisSerializer);
        redisTemplate.setHashKeySerializer(redisSerializer);
        redisTemplate.setHashValueSerializer(redisSerializer);
        valueOperations = redisTemplate.opsForValue();}

    @Override
    public void set(String key, String value) {valueOperations.set(key, value, DURATION, TimeUnit.MILLISECONDS);
        log.info("key={}, value is: {} into redis cache", key, value);
    }

    @Override
    public String get(String key) {String redisValue = valueOperations.get(key);
        log.info("get from redis, value is: {}", redisValue);
        return redisValue;
    }

    @Override
    public boolean delete(String key) {boolean result = redisTemplate.delete(key);
        log.info("delete from redis, key is: {}", key);
        return result;
    }

    @Override
    public Long getExpireTime(String key) {return valueOperations.getOperations().getExpire(key);
    }
}

RedisTemplate 简略封装

业务实现
登陆性能

public String login(LoginUserVO loginUserVO) {
    //1. 判断用户名明码是否正确
    UserPO userPO = userMapper.getByUsername(loginUserVO.getUsername());
    if (userPO == null) {throw new UserException(ErrorCodeEnum.TNP1001001);
    }
    if (!loginUserVO.getPassword().equals(userPO.getPassword())) {throw new UserException(ErrorCodeEnum.TNP1001002);
    }

    //2. 用户名明码正确生成 token
    UserTokenDTO userTokenDTO = new UserTokenDTO();
    PropertiesUtil.copyProperties(userTokenDTO, loginUserVO);
    userTokenDTO.setId(userPO.getId());
    userTokenDTO.setGmtCreate(System.currentTimeMillis());
    String token = JWTUtil.generateToken(userTokenDTO);

    //3. 存入 token 至 redis
    redisService.set(userPO.getId(), token);
    return token;
}

阐明:

判断用户名明码是否正确
用户名明码正确则生成 token
将生成的 token 保留至 redis
登出性能

public boolean loginOut(String id) {boolean result = redisService.delete(id);
     if (!redisService.delete(id)) {throw new UserException(ErrorCodeEnum.TNP1001003);
     }

     return result;
}

将对应的 key 删除即可。

更新明码性能

public String updatePassword(UpdatePasswordUserVO updatePasswordUserVO) {
    //1. 批改明码
    UserPO userPO = UserPO.builder().password(updatePasswordUserVO.getPassword())
            .id(updatePasswordUserVO.getId())
            .build();
    UserPO user = userMapper.getById(updatePasswordUserVO.getId());
    if (user == null) {throw new UserException(ErrorCodeEnum.TNP1001001);
    }

    if (userMapper.updatePassword(userPO) != 1) {throw new UserException(ErrorCodeEnum.TNP1001005);
    }
    //2. 生成新的 token
    UserTokenDTO userTokenDTO = UserTokenDTO.builder()
            .id(updatePasswordUserVO.getId())
            .username(user.getUsername())
            .gmtCreate(System.currentTimeMillis()).build();
    String token = JWTUtil.generateToken(userTokenDTO);
    //3. 更新 token
    redisService.set(user.getId(), token);
    return token;
}

阐明:更新用户明码时须要从新生成新的 token,并将新的 token 返回给前端,由前端更新保留在 local storage 中的 token,同时更新存储在 redis 中的 token,这样实现能够防止用户从新登陆,用户体验感不至于太差。

其余阐明

在理论我的项目中,用户分为普通用户和管理员用户,只有管理员用户领有删除用户的权限,这一块性能也是波及 token 操作的,然而我太懒了,demo 工程就不写了
在理论我的项目中,明码传输是加密过的
拦截器类

public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) throws Exception {String authToken = request.getHeader("Authorization");
    String token = authToken.substring("Bearer".length() + 1).trim();
    UserTokenDTO userTokenDTO = JWTUtil.parseToken(token);
    //1. 判断申请是否无效
    if (redisService.get(userTokenDTO.getId()) == null 
            || !redisService.get(userTokenDTO.getId()).equals(token)) {return false;}

    //2. 判断是否须要续期
    if (redisService.getExpireTime(userTokenDTO.getId()) < 1 * 60 * 30) {redisService.set(userTokenDTO.getId(), token);
        log.error("update token info, id is:{}, user info is:{}", userTokenDTO.getId(), token);
    }
    return true;
}

阐明:拦截器中次要做两件事,一是对 token 进行校验,二是判断 token 是否须要进行续期 token 校验:

判断 id 对应的 token 是否不存在,不存在则 token 过期
若 token 存在则比拟 token 是否统一,保障同一时间只有一个用户操作
token 主动续期:为了不频繁操作 redis,只有当离过期工夫只有 30 分钟时才更新过期工夫

拦截器配置类

@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(authenticateInterceptor())
                .excludePathPatterns("/logout/**")
                .excludePathPatterns("/login/**")
                .addPathPatterns("/**");
    }

    @Bean
    public AuthenticateInterceptor authenticateInterceptor() {return new AuthenticateInterceptor();
    }
}

源码附件曾经打包好上传到百度云了,大家自行下载即可~

链接: https://pan.baidu.com/s/14G-b…
提取码: yu27
百度云链接不稳固,随时可能会生效,大家放松保留哈。

如果百度云链接生效了的话,请留言通知我,我看到后会及时更新~

开源地址
码云地址:
https://gitee.com/ZhongBangKe…

Github 地址:
https://gitee.com/ZhongBangKe…

正文完
 0