【博客系统】博客系统第五弹:基于令牌技术实现用户登录接口


使用令牌机制实现用户登录接口


用户登录流程


  1. 登录页面用户名密码提交给服务器
  2. 服务器端验证用户名密码是否正确。如果正确,服务器生成令牌,下发给客户端。
  3. 客户端把令牌存储起来(比如 Cookie、localStorage 等),后续请求时,把 token 发给服务器。
  4. 服务器对令牌进行校验。如果令牌正确,进行下一步操作。


约定前后端交互接口


  • [请求]

    复制代码
    /user/login
  • [参数]

    json 复制代码
    {
      "userName": "test",
      "password": "123456"
    }
  • [响应]

    json 复制代码
    {
      "code": 200,
      "errMsg": null,
      "data": {
        "userId": 1,
        "token": "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiemhhbmdzYW4iLCJpZCI6MSwiaWF0IjoxNzI0OTE5NjgyLCJleHAiOjE3MjQ5MjE0ODJ9.5hwKlAh2jPPBNn3uPja4JTGguZNB3QrpRoPqCep7qME"
      }
    }
    • 验证成功,返回 token;验证失败返回空字符串。

实现服务器代码


创建JWT工具类


java 复制代码
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 类似于一个 Map

        return claims;
    }
}

创建请求和响应实体类


为了处理用户登录请求并返回响应,我们创建了一个新的类 UserLoginResponse,作为用户登录接口的返回类型。

java 复制代码
@Data
public class UserLoginResponse {
    private Integer userId;
    private String token;
}

实现****Controller



java 复制代码
@RequestMapping("/user")
@RestController
public class UserController {
    @RequestMapping("/login")
    public UserLoginResponse login(){

    }
}

在实现图书管理系统时,我们在给该接口传递参数是直接使用 userInfo

java 复制代码
@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;
}

在登录接口的实现中,前端仅需传递 userNamepassword 作为参数,但现有的 userInfo 实体类包含额外属性,这些属性不应在调用登录接口时被传递

因此,我们需要创建一个新的实体类 UserLoginRequest,专门用于接收登录接口的参数,以确保接口的简洁性和安全性。

java 复制代码
@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 对其进行校验。

java 复制代码
public class UserController {

    // @Autowired
    // (6) UserService 接口只有一个实现类 UserServiceImpl , 所以可以使用 @Autowired, 会自动匹配到 UserServiceImpl 对象

    @Resource(name = "userServiceImpl")  // 注意, bean 名称不是 UserServiceImpl
    private 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

java 复制代码
public interface UserService {
    UserLoginResponse checkPassword(UserLoginRequest userLoginRequest);
}

UserServiceImpl

java 复制代码
@Service
public class UserServiceImpl implements UserService {
    // (1) 明确一下, Service 层写相关的业务逻辑

    @Autowired
    private UserInfoMapper userInfoMapper;
    // (3) 查询数据库, 就需要先注入 Mapper 对象

    @Override
    public 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 catch
        UserInfo userInfo = userInfoMapper.selectOne(queryWrapper);
        
        if(userInfo == null){
            // (4) 用户名不存在, 在 common.exception 中重新定义一个只需要传 errMsg 参数的异常 BlogException
            throw new BlogException("用户不存在");
        }
        
        // (5) 判断密码是否正确
        if(!userLoginRequest.getPassword().equals(userInfo.getPassword())){
            throw new BlogException("用户密码错误");
        }
        
        // (6) 接下来处理的是密码正确的逻辑, 需要返回一个 token
        Map<String, Object> map = new HashMap<>();
        map.put("id",  userInfo.getId());
        map.put("name", userInfo.getUserName());
        String token = JwtUtils.genToken(map);
        
        // (7) 需要给 UserLoginResponse 对象的属性赋值, 所以在 UserLoginResponse 前加 @AllArgsConstructor
        return new UserLoginResponse(userInfo.getId(), token);
    }
}

checkPassword() 方法的注解 (4)注解 (7)

java 复制代码
@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, 只需要传 errMsg
    public BlogException(String errMsg) {
        this.errMsg = errMsg;
    }
}

@Data
@AllArgsConstructor  // (7) 加上全参构造函数, 方便 new UserLoginResponse(userInfo.getId(), token)
public class UserLoginResponse {
    private Integer userId;
    private String token;
}

测试接口



传参的数据需要是 JSON 格式


密码错误的情况:


用户不存在的情况:

java 复制代码
@Service
public class UserServiceImpl implements UserService {

    @Autowired
    private UserInfoMapper userInfoMapper;
    
    @Override
    public 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 {

    @ExceptionHandler
    public Result exceptionHandle(Exception exception){
        log.error("发生异常, e", exception);
        return Result.fail(exception.getMessage());
    }

    @ExceptionHandler
    public Result exceptionHandler(BlogException exception){
        log.error("发生异常, e", exception);
        return Result.fail(exception.getErrMsg());  
        // 用户不存在, 要拿 exception.getErrMsg() 而不是 exception.getMessage()
    }
}


密码为空字符串的情况:

java 复制代码
@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)进行统一异常处理:

java 复制代码
package com.bit.springblogdemo.common.advice;

@Slf4j
@ResponseBody
@ControllerAdvice
public class ExceptionAdvice {
	// 其他异常的处理.....
    
    @ExceptionHandler
    public Result exceptionHandler(MethodArgumentNotValidException exception){
        // 记不住什么时候加 {} , 就全部都加 {}
        log.error("发生异常, e: {}", exception.getMessage());
        return Result.fail("密码长度不合法");
    }
}

在拦截器中处理MethodArgumentNotValidException ,也可以使用在 @Length 注解中设置错误信息 Message,并作为响应信息的 errMsg 返回:

java 复制代码
@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异常进行处理的拦截器方法:

java 复制代码
// 改进前
@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异常:


可以看到,HandlerMethodValidationExceptiondefaultMessage 存放的地方与MethodArgumentNotValidException 是不同的:


如果觉得此时的defaultMessage 不够好,可以直接在对应的 @NotNull 注解中写入自定义的 Message

java 复制代码
@RequestMapping("/getBlogDetail")
public BlogInfoReponse getBlogDetail(@NotNull(message = "不是牢底, 你把你要搜的博客 id 输明白啊, 你啥都不输是几个意思呢?") Integer blogId){
    log.info("获取博客详情, blogId: {}", blogId);
    return blogService.getBlogDetail(blogId);
}

修改对应的异常处理器

java 复制代码
    @ExceptionHandler
    public 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


前端收到 userIdtoken 之后,保存在 localStorage 中。

javascript 复制代码
<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/json
        data : 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****相关操作

存储数据

javascript 复制代码
localStorage.setItem("user_token", "value");

读取数据

javascript 复制代码
localStorage.getItem("user_token");

删除数据

javascript 复制代码
localStorage.removeItem("user_token");

Ajax 发生异常时,进行异常处理,公共处理,可以提取到 common.js

javascript 复制代码
$(document).ajaxError(function(event, xhr, options, exc) {
    if (xhr.status == 400) {
        alert("参数校验失败");
    }
});

部署程序,验证效果。


测试接口


用户名为空的情况


用户名不存在的情况


用户名正确,密码错误的情况


用户名正确,密码为空的情况


用户密码输入正确,跳转博客列表

通过以上的操作,煮波就带着大家过了一遍关于用户登录接口实现的步骤咯~~~


相关推荐
托尼沙滩裤几秒前
Blob文件导出:FileReader是否必需?✨
javascript
He_k4 小时前
‘js@https://registry.npmmirror.com/JS/-/JS-0.1.0.tgz‘ is not in this registry
开发语言·javascript·ecmascript
琢磨先生David5 小时前
责任链模式:构建灵活可扩展的请求处理体系(Java 实现详解)
java·设计模式·责任链模式
-曾牛6 小时前
使用Spring AI集成Perplexity AI实现智能对话(详细配置指南)
java·人工智能·后端·spring·llm·大模型应用·springai
Xiao Ling.6 小时前
设计模式学习笔记
java
MyikJ6 小时前
Java面试:从Spring Boot到分布式系统的技术探讨
java·大数据·spring boot·面试·分布式系统
汪子熙7 小时前
Angular i18n 资源加载利器解析: i18n-http-backend
前端·javascript·面试
louisgeek7 小时前
Java 插入排序之希尔排序
java
小兵张健7 小时前
用户、资金库表和架构设计
java·后端·架构
洛小豆7 小时前
ConcurrentHashMap.size() 为什么“不靠谱”?答案比你想的复杂
java·后端·面试