前言
JWT主要用于用户登录鉴权,那么它和之前的Session认证有什么区别呢?
- 在JWT之前有Session认证,但Session存在于服务器中,由于HTTP协议是无状态协议,每次请求都要携带用户唯一标识,例如SessionId存在Cookie中,每次请求都放到请求头中带给服务端进行校验
- Session的缺点很明显,因为它是存在应用服务器中的,如果服务器是分布式应用时,Session不能共享,很难拓展,如果将Session进行同步又要引入Redis
- 引入Redis的好处是,分布式应用能很方便的从Redis获取SessionId,Redis也有提供分布式同步数据,但每次请求都要向Redis发出查询请求,也要给Redis增加压力,增加了请求的耗时,每个登录的用户的SessionId都要消耗Redis的存储空间,依旧不是很好的方式
JWT是什么?
- JWT,全称为
Json Web Token
,是一个开放标准(RFC 7519),简单来说就是一种认证机制。 - JWT分为3个部分,JWT头部、载荷、签名,每个部分使用.分开,xxxx.yyyy.zzz
JWT 头部
- 头部,它是JWT的第一部分,用于描述JWT元数据的JSON对象
- alg 属性,表示签名使用的算法,默认为 HMAC SHA256(写为HS256)
- typ 属性,表示令牌的类型,JWT 令牌统一写为JWT
{
"alg": "HS256",
"typ": "JWT"
}
- 最后,使用 Base64 编码算法,将上面的JSON对象转换为字符串
Payload 载荷
- 载荷,它是JWT的第二部分,保存需要传递的数据,例如我们可以保存用户Id、用户昵称、用户的个性签名等,JWT也提供了7个默认字段
- iss (issuer):签发人/发行人
- sub (subject):主题
- aud (audience):用户
- exp (expiration time):过期时间
- nbf (Not Before):生效时间,在此之前是无效的
- iat (Issued At):签发时间
- jti (JWT ID):用于标识该 JWT
示例:
{
//默认字段
"sub":"user",
//自定义字段
"userId":"10086",
"nickname":"Barry",
"sign":"I am Flash",
}
- 注意:默认JWT是不加密的,3段JSON对象使用的是Base64编码,但Base64可以解码,所以不要在JWT中存放用户的敏感信息,例如用户账号的密码之类的
Signature 签名
- 签名,它是JWT的最后一部分,生成规则:
- 自定义一个加密盐secret,存放在服务器中,一定要保密,不能被其他人知道
- 将上面的头部、载荷部分的JSON对象进行Base64编码,再使用.号进行连接,然后使用头部中声明的加密算法,加上加密盐进行加密,得出签名部分,这个签名无法反向解密,确保了安全性
- 最终得出:header(Base64加密).payload(Base64加密).signature
- 那么服务端校验Token,则是先使用.号分割为3部分,然后使用JWT的前2段,Base64编码后,得到头部header和载荷playload,就能得到头部的加密算法,配合服务器上的加密盐secret,重新加密一次第三部分签名,接着和JWT的第三部分做一次比对,如果相同,就是校验通过
JWT的优点
- JWT使用JSON格式,所以可以跨语言
- JWT第二部分playload,可以存储一些非敏感的用户数据,服务端接收到请求后,可以直接使用这部分数据,而不需要查询MySQL或Redis
- JWT的结构简单,占用小,方便传输
- 不需要在服务端存储会话信息,减少服务器的内存,分布式应用非常适合使用
JWT的缺点
- 安全性无法保证,由于JWT的playload载荷部分只是使用Base64编码,所以不能存储敏感信息
- JWT颁发的token,无法中途废弃,由于token在过期之前都是能够使用的,所以如果用户信息发生改变,但旧的token中的用户信息还是旧的,而且旧的token还没到过期时间的话,就一直是有效的
- 要解决这个问题,也可以使用Redis存储token,然后映射另外一段唯一ID,客户端保存的是这段唯一ID,如果要中途废弃Token,在Redis中删除这个唯一ID,就可以让客户端只能重新登录,但引入Redis,有让无状态转变为了有状态
环境说明
- 前端
- axios
- toastr
- NProgress
- ES6
- 后端
- SpringBoot 2.3.6
- JWT
- Java8
项目地址
业务分析
- 前端,注册成功后,发起登录,后端校验用户名、密码等,校验成功,生成token,以及返回用户信息
- 前端接收到后端返回的数据,保存token到本地存储中,后续在需要登录校验的请求都在请求头中带上token。一般在axios的请求拦截器中,统一设置token到请求头
- 后端接收到前端请求后,判断token是否存在、token是否合法、token是否过期,校验通过则进行接口的业务操作,操作完成后返回业务数据,校验不通过则返回401状态码。一般在SpringMVC的拦截器中,统一进行token校验,校验不通过时抛出自定义异常,然后在全局异常拦截器中,返回统一的JSON结构数据
- 对于401状态码的处理,前端一般在axios的响应拦截器中,统一对401状态码进行判断,如果后端返回401,则删除本地存储中的token,Toast提示用户需要重新登录,并跳转到登录页面
- 对于一些明确是私有页面,也就是需要用户登录才能进入的页面,一般前端会直接判断本地存储是否有token,没有则直接跳转到登录页面,强制用户登录才能操作
效果图
前端
基础设施
axios配置
- 配置axios的基地址
- 设置请求、响应拦截器
- 请求拦截器
- 统一在请求头中添加token
- 显示和隐藏进度条
- 响应拦截器
- 统一对数据进行剥离,拆解一层data,后续在使用请求结果时,可以减少一层
- 统一处理401状态码,就是token过期后的处理,例如清除本地存储的token、Toast提示、跳转登录页等
- 显示、隐藏进度条
// axios基地址
axios.defaults.baseURL = 'http://localhost:8080';
// 添加请求拦截器
axios.interceptors.request.use(function (config) {
// 显示进度条
NProgress.start();
// 添加token到请求头
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = token;
}
return config;
}, function (error) {
// 关闭进度条
NProgress.done();
// 对请求错误做些什么
return Promise.reject(error);
});
// 添加响应拦截器
axios.interceptors.response.use(function (response) {
// 2xx 范围内的状态码都会触发该函数。
// 关闭进度条
NProgress.done();
// 数据剥离,剥离一层data,再继续传递,就不需要每次都拆多一层data了
return response.data;
}, function (error) {
// 关闭进度条
NProgress.done();
// 超出 2xx 范围的状态码都会触发该函数。
// 判断HTTP状态码,如果为401,则是token过期
const statusCode = error.response.status;
if (statusCode === 401) {
// 删除本地token
localStorage.removeItem("cms90");
// 提示用户
toastr.error(error.response.data.message);
// 延迟一会,跳转到首页
setTimeout(() => {
location.href = 'login.html';
}, 1500);
}
return Promise.reject(error);
});
提供检查是否登录的函数
- 本质是查询localStorage本地存储,是否有token存在,不存在则证明没有登录
// 检查是否登录,未登录则跳转到登录页面
function checkLogin() {
const token = localStorage.getItem('token');
if (!token) {
toastr.error('请先登录');
setTimeout(() => {
location.href = 'login.html';
}, 1000);
}
}
注册、登录业务
注册
- 页面有账号、密码输入框、注册按钮、已有账号,去登录按钮
注册页面(register.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>注册</title>
<link rel="stylesheet" href="./lib/toastr/toastr.min.css" />
<link rel="stylesheet" href="./lib/NProgress/NProgress.css" />
<link rel="stylesheet" href="./css/register.css">
</head>
<body>
<form id="register-form">
<h1>注册</h1>
<input class="username" type="text" name="username" placeholder="请输入用户名"><br>
<input class="password" type="password" name="password" placeholder="请输入密码"><br>
<button class="register-btn" type="submit">注册</button><br>
<a class="go-login-a" href="./login.html">已有账号,去登录</a>
</form>
<script src="./lib/jquery-3.6.0.min.js"></script>
<!-- Toast提示 -->
<script src="./lib/toastr/toastr.min.js"></script>
<script src="./lib/axios.js"></script>
<script src="./lib/form-serialize.js"></script>
<!-- 引入 NProgress js -->
<script src="./lib/NProgress/NProgress.js"></script>
<!-- 引入公共的js -->
<script src="./js/common.js"></script>
<script src="./js/register.js"></script>
</body>
</html>
CSS(register.less)
#register-form {
text-align: center;
margin: 250px auto;
.username {
margin-bottom: 10px;
}
.password {
margin: 5px;
}
.register-btn {
margin-top: 5px;
}
.go-login-a {
font-size: 12px;
}
}
JS(register.js)
- 监听注册表单提交事件
- 使用serialize插件,收集表单数据
- 正则表达式校验,表单数据
- 使用axios发送注册请求
- 注册成功
- Toast提示用户注册成功
- 跳转到登录页面
- 注册失败
- Toast提示用户,具体失败原因
// 注册表单
const registerForm = document.querySelector('#register-form');
// 监听表单的提交事件
registerForm.addEventListener('submit', async function (e) {
// 阻止默认行为
e.preventDefault();
// 校验表单
const data = serialize(registerForm, {
hash: true,
empty: true
});
const { username, password } = data;
// 用户名正则表达式
const usernameReg = /[a-zA-Z0-9]{3,15}/;
// 密码的正则表达式
const passwordReg = /[a-zA-Z0-9]{6,15}/;
if (!usernameReg.test(username)) {
toastr.error('用户名不合法,必须是3~15位的英文字母和数字组成');
return;
}
if (!passwordReg.test(password)) {
toastr.error('密码不合法,必须是6~15位的英文字母和数字组成');
return;
}
// 发送注册请求
const result = await axios({
url: `/user/register`,
method: 'POST',
data: {
username: username,
password: password
}
});
// 注册失败,显示错误信息
if (result.code !== 0) {
toastr.error(result.message);
} else {
toastr.success('注册成功');
// 注册成功,延时跳转到登录页面
setTimeout(() => {
location.href = 'login.html';
}, 1500);
}
});
登录
- 页面有账号、密码输入框、登录按钮、没有账号,去注册按钮
登录界面(login.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录</title>
<link rel="stylesheet" href="./lib/toastr/toastr.min.css" />
<link rel="stylesheet" href="./lib/NProgress/NProgress.css" />
<link rel="stylesheet" href="./css/login.css">
</head>
<body>
<form id="login-form">
<h1>登录</h1>
<input class="username" type="text" name="username" placeholder="请输入用户名"><br>
<input class="password" type="password" name="password" placeholder="请输入密码"><br>
<button class="login-btn" type="submit">登录</button><br>
<a class="go-register-a" href="./register.html">没有账号,去注册</a>
</form>
<script src="./lib/jquery-3.6.0.min.js"></script>
<!-- Toast提示 -->
<script src="./lib/toastr/toastr.min.js"></script>
<script src="./lib/axios.js"></script>
<script src="./lib/form-serialize.js"></script>
<!-- 引入 NProgress js -->
<script src="./lib/NProgress/NProgress.js"></script>
<!-- 引入公共的js -->
<script src="./js/common.js"></script>
<script src="./js/login.js"></script>
</body>
</html>
CSS(login.less)
#login-form {
text-align: center;
margin: 250px auto;
.username {
margin-bottom: 10px;
}
.password {
margin: 5px;
}
.login-btn {
margin-top: 5px;
}
.go-register-a {
font-size: 12px;
}
}
JS(login.js)
- 监听登录表单提交事件
- 使用serialize插件,收集表单数据
- 正则表达式校验,表单数据
- 使用axios发送登录请求
- 登录成功
- Toast提示用户
- 保存token、用户信息到本地存储localStorage
- 跳转到登录页面
- 登录失败
- Toast提示用户,具体失败原因
// 登录表单
const loginForm = document.querySelector('#login-form');
// 监听表单的提交事件
loginForm.addEventListener('submit', async function (e) {
// 阻止默认行为
e.preventDefault();
// 校验表单
const data = serialize(loginForm, {
hash: true,
empty: true
});
const { username, password } = data;
// 用户名正则表达式
const usernameReg = /[a-zA-Z0-9]{3,15}/;
// 密码的正则表达式
const passwordReg = /[a-zA-Z0-9]{6,15}/;
if (!usernameReg.test(username)) {
toastr.error('用户名不合法,必须是3~15位的英文字母和数字组成');
return;
}
if (!passwordReg.test(password)) {
toastr.error('密码不合法,必须是6~15位的英文字母和数字组成');
return;
}
// 发送登录请求
const result = await axios({
url: `/user/login`,
method: 'POST',
data: {
username: username,
password: password
}
});
// 登录失败,显示错误信息
if (result.code !== 0) {
toastr.error(result.message);
} else {
toastr.success('登录成功');
// 登录成功,保存Token和用户名到本地
localStorage.setItem('token', result.data.token);
localStorage.setItem('username', result.data.username);
// 延时跳转到首页
setTimeout(() => {
location.href = 'index.html';
}, 1500);
}
});
首页
- 页面有欢迎语、用户名、退出登录按钮
首页界面(index.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>首页</title>
<link rel="stylesheet" href="./lib/toastr/toastr.min.css" />
<link rel="stylesheet" href="./lib/NProgress/NProgress.css" />
<link rel="stylesheet" href="./css/index.css">
</head>
<body>
<div class="container">
<h1>欢迎你 <span class="username"></span></h1>
<button class="logout-btn">退出登录</button>
</div>
<script src="./lib/jquery-3.6.0.min.js"></script>
<!-- Toast提示 -->
<script src="./lib/toastr/toastr.min.js"></script>
<script src="./lib/axios.js"></script>
<script src="./lib/form-serialize.js"></script>
<!-- 引入 NProgress js -->
<script src="./lib/NProgress/NProgress.js"></script>
<!-- 引入公共的js -->
<script src="./js/common.js"></script>
<script src="./js/index.js"></script>
</body>
</html>
CSS(index.less)
.container {
display: flex;
justify-content: space-between;
align-items: center;
}
.username {
color: blue;
}
.logout-btn {
width: 100px;
height: 50px;
}
JS(index.js)
- 访问私有页面,检查本地存储有token,没有则Toast提示用户,并直接跳转去登录页面
- 发送请求,获取用户信息,并渲染页面
- 点击退出登录按钮,清除本地存储的token、用户信息,Toast提示退出成功,跳转到登录页面
const username = document.querySelector('.username');
const logoutBtn = document.querySelector('.logout-btn');
// 检查是否登录,未登录跳转到登录页面
checkLogin();
// 获取用户信息
async function getUserInfo() {
const result = await axios({
url: `/user/getUserInfo`,
method: 'GET'
});
// 显示用户名
username.innerText = result.data.username;
}
// 退出登录
logoutBtn.addEventListener('click', function (e) {
if (confirm('确认退出登录吗?')) {
// 删除本地存储的Token和用户信息
localStorage.removeItem('token');
localStorage.removeItem('username');
// 提示用户
toastr.success('退出成功');
// 跳转到登录页面
setTimeout(() => {
location.href = 'login.html';
}, 1000);
}
});
getUserInfo();
后端
基础设施
添加依赖
<!-- jwt依赖 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
</dependency>
JWT密钥
- 定义JWT使用的密钥,也就是JWT第三部分处,使用第一部分头部的加密算法和这个加密盐,加密JWT的第一、二部分(已Base64编码后),得到第三部分
- 加密盐,一般会单独以一个文件存放在服务器的某个目录,再通过配置中心,配置其地址,这里简单起见,直接写死在Java代码中
public class ServiceConstant {
/**
* 服务器加密盐,token加密使用
*/
public static final String SERVER_SECRET = "XX#$%()(#*!(){LWPW\"";
}
JWT工具类
- JWT工具类,定义了以下方法
- 创建token:createToken
- 校验token:verifierToken
- 解析token:parseToken
- 解析token,并获取载荷中保存的用户Id:getUserIdByToken
- 以及token的过期时间,一般这个时间会配置到配置中心,例如nacos,这里简单起见,直接写死在Java代码中
public class JwtUtil {
private static final String EXP = "exp";
private static final String PAYLOAD = "payload";
/**
* 过期时间,一个月
*/
private static final long EXPIRED_TIME = TimeUnit.DAYS.toMillis(30);
private static final String KEY_USER_ID = "userId";
private JwtUtil() {
}
/**
* 生成Token
*
* @param userId 用户Id
*/
public static String createToken(Long userId) {
Map info = new HashMap<>();
info.put(KEY_USER_ID, userId);
return createToken(info, EXPIRED_TIME, ServiceConstant.SERVER_SECRET);
}
/**
* 加密生成token
*
* @param object 载体信息
* @param maxAge 有效时长
* @param secret 服务器私钥
* @return Token令牌
*/
private static <T> String createToken(T object, long maxAge, String secret) {
try {
final JWTSigner signer = new JWTSigner(secret);
final Map<String, Object> claims = new HashMap<>();
ObjectMapper mapper = new ObjectMapper();
String jsonString = mapper.writeValueAsString(object);
claims.put(PAYLOAD, jsonString);
claims.put(EXP, System.currentTimeMillis() + maxAge);
return signer.sign(claims);
} catch (Exception e) {
return null;
}
}
/**
* 验证Token是否有效
*
* @param token 令牌
*/
public static boolean verifierToken(String token) {
Long userId = getUserIdByToken(token);
return userId != null && userId > 0;
}
/**
* 解析token
*
* @param token 加密后的token字符串
* @param clazz 解密后的对象类型
* @param secret 服务器私钥
* @return 如果token验证成功返回classT类型的对象,如果验证失败或过期返回null
*/
private static <T> T parseToken(String token, Class<T> clazz, String secret) {
final JWTVerifier verifier = new JWTVerifier(secret);
try {
final Map<String, Object> claims = verifier.verify(token);
if (claims.containsKey(EXP) && claims.containsKey(PAYLOAD)) {
long exp = (Long) claims.get(EXP);
long currentTimeMillis = System.currentTimeMillis();
//没有过期
if (currentTimeMillis < exp) {
String json = (String) claims.get(PAYLOAD);
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper.readValue(json, clazz);
}
}
return null;
} catch (Exception e) {
return null;
}
}
/**
* 从Token中获取UserId
*
* @param token 令牌
*/
public static Long getUserIdByToken(String token) {
Map map = parseToken(token, Map.class, ServiceConstant.SERVER_SECRET);
if (map == null) {
return null;
}
return Long.parseLong((map.get(KEY_USER_ID)).toString());
}
}
自定义Token校验注解
- 定义一个自定义注解,用于配置在Controller的接口方法中,需要校验token的接口方法,会在SpringMVC的拦截器中,校验token成功再调用,如果校验不通过则直接拦截请求,返回401
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface VerifyToken {
/**
* 是否必须校验,默认为true为校验,如果设置为false,则不校验
*/
boolean required() default true;
}
自定义异常枚举类
- 定义自定义异常类,定义了很多业务异常code和错误信息,例如:
- 在Service层校验数据,发现有业务问题,直接抛出异常,统一在全局异常处理器中,返回统一的JSON格式数据
- SpringMVC的拦截器中,校验token成功再调用,如果校验不通过,抛出异常,在统一在全局异常处理器中返回
401
HTTP状态码
public enum BizEnum {
USER_ID_NOT_NULL(416, "用户Id不能为空"),
DB_ERROR(999, "数据库错误"),
SYSTEM_ERROR(100000, "系统异常"),
TOKEN_NO_EXIST(1000, "Token令牌必传"),
TOKEN_INVALID(1001, "Token令牌非法"),
USER_NOT_EXIST(1002, "用户不存在"),
REGISTER_USER_IS_EXIST(1004, "注册失败,该用户已存在");
private final int code;
private final String message;
BizEnum(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
}
自定义Token校验异常
- 自定义Token校验异常,在SpringMVC的拦截器中,如果校验不通过,抛出该异常
@Data
@EqualsAndHashCode(callSuper = true)
public class UnauthorizedException extends RuntimeException {
private BizEnum bizEnum;
public UnauthorizedException(BizEnum bizEnum) {
super(bizEnum.getMessage());
this.bizEnum = bizEnum;
}
}
全局异常拦截器
- 处理UnauthorizedException自定义Token校验异常,返回
401
HTTP状态码,以及对应的错误信息 - 处理UnauthorizedException,返回服务器异常的错误信息
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* Token异常处理
*/
@ExceptionHandler(UnauthorizedException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ApiResult<String> unauthorizedErrorHandler(UnauthorizedException e) {
log.error(e.getMessage());
return ApiResult.fail(e.getBizEnum());
}
/**
* 默认异常处理
*/
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResult<String> defaultErrorHandler(Exception e) {
log.error(e.getMessage());
return ApiResult.fail(BizEnum.SYSTEM_ERROR);
}
}
SpringMVC拦截器
- 一个自定义的SpringMVC拦截器,统一拦截Controller层方法,反射读取
VerifyToken
注解,如果是需要校验的接口方法,则读取请求头的token,并校验token的合法性,如校验通过则放行该接口方法,否则抛出UnauthorizedException
异常,统一在全局异常处理器中,返回401
HTTP状态码给前端
public class AuthInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取header上的token
String token = request.getHeader("Authorization");
//如果不是Controller上的方法直接放行
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
//获取Controller上的方法,查看是否标识了自定义注解
if (method.isAnnotationPresent(VerifyToken.class)) {
VerifyToken annotation = method.getAnnotation(VerifyToken.class);
//设置了需要校验,则进行校验,否则不校验,直接放行
if (annotation.required()) {
//Token没传
if (StringUtils.isBlank(token)) {
throw new UnauthorizedException(BizEnum.TOKEN_NO_EXIST);
}
//校验Token
boolean isValid = JwtUtil.verifierToken(token);
if (!isValid) {
throw new UnauthorizedException(BizEnum.TOKEN_INVALID);
}
//获取Token中的信息
Long userId;
try {
userId = JwtUtil.getUserIdByToken(token);
} catch (Exception e) {
throw new UnauthorizedException(BizEnum.TOKEN_INVALID);
}
//获取Token中的UserId,获取不到,则也无效
if (userId == null) {
throw new UnauthorizedException(BizEnum.TOKEN_INVALID);
}
//保存用户Id,方便在后续的Service层中获取用户Id
UserHolder.setUserId(userId);
return true;
}
}
return true;
}
}
SpringMVC配置类
- 添加Token验证拦截器,并设置拦截所有请求
@Configuration
public class InterceptorConfig extends WebMvcConfigurerAdapter {
@Bean
public AuthInterceptor authInterceptor() {
return new AuthInterceptor();
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry
//添加Token验证拦截器
.addInterceptor(authInterceptor())
//拦截所有请求
.addPathPatterns("/**");
}
}
跨域配置
@Configuration
public class CrosConfig extends WebMvcConfigurerAdapter {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry
//设置允许跨域的路径
.addMapping("/**")
//设置允许跨域请求的域名
.allowedOriginPatterns("*")
//是否允许证书
.allowCredentials(true)
//设置允许的请求方式
.allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
//设置允许的header属性
.allowedHeaders("*")
//允许跨域时间
.maxAge(3600);
}
}
注册、登录业务
启动类
- 配置MyBatis扫描的包路径
@SpringBootApplication
//指定MyBatis扫描的包路径
@MapperScan("com.zh.jwt.mapper")
public class AppApplication {
public static void main(String[] args) {
SpringApplication.run(AppApplication.class, args);
}
}
application.yml配置文件
- 服务端端口:8080
- 服务名称:user-server
- 配置MySQL连接地址和密码、连接池等信息
- Log打印配置
- MyBatisPlus配置
server:
port: 8080
spring:
application:
name: user-server
mvc:
#处理404问题,出现错误时, 直接抛出异常,统一捕获异常进行统一的json返回
throw-exception-if-no-handler-found: true
resources:
#不要为工程中的资源文件建立映射,不配置的话,404处理不生效
add-mappings: false
#配置数据库
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/springboot_jwt?useUnicode=true&characterEncoding=UTF-8&useSSL=false
username: root
password: root
# druid连接池
type: com.alibaba.druid.pool.DruidDataSource
# 指定数据库类型 mysql
dbType: mysql
# 启动初始化连接数量
initialSize: 5
# 最小空闲连接
minIdle: 5
# 最大连接数量(包含使用中的和空闲的)
maxActive: 20
# 最大连接等待时间 ,超出时间报错
maxWait: 60000
# 设置执行一次连接回收器的时间
timeBetweenEvictionRunsMillis: 60000
# 设置时间: 该时间内没有任何操作的空闲连接会被回收
minEvictableIdleTimeMillis: 300000
# 验证连接有效性的sql
validationQuery: select 'x'
# 空闲时校验
testWhileIdle: true
# 使用中是否校验有效性
testOnBorrow: false
# 归还连接池时是否校验
testOnReturn: false
# mysql 不推荐打开预处理连接池
poolPreparedStatements: false
#设置过滤器 stat用于接收状态,wall防止sql注入,logback说明使用logback进行日志输出
filters: stat,wall,logback
# 统计所有数据源状态
userGlobalataSourceStat: true
#配置Log打印
logging:
level:
root: warn
com.zh.jwt: trace
pattern:
console: '%p%m%n'
mybatis-plus:
#配置mapper文件的位置,注意,如果你是maven多模块下使用,路径前需要加classpath*,即加载多个jar包下的xml文件
#xml的包路径,如果和java的一样,千万通配符后面要加后缀,否则启动时,自动注入会报错,MalformedByteSequenceException: 2 字节的 UTF-8 序列的字节 2 无效
mapper-locations:
- classpath*:com/zh/jwt/mapper/*.xml
global-config:
db-config:
#全局id策略
id-type: auto
#字段生成sql的where策略,默认实体中的字段为null时,不添加到sql中,如果想为null,也添加到sql语句中,则使用ignored
#一般不设置为ignored,因为在update语句中,如果不设置值,就会用null覆盖掉原有的值。一般我们是希望设置值的才更新,为null则不更新
#not_empty,如果字段值为null或者空字符串,会忽略掉,不添加到sql语句中
field-strategy: default
#统一表名前缀
#table-prefix: mp_
#数据库表是否使用下划线间隔命名,默认为true
table-underline: true
#逻辑删除,未删除的字段值,0
logic-not-delete-value: 0
#逻辑删除,已删除的字段值,1
logic-delete-value: 1
#实体别名包配置
type-aliases-package: com.zh.jwt.model
#注意configuration不能和config-location同时出现,不然会报错
configuration:
#驼峰转下划线(实体类用驼峰,数据库表字段用下划线),默认为true
map-underscore-to-camel-case: true
数据库表SQL
- 库名:springboot_jwt
- 表名:tb_user
/*
SQLyog Enterprise v12.14 (64 bit)
MySQL - 5.7.17-log : Database - springboot_jwt
*********************************************************************
*/
/*!40101 SET NAMES utf8 */;
/*!40101 SET SQL_MODE=''*/;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
CREATE DATABASE /*!32312 IF NOT EXISTS*/`springboot_jwt` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;
USE `springboot_jwt`;
/*Table structure for table `tb_user` */
DROP TABLE IF EXISTS `tb_user`;
CREATE TABLE `tb_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`username` varchar(15) DEFAULT NULL COMMENT '用户名',
`password` varchar(32) DEFAULT NULL COMMENT '密码',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1684066110497906693 DEFAULT CHARSET=utf8mb4;
/*Data for the table `tb_user` */
insert into `tb_user`(`id`,`username`,`password`,`create_time`,`update_time`) values
(1,'barry','e10adc3949ba59abbe56e057f20f883e','2023-07-25 00:00:00','2023-07-25 00:00:00'),
(1683872660632682497,'wally','e10adc3949ba59abbe56e057f20f883e','2023-07-26 16:27:24','2023-07-26 16:27:28'),
(1684039328491175938,'hezihao','e10adc3949ba59abbe56e057f20f883e','2023-07-26 16:27:37','2023-07-26 16:27:38'),
(1684066110497906689,'hezihao2','e10adc3949ba59abbe56e057f20f883e','2023-07-26 16:27:40','2023-07-26 16:27:42');
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
实体类
用户表实体类
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("tb_user")
public class TbUser implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 用户名
*/
@TableField(value = "username")
private String username;
/**
* 密码
*/
private String password;
/**
* 创建时间
*/
@TableField(value = "create_time")
private Date createTime;
/**
* 更新时间
*/
@TableField(value = "update_time")
private Date updateTime;
}
用户表DTO
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class UserDTO {
/**
* 用户Id
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
}
用户表VO
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class UserVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 用户Id
*/
private Long id;
/**
* 用户名
*/
private String username;
}
登录结果VO
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class LoginResultVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 用户Id
*/
private Long id;
/**
* 令牌
*/
private String token;
/**
* 用户名
*/
private String username;
}
用户控制器
- 提供3个接口方法
- 登录:login
- 注册:register
- 获取用户信息:getUserInfo
@RequestMapping("/user")
@RestController
public class UserController {
@Autowired
private UserService userService;
/**
* 登录
*/
@PostMapping("/login")
public ApiResult<LoginResultVO> login(@RequestBody UserDTO dto) {
return userService.login(dto);
}
/**
* 注册
*/
@PostMapping("/register")
public ApiResult<Void> register(@RequestBody UserDTO dto) {
return userService.register(dto);
}
/**
* 获取用户信息
*/
@VerifyToken
@GetMapping("/getUserInfo")
public ApiResult<UserVO> getUserInfo() {
return userService.getUserInfo();
}
}
用户业务层
用户业务层接口
public interface UserService extends IService<TbUser> {
/**
* 获取用户信息
*/
ApiResult<UserVO> getUserInfo();
/**
* 登录
*/
ApiResult<LoginResultVO> login(UserDTO dto);
/**
* 注册
*/
ApiResult<Void> register(UserDTO dto);
}
用户业务层实现
- 登录:
- 用户名是否存在
- 校验用户名和密码是否一致
- 校验通过,则生成Token,以及返回用户信息
- 校验不通过,返回异常信息
- 注册
- 校验用户名是否已被占用
- 未占用,保存用户名和密码,插入到数据库表中
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, TbUser> implements UserService {
@Override
public ApiResult<UserVO> getUserInfo() {
Long userId = UserHolder.getUserId();
//使用用户Id,查询用户信息
TbUser tbUser = baseMapper.selectById(userId);
if (tbUser == null) {
return ApiResult.fail("无数据");
}
//属性拷贝
UserVO vo = new UserVO();
BeanUtils.copyProperties(tbUser, vo);
return ApiResult.success(vo);
}
@Override
public ApiResult<LoginResultVO> login(UserDTO dto) {
String username = dto.getUsername();
String password = dto.getPassword();
if (StringUtils.isBlank(username) || StringUtils.isBlank(password)) {
return ApiResult.fail("登录失败,账号或密码不能为空");
}
LambdaQueryWrapper<TbUser> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(TbUser::getUsername, username);
TbUser tbUser = baseMapper.selectOne(queryWrapper);
//用户不存在
if (tbUser == null) {
return ApiResult.fail(BizEnum.USER_NOT_EXIST);
}
//密码MD5加密
String pwd = DigestUtils.md5DigestAsHex(password.getBytes());
//校验密码
if (tbUser.getPassword().equals(pwd)) {
LoginResultVO vo = new LoginResultVO();
BeanUtils.copyProperties(tbUser, vo);
//生成Token
String token = JwtUtil.createToken(vo.getId());
vo.setToken(token);
return ApiResult.success(vo);
}
return ApiResult.fail("登录失败,请检查用户名和密码是否正确");
}
@Override
public ApiResult<Void> register(UserDTO dto) {
String username = dto.getUsername();
String password = dto.getPassword();
if (StringUtils.isBlank(username) || StringUtils.isBlank(password)) {
return ApiResult.fail("注册信息不完整,用户名和密码不能为空");
}
//校验用户是否已经注册了
LambdaQueryWrapper<TbUser> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(TbUser::getUsername, username);
TbUser tbUser = baseMapper.selectOne(queryWrapper);
if (tbUser != null) {
throw new BizException(BizEnum.REGISTER_USER_IS_EXIST);
}
//密码MD5加密
String pwd = DigestUtils.md5DigestAsHex(password.getBytes());
//增加一个用户
TbUser user = new TbUser();
user.setUsername(username);
user.setPassword(pwd);
//创建时间
user.setCreateTime(new Date());
//更新时间
user.setUpdateTime(new Date());
baseMapper.insert(user);
return ApiResult.success("注册成功");
}
}
用户表Mapper
- 暂时没有自定义方法,都是使用MyBatisPlus提供的单表查询方法
public interface UserMapper extends BaseMapper<TbUser> {
}