基于 JWT 和 Redis 实现高效账号密码验证码登录功能
一、为什么选择 JWT 和 Redis?
- JWT 的优势
- JWT 是一种开放标准,它定义了一种紧凑且自包含的方式,用于在各方之间安全地传输信息。在登录场景中,JWT 可以携带用户的身份信息,并且可以进行数字签名,确保信息的完整性和真实性。
- 无状态特性:JWT 使得服务器无需在内存中存储用户的会话状态,从而减轻了服务器的负担,提高了可扩展性。
- 跨平台性:JWT 可以在不同的平台和设备之间轻松传递,适用于多种客户端类型,如网页、移动应用等。
- Redis 的作用
- 作为一种高性能的内存数据库,Redis 可以快速存储和检索临时数据。在登录过程中,我们可以利用 Redis 来存储验证码、用户会话信息等,提高系统的响应速度。
二、实现账号密码验证码登录的流程
- 用户输入账号密码和验证码
- 用户在界面输入账号和密码,验证码方式登录。
- 系统接收到用户的输入后,进行初步的校验,确保输入的格式正确。
- 生成验证码并存储到 Redis
- 前端获取验证码接口,后端返回验证码图片的base64和一个uuid,并将uuid作为key其存储到 Redis 中。同时,设置一个过期时间,以确保验证码的有效性。
- 校验账号密码或验证码
- 用户输入的是账号密码和验证码,检查验证码是否正确,用户账号和密码与数据库中的存储的用户信息进行比对。如果匹配成功,则用户身份验证通过。
- 生成 JWT 令牌
- 一旦用户身份验证通过,系统会生成一个 JWT 令牌。这个令牌包含了用户的身份信息,如用户 ID。
- JWT 令牌可以使用密钥进行签名,确保其安全性。
- 返回 JWT 令牌给客户端
- 系统将生成的 JWT 令牌返回给前端,后续的请求中,前端可以将 JWT 令牌包含在请求头中,服务器可以通过验证 JWT 令牌来确定用户的身份。
三、拦截器中的 Token 验证
- 拦截器在接收到请求时,首先从请求头的
Authorization
字段中判断是否存在 Token。如果存在 Token,说明用户可能已经登录过,需要进一步验证 Token 的有效性。 - 从 Token 中提取用户 ID,通过用户 ID 在 Redis 中获取存储的 Token。这样可以确保 Token 是由服务器生成并存储在可靠的地方,而不是被篡改或伪造的。
- 比较从请求中获取的 Token 和从 Redis 中获取的 Token 是否一致。如果一致,说明 Token 有效;如果不一致,说明 Token 可能已经过期、被篡改或者用户未登录。
- 将 Token 信息放入请求属性
- 如果 Token 验证通过,将 Token 中的相关信息(如用户 ID)放入请求属性中。这样,后续的业务逻辑可以方便地从请求属性中获取用户信息,而无需再次解析 Token。
四、优势分析
- 安全性高
- 通过将 Token 存储在 Redis 中,并在每次请求时进行验证,可以有效地防止 Token 被篡改或伪造。
- 利用用户 ID 在 Redis 中获取 Token 进行比较,可以确保 Token 的来源可靠。
- 性能优化
- Redis 是一种内存数据库,具有高速读写的特点。将 Token 存储在 Redis 中可以快速地进行验证,提高系统的响应速度。
- 拦截器在请求处理的早期阶段进行 Token 验证,可以避免不必要的业务逻辑处理,提高系统的整体性能。
- 易于扩展
- 这种方式可以方便地与其他安全机制(如加密、签名等)结合使用,进一步提高系统的安全性。
- 如果需要支持多用户、多角色等复杂场景,可以通过在 Token 中添加更多的信息,并在验证过程中进行相应的处理。
五、可能的问题及解决方案
- Token 过期处理
- 问题:如果 Token 过期,用户需要重新登录,这可能会影响用户体验。
- 解决方案:可以在 Token 中设置过期时间,并在拦截器中判断 Token 是否过期。如果 Token 即将过期,可以自动刷新 Token,并将新的 Token 返回给客户端。
- Redis 故障处理
- 问题:如果 Redis 出现故障,无法获取存储的 Token,会导致系统无法正常验证用户身份。
- 解决方案:可以考虑使用备份机制,将 Token 同时存储在其他可靠的地方,如数据库中。当 Redis 出现故障时,可以从备份中获取 Token 进行验证。
- 并发请求处理
- 问题:在高并发情况下,多个请求同时进行 Token 验证,可能会导致 Redis 的压力过大。
- 解决方案:可以使用缓存机制,将验证通过的 Token 缓存一段时间,减少对 Redis 的访问次数。同时,可以考虑使用分布式锁等技术,确保在并发情况下 Token 的验证过程是正确的。
六、以上实现总结
通过将 Token 存储在 Redis 中,并在拦截器中进行验证,可以实现一种安全、高效的用户身份验证机制。这种方式具有安全性高、性能优化、易于扩展等优点,但也需要注意处理可能出现的问题,如 Token 过期、Redis 故障和并发请求等。在实际应用中,可以根据具体情况进行优化和调整,以满足不同的业务需求。
七、代码实现
- 引入依赖
<!-- jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
- jwt工具类
public class JWTUtil {
private static final String SECRET_KEY = "xxxxx"; // 定义key
private static final long EXPIRATION_TIME = 864_000_000;// 10 天有效期
// 生成 JWT
public static String generateToken(String userId) {
return Jwts.builder()
.setSubject(userId)
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(SignatureAlgorithm.HS512, SECRET_KEY)
.compact();
}
// 从 JWT 中获取 userId
public static String getUserIdFromToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
// 验证 JWT 是否过期
public static boolean isTokenExpired(String token) {
Claims claims = Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody();
return claims.getExpiration().before(new Date());
}
}
- 保存token和获取token
@Service
public class AuthService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String REDIS_TOKEN_PREFIX = "user_token:";
// 保存 JWT 到 Redis
public void saveToken(String userId, String token) {
redisTemplate.opsForValue().set(REDIS_TOKEN_PREFIX + userId, token, 30, TimeUnit.DAYS);
}
// 从 Redis 中获取 Token
public String getToken(String userId) {
return redisTemplate.opsForValue().get(REDIS_TOKEN_PREFIX + userId);
}
// 删除 Redis 中的 Token(登出)
public void deleteToken(String userId) {
redisTemplate.delete(REDIS_TOKEN_PREFIX + userId);
}
}
- 登录Controller
@RestController
@RequestMapping("/sysLogin")
@Api(tags = "用户登录接口")
public class SysLoginController {
Logger logger = LogManager.getLogger(this.getClass());
@Autowired
private SysLoginService loginService;
@Autowired
private DefaultKaptcha defaultKaptcha;
@Autowired
public StringRedisTemplate redisTemplate;
// 登录接口
@PostMapping("/login")
@ApiOperation(value = "用户登录",tags = "用户登录接口")
public Result<String> login(@RequestBody LoginBody loginBody, HttpServletRequest request) {
logger.info("Request URI: {}, Method: {}, Params: {}", request.getRequestURI(), "login", loginBody.toString());
// 调用serivce登录认证,返回token令牌
String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
loginBody.getUuid());
logger.info("Login success, token: {}", token);
return Result.success(Constants.TOKEN, token);
}
// 登出接口
@PostMapping("/logout")
@ApiOperation(value = "用户登出",tags = "登出接口接口")
public Result<String> logout(@RequestParam String userId,HttpServletRequest request) {
logger.info("Request URI: {}, Method: {}, Params: {}", request.getRequestURI(), "logout", userId);
loginService.logout(userId);
return Result.success();
}
/**
* 生成验证码
*
* @throws Exception
*/
@RequestMapping(value = "/captcha", method = RequestMethod.GET)
public Result<Map<String, Object>> getCaptchaImage(HttpServletRequest request) throws Exception {
logger.info("Request URI: {}, Method: {}", request.getRequestURI(), "getCaptchaImage");
// 保存验证码信息
String uuid = UUIDGenerator.generate();
String verifyKey = CacheConstants.CAPTCHA_CODE_KEY + uuid;
String capStr = defaultKaptcha.createText();;
String code = defaultKaptcha.createText();
BufferedImage image = defaultKaptcha.createImage(capStr);
redisTemplate.opsForValue().set(verifyKey, code, Constants.CAPTCHA_EXPIRATION, TimeUnit.MINUTES);
// 转换流信息写出
FastByteArrayOutputStream os = new FastByteArrayOutputStream();
try {
ImageIO.write(image, "jpg", os);
} catch (IOException e) {
return Result.error(e.getMessage());
}
Map<String, Object> map = new HashMap<>();
map.put("uuid", uuid);
map.put("img", Base64.encode(os.toByteArray()));
logger.info("Generate captcha success, uuid: {}, code: {}", uuid, code);
return Result.success(map);
}
- service方法
@Component
public class SysLoginService {
@Autowired
private AuthService authService;
//用户表
@Autowired
private AccoutInfoService accoutInfoService;
@Autowired
private StringRedisTemplate redisTemplate;
Logger logger = LogManager.getLogger(this.getClass());
/**
* 登录验证
*
* @param username 用户名
* @param password 密码
* @param code 验证码
* @param uuid 唯一标识
* @return 结果
*/
public String login(String username, String password, String code, String uuid){
// 验证码校验
validateCaptcha(username, code, uuid);
// 登录前置校验
String userId = loginPreCheck(username, password);
String token = JWTUtil.generateToken(userId);
authService.saveToken(userId, token);
return token;
}
/**
* 退出登录
* @param userId
*/
public void logout(String userId) {
authService.deleteToken(userId);
}
public void validateCaptcha(String username, String code, String uuid){
logger.info("{}验证码校验开始",username);
// 构建验证码的缓存键
String verifyKey = CacheConstants.CAPTCHA_CODE_KEY + uuid;
// 获取验证码
String captcha = redisTemplate.opsForValue().get(verifyKey);
if (captcha == null || StringUtils.isEmpty(captcha)){
// 抛出验证码已失效异常
logger.info("{}验证码已失效",username);
throw new CaptchaExpireException();
}
if (!code.equalsIgnoreCase(captcha) ){
// 抛出验证码不匹配异常
logger.info("{}验证码不匹配",username);
throw new CaptchaException();
}
}
public String loginPreCheck(String username, String password){
AccoutInfo accoutInfo = accoutInfoService.getAccoutInfoByLoginName(username);
//效验用户名是否存在
if (accoutInfo == null) {
//抛出用户不存在异常
logger.info("{}用户不存在",username);
throw new UserDoesNotExistException();
}
int status = accoutInfo.getStatus();
if (status == UserStatusEnum.DISABLED.getCode()) {
logger.info("登录用户:{} 已被禁用.", username);
throw new ServiceException("用户已被禁用", HttpStatus.FORBIDDEN.value());
}
String userPassword = accoutInfo.getPassword();
//验证密码,可自行定义密码规则
if (!userPassword.equals(DigestUtils.md5Hex(password))){
//密码不正确
logger.info("{}密码不正确:{}",username,DigestUtils.md5Hex(userPassword));
throw new UserPasswordNotMatchException("用户密码不匹配");
}
return accoutInfo.getId();
}
}
- 拦截器实现
@Component
public class JWTInterceptor implements HandlerInterceptor {
@Autowired
private AuthService authService;
@Autowired
private ObjectMapper objectMapper;
Logger logger = LogManager.getLogger(this.getClass());
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
long startTime = System.currentTimeMillis();
// 记录请求开始时间
request.setAttribute("startTime", startTime);
// 记录请求信息
String uri = request.getRequestURI();
String method = request.getMethod();
String params = getParams(request);
logger.debug("****************************************************");
logger.info("Request URI: {}, Method: {}, Params: {}", uri, method, params);
logger.debug("****************************************************");
try {
// 获取请求头中的 token
String token = request.getHeader("Authorization");
if (token == null || token.isEmpty()) {
logger.error("Missing token");
sendErrorResponse(response, "Missing token", HttpStatus.UNAUTHORIZED.value());
return false;
}
// 从 token 中解析 userId
String userId = JWTUtil.getUserIdFromToken(token);
// 检查 Redis 中的 token 是否有效
String redisToken = authService.getToken(userId);
if (redisToken == null || !redisToken.equals(token)) {
logger.error("Token expired or invalid");
sendErrorResponse(response, "Token expired or invalid", HttpStatus.UNAUTHORIZED.value());
return false;
}
// 将 userId 存入请求属性,供后续使用
request.setAttribute("userId", userId);
return true;
} catch (Exception e) {
logger.error("Invalid token:{}",e);
sendErrorResponse(response, "Invalid token", HttpStatus.UNAUTHORIZED.value());
return false;
}
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
long startTime = (long) request.getAttribute("startTime");
long endTime = System.currentTimeMillis();
long executeTime = endTime - startTime;
// 记录响应状态和执行时间
int status = response.getStatus();
logger.info("Response Status: {}, Execute Time: {} ms", status, executeTime);
}
private String getParams(HttpServletRequest request) {
Map<String, String[]> parameterMap = request.getParameterMap();
StringBuilder paramsBuilder = new StringBuilder();
for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
if (paramsBuilder.length() > 0) {
paramsBuilder.append(", ");
}
paramsBuilder.append(entry.getKey()).append(": ").append(Arrays.toString(entry.getValue()));
}
return paramsBuilder.toString();
}
private void sendErrorResponse(HttpServletResponse response, String message, int customStatusCode) throws IOException {
// 设置标准的HTTP状态码
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("msg", message);
errorResponse.put("code", customStatusCode);
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
}
- 注册拦截器
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private SysLogInterceptor sysLogInterceptor;
@Autowired
private JWTInterceptor jwtInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册 JWT 拦截器,并指定拦截路径
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/sysLogin/**") // 拦截所有请求
.excludePathPatterns("/sysLogin/login", "/sysLogin/logout","/sysLogin/captcha"); // 登录和登出接口不拦截
}
}