【尚庭公寓springboot + vue的项目实战】- 后台登录模块
一、 技术栈要求
1.JWT(json web token)
什么是token?
(Token)令牌是一个代表用户身份和权限的字符串,用于在客户端和服务器之间进行身份验证和授权。令牌可以是任何形式的字符串,通常由服务器生成并在客户端存储。令牌可以包含有关用户身份、访问权限、过期时间等信息。
我们所说的Token,通常指JWT(JSON Web TOKEN)。JWT是一种轻量级的安全传输方式,用于在两个实体之间传递信息,通常用于身份验证和信息传递。
JWT是一个字符串,如下图所示,该字符串由三部分组成,三部分由.分隔。三个部分分别被称为
header(头部)payload(负载)signature(签名)
例如如下就是一个完整的token,用小数点来分开三个部分
各部分的作用如下:
-
Header(头部)
Header部分是由一个Json对象经过经过
base64url编码得到的,这个JSON对象用于保存JWT 的类型(type)、签名算法(alg)等元信息,例如{ "alg": "HS256", "typ": "JWT" } -
Payload(负载)
也称为 Claims(声明),也是由一个JSON对象经过base64url编码得到的,用于保存要传递的具体信息。JWT规范定义了7个官方字段,如下:
- iss (issuer):签发人
- exp (expiration time):过期时间
- sub (subject):主题
- aud (audience):受众
- nbf (Not Before):生效时间
- iat (Issued At):签发时间
- jti (JWT ID):编号
除此之外,我们还可以自定义任何字段,例如将我们要存储的信息写进
{
"sub": "userInfo",
"name": "John Doe",
"iat": 1516239022
}
-
Signature(签名)**
由头部、负载和秘钥一起经过(header中指定的签名算法)计算得到的一个字符串,用于防止消息被篡改。
2.图形验证码 ->easy-captcha工具**
3.单节点redis 保存验证码
4.ThreadLocal 保存token中的负载数据(登录用户信息)
二、 登录具体实现
1. 后台客户端登录模块
1.1 实现获取图形验证码
1.1.1引入相关依赖
在common模块对应的pom.xml文件下引入依赖(具体内容可以参考官方文档EasyCaptcha: Java图形验证码,支持gif、中文、算术等类型,可用于Java Web、JavaSE等项目。)
<dependency>
<groupId>com.github.whvcse</groupId>
<artifactId>easy-captcha</artifactId>
</dependency>
并在application.yml中增加如下配置
spring:
data:
redis:
host: <hostname>
port: <port>
database: 0
注意:上述hostname和port需根据实际情况进行修改
1.1.2 编写相关代码
- 在
com.atguigu.lease.web.admin.controller.login.LoginController类下
@Operation(summary = "获取图形验证码")
@GetMapping("login/captcha")
public Result<CaptchaVo> getCaptcha() {
CaptchaVo captchaVo = loginService.getCaptcha();
return Result.ok(captchaVo);
}
- 在
LoginService中增加如下内容:
CaptchaVo getCaptcha();
- 进入
LoginServiceImpl类下,编写getCaptcha()方法
@Override
public CaptchaVo getCaptcha() {
//图片对象specCaptcha (设置图片的样式)
SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 4);
//验证码
String verCode = specCaptcha.text().toLowerCase();
//redis保存verCode验证码的key
String key = RedisConstant.ADMIN_LOGIN_PREFIX + UUID.randomUUID();
//将验证码和指定前缀的随机key存入redis并设置过期时间
stringRedisTemplate.opsForValue().set(key,verCode,RedisConstant.ADMIN_LOGIN_CAPTCHA_TTL_SEC, TimeUnit.SECONDS);
//把图形验证码的Base64编码,和redis中对应验证码的key进行封装返回
CaptchaVo captchaVo = new CaptchaVo(specCaptcha.toBase64(), key);
return captchaVo;
}
- 为方便管理,可以将Reids相关的一些值定义为常量,例如key的前缀、TTL时长,内容如下。大家可将这些常量统一定义在common模块下的
com.atguigu.lease.common.constant.RedisConstant类中
public class RedisConstant {
public static final String ADMIN_LOGIN_PREFIX = "admin:login:";
public static final Integer ADMIN_LOGIN_CAPTCHA_TTL_SEC = 60;
public static final String APP_LOGIN_PREFIX = "app:login:";
public static final Integer APP_LOGIN_CODE_RESEND_TIME_SEC = 60;
public static final Integer APP_LOGIN_CODE_TTL_SEC = 60 * 10;
}
1.2 登录接口
1.2.1 登录校验逻辑
用户登录的校验逻辑分为三个主要步骤,分别是校验验证码,校验用户状态和校验密码,具体逻辑如下
- 前端发送
username、password、captchaKey、captchaCode请求登录。 - 判断
captchaCode是否为空,若为空,则直接响应验证码为空;若不为空进行下一步判断。 - 根据
captchaKey从Redis中查询之前保存的code,若查询出来的code为空,则直接响应验证码已过期;若不为空进行下一步判断。 - 比较
captchaCode和code,若不相同,则直接响应验证码不正确;若相同则进行下一步判断。 - 根据
username查询数据库,若查询结果为空,则直接响应账号不存在;若不为空则进行下一步判断。 - 查看用户状态,判断是否被禁用,若禁用,则直接响应
账号被禁;若未被禁用,则进行下一步判断。 - 比对
password和数据库中查询的密码,若不一致,则直接响应账号或密码错误,若一致则进行入最后一步。 - 创建JWT,并响应给浏览器。
graph TD
A[前端提交登录信息] --> B{验证码校验}
B -->|失败| C[返回错误码]
B -->|成功| D{账号状态校验}
D -->|异常| C
D -->|正常| E{密码校验}
E -->|错误| C
E -->|正确| F[生成JWT]
1.2.2 配置相关依赖
由于登录接口需要为登录成功的用户创建并返回JWT,本项目引入开源工具Java-JWT,具体内容可参考官方文档。
在common模块的pom.xml文件中增加如下内容
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<scope>runtime</scope>
</dependen
创建JWT工具类
在common模块下创建com.atguigu.lease.common.utils.JwtUtil工具类,内容如下
public class JwtUtil {
private static final Long expire = 1000 * 60 * 60L; //过期时间为1个小时,可按要求自行设定
private static final SecretKey secretKey = Keys.hmacShaKeyFor("b946ccc987465afcda7e45b1715219711a13518d1f1663b8c53b848cb0143441".getBytes());
/**
* 创建token
* 包含三部分: 头,负载,签名
* 负载:在token中保存的登录用户的相关信息(userName 账号 userId 唯一标识)
*/
public static String createToken(String userName,Long userId){
String token = Jwts.builder()
.setSubject("login-token") //token的主题
.setExpiration(new Date(System.currentTimeMillis() + expire)) //token的过期时间
.claim("userId", userId)
.claim("userName", userName)
.signWith(secretKey) //token的签名
.compressWith(CompressionCodecs.GZIP) //将token进行压缩,提高传输效率
.compact(); //将jwt的各个部分组合成一个完整的、可传输的字符串
return token;
}
//解析token
public static Claims parseToken(String token){
Jws<Claims> claimsJws = null;
try {
claimsJws = Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
} catch (ExpiredJwtException e) {
throw new LeaseException(ResultCodeEnum.TOKEN_EXPIRED); //token过期
} catch (JwtException e){
throw new LeaseException(ResultCodeEnum.TOKEN_INVALID); //token非法
}
return claimsJws.getBody();
}
}
1.2.3 接口逻辑实现
- 查看请求数据结构
查看web-admin模块下的com.atguigu.lease.web.admin.vo.login.LoginVo,具体内容如下
package com.atguigu.lease.web.admin.vo.login;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "后台管理系统登录信息")
public class LoginVo {
@Schema(description="用户名")
private String username;
@Schema(description="密码")
private String password;
@Schema(description="验证码key")
private String captchaKey;
@Schema(description="验证码code")
private String captchaCode;
}
- 在
com.atguigu.lease.web.admin.controller.login.LoginController类下
@Operation(summary = "登录")
@PostMapping("login")
public Result<String> login(@RequestBody LoginVo loginVo) {
//登录成功 生成token返回给客户端
String token = loginService.login(loginVo);
return Result.ok(token);
}
- 在
LoginService中增加如下内容:
String login(LoginVo loginVo);
- 进入
LoginServiceImpl类下,编写getCaptcha()方法
/**
*实现用户登录
*/
@Override
public String login(LoginVo loginVo) {
//1.先判断验证码
String captchaCode = loginVo.getCaptchaCode();
if(!StringUtils.hasText(captchaCode)){ //未输入验证码
throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_NOT_FOUND);
}
//获取redis中的验证码
String redisCode = stringRedisTemplate.opsForValue().get(loginVo.getCaptchaKey());、
//判断redis中验证码是否存在
if(redisCode==null){
throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_EXPIRED);//验证码过期
}
//比较验证码是否相等
if(!redisCode.equals(captchaCode)){
throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_ERROR);//验证码输入错误
}
//2.再判断用户登录信息
//根据用户名查询数据库用户的信息
SystemUser systemUser = systemUserMapper.selectOne(new LambdaQueryWrapper<SystemUser>().eq(SystemUser::getUsername, loginVo.getUsername()));
if(systemUser==null){
throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_NOT_EXIST_ERROR); //账号不存在
}
//如果账号存在,判断账号是否禁用
if(systemUser.getStatus()==BaseStatus.DISABLE){
throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_DISABLED_ERROR);//该用户已被禁用
}
//如果账号状态正常,判断密码
if(!systemUser.getPassword().equals(DigestUtils.md5Hex(loginVo.getPassword()))){
throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_ERROR); //密码错误
}
//登录成功,生成token并返回
String token = JwtUtil.createToken(systemUser.getUsername(),systemUser.getId());
return token;
}
1.3 登录简化
为了所有受保护的接口增加检验token合法性的逻辑,否则登录功能将没有任何意义,所以我们要添加拦截器拦截前端的对应请求
**注意:**我们的token在登录时返回给前端,token就会被保存在浏览器,前端每次发送请求都会在请求头中携带token
如图
- 编写HandlerInterceptor**
在web-admin模块中创建
com.atguigu.lease.web.admin.custom.interceptor.AuthenticationInterceptor类,内容如下
```java
package com.atguigu.lease.web.admin.custom.interceptor;
import com.atguigu.lease.common.context.LoginUser;
import com.atguigu.lease.common.context.LoginUserContext;
import com.atguigu.lease.common.exception.LeaseException;
import com.atguigu.lease.common.result.ResultCodeEnum;
import com.atguigu.lease.common.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 验证token的拦截器(检查是否登录)
*/
@Component
public class AuthenticationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("access_token");
if(token==null){
throw new LeaseException(ResultCodeEnum.ADMIN_LOGIN_AUTH);//未登录
}else{
//解析token
Claims claims = JwtUtil.parseToken(token);
Long userId = claims.get("userId", Long.class);
String userName = claims.get("userName", String.class);
//将token中的负载信息保存到ThreadLocal中
LoginUser loginUser = new LoginUser(userId,userName);
LoginUserContext.set(loginUser);
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//将保存到ThreadLocal中的token的负载信息删除
LoginUserContext.remove();
}
}
```
-
注册HandlerInterceptor
在web-admin模块的
com.atguigu.lease.web.admin.custom.config.WebMvcConfiguration中增加如下内容
package com.atguigu.lease.web.admin.custom.config;
import com.atguigu.lease.web.admin.custom.converter.StringToBaseEnumConverterFactory;
import com.atguigu.lease.web.admin.custom.converter.StringToItemTypeConverter;
import com.atguigu.lease.web.admin.custom.interceptor.AuthenticationInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* ClassName: WebMvcConfiguration
* Description:springmvc的配置类(配置文件)
*
* @Author linz
* @Creat 2025/2/6 20:09
* @Version 1.00
*/
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
@Autowired
AuthenticationInterceptor authenticationInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authenticationInterceptor)
.addPathPatterns("/admin/**").excludePathPatterns("/admin/login/**");
}
}
- 使用ThreadLocal保存当前登录用户的信息
由于配置了AuthenticationInterceptor,所以类似的接口被调用时,JWT都会被重复的解析两次,一次是在拦截器中,一次是在Controller中。
为了避免重复解析,也为了方便使用当前登录用户的信息。我们可以修改一下AuthenticationInterceptor的逻辑,在解析完JWT后,将得到的用户信息保存到线程本地变量ThreadLocal中,由于Spring MVC中每个请求的处理流程都是在单个线程中完成的,所以将登陆用户的信息放置于ThreadLocal中后,我们在Controller、Service中都可以十分方便的获取到。
具体实现如下:
-
定义登陆用户信息实体
查看LoginUser类
package com.atguigu.lease.common.context; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; /** * ClassName: LoginUser * Description: 对应token负载数据的类 */ @Data @AllArgsConstructor @NoArgsConstructor public class LoginUser { private Long userId; private String userName; }在common模块中创建
com.atguigu.lease.common.context.LoginUserContext类,内容如下package com.atguigu.lease.common.context; /** * 维护ThreadLocal本地线程对象 */ public class LoginUserContext { private static final ThreadLocal<LoginUser> userThreadLocal = new ThreadLocal<>(); //向threadlocal对象保存token负载信息 public static void set(LoginUser loginUser) { userThreadLocal.set(loginUser); } //从threadlocal对象获取token负载信息 public static LoginUser get() { return userThreadLocal.get(); } //删除threadlocal对象保存的负载信息 public static void remove() { userThreadLocal.remove(); } }
后端完整源码在此处 欢迎交流学习