【博客系统】博客系统第五弹:基于令牌技术实现用户登录接口
使用令牌机制实现用户登录接口
用户登录流程
登录页面
把用户名密码
提交给服务器
。- 服务器端验证用户名密码是否正确。如果正确,服务器生成令牌,下发给客户端。
- 客户端把令牌存储起来(比如 Cookie、localStorage 等),后续请求时,把 token 发给服务器。
- 服务器对令牌进行校验。如果令牌正确,进行下一步操作。
约定前后端交互接口
-
[请求]
/user/login
-
[参数]
{"userName": "test","password": "123456" }
-
[响应]
{"code": 200,"errMsg": null,"data": {"userId": 1,"token": "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiemhhbmdzYW4iLCJpZCI6MSwiaWF0IjoxNzI0OTE5NjgyLCJleHAiOjE3MjQ5MjE0ODJ9.5hwKlAh2jPPBNn3uPja4JTGguZNB3QrpRoPqCep7qME"} }
- 验证成功,返回 token;验证失败返回空字符串。
实现服务器代码
创建JWT 工具类
public class JwtUtils {// (1) 该工具类有两个功能: 1. 生成 token; 2. 校验 token;// (6) 生成的密钥 Key 是被 genToken()、parseToken() 共用的, 因此提取出来使用// (7) 固定密钥字符串, 要设置为静态private static String SECRET_STRING = "sYAN5HvB8HQRzX1QTEFRhseSsgXIDJsggPhC1gNLa0Y";// (8) 根据固定密钥字符串, 生成静态密钥对象private static Key key = Keys.hmacShaKeyFor(SECRET_STRING.getBytes(StandardCharsets.UTF_8)); private static Key key1 = Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET_STRING)); // (9) SECRET_STRING.getBytes(StandardCharsets.UTF_8)、Decoders.BASE64.decode(SECRET_STRING)// 这两种方法都可以生成字符数组// (2) 将 Map 类型的令牌参数传入 genToken(), 根据该令牌生成 String 类型的 token 并返回public static String genToken(Map<String, Object> claims){String compact = Jwts.builder().setClaims(claims).signWith(key).compact();return compact;}// (5) 校验方法返回类型, 是根据 getBody() 的返回值可以被 Claims 类型接收设置的public static Claims parseToken(String token){// (3) 创建解析器,设置签名密钥JwtParser build = Jwts.parserBuilder().setSigningKey(key).build();Claims claims = build.parseClaimsJws(token).getBody();// (4) Claims 继承了 Map<String, Object> 接口, 可以简单认为 Claims 类似于一个 Mapreturn claims;}
}
创建请求和响应实体类
为了处理用户登录请求并返回响应,我们创建了一个新的类 UserLoginResponse
,作为用户登录接口的返回类型。
@Data
public class UserLoginResponse {private Integer userId;private String token;
}
实现 Controller
@RequestMapping("/user")
@RestController
public class UserController {@RequestMapping("/login")public UserLoginResponse login(){}
}
在实现图书管理系统时,我们在给该接口传递参数是直接使用 userInfo
@Data
public class UserInfo {@TableId(type = IdType.AUTO)private Integer id;private String userName;private String password;private String githubUrl;private Integer deleteFlag;@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")private LocalDate createTime;@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")private LocalDate updateTime;
}
在登录接口的实现中,前端仅需传递 userName
和 password
作为参数,但现有的 userInfo
实体类包含额外属性,这些属性不应在调用登录接口时被传递
。
因此,我们需要创建一个新的实体类 UserLoginRequest
,专门用于接收登录接口的参数,以确保接口的简洁性和安全性。
@Data
public class UserLoginRequest {// 直接在 UserLoginRequest 对象的属性中进行校验, 而不是在 Login 接口中校验// 如果要使用下面的注解, 对传入的 UserLoginRequest 对象进行校验, 需要在 login 接口参数前加上注解 @Validated @NotNull(message = "用户名不能为空")@Length(max = 20, min = 1, message = "用户名长度不合法")private String userName;@NotNull(message = "密码不能为空")private String password;
}
接下来,我们将通过 UserLoginRequest
实体类接收登录接口的参数,并使用 @Validated
对其进行校验。
public class UserController {// @Autowired// (6) UserService 接口只有一个实现类 UserServiceImpl , 所以可以使用 @Autowired, 会自动匹配到 UserServiceImpl 对象@Resource(name = "userServiceImpl") // 注意, bean 名称不是 UserServiceImplprivate UserService userService;// (7) 直接使用 @Resource , 传入对象名称, 显示指定注入对象 UserServiceImpl@RequestMapping("/login")public UserLoginResponse login(@RequestBody @Validated UserLoginRequest userLoginRequest){// (1) 传入的参数是 JSON 格式的,因此需要在参数前加上 @RequestBody// (2) 对传入的参数对象 UserLoginRequest 先进行校验// (3) 在参数名前加上 @Validated 注解, 才会对 UserLoginRequest 对象中的属性进行校验// (4) 校验完成, 先打印一个日志, 只打印用户名, 不用打印密码log.info("用户登录, 用户名:" + userLoginRequest.getUserName());// (5) 接下来, 需要调用 Service 层对数据进行处理, 并且返回 Service 层处理的结果// (8) Service 层主要是先校验 userName 对应的 password 是否正确, 再返回用户的数据return userService.checkPassword(userLoginRequest);}
}
校验通过后,将 UserLoginRequest
对象传递给 Service 层进行进一步处理。
实现 Service
UserService
public interface UserService {UserLoginResponse checkPassword(UserLoginRequest userLoginRequest);
}
UserServiceImpl
@Service
public class UserServiceImpl implements UserService {// (1) 明确一下, Service 层写相关的业务逻辑@Autowiredprivate UserInfoMapper userInfoMapper;// (3) 查询数据库, 就需要先注入 Mapper 对象@Overridepublic UserLoginResponse checkPassword(UserLoginRequest userLoginRequest) {// (4) 构造查询条件 SQL, 根据 userName 和 deleteFlag 查询数据库中的数据QueryWrapper<UserInfo> queryWrapper = new QueryWrapper<>();queryWrapper.lambda().eq(UserInfo::getUserName, userLoginRequest.getUserName()).eq(UserInfo::getDeleteFlag, 0);// (2) 先查询数据库, 使用 selectOne() 只查询一条数据, 如果查出多条数据直接报错// (3) 最好可以加上一个 try catchUserInfo userInfo = userInfoMapper.selectOne(queryWrapper);if(userInfo == null){// (4) 用户名不存在, 在 common.exception 中重新定义一个只需要传 errMsg 参数的异常 BlogExceptionthrow new BlogException("用户不存在");}// (5) 判断密码是否正确if(!userLoginRequest.getPassword().equals(userInfo.getPassword())){throw new BlogException("用户密码错误");}// (6) 接下来处理的是密码正确的逻辑, 需要返回一个 tokenMap<String, Object> map = new HashMap<>();map.put("id", userInfo.getId());map.put("name", userInfo.getUserName());String token = JwtUtils.genToken(map);// (7) 需要给 UserLoginResponse 对象的属性赋值, 所以在 UserLoginResponse 前加 @AllArgsConstructorreturn new UserLoginResponse(userInfo.getId(), token);}
}
checkPassword()
方法的注解 (4)
和注解 (7)
@Data
public class BlogException extends RuntimeException{private int code;private String errMsg;public BlogException(int code, String errMsg) {this.code = code;this.errMsg = errMsg;}// (4) 新加的 BlogException, 只需要传 errMsgpublic BlogException(String errMsg) {this.errMsg = errMsg;}
}@Data
@AllArgsConstructor // (7) 加上全参构造函数, 方便 new UserLoginResponse(userInfo.getId(), token)
public class UserLoginResponse {private Integer userId;private String token;
}
测试接口
传参的数据需要是 JSON 格式
密码错误的情况:
用户不存在的情况:
@Service
public class UserServiceImpl implements UserService {@Autowiredprivate UserInfoMapper userInfoMapper;@Overridepublic UserLoginResponse checkPassword(UserLoginRequest userLoginRequest) {QueryWrapper<UserInfo> queryWrapper = new QueryWrapper<>();queryWrapper.lambda().eq(UserInfo::getUserName, userLoginRequest.getUserName()).eq(UserInfo::getDeleteFlag, 0);if(userInfo == null){// 传入的 UserLoginRequest 对象的 userName 在数据库中不存在, 说明用户名不存在throw new BlogException("用户不存在");}// (5) 判断密码是否正确if(!userLoginRequest.getPassword().equals(userInfo.getPassword())){throw new BlogException("用户密码错误");}// ......}
}@Slf4j
@ResponseBody
@ControllerAdvice
public class ExceptionAdvice {@ExceptionHandlerpublic Result exceptionHandle(Exception exception){log.error("发生异常, e", exception);return Result.fail(exception.getMessage());}@ExceptionHandlerpublic Result exceptionHandler(BlogException exception){log.error("发生异常, e", exception);return Result.fail(exception.getErrMsg()); // 用户不存在, 要拿 exception.getErrMsg() 而不是 exception.getMessage()}
}
密码为空字符串的情况:
@Data
public class UserLoginRequest {@NotNull(message = "用户名不能为空")@Length(max = 20, min = 1, message = "用户名长度不合法")private String userName;@NotNull(message = "密码不能为空") @Length(min = 5)private String password;
}
此时返回的响应中,errMsg 返回的内容是不好读的,我们需要对 password 为""
这种异常再次进行统一异常处理;先来看后端打印的错误日志中出现的异常:
对该异常(MethodArgumentNotValidException
)进行统一异常处理:
package com.bit.springblogdemo.common.advice;@Slf4j
@ResponseBody
@ControllerAdvice
public class ExceptionAdvice {// 其他异常的处理.....@ExceptionHandlerpublic Result exceptionHandler(MethodArgumentNotValidException exception){// 记不住什么时候加 {} , 就全部都加 {}log.error("发生异常, e: {}", exception.getMessage());return Result.fail("密码长度不合法");}
}
在拦截器中处理MethodArgumentNotValidException
,也可以使用在 @Length
注解中设置错误信息 Message,并作为响应信息的 errMsg 返回:
@Data
public class UserLoginRequest {// 直接在 UserLoginRequest 对象的属性中进行校验, 而不是在 Login 接口中校验@NotNull(message = "用户名不能为空")@Length(max = 20, min = 1, message = "用户名长度不合法")private String userName;@NotNull(message = "密码不能为空")@Length(min = 5, message = "不是牢底, 一个密码都输不明白, 采九朵莲啊......")private String password;
}
重新运行程序,输入空字符串密码:
使用 @Length
设置了 default message
,接下来,我们要在统一异常处理类中,对,拿到default message
开启调试模式后,在 Postman 中重新发送请求,再回到控制台:
接下来,我们就需要改进这个对 MethodArgumentNotValidException
异常进行处理的拦截器方法:
// 改进前
@ExceptionHandler
public Result exceptionHandler(MethodArgumentNotValidException exception){log.error("发生异常, e: {}", exception.getMessage());return Result.fail("密码长度不合法");
}// 改进后
@ExceptionHandler
public Result exceptionHandler(MethodArgumentNotValidException exception){String msg = exception.getBindingResult().getFieldError().getDefaultMessage();// 注意: 因为刚刚 err 列表中, 只有一个元素, 说明只有一个报错, 就可以使用 getFieldError(), 表示获取 err 列表的第一个元素// 最好先做一些空指针判断, 避免获取 msg 时出现空指针异常, 这里就先不做了log.error("发生异常, e: {}", exception.getMessage());return Result.fail(msg);
}
改进好异常处理拦截器后,重新发送请求:
以上演示的使用断点,获取MethodArgumentNotValidException exception
中的 defaultMessage
属性,对于其他异常获取该属性,也是类似的,先通过打断点,找到 err 中类似于defaultMessage
的属性,然后通过异常的引用,不断调用 get 方法来获取,举一反三;
先打断点,点击小绿虫,然后调用获取博客详情接口,传空参数
触发 HandlerMethodValidationException
异常:
可以看到,HandlerMethodValidationException
的 defaultMessage
存放的地方与MethodArgumentNotValidException
是不同的:
如果觉得此时的defaultMessage
不够好,可以直接在对应的 @NotNull
注解中写入自定义的 Message
@RequestMapping("/getBlogDetail")
public BlogInfoReponse getBlogDetail(@NotNull(message = "不是牢底, 你把你要搜的博客 id 输明白啊, 你啥都不输是几个意思呢?") Integer blogId){log.info("获取博客详情, blogId: {}", blogId);return blogService.getBlogDetail(blogId);
}
修改对应的异常处理器
@ExceptionHandlerpublic Result exceptionHandler(HandlerMethodValidationException exception){String msg = exception.getAllErrors().stream().findFirst().get().getDefaultMessage();log.error("发生异常, e: {}", exception.getMessage());return Result.fail(msg);}
重新运行程序,并且发送 blogId == null 的请求:
值得一提:
实现客户端和服务端交互
实现登录页面login.html
前端收到 userId
和 token
之后,保存在 localStorage
中。
<script src="js/jquery.min.js"></script>
<script>function login() {// location.assign("blog_list.html"); // 这里先注掉, 等完成登录校验后, 再跳转页面// (1) location.assign("blog_list.html") 等同于 location.href = blog_list.html, 都是页面跳转的一种方式// (2) 输入账号密码, 点击登录按钮后, 调用后端接口校验账号密码是否正确$.ajax({type : "post",url : "/user/login",contentType : "application/json", // (4) 还要声明请求头类型是 application/jsondata : JSON.stringify({ // (3) 要传给后端的是 JSON 字符串, stringify() 会把 JSON 对象转为 JSON 字符串userName : $("#username").val(),password : $("#password").val()// (5) 使用 id 选择器, 选择输入框中输入的账号和密码}),// (6) 使用 Postman 测试不同账号和密码的输入场景, 根据后端返回的响应格式, 确定前端如何解析响应数据的属性success: function (result) {if(result != null && result.code == "SUCCESS"){// (10) 密码正确, result.data 包含 userId 和 token 两个属性, 需将 token 存储到浏览器的 localStorage 或 cookie 中// (11) token 存在 cookie 中, 一般是通过后端进行 setCookie 操作的, 本次我们选择将 token 存到 Local storage 中// (12) localStorage.setItem() -> 存/更新、 localStorage.getItem() -> 取、 localStorage.removeItem() - >删除localStorage.setItem("loginUserId", result.data.userId);localStorage.setItem("userToken", result.data.token);// (13) 这三个方法的参数都是 (key: String, value: String), 我们需要分别把 userId、 token 作为参数传入 setItem() 方法中// (14) 这里同样省略了对 result.data 的非空校验location.href = "blog_list.html"; // (15) 登录成功, 跳转到博客列表页面}else {// (7) 密码错误, 密码长度不符合要求, 账号名不存在......等情况// (8) result == null 的情况未处理// (9) 即便 success 触发意味着 result 不为 null, 在多人协作中, 仍需处理 result == null 的情况, 以确保代码的健壮性alert(result.errMsg);}}})
}
</script>
token
存到浏览器
的 Local storage
中,可以在浏览器中查看:
Local Storage 相关操作
存储数据
localStorage.setItem("user_token", "value");
读取数据
localStorage.getItem("user_token");
删除数据
localStorage.removeItem("user_token");
Ajax 发生异常时,进行异常处理,公共处理,可以提取到 common.js
$(document).ajaxError(function(event, xhr, options, exc) {if (xhr.status == 400) {alert("参数校验失败");}
});
部署程序,验证效果。
测试接口
用户名为空的情况
用户名不存在的情况
用户名正确,密码错误的情况
用户名正确,密码为空的情况
用户密码输入正确,跳转博客列表
通过以上的操作,煮波就带着大家过了一遍关于用户登录接口实现的步骤咯~~~