目录
Spring Security
1. 关于Spring Security
Spring Security主要解决了认证和授权相关的问题。
认证:验证用户提交的登录信息,判断是否可以通过。
授权:当认证通过后,给予通过认证的用户一些信息,后续,将根据这些信息来判断此用户是否允许执行某些访问。
2. 添加依赖
在Spring Boot项目,当需要使用Spring Security时,需要添加spring-boot-starter-security
依赖:
<!-- Spring Boot Security,用于处理认证与授权 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
3. Spring Security的基本特点
当添加以上依赖项后,再次启动项目,Spring Security会执行自动配置,使得:
-
此主机上所有的请求都必须先登录才可以访问,并提供了登录页面,包括根本不存在的URL
-
此特点是由
WebSecurityConfigurerAdapter
类中的protected void configure(HttpSecurity http)
方法决定的-
此配置方法中的
http.formLogin()
将决定是否开启登录表单
-
-
-
默认的用户名是
user
,默认是密码在启动日志中,每次启动都会变化-
Using generated security password: 92062850-fb3d-4b86-be10-209ac26c143e
-
-
当登录成功后,会自动跳转到此前尝试访问的URL
-
例如,当尝试访问 http://localhost:9081/ 时,由于没有登录,会自动重定向到 http://localhost:9081/login 显示了登录页面,当登录成功后,会自动重定向到此前尝试访问的 http://localhost:9081/
-
-
Spring Security默认使用Session保存用户的登录信息
-
例如,当关闭浏览器后,再次访问,需要重新登录
-
-
通过 http://localhost:9081/logout 退出登录
-
自带
BCryptPasswordEncoder
,可以用于使用BCrypt
算法对密码进行加密处理 -
如果项目运行时Spring容器中有密码编码器(
PasswordEncoder
),Spring Security框架会自动使用它 -
Spring Security默认开启了“禁止跨域的异步提交”,避免“伪造的跨域攻击”
4. 关于BCrypt算法
BCrypt算法是一种基于哈希算法的算法,所以,这种算法也是不可逆的!
通过BCrypt算法进行编码后的结果,长度固定为60字符。
使用同一个原文进行反复编码,每次得到的结果都是不同的,因为在编码过程中,BCrypt使用了随机的盐,并且,使用的盐也作为编码结果的一部分保存了下来。
在开发实现中,通常,可以使用配置类中的@Bean
方法来创建BCryptPasswordEncoder
对象,此对象将是由Spring进行管理的,当需要使用时,自动装配即可,例如:
@Slf4j
@Configuration
public class SecurityConfiguration {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@Slf4j
@Service
public class AdminServiceImpl implements IAdminService {
@Autowired
private PasswordEncoder passwordEncoder;
}
5. 关于401
和403
这2个HTTP响应码
在HTTP协议中,401
表示未认证的,通常是没有成功登录的,403
表示未授权的,通常是已经登录,但是不具备相关的操作权限。
在Spring Security中,在许多“禁止”的场景中都会使用403
,并不完全符全HTTP协议的规范。
6. 关于伪造跨域攻击
在多选项卡的浏览器中,如果在第1个选项卡中登录了某个平台,在同一个窗口中打开其它选项卡,访问同一平台,都会被平台识别,视为“已通过认证”。
所谓的伪造跨域攻击,就是利用以上特性,在网页源代码中隐藏一些恶意访问的、会自动提交的请求URL(例如使用隐藏的<img>
标签的src
属性),例如,当用户在第1个选项卡登录了某银行系统,打开的第2个选项卡是有恶意代码的平台,第2个选项卡的网页发出“转账”的请求,会被银行系统视为“已通过认证的”。
-
提示:以上只是举例,事实上,现在防止这种做法的技术已经非常成熟,不会出现此问题,并且,银行转账通常都需要再次输出密码,而不会收到请求就直接转账
在使用Spring Security时,应该自定义配置类,继承自WebSecurityConfigurerAdpater
,并重写void configure(HttpSecurity http)
方法,在其中调用http.csrf()
方法。1.1. 关于伪造跨域攻击(续前日5.6)
Spring Security在防止伪造跨域攻击时,会自动生成值为UUID的Token(票据 / 令牌),并且,将此值响应给客户端,针对客户端后续提交的 POST
/ DELETE
/ PUT
/ PATCH
类型的请求,都要求携带名为 _csrf
的参数,且值就是此UUID,如果客户端提交请求时没有携带此值,则视为“伪造的跨域攻击”,将响应 403
错误。
在继承了WebSecurityConfigurerAdapter
的配置类中,重写configurer(HttpSecurity http)
方法,调用http.csrf().disable()
即可禁用它,即不再检查各请求是否为“伪造跨域”的访问。
提示:禁用后,会存在被伪造跨域攻击的风险,但是,我们会在后续的学习中解决它。
7. 使用数据库中的账号实现登录认证
要能够使用数据库中的账号实现登录认证,必须至少实现”根据用户名查询用户登录信息“的查询功能!
则在Mapper层,需要:
-
在项目的根包下创建
pojo.vo.AdminLoginInfoVO.java
类,在此类中添加登录时所需的数据属性:-
@Data public class AdminLoginInfoVO implements Serializable { // 必须包括:id, username, password, enable }
-
-
在
AdminMapper.java
接口中添加查询方法:-
AdminLoginInfoVO getLoginInfoByUsername(String usernanme);
-
-
在
AdminMapper.xml
中配置以上抽象方法映射的SQL语句:-
<!-- AdminLoginInfoVO getLoginInfoByUsername(String usernanme); --> <select id="getLoginInfoByUsername" resultMap="LoginResultMap"> SELECT <include refid="LoginQueryFields"/> FROM ams_admin WHERE username=#{username} </select> <sql id="LoginQueryFields"> <if test="true"> id, username, password, enable </if> </sql> <resultMap id="LoginResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO"> <id column="id" property="id"/> <result column="username" property="username"/> <result column="password" property="password"/> <result column="enable" property="enable"/> </resultMap>
-
-
在
AdminMapperTests.java
中编写并执行测试:-
@Test void testGetLoginInfoByUsername() { String username = "root"; AdminLoginInfoVO loginInfoByUsername = mapper.getLoginInfoByUsername(username); log.debug("根据用户名【{}】查询登录信息:{}", username, loginInfoByUsername); }
-
Spring Security在执行认证时,会根据用户提交的用户名,自动调用UserDetailsService
接口类型的对象中的UserDetails loadUserByUsername(String username);
方法,当得到返回的UserDetails
后,会自动处理后续的细节,例如验证密码是否正确、将认证信息(登录成功后的用户信息)保存下来,便于后续识别用户身份等。
所以,在根包下创建security.UserDetailsServiceImpl
类,实现UserDetailsService
接口,并且,在类上添加@Service
注解,并实现接口中声明的抽象方法:
package cn.tedu.csmall.passport.security;
import cn.tedu.csmall.passport.mapper.AdminMapper;
import cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private AdminMapper adminMapper;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("Spring Security自动调用loadUserByUsername()方法获取用户名为【{}】的用户详情……", s);
AdminLoginInfoVO loginInfoByUsername = adminMapper.getLoginInfoByUsername(s);
log.debug("从数据库中查询到的用户信息:{}", loginInfoByUsername);
if (loginInfoByUsername == null) {
String message = "登录失败,用户名不存在!";
log.warn(message);
throw new BadCredentialsException(message);
}
UserDetails userDetails = User.builder()
.username(loginInfoByUsername.getUsername())
.password(loginInfoByUsername.getPassword())
.accountExpired(false) // 账号是否已过期
.accountLocked(false) // 账号是否已锁定
.credentialsExpired(false) // 凭证是否已过期
.disabled(loginInfoByUsername.getEnable() == 0) // 账号是否已禁用
.authorities("临时设置的权限,避免报错,暂无意义") // 权限,【注意】必须调用此方法表示此用户具有哪些权限
.build();
log.debug("即将向Spring Security框架返回UserDetails对象:{}", userDetails);
return userDetails;
}
}
提示:一旦Spring窗口存在
UserDetailsService
接口类型的对象,在启动项目时(包括执行测试时),将不再生成随机的临时密码,此前使用的user
账号也将不再允许使用!
完成以上代码后,可以在Security的配置类中,通过http.formLogin();
方法启用登录页面,并启动项目,通过 http://localhost:9081/login 打开登录页面,此时,可以使用数据库的账号尝试登录。
注意:此前完成的查询功能中,必须查询password
和enable
这2个字段的值!
注意:因为Spring Security会自动应用密码编码器(在Security配置类中使用@Bean
方法配置的PasswordEncoder
),数据库中的密码值必须是BCrypt编码结果!
注意:必须确保尝试登录的账号的enable
值是有效的,如果为null
,则会导致NPE!
8. 现有的问题
目前,已经可以使用数据库中的账号进行登录认证,但是,存在以下问题:
-
当前做法并不是前后端分离的
-
Spring Security默认使用Session保存认证信息
9. 关于Session
HTTP协议本身是无状态协议!
-
无状态:同一个客户端的多次请求,服务器并不能识别此客户端的身份,例如:第2次收到此客户端的请求时,并不知道此客户端此前已经提交过一次请求,更不知道第1次处理此客户端请求时产生的数据
在开发实践中,是需要明确客户端身份的,所以,从技术层面,使用了Session来解决HTTP协议无状态的问题。
Session的本质是一个MAP结构的数据,当客户端首次向服务器端提交请求时,服务器端会响应一个Session ID到客户端,客户端在后续的访问中,都会在请求中自动携带此Session ID,同时,服务器端的内存中会存在每个Session ID对应的Session数据,从而,每个客户端都可以访问到自己的此前存入的数据。
由于Session是在服务器端的内存中的数据,因此,默认情况下,并不适合于集群系统,更不适用于分布式系统。
10. 使用前后端分离的方式处理认证
在自定义的SecurityConfiguration
配置类中添加:
@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
// 调用父类的方法得到AuthenticationManager
return super.authenticationManager();
}
因为自定义的配置类继承自WebSecurityConfigurerAdapter
类,此父类中存在authenticationManager()
方法,可以返回AuthenticationManager
对象,可用于后续自行调用authenticate()
,使得Spring Security执行认证!所以,为了保证后续代码可以调用AuthenticationManager
的authenticate()
方法,应该在当前配置类中重写authenticationManager()
方法(如以上代码所示),其主要目的是为了调用父类的方法,并且,在重写的方法上添加@Bean
注解,由于当前类也是配置类(有@Configuration
注解),则Spring会自动调用此@Bean
注解的方法,得到AuthenticationManager
对象并保存在Spring容器中,以至于后续编写代码时,可以随时自动装配AuthenticationManager
对象!
在根包下创建pojo.dto.AdminLoginInfoDTO
类,在类中声明username
、password
这2个属性:
package cn.tedu.csmall.passport.pojo.dto;
import lombok.Data;
import java.io.Serializable;
@Data
public class AdminLoginInfoDTO implements Serializable {
private String username;
private String password;
}
在IAdminService
中自定义登录认证的方法:
void login(AdminLoginInfoDTO adminLoginInfoDTO);
在AdminServiceImpl
中实现以上新增的抽象方法:
@Autowired
private AuthenticationManager authenticationManager;
@Override
public void login(AdminLoginInfoDTO adminLoginInfoDTO) {
// 调用AuthenticationManager的authenticate()方法执行认证
}
具体实现为:
@Autowired
private AuthenticationManager authenticationManager;
@Override
public void login(AdminLoginInfoDTO adminLoginInfoDTO) {
log.debug("开始处理【登录认证】的业务,参数:{}", adminLoginInfoDTO);
// 调用AuthenticationManager的authenticate()方法执行认证
// 在authenticate()方法的执行过程中
// Spring Security会自动调用UserDetailsService对象的loadUserByUsername()获取用户详情
// 并根据loadUserByUsername()返回的用户详情自动验证是否启用、判断密码是否正确等
Authentication authentication
= new UsernamePasswordAuthenticationToken(
adminLoginInfoDTO.getUsername(),
adminLoginInfoDTO.getPassword());
authenticationManager.authenticate(authentication);
}
提示:此步骤不便于测试。
在AdminController
中添加处理登录认证的请求的方法:
@PostMapping("/login")
public JsonResult<Void> login(AdminLoginInfoDTO adminLoginInfoDTO) {
adminService.login(adminLoginInfoDTO);
return JsonResult.ok();
}
最后,还需要在Security的配置类中,将/admins/login
添加到”白名单“中。
此时,可以通过Knife4j的在线API文档的调试功能尝试登录。
为了更好的显示错误信息,还应该对相关异常进行处理!首先,在ServiceCode
中添加新的业务状态码:
public enum ServiceCode {
OK(20000),
ERR_BAD_REQUEST(40000),
ERR_UNAUTHORIZED(40100), // 新增
ERR_UNAUTHORIZED_DISABLED(40110), // 新增
ERR_FORBIDDEN(40300), // 新增
// ... ...(原有其它代码)
并且,在全局异常处理器(GlobalExceptionHandler
)中处理新的异常:
@ExceptionHandler({
InternalAuthenticationServiceException.class,
BadCredentialsException.class
})
public JsonResult<Void> handleAuthenticationException(AuthenticationException e) {
log.debug("处理AuthenticationException");
log.debug("异常类型:{}", e.getClass().getName());
log.debug("异常信息:{}", e.getMessage());
Integer serviceCode = ServiceCode.ERR_UNAUTHORIZED.getValue();
String message = "登录失败,用户名或密码错误!";
return JsonResult.fail(serviceCode, message);
}
@ExceptionHandler
public JsonResult<Void> handleDisabledException(DisabledException e) {
log.debug("处理DisabledException");
Integer serviceCode = ServiceCode.ERR_UNAUTHORIZED_DISABLED.getValue();
String message = "登录失败,此账号已经禁用!";
return JsonResult.fail(serviceCode, message);
}
注意:此时,项目已经可以判断用户名、密码是否正确,但是,即使使用了正确的用户名、密码,且服务器响应的state
是20000
,也并不是真正意义上的登录成功!因为Session中根本没有当前用户的认证信息!所以,即使登录成功,再去访问那些不在”白名单“中的URL,仍会响应403
错误!
11. 关于Token与JWT
Token:票据,令牌。
当用户尝试登录,将请求提交到服务器端,如果服务器端认证通过,会生成一个Token数据并响应到客户端,此Token是有意义的数据,此客户端在后续的每一次请求中,都应该携带此Token数据,服务器端通过解析此Token来识别用户身份!
关于Session与Token:Session默认是保存在服务器的内存中的数据,会占用一定的服务器内存资源,并且,不适合集群或分布式系统(虽然可以通过共享Session来解决),客户携带的Session ID只具有唯一性的特点(理论上),不具备数据含义……而Token的本质是将有意义的数据进行加密处理后的结果,各服务器都只需要具有解析这个加密数据的功能即可获取到其中的信息含义,理论上不占用内存资源,更适用于集群和分布式系统,但是,存在一定的被解密的风险(概率极低)。
JWT = JSON Web Token,是使用JSON格式表示多项数据的Token。
在使用JWT之前,需要在项目中添加相关的依赖,用于生成JWT和解析JWT,例如添加:
<!-- JJWT(Java JWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
提示:更多依赖项可参考 JSON Web Token Libraries - jwt.io
一个原始的JWT数据应该包含3个部分:
HEADER:ALGORITHM & TOKEN TYPE(算法与Token类型)
{
"alg": "HS256",
"typ": "JWT"
}
PAYLOAD(载荷):DATA
此部分的数据是自定义的,可按需存入任何所需的数据。
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
VERIFY SIGNATURE(验证签名)
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your-256-bit-secret
)
使用jjwt
生成和解析JWT数据的示例:
package cn.tedu.csmall.passport;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.junit.jupiter.api.Test;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JwtTests {
// Secret Key
String secretKey = "97iuFDVDfv97iuk534Tht3KJR89kBGFSBgfds";
@Test
public void testGenerate() {
// 准备Claims值
Map<String, Object> claims = new HashMap<>();
claims.put("id", 9527);
claims.put("name", "LiuLaoShi");
claims.put("nickname", "JavaCangLaoShi");
// JWT的过期时间
Date expiration = new Date(System.currentTimeMillis() + 5 * 60 * 1000);
System.out.println("过期时间:" + expiration);
// JWT的组成:Header(头:算法和Token类型)、Payload(载荷)、Signature(签名)
String jwt = Jwts.builder()
// Header
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
// Payload
.setClaims(claims)
.setExpiration(expiration)
// Signature
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
System.out.println("JWT=" + jwt);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiTGl1TGFvU2hpIiwiaWQiOjk1MjcsImV4cCI6MTY2MjQ1NDg5NH0.mHYjK70qenmqmQ5_NrjZsh2P0t-QPKvBedVDRqH2ed8
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiTGl1TGFvU2hpIiwiaWQiOjk1MjcsImV4cCI6MTY2MjQ1NTA0NH0._7o_k9s3we-Ti-9rO4FpYzWxPxNDTFaLbAjZz-bOa8M
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
// .
// eyJuYW1lIjoiTGl1TGFvU2hpIiwibmlja25hbWUiOiJKYXZhQ2FuZ0xhb1NoaSIsImlkIjo5NTI3LCJleHAiOjE2NjI0NTUwOTV9
// .
// KaiBd1LskHVPZzwfDdeoZOCHQ4FB-P_69at0g-1jyqs
}
@Test
public void testParse() {
// 注意:必须使用相同secretKey生成的JWT,否则会解析失败
// 注意:不可以使用过期的JWT,否则会解析失败
// 注意:复制粘贴此JWT时,不要带“尾巴”,否则会解析失败
// 注意:不可以恶意修改JWT中的任何字符,否则会解析失败
String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiTGl1TGFvU2hpIiwibmlja25hbWUiOiJKYXZhQ2FuZ0xhb1NoaSIsImlkIjo5NTI3LCJleHAiOjE2NjI0NTY3ODN9.32MwkSbDz1ce4EvEKHFMCIjcQFUDZz6hn5MtAYr0njQ";
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
Integer id = claims.get("id", Integer.class);
String name = claims.get("name", String.class);
String nickname = claims.get("nickname", String.class);
System.out.println("id = " + id);
System.out.println("name = " + name);
System.out.println("nickname = " + nickname);
}
}
12. 解析JWT时可能出现的错误
如果使用过期的JWT,在解析时将出现错误:
io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-09-06T17:33:03Z. Current time: 2022-09-08T09:04:26Z, a difference of 142283930 milliseconds. Allowed clock skew: 0 milliseconds.
如果使用的JWT数据的签名有误,在解析时将出现错误:
io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
如果使用的JWT数据格式有误,在解析时将出现错误:
io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"alg !L��؈�������)]P�
后续,将需要对这3种异常进行捕获并处理!
13. 使用JWT实现认证
在使用Spring Security框架处理认证时,如果认证通过,必须把通过认证的用户的信息存入到SecurityContext
(Spring Security框架的上下文)对象中,后续,Spring Security框架会自动的尝试从SecurityContext
中获取认证信息,如果获取到有效的认证信息,则视为“已登录”,否则,将视为“未登录”!
使用JWT实现认证需要完成的开发任务:
-
当认证通过时生成JWT,并将JWT响应到客户端
-
当客户端后续提交请求时,应该自觉携带JWT,而服务器端将对JWT进行解析,如果解析成功,将得此客户端的用户信息,并将认证信息存入到
SecurityContext
中
14. 当认证通过时生成JWT,并将JWT响应到客户端
首先,需要修改IAdminService
中处理认证的方法(login()
方法)的声明,将返回值类型修改为String
:
String login(AdminLoginInfoDTO adminLoginInfoDTO);
并且,AdminServiceImpl
中方法的声明也同步修改,在实现过程中,当通过认证后,应该生成JWT并返回:
@Override
public String login(AdminLoginInfoDTO adminLoginInfoDTO) {
log.debug("开始处理【登录认证】的业务,参数:{}", adminLoginInfoDTO);
// 调用AuthenticationManager的authenticate()方法执行认证
// 在authenticate()方法的执行过程中
// Spring Security会自动调用UserDetailsService对象的loadUserByUsername()获取用户详情
// 并根据loadUserByUsername()返回的用户详情自动验证是否启用、判断密码是否正确等
Authentication authentication
= new UsernamePasswordAuthenticationToken(
adminLoginInfoDTO.getUsername(),
adminLoginInfoDTO.getPassword());
Authentication authenticateResult
= authenticationManager.authenticate(authentication);
log.debug("Spring Security已经完成认证,且认证通过,返回的结果:{}", authenticateResult);
log.debug("返回认证信息中的当事人(Principal)类型:{}", authenticateResult.getPrincipal().getClass().getName());
log.debug("返回认证信息中的当事人(Principal)数据:{}", authenticateResult.getPrincipal());
// 从认证返回结果中取出当事人信息
User principal = (User) authenticateResult.getPrincipal();
String username = principal.getUsername();
log.debug("认证信息中的用户名:{}", username);
// 生成JWT,并返回
// 准备Claims值
Map<String, Object> claims = new HashMap<>();
claims.put("username", username);
// JWT的过期时间
Date expiration = new Date(System.currentTimeMillis() + 15 * 24 * 60 * 60 * 1000);
log.debug("即将生成JWT数据,过期时间:{}", expiration);
// JWT的组成:Header(头:算法和Token类型)、Payload(载荷)、Signature(签名)
String secretKey = "97iuFDVDfv97iuk534Tht3KJR89kBGFSBgfds";
String jwt = Jwts.builder()
// Header
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
// Payload
.setClaims(claims)
.setExpiration(expiration)
// Signature
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
log.debug("已经生成JWT数据:{}", jwt);
return jwt;
}
提示:以上生成JWT的代码暂未封装!
最后,在AdminController
中,将处理认证的方法(login()
方法)的返回值类型由JsonResult<Void>
修改为JsonResult<String>
,并且,在方法体中,调用IAdminService
的认证方法时,必须获取返回值,最终将此返回值封装到JsonResult
对象中,响应到客户端:
// http://localhost:9081/admins/login
@ApiOperation("管理员管理")
@ApiOperationSupport(order = 88)
@PostMapping("/login")
public JsonResult<String> login(AdminLoginInfoDTO adminLoginInfoDTO) {
String jwt = adminService.login(adminLoginInfoDTO);
return JsonResult.ok(jwt);
}
15. 解析JWT并处理SecurityContext
当客户端成功的通过认证后,将可以得到JWT,后续,客户端可以携带JWT提交请求,但是,作为服务器端,并不知道客户端将会向哪个URL提交请求,或者说,不管客户端向哪个URL提交请求,服务器端都应该尝试解析JWT,以识别客户端的身份,则解析JWT的代码可以使用“过滤器”组件来实现!
过滤器(
Filter
):是Java EE中的核心组件,此组件是最早接收到请求的组件!并且,此组件可作用于若干个请求的处理过程。
关于客户端携带JWT,业内通用的做法是:将JWT携带在请求头(Request Header)中名为Authorization
的属性中!
所以,此过滤器将固定的通过请求头(Request Header)中的Authorization
属性获取JWT数据,并尝试解析。
由于Spring Security框架判断是否登录的标准是:在SecurityContext
中是否存在认证信息!所以,当成功解析JWT数据后,应该将认证信息保存到SecurityContext
中。
另外,还有几个细节:
-
一旦
SecurityContext
中存在认证信息,在后续的访问中,即使不携带JWT数据,只要在SecurityContext
还存在此前存入的认证信息,就会被视为“已经通过认证”,所以,为了避免此问题,应该在接收到请求的那一刻就直接清除SecurityContext
-
认证的过程应该是“先将认证信息存入到
SecurityContext
(由我们的过滤器执行),再判断是否是通过认证的状态(由Spring Security的过滤器等组件执行)”,所以,当前过滤器必须在Spring Security的相关过滤器之前执行。
所以,在根包下创建filter.JwtAuthorizationFilter
类,以解析JWT、向SecurityContext
中存入认证信息:
package cn.tedu.csmall.passport.filter;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* 解析JWT的过滤器
*
* 1. 首先,清除SecurityContext中的认证信息
* 2. 如果客户端没有携带JWT,则放行,由后续的组件进行处理
* 3. 如果客户端携带了有效的JWT,则解析,并将解析结果用于创建认证对象,最终,将认证对象存入到SecurityContext
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.debug("处理JWT的过滤器开始执行……");
// 清除SecurityContext中原有的认证信息
// 避免曾经成功访问过,后续不携带JWT也能被视为“已认证”
SecurityContextHolder.clearContext();
// 尝试从请求头中获取JWT数据
String jwt = request.getHeader("Authorization");
log.debug("尝试从请求头中获取JWT数据:{}", jwt);
// 判断客户端是否携带了有效的JWT数据,如果没有,直接放行
if (!StringUtils.hasText(jwt) || jwt.length() < 113) {
log.debug("获取到的JWT被视为【无效】,过滤器执行【放行】");
filterChain.doFilter(request, response);
return;
}
// 程序执行到此处,表示客户端携带了有效的JWT,则尝试解析
log.debug("获取到的JWT被视为【有效】,则尝试解析……");
String secretKey = "97iuFDVDfv97iuk534Tht3KJR89kBGFSBgfds";
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
String username = claims.get("username", String.class);
log.debug("从JWT中解析得到【username】的值:{}", username);
// 准备权限,将封装到认证信息中
List<GrantedAuthority> authorityList = new ArrayList<>();
GrantedAuthority authority = new SimpleGrantedAuthority("这是一个山寨的权限");
authorityList.add(authority);
// 准备存入到SecurityContext的认证信息
Authentication authentication
= new UsernamePasswordAuthenticationToken(
username, null, authorityList);
// 将认证信息存入到SecurityContext中
SecurityContext securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(authentication);
log.debug("过滤器执行【放行】");
filterChain.doFilter(request, response);
}
}
然后,在SecurityConfiguration
中自动装配此过滤器:
@Autowired
private JwtAuthorizationFilter jwtAuthorizationFilter;
并在configurer()
方法中补充:
// 将JWT过滤器添加在Spring Security的UsernamePasswordAuthenticationFilter之前
http.addFilterBefore(jwtAuthorizationFilter,
UsernamePasswordAuthenticationFilter.class);
完成后,重启项目,在Knife4j的在线API文档中,先不携带JWT并使用正确的账号登录,然后,携带登录返回的JWT即可向那些不在白名单中的URL进行访问!
16. 关于账号的权限
当处理认证时,应该从数据库中查询出此用户的权限,并且,将权限封装到UserDetails
对象中,当认证成功后,返回的认证对象中的当事人信息就会包含权限信息,接下来,可以将权限信息也写入到JWT中!
后续,在解析JWT时,也可以从中解析得到权限信息,并将权限信息存入到SecurityContext
中,则后续Spring Security的相关组件可以实现对权限的验证!
17. 查询管理员的权限
在处理认证时,会调用AdminMapper
接口中的AdminLoginInfoVO getLoginInfoByUsername(String username);
方法,此方法的返回值应该包含管理员的权限。
则SQL语句大致是:
select
ams_admin.id,
ams_admin.username,
ams_admin.password,
ams_admin.enable,
ams_permission.value
from ams_admin
left join ams_admin_role on ams_admin.id=ams_admin_role.admin_id
left join ams_role_permission on ams_admin_role.role_id=ams_role_permission.role_id
left join ams_permission on ams_role_permission.permission_id=ams_permission.id
where username='root';
为了保证查询结果可以封装权限信息,需要在返回值类型中添加属性:
@Data
public class AdminLoginInfoVO implements Serializable {
private Long id;
private String username;
private String password;
private Integer enable;
private List<String> permissions; // 新增
}
然后,重新配置getLoginInfoByUsername()
方法映射的SQL查询:
<!-- AdminLoginInfoVO getLoginInfoByUsername(String usernanme); -->
<select id="getLoginInfoByUsername" resultMap="LoginResultMap">
SELECT
<include refid="LoginQueryFields"/>
FROM
ams_admin
LEFT JOIN ams_admin_role ON ams_admin.id=ams_admin_role.admin_id
LEFT JOIN ams_role_permission ON ams_admin_role.role_id=ams_role_permission.role_id
LEFT JOIN ams_permission ON ams_role_permission.permission_id=ams_permission.id
WHERE
username=#{username}
</select>
<sql id="LoginQueryFields">
<if test="true">
ams_admin.id,
ams_admin.username,
ams_admin.password,
ams_admin.enable,
ams_permission.value
</if>
</sql>
<!-- collection标签:用于配置返回结果类型中List类型的属性 -->
<!-- collection标签的ofType属性:List中的元素类型 -->
<!-- collection子级:需要配置如何创建出List中的每一个元素 -->
<resultMap id="LoginResultMap"
type="cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO">
<id column="id" property="id"/>
<result column="username" property="username"/>
<result column="password" property="password"/>
<result column="enable" property="enable"/>
<collection property="permissions" ofType="java.lang.String">
<constructor>
<arg column="value"/>
</constructor>
</collection>
</resultMap>
完成后,应该及时测试!
18. 使用JWT保存权限
在UserDetailsServiceImpl
中,调用的adminMapper.getLoginInfoByUsername()
中已经包含用户的权限,则,在返回的UserDetails
对象中封装权限信息:
UserDetails userDetails = User.builder()
.username(loginAdmin.getUsername())
.password(loginAdmin.getPassword())
.accountExpired(false)
.accountLocked(false)
.credentialsExpired(false)
.disabled(loginAdmin.getEnable() == 0)
.authorities(loginAdmin.getPermissions().toArray(new String[] {})) // 调整
.build();
在AdminServiceImpl
中,执行认证且成功后,返回的Authentication
对象中的“当事人”就是以上返回的UserDetails
对象,所以,此对象中是包含了以上封装的权限信息的,则可以将权限信息取出并封装到JWT中。
需要注意:如果直接将权限(Collection<? extends GrantedAuthority>
)存入到JWT数据中,相当于把Collection<? extends GrantedAuthority>
转换成String
,此过程会丢失数据的原始类型,且不符合自动反序列化格式,后续解析时,无法直接还原成Collection<? extends GrantedAuthority>
类型!为解决此问题,可以先将Collection<? extends GrantedAuthority>
转换成JSON格式的字符串再存入到JWT中,后续,解析JWT时得到的也会是JSON格式的字符串,可以反序列化为Collection<? extends GrantedAuthority>
格式!
则先添加JSON工具类的依赖项:
<!-- fastjson:实现对象与JSON的相互转换 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.75</version>
</dependency>
然后,在AdminServiceImpl
中,先从认证成功的返回结果中取出权限,然后存入到JWT中:
// 从认证返回结果中取出当事人信息
User principal = (User) authenticateResult.getPrincipal();
String username = principal.getUsername();
log.debug("认证信息中的用户名:{}", username);
// ===== 以下是新增 ======
Collection<GrantedAuthority> authorities = principal.getAuthorities();
log.debug("认证信息中的权限:{}", authorities);
String authorityListString = JSON.toJSONString(authorities);
log.debug("认证信息中的权限转换为JSON字符串:{}", authorityListString);
// 生成JWT,并返回
// 准备Claims值
Map<String, Object> claims = new HashMap<>();
claims.put("username", username);
claims.put("authorities", authorityListString); // 新增
最后,在JwtAuthorizationFilter
中,解析JWT时,取出权限的JSON字符串,将其反序列化为符合Collection<? extends GrantedAuthority>
的格式:List<SimpleGrantedAuthority>
,并用于存入到认证信息中:
String username = claims.get("username", String.class);
log.debug("从JWT中解析得到【username】的值:{}", username);
String authorityListString = claims.get("authorities", String.class); // 新增
log.debug("从JWT中解析得到【authorities】的值:{}", authorityListString); // 新增
// 准备权限,将封装到认证信息中
List<SimpleGrantedAuthority> authorityList
= JSON.parseArray(authorityListString, SimpleGrantedAuthority.class);
// 准备存入到SecurityContext的认证信息
Authentication authentication
= new UsernamePasswordAuthenticationToken(
username, null, authorityList);
19. 使用Spring Security控制访问权限
首先,需要在Spring Security的配置类上开启方法前的权限检查:
@Slf4j
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true) // 新增
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
// 省略配置类中原有代码
}
然后,在需要对权限进行检查(控制)的控制器类的方法上,使用注解来配置权限,例如:
// http://localhost:9081/admins
@ApiOperation("查询管理员列表")
@ApiOperationSupport(order = 400)
@PreAuthorize("hasAuthority('/ams/admin/read')") // 新增
@GetMapping("")
public JsonResult<List<AdminListItemVO>> list() {
log.debug("开始处理【查询管理员列表】的请求……");
List<AdminListItemVO> list = adminService.list();
return JsonResult.ok(list);
}
以上新增的@PreAuthorize("hasAuthority('/ams/admin/read')")
就表示已经通过认证的用户必须具有 '/ams/admin/read'
权限才可以访问此请求路径(http://localhost:9081/admins
),如果没有权限,将抛出org.springframework.security.access.AccessDeniedException: 不允许访问
。
由于无操作权限时会出现新的异常,则在GlobalExceptionHandler
中补充对此类异常的处理:
@ExceptionHandler
public JsonResult<Void> handleAccessDeniedException(AccessDeniedException e) {
log.debug("处理AccessDeniedException");
Integer serviceCode = ServiceCode.ERR_FORBIDDEN.getValue();
String message = "请求失败,当前账号无此操作权限!";
return JsonResult.fail(serviceCode, message);
}
20. 在控制器中识别当前登录的用户
当已经通过认证的用户访问服务器时,将携带JWT数据,而JWT数据在过滤器(JwtAuthorizationFilter
)就已经解析完成,如果在控制器中需要识别用户的身份,只在在过滤器将用户信息存储到认证信息(Authentication
)中,并且,在控制器中获取相关数据!
通常,识别用户的身份时,需要获取当前登录的用户的id,Spring Security处理认证时,需要的用户信息的数据类型是UserDetails
接口类型的,并且,Spring Security提供了User
作为此接口类型的实现,但是,User
类型中并没有id
、头像、昵称等各软件设计时的个性化数据属性,在开发实践时,为了保证能够得到这些个性化数据,应该使用自定义类型实现UserDetails
接口,或者,自定义类型继承自User
类,并在UserDetailsServiceImpl
中返回此类对象!
则在根包下创建security.AdminDetails
类:
package cn.tedu.csmall.passport.security;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import java.util.Collection;
public class AdminDetails extends User {
private Long id;
public AdminDetails(String username, String password, boolean enabled,
Collection<? extends GrantedAuthority> authorities) {
super(username, password, enabled, true, true, true, authorities);
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
}
在实现UserDetailsService
接口时,此前返回的对象都是User
对象,现在就可以返回自定义的AdminDetails
对象了:
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("Spring Security自动调用loadUserByUsername()方法获取用户名为【{}】的用户详情……", s);
AdminLoginInfoVO loginAdmin = adminMapper.getLoginInfoByUsername(s);
log.debug("从数据库中查询到的用户信息:{}", loginAdmin);
if (loginAdmin == null) {
String message = "登录失败,用户名不存在!";
log.warn(message);
throw new BadCredentialsException(message);
}
List<String> permissions = loginAdmin.getPermissions();
List<GrantedAuthority> authorities = new ArrayList<>();
for (String permission : permissions) {
authorities.add(new SimpleGrantedAuthority(permission));
}
AdminDetails adminDetails = new AdminDetails(
loginAdmin.getUsername(), loginAdmin.getPassword(),
loginAdmin.getEnable() == 1, authorities);
adminDetails.setId(loginAdmin.getId());
// UserDetails userDetails = User.builder()
// .username(loginAdmin.getUsername())
// .password(loginAdmin.getPassword())
// .accountExpired(false) // 账号是否已过期
// .accountLocked(false) // 账号是否已锁定
// .credentialsExpired(false) // 凭证是否已过期
// .disabled(loginAdmin.getEnable() == 0) // 账号是否已禁用
// .authorities(loginAdmin.getPermissions().toArray(new String[] {})) // 权限,【注意】必须调用此方法表示此用户具有哪些权限
// .build();
log.debug("即将向Spring Security框架返回UserDetails对象:{}", adminDetails);
return adminDetails;
}
以上方法返回的对象仍是Spring Security处理认证时判断是否允许登录的对象,也是认证成功后返回的认证信息中的当事人,所以,在AdminServiceImpl
的login()
方法中,当认证成功后,可以获取认证信息中的当事人,并从中获取到id
等信息,用于保存到JWT数据:
// 从认证返回结果中取出当事人信息
AdminDetails principal = (AdminDetails) authenticateResult.getPrincipal(); // 修改
Long id = principal.getId(); // 新增
log.debug("认证信息中的用户id:{}", id); // 新增
String username = principal.getUsername();
log.debug("认证信息中的用户名:{}", username);
Collection<GrantedAuthority> authorities = principal.getAuthorities();
log.debug("认证信息中的权限:{}", authorities);
String authorityListString = JSON.toJSONString(authorities);
log.debug("认证信息中的权限转换为JSON字符串:{}", authorityListString);
// 生成JWT,并返回
// 准备Claims值
Map<String, Object> claims = new HashMap<>();
claims.put("id", id); // 新增
claims.put("username", username);
claims.put("authorities", authorityListString);
至此,当用户通过认证时,得到的JWT数据中将包含此用户的id。
通常,在控制器中需要识别用户的身份时,需要的信息可能有多个,例如用户的id
、用户名等,可以将这些信息封装到自定义对象中,例如,在根包下创建security.LoginPrincipal
类:
package cn.tedu.csmall.passport.security;
import lombok.Data;
import java.io.Serializable;
@Data
public class LoginPrincipal implements Serializable {
private Long id;
private String username;
}
然后,在过滤器(JwtAuthorizationFilter
)中,当解析JWT时,就可以从中获取id
与用户名,并使用这2个值来创建LoginPrincipal
对象,最后,将LoginPrincipal
对象封装到认证信息的当事人中:
Long id = claims.get("id", Long.class); // 新增
log.debug("从JWT中解析得到【id】的值:{}", id); // 新增
String username = claims.get("username", String.class);
log.debug("从JWT中解析得到【username】的值:{}", username);
String authorityListString = claims.get("authorities", String.class);
log.debug("从JWT中解析得到【authorities】的值:{}", authorityListString);
// 准备权限,将封装到认证信息中
List<SimpleGrantedAuthority> authorityList
= JSON.parseArray(authorityListString, SimpleGrantedAuthority.class);
// 创建自定义的当事人类型的对象
LoginPrincipal loginPrincipal = new LoginPrincipal(); // 新增
loginPrincipal.setId(id); // 新增
loginPrincipal.setUsername(username); // 新增
// 准备存入到SecurityContext的认证信息
Authentication authentication
= new UsernamePasswordAuthenticationToken(
loginPrincipal, null, authorityList); // 修改了第1个参数值,改为loginPrincipal
至此,当客户端携带(最新的)JWT到服务器端时,过滤器可以解析得到id
和username
,并且,这些属性最终将保存到SecurityContext
的认证信息中,则后续控制器可以随时获取这些信息,例如:
@ApiOperation("查询管理员列表")
@ApiOperationSupport(order = 400)
@PreAuthorize("hasAuthority('/ams/admin/read')")
@GetMapping("")
public JsonResult<List<AdminListItemVO>> list(
// 下一行的参数声明是新增的
@ApiIgnore @AuthenticationPrincipal LoginPrincipal loginPrincipal) {
log.debug("开始处理【查询管理员列表】的请求……");
log.debug("从SecurityContext中获取到的信息:"); // 新增
log.debug("当事人id = {}", loginPrincipal.getId()); // 新增
log.debug("当事人用户名 = {}", loginPrincipal.getUsername()); // 新增
List<AdminListItemVO> list = adminService.list();
return JsonResult.ok(list);
}
21. 关于secretKey值
在AdminServiceImpl
中生成JWT、在JwtAuthorizationFilter
中解析JWT,都需要使用到相同的secretKey
值,目前,在这2个代码片段中各自使用局部变量声明了此变量,并且,2个文件中的这2个变量的值是相同的,但是,各声明一个局部变量是不合理的!
可以在application.properties
中添加自定义配置:
# 当前项目的自定义配置:JWT使用的secretKey
csmall.jwt.secret-key=97iuFDVDfv97iuk534Tht3KJR89kBGFSBgfds
然后,在这2个类中都添加:
@Value("${csmall.jwt.secret-key}")
private String secretKey;
各这2个类都可以读取到application.properties
中的配置值,不必再各自声明secretKey
局部变量了!
另外,建议将“JWT的有效时长”也进行类似的处理,例如:
# 当前项目的自定义配置:JWT的有效时长,以分钟为单位
csmall.jwt.duration-in-minute=10000
22. 处理解析JWT时可能出现的异常
由于解析JWT是在过滤器(JwtAuthorizationFilter
)中执行的,而过滤器是Java EE中最早接收到请求的组件,如果此时出现异常,Spring MVC框架的相关组件还没有开始执行,即“全局异常处理器”是不会发挥作用的!
对于解析JWT可能出现的异常,应该由过滤器组件直接进行处理!
首先,在ServiceCode
中补充新的业务状态码:
public enum ServiceCode {
OK(20000),
ERR_BAD_REQUEST(40000),
ERR_UNAUTHORIZED(40100),
ERR_UNAUTHORIZED_DISABLED(40110),
ERR_FORBIDDEN(40300),
ERR_NOT_FOUND(40400),
ERR_CONFLICT(40900),
ERR_INSERT(50000),
ERR_DELETE(50100),
ERR_UPDATE(50200),
ERR_JWT_EXPIRED(60000), // 新增
ERR_JWT_PARSE(60100); // 新增
// 省略其它原有代码
然后,在过滤器中,解析JWT时,使用try...catch
语法捕获并处理异常:
// 程序执行到此处,表示客户端携带了有效的JWT,则尝试解析
log.debug("获取到的JWT被视为【有效】,则尝试解析……");
Claims claims = null;
response.setContentType("application/json; charset=utf-8");
try {
claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
} catch (ExpiredJwtException e) {
log.debug("解析JWT失败:{}:{}", e.getClass().getName(), e.getMessage());
Integer serviceCode = ServiceCode.ERR_JWT_EXPIRED.getValue();
String message = "登录信息已过期,请重新登录!";
JsonResult<Void> jsonResult = JsonResult.fail(serviceCode, message);
String jsonString = JSON.toJSONString(jsonResult);
PrintWriter writer = response.getWriter();
writer.println(jsonString);
writer.close();
return;
} catch (SignatureException e) {
log.debug("解析JWT失败:{}:{}", e.getClass().getName(), e.getMessage());
Integer serviceCode = ServiceCode.ERR_JWT_PARSE.getValue();
String message = "无法获取到有效的登录信息,请重新登录!";
JsonResult<Void> jsonResult = JsonResult.fail(serviceCode, message);
String jsonString = JSON.toJSONString(jsonResult);
PrintWriter writer = response.getWriter();
writer.println(jsonString);
writer.close();
return;
} catch (MalformedJwtException e) {
log.debug("解析JWT失败:{}:{}", e.getClass().getName(), e.getMessage());
Integer serviceCode = ServiceCode.ERR_JWT_PARSE.getValue();
String message = "无法获取到有效的登录信息,请重新登录!";
JsonResult<Void> jsonResult = JsonResult.fail(serviceCode, message);
String jsonString = JSON.toJSONString(jsonResult);
PrintWriter writer = response.getWriter();
writer.println(jsonString);
writer.close();
return;
} catch (Throwable e) {
log.debug("解析JWT失败:{}:{}", e.getClass().getName(), e.getMessage());
Integer serviceCode = ServiceCode.ERR_JWT_PARSE.getValue();
String message = "无法获取到有效的登录信息,请重新登录!";
JsonResult<Void> jsonResult = JsonResult.fail(serviceCode, message);
String jsonString = JSON.toJSONString(jsonResult);
PrintWriter writer = response.getWriter();
writer.println(jsonString);
writer.close();
e.printStackTrace();
return;
}
23. 结合前端的登录页面
目前,后端的登录功能,如果成功登录,响应:
{
"state": 20000,
"message": null,
"data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiZXhwIjoxNjYyNzEyNDMxLCJhdXRob3JpdGllcyI6Ilt7XCJhdXRob3JpdHlcIjpcIi9hbXMvYWRtaW4vZGVsZXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL2Ftcy9hZG1pbi9yZWFkXCJ9LHtcImF1dGhvcml0eVwiOlwiL2Ftcy9hZG1pbi91cGRhdGVcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL3Byb2R1Y3QvZGVsZXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9wcm9kdWN0L3JlYWRcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL3Byb2R1Y3QvdXBkYXRlXCJ9XSIsInVzZXJuYW1lIjoicm9vdCJ9.VRK8btzrHmwU7gQ7Hu0-6nLYPYvh6-KlXSBTVH2NjAE"
}
如果用户名错误,响应:
{
"state": 40100,
"message": "登录失败,用户名或密码错误!",
"data": null
}
如果密码错误,响应:
{
"state": 40100,
"message": "登录失败,用户名或密码错误!",
"data": null
}
如果账号被禁用,响应:
{
"state": 40110,
"message": "登录失败,此账号已经禁用!",
"data": null
}