springboot3已经推出有一段时间了,近期公司里面的小项目使用的都是springboot3版本的,安全框架还是以springsecurity为主,毕竟亲生的。
本文针对基于springboot3和springsecurity实现用户登录认证访问以及异常处理做个记录总结,也希望能帮助到需要的朋友。
pom.xml (供参考)
4.0.0 com.zjtx.tech.security security_demo 1.0-SNAPSHOT org.springframework.boot spring-boot-starter-parent 3.1.2 20 20 UTF-8 org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-security org.projectlombok lombok
相对来说比较简单:
注意:
- springboot3要求使用的jdk版本在17+,本文使用的是openjdk20版本,springboot使用的是3.1.2版本。
- 我们第一个版本先采用模拟数据实现功能,后续再补充实际逻辑,再根据需要调整pom文件
主要包含统一响应、统一异常处理、自定义异常类等。
统一响应类-Result.java
package com.zjtx.tech.security.demo.common; import java.io.Serial; import java.io.Serializable; public class Resultimplements Serializable { @Serial private static final long serialVersionUID = 1L; // 状态码 private int code; // 消息描述 private String msg; // 数据内容 private T data; public Result() {} public Result(int code, String msg, T data) { this.code = code; this.msg = msg; this.data = data; } // 成功响应构造器 public static Result ok(T data) { return new Result<>(200, "success", data); } // 失败响应构造器 public static Result fail(int code, String msg) { return new Result<>(code, msg, null); } // 错误响应构造器 public static Result error(String errorMessage) { return new Result<>(500, errorMessage, null); } // getters and setters public int getCode() { return code; } public void setCode(int code) { this.code = code; } public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } public T getData() { return data; } public void setData(T data) { this.data = data; } }
JSON转换工具类-JsonUtil.java
package com.zjtx.tech.security.demo.util; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.List; public class JsonUtil { private static final ObjectMapper objectMapper = new ObjectMapper(); /** * 将Java对象转换为JSON字符串 * @param obj 需要转换的Java对象 * @return JSON格式的字符串 */ public static String toJson(Object obj) { try { return objectMapper.writeValueAsString(obj); } catch (JsonProcessingException e) { throw new RuntimeException("Failed to convert object to JSON", e); } } /** * 将JSON字符串转换为指定类型的Java对象 * @param jsonStr JSON格式的字符串 * @param clazz 目标对象的Class类型 * @param泛型类型 * @return 转换后的Java对象实例 */ public static T toObject(String jsonStr, Class clazz) { try { return objectMapper.readValue(jsonStr, clazz); } catch (JsonProcessingException e) { throw new RuntimeException("Failed to convert JSON string to object", e); } } /** * 将JSON字符串转换为指定类型的Java List对象 * @param jsonStr JSON格式的字符串 * @param elementType 列表中元素的Class类型 * @param 泛型类型 * @return 转换后的Java List对象实例 */ public static List jsonToList(String jsonStr, Class elementType) { try { JavaType javaType = objectMapper.getTypeFactory().constructParametricType(List.class, elementType); return objectMapper.readValue(jsonStr, javaType); } catch (JsonProcessingException e) { throw new RuntimeException("Failed to convert JSON string to list", e); } } }
自定义异常类-AuthorizationExceptionEx.java
package com.zjtx.tech.security.demo.exceptions; import org.springframework.security.core.AuthenticationException; public class AuthorizationExceptionEx extends AuthenticationException { public AuthorizationExceptionEx(String msg, Throwable cause) { super(msg, cause); } public AuthorizationExceptionEx(String msg) { super(msg); } }
自定义异常类-ServerException.java
package com.zjtx.tech.security.demo.exceptions; public class ServerException extends RuntimeException { public ServerException(String message) { super(message); } public ServerException(String message, Throwable cause) { super(message, cause); } }
全局异常捕获处理-GlobalExceptionHandler.java
package com.zjtx.tech.security.demo.exceptions; import com.zjtx.tech.security.demo.common.Result; import org.springframework.security.access.AccessDeniedException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(AuthorizationExceptionEx.class) public ResultauthorizationExceptionHandling(AuthorizationExceptionEx ex) { System.out.println("authorizationExceptionHandling = " + ex); return Result.fail(1000, ex.getMessage()); } // handling specific exception @ExceptionHandler(ServerException.class) public Result serverExceptionHandling(ServerException ex) { System.out.println("serverExceptionHandling = " + ex); return Result.fail(6000, ex.getMessage()); } @ExceptionHandler(AccessDeniedException.class) public Result accessDeniedExceptionHandling(AccessDeniedException ex) { System.out.println("accessDeniedExceptionHandling = " + ex); return Result.fail(403, "权限不足"); } // handling global exception @ExceptionHandler(Exception.class) public Result exceptionHandling(Exception ex) { System.out.println("exceptionHandling = " + ex); return Result.fail(500, "服务器内部异常,请稍后重试"); } }
MySecurityConfigurer.java
package com.zjtx.tech.security.demo.config; import com.zjtx.tech.security.demo.provider.MobilecodeAuthenticationProvider; import com.zjtx.tech.security.demo.provider.MyAuthenticationEntryPoint; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.DelegatingPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder; import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @EnableMethodSecurity @EnableWebSecurity @Configuration public class MySecurityConfigurer { @Resource private MyAuthenticationEntryPoint myAuthenticationEntryPoint; @Resource private UserDetailsService customUserDetailsService; @Resource private PasswordEncoder passwordEncoder; @Resource private TokenAuthenticationFilter tokenAuthenticationFilter; @Bean public MobilecodeAuthenticationProvider mobilecodeAuthenticationProvider() { MobilecodeAuthenticationProvider mobilecodeAuthenticationProvider = new MobilecodeAuthenticationProvider(); mobilecodeAuthenticationProvider.setUserDetailsService(customUserDetailsService); return mobilecodeAuthenticationProvider; } @Bean public DaoAuthenticationProvider daoAuthenticationProvider() { DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); daoAuthenticationProvider.setPasswordEncoder(passwordEncoder); daoAuthenticationProvider.setUserDetailsService(customUserDetailsService); daoAuthenticationProvider.setHideUserNotFoundExceptions(false); return daoAuthenticationProvider; } /** * 定义认证管理器AuthenticationManager * @return AuthenticationManager */ @Bean public AuthenticationManager authenticationManager() { ListauthenticationProviders = new ArrayList<>(); authenticationProviders.add(mobilecodeAuthenticationProvider()); authenticationProviders.add(daoAuthenticationProvider()); return new ProviderManager(authenticationProviders); } @Bean @Order(2) public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests((authorize) -> authorize.requestMatchers(new AntPathRequestMatcher("/login/**")).permitAll() .anyRequest().authenticated()) .cors(Customizer.withDefaults()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling(configure -> { configure.authenticationEntryPoint(myAuthenticationEntryPoint); }) .csrf(AbstractHttpConfigurer::disable); return http.build(); } }
这个类是springsecurity的统一配置类,不仅包含了AuthorizationProvider这个关键认证bean的定义,同时还定义了访问策略以及异常处理策略等信息。其中使用了springsecurity6中相对较新的语法,参考价值相对较高。
里面涉及到几个关键的bean,如下:
上面这些关键类我们接下来都会一一给出示例代码。
MobilecodeAuthenticationProvider.java
package com.zjtx.tech.security.demo.provider; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import java.util.HashMap; import java.util.Map; public class MobilecodeAuthenticationProvider implements AuthenticationProvider { private UserDetailsService userDetailsService; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { MobilecodeAuthenticationToken mobilecodeAuthenticationToken = (MobilecodeAuthenticationToken) authentication; String phone = mobilecodeAuthenticationToken.getPhone(); String mobileCode = mobilecodeAuthenticationToken.getMobileCode(); System.out.println("登陆手机号:" + phone); System.out.println("手机验证码:" + mobileCode); // 模拟从redis中读取手机号对应的验证码及其用户名 MapdataFromRedis = new HashMap<>(); dataFromRedis.put("code", "6789"); dataFromRedis.put("username", "admin"); // 判断验证码是否一致 if (!mobileCode.equals(dataFromRedis.get("code"))) { throw new BadCredentialsException("验证码错误"); } // 如果验证码一致,从数据库中读取该手机号对应的用户信息 CustomUserDetails loadedUser = (CustomUserDetails) userDetailsService.loadUserByUsername(dataFromRedis.get("username")); if (loadedUser == null) { throw new UsernameNotFoundException("用户不存在"); } return new MobilecodeAuthenticationToken(loadedUser, null, loadedUser.getAuthorities()); } @Override public boolean supports(Class> aClass) { return MobilecodeAuthenticationToken.class.isAssignableFrom(aClass); } public void setUserDetailsService(UserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } }
说明如下:
上面类中比较关键的就是authenticate和support方法,如果看过一点源码的话可以知道这里会存在多个Provider,通过support方法来确定使用哪个Provider的实现类。
authenticate就是具体的认证逻辑,如判断验证码是否正确,根据手机号查找用户信息等。
authenticate方法中的参数就是在用户登录时组装和传递进来的。
其中涉及到UserDetailService的实现类如下:
package com.zjtx.tech.security.demo.provider; import jakarta.annotation.Resource; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.Collection; @Service public class MyUserDetailsService implements UserDetailsService { @Resource private PasswordEncoder passwordEncoder; private static final Collectionauthorities = new ArrayList<>(); static { GrantedAuthority defaultRole = new SimpleGrantedAuthority("common"); GrantedAuthority xxlJobRole = new SimpleGrantedAuthority("xxl-job"); authorities.add(defaultRole); authorities.add(xxlJobRole); } @Override public UserDetails loadUserByUsername(String username) throws AuthenticationException { CustomUserDetails userDetails; // 这里模拟从数据库中获取用户信息 if (username.equals("admin")) { //这里的admin用户拥有common和xxl-job两个权限 userDetails = new CustomUserDetails("admin", passwordEncoder.encode("123456"), authorities); userDetails.setAge(25); userDetails.setSex(1); userDetails.setAddress("xxxx小区"); return userDetails; } else { throw new UsernameNotFoundException("用户不存在"); } } }
目前这个类中采用的是模拟数据,后续我们会在这个基础上接入真实数据及实现。
还涉及到MobilecodeAuthenticationToken.java这个类,如下:
package com.zjtx.tech.security.demo.provider; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import java.util.Collection; /** * 手机验证码认证信息,在UsernamePasswordAuthenticationToken的基础上添加属性 手机号、验证码 */ public class MobilecodeAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = 530L; private Object principal; private Object credentials; private String phone; private String mobileCode; public MobilecodeAuthenticationToken(String phone, String mobileCode) { super(null); this.phone = phone; this.mobileCode = mobileCode; this.setAuthenticated(false); } public MobilecodeAuthenticationToken(Object principal, Object credentials, Collection extends GrantedAuthority> authorities) { super(authorities); this.principal = principal; this.credentials = credentials; super.setAuthenticated(true); } public Object getCredentials() { return this.credentials; } public Object getPrincipal() { return this.principal; } public String getPhone() { return phone; } public String getMobileCode() { return mobileCode; } public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { if (isAuthenticated) { throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead"); } else { super.setAuthenticated(false); } } public void eraseCredentials() { super.eraseCredentials(); this.credentials = null; } }
涉及到的用户信息类如下:
package com.zjtx.tech.security.demo.provider; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.User; import java.util.Collection; import java.util.List; public class CustomUserDetails extends User { private int age; private int sex; private String address; private String phone; private Listroles; public CustomUserDetails(String username, String password, Collection extends GrantedAuthority> authorities) { super(username, password, authorities); } public CustomUserDetails(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection extends GrantedAuthority> authorities) { super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities); } public int getAge() { return age; } public void setAge(int age) { this.age = age; } public int getSex() { return sex; } public void setSex(int sex) { this.sex = sex; } public String getAddress() { return address; } public void setAddress(String address) { this.address = address; } public String getPhone() { return phone; } public void setPhone(String phone) { this.phone = phone; } }
继承了org.springframework.security.core.userdetails.User这个类同时添加了一些自定义属性,可自行扩展。
上面在安全配置类中用到了这个异常处理类,主要处理认证异常和访问被拒绝。
MyAuthenticationEntryPoint.java
package com.zjtx.tech.security.demo.provider; import com.zjtx.tech.security.demo.common.Result; import com.zjtx.tech.security.demo.util.JsonUtil; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; import java.io.IOException; @Component public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint, AccessDeniedHandler { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { Resultresult = Result.fail(401, "用户未登录或已过期"); response.setContentType("text/json;charset=utf-8"); response.getWriter().write(JsonUtil.toJson(result)); } @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { Result result = Result.fail(403, "权限不足"); response.setContentType("text/json;charset=utf-8"); response.getWriter().write(JsonUtil.toJson(result)); } }
比较简单,实现了两个接口,返回不同的json数据。JsonUtil比较简单,就不在此列出了。
过滤器在认证中扮演者非常重要的角色,我们也定义了一个用于token校验的filter,如下:
package com.zjtx.tech.security.demo.config; import com.zjtx.tech.security.demo.provider.CustomUserDetails; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.annotation.WebFilter; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.lang.NonNull; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.HashMap; import java.util.Map; @Component @WebFilter public class TokenAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(@NonNull HttpServletRequest servletRequest, @NonNull HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException, ServletException { String token = getToken(servletRequest); // 如果没有token,跳过该过滤器 if (StringUtils.hasText(token)) { // 模拟redis中的数据 Mapmap = new HashMap<>(); //这里放入了两个示例token 仅供测试 map.put("test_token1", new CustomUserDetails("admin", new BCryptPasswordEncoder().encode("123456"), AuthorityUtils.createAuthorityList("common", "xxl-job"))); map.put("test_token2", new CustomUserDetails("root", new BCryptPasswordEncoder().encode("123456"), AuthorityUtils.createAuthorityList("common"))); // 这里模拟从redis获取token对应的用户信息 CustomUserDetails customUserDetail = map.get(token); if (customUserDetail != null) { UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(customUserDetail, null, customUserDetail.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authRequest); } } filterChain.doFilter(servletRequest, httpServletResponse); } /** * 从请求中获取token * @param servletRequest 请求对象 * @return 获取到的token值 可以为null */ private String getToken(HttpServletRequest servletRequest) { //先从请求头中获取 String headerToken = servletRequest.getHeader("Authorization"); if(StringUtils.hasText(headerToken)) { return headerToken; } //再从请求参数里获取 String paramToken = servletRequest.getParameter("accessToken"); if(StringUtils.hasText(paramToken)) { return paramToken; } return null; } }
主要完成的工作就是从请求头或者请求参数中获取token,与redis或其他存储介质中的进行比对,如果存在对应用户则正常访问,否则执行其他策略或者抛出异常。
这里内置了两个token,分别拥有不同权限。
springsecurity中提供了一个PasswordEncoder接口,用于对密码进行加密和比对,我们也定义这样一个bean
package com.zjtx.tech.security.demo.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.DelegatingPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder; import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder; import java.util.HashMap; import java.util.Map; @Configuration public class PasswordEncoderConfig { /** * 获取密码编码方式 */ @Value("${password.encode.key:bcrypt}") private String passwordEncodeKey; /** * 获取密码编码器 * @return 密码编码器 */ @Bean public PasswordEncoder passwordEncoder() { Mapencoders = new HashMap<>(); encoders.put("bcrypt", new BCryptPasswordEncoder()); encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8()); encoders.put("scrypt", new SCryptPasswordEncoder(4,8, 1,32, 16)); return new DelegatingPasswordEncoder(passwordEncodeKey, encoders); } }
这里采用的实现类是DelegatingPasswordEncoder,一个好处是它可以兼容多种加密方式,区分的办法是根据加密后的字符串前缀,如bcrypt加密后的结果前缀就是{bcrypt},方便配置和扩展,不做过多阐述。
登录接口
package com.zjtx.tech.security.demo.controller; import com.zjtx.tech.security.demo.common.Result; import com.zjtx.tech.security.demo.provider.MobilecodeAuthenticationToken; import jakarta.annotation.Resource; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.UUID; @RestController @RequestMapping("/login") public class LoginController { @Resource private AuthenticationManager authenticationManager; /** * 用户名密码登录 * @param username 用户名 * @param password 密码 * @return 返回登录结果 */ @GetMapping("/usernamePwd") public Result> usernamePwd(String username, String password) { UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, password); try { authenticationManager.authenticate(usernamePasswordAuthenticationToken); } catch (BadCredentialsException | UsernameNotFoundException e) { throw new ServerException(e.getMessage()); } String token = UUID.randomUUID().toString().replace("-", ""); return Result.ok(token); } /** * 手机验证码登录 * @param phone 手机号 * @param mobileCode 验证码 * @return 返回登录结果 */ @GetMapping("/mobileCode") public Result> mobileCode(String phone, String mobileCode) { MobilecodeAuthenticationToken mobilecodeAuthenticationToken = new MobilecodeAuthenticationToken(phone, mobileCode); Authentication authenticate; try { authenticate = authenticationManager.authenticate(mobilecodeAuthenticationToken); } catch (Exception e) { e.printStackTrace(); return Result.error("验证码错误"); } System.out.println(authenticate); String token = UUID.randomUUID().toString().replace("-", ""); return Result.ok(token); } }
可以看到这个controller提供了用户名+密码登录和手机号+验证码登录两个接口。
测试用的接口:
package com.zjtx.tech.security.demo.controller; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("test") public class TestController { @GetMapping("demo") @PreAuthorize("hasAuthority('xxl-job')") public String demo(){ Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); System.out.println("authentication = " + authentication); return "hello world"; } }
这个controller定义了一个方法,这个方法需要用户拥有xxl-job的权限。
结合我们之前定义的一些类,猜测期望结果应该是这样的:
启动项目,默认端口8080,使用postman模拟请求进行简单测试。
经验证,结果符合预期。
本文中我们完成了基于springboot3+springsecurity实现用户认证登录及鉴权访问的简单demo, 接下来我们会继续把获取及验证用户、生成token、校验token做个完善。
作为记录的同时也希望能帮助到需要的朋友。
创作不易,欢迎一键三连。