目录
Spring Security框架的依赖项
Spring Security框架的典型特征
关于Spring Security的配置
关于默认的登录页
关于请求的授权访问(访问控制)
使用自定义的账号登录
使用数据库中的账号登录
关于密码编码器
使用BCrypt算法
关于伪造的跨域攻击
使用前后端分离的登录
关于认证的标准
未通过认证时拒绝访问
识别当事人(Principal)
实现根据权限限制访问
补充解释(关于使用resultMap标签):
基于方法的权限检查
添加Token
首先添加Token-JWT的依赖项:
生成JWT:
解析JWT
补充:
在项目中使用JWT识别用户的身份
核心流程
验证登录成功时响应JWT
解析客户端携带的JWT
我们这次选择去继承Spring系列框架提供的OncePerRequestFilter这个类。
关于认证信息中的当事人
处理解析JWT时的异常
处理复杂请求的跨域问题
单点登录
关于Spring Security框架
Spring Security框架主要解决了认证与授权相关的问题。
认证信息(Authentication):表示用户的身份信息
认证(Authenticate):识别用户的身份信息的行为,例如:登录
授权(Authorize):授予用户权限,使之可以进行某些访问,反之,如果用户没有得到必要的授权,将无法进行访问
Spring Security框架的依赖项
在Spring Boot中使用Spring Security时需要添加spring-boot-starter-security
依赖。
Spring Security框架的典型特征
当添加了spring-boot-starter-security
依赖后,在启动项目时执行一些自动配置,具体表现有:
-
所有请求(包括根本不存在的)都是必须要登录才允许访问的,如果未登录,会自动跳转到框架自带的登录页面(1.项目重启之后需要重新登录,2.原来想去的页面会要求登录,登录完成之后回到原来的位置)
- 当尝试登录时,如果在打开登录页面后重启过服务器端,则第1次的输入是无效的
-
默认的用户名是
user
,密码是在启动项目是控制台提示的一段UUID值,每次启动项目时都不同(同一时空的唯一性,即同一时间同一空间的值都不同)-
UUID是通过128位算法(运算结果是128个bit)运算得到的,是一个随机数,在同一时空是唯一的,通常使用32个十六进制数来表示,每种平台生成UUID的API和表现可能不同,UUID值的种类有2的128次方个,即:3.4028237e+38,也就是340282366920938463463374607431768211456
-
-
当登录成功后,会自动跳转到此前尝试访问的URL
-
当登录成功后,可以通过
/logout
退出登录
-
默认不接受普通
POST
请求,如果提交POST
请求,将响应403(Forbidden)
关于Spring Security的配置
在项目的根包下创建config.SecurityConfiguration
类,作为Spring Security的配置类,此类需要继承自WebSecurityConfigurerAdapter
,并重写void configure(HttpSecurity http)
方法,例如:
java">@Slf4j
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// super.configure(http); // 不要保留调用父级同名方法的代码,不要保留!不要保留!不要保留!
}
}
做了配置后,此时重启工程就不需要登陆了,就算访问登录页面也没有。
写此配置是为了调整Spring Security框架的特征的所有表现由自己来设置。
关于默认的登录页
在自定义的配置类中的void configure(HttpSecurity http)
方法中,调用参数对象的formLogin()
方法即可开启默认的登录表单,如果没有调用此方法,则不会应用默认的登录表单,例如:
@Slf4j
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// super.configure(http); // 不要保留调用父级同名方法的代码,不要保留!不要保留!不要保留!
// 如果调用以下方法,当Security认为需要通过认证,但实际未通过认证时,就会跳转到登录页面
// 如果未调用以下方法,将会响应403错误
http.formLogin();
}
}
关于请求的授权访问(访问控制)
在刚刚添加spring-boot-starter-security
时,所有请求都是需要登录后才允许访问的,当添加了自定义的配置类且没有调用父级同名方法后,所有请求都是不需要登录就可以访问的!
为了实现一部分需要登录,一部分不需要登录就需要做配置类,不然如果是自己做了一个登录页面,访问登录页面还需要登录就不合适。
在配置类中的void configure(HttpSecurity http)
方法中,调用参数对象的authorizeRequests()
方法开始配置授权访问:
@Override
protected void configure(HttpSecurity http) throws Exception {
// 白名单
// 使用1个星号,可以通配此层级的任何资源,例如:/admin/*,可以匹配:/admin/add-new、/admin/list,但不可以匹配:/admin/password/change
// 使用2个连续的星可以,可以通配若干层级的资源,例如:/admin/**,可以匹配:/admin/add-new、/admin/password/change
String[] urls = {
"/doc.html",
"/**/*.css",
"/**/*.js",
"/swagger-resources",
"/v2/api-docs",
};
// 配置授权访问
// 注意:以下授权访问的配置,是遵循“第一匹配原则”的,即“以最先匹配到的规则为准”
// 例如:anyRequest()是匹配任何请求,通常,应该配置在最后,表示“除了以上配置过的以外的所有请求”
// 所以,在开发实践中,应该将更具体的请求配置在靠前的位置,将更笼统的请求配置在靠后的位置
http.authorizeRequests() // 开始对请求进行授权
.mvcMatchers(urls) // 匹配某些请求
.permitAll() // 许可,即不需要通过认证就可以访问
.anyRequest() // 任何请求
.authenticated() // 要求已经完成认证的
;
}
http.authorizeRequests() // 开始对请求进行授权
表示开始对请求进行授权 。
.anyRequest() // 任何请求
.authenticated() // 要求已经完成认证的
上面两句需要连起来理解,表示任何请求都要求是已经完成认证的。加上这两句就回到了最开始的样子,所有的请求都需要登录才能访问,不登录访问不了。
.mvcMatchers(urls) // 匹配某些请求
.permitAll() // 许可,即不需要通过认证就可以访问
这两句话也是连起来理解的, 理解同上面一样,上面是任何请求,这里是匹配某些请求,上面的行为是所有都要求认证,这里的行为是许可访问,不需要通过认证。(因为遵循“第一匹配原则”的,即“以最先匹配到的规则为准”,所有这两行代码要放在最上面才有效)
这里的urls是怎么来的:
首先为了方便页面正确显示,勾上禁用缓存。
看下面错误提示,看到有一堆的200都是login的,为了更好的看到提示信息,把http.formLogin()关掉。
可以看到大量的403
从中我们对这些403进行许可(案例访问的API文档,给文档需要的资源进行许可,就可以顺利访问API文档了),urls就是这么来的。
注意:有的比如表面是说的api-docs这样一个名字,实际在配白名单的时候,看它的url是在一个v2的文件夹里面,配置为 "/v2/api-docs"。
使用自定义的账号登录
在使用Spring Security框架时,可以自定义组件类,实现UserDetailsService
接口,则Spring Security就会基于此类的对象来处理认证!
则在项目的根包下创建security.UserDetailsServiceImpl
,在类上添加@Service
注解使其成为组件类,实现UserDetailsService
接口:
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
return null;
}
}
(通过loadUserByUsername(String s)这个方法的名字可以理解为通过用户名加载用户,参数s就是username用户名,返回UserDetails用户详情)
在项目中存在UserDetailsService
接口类型的组件对象时,尝试登录时,Spring Security就会自动使用登录表单中输入的用户名来调用以上方法, 把输入的用户名作为一个参数, 并得到方法返回的UserDetails
类型的结果, 此结果中应该包含用户的相关信息,例如密码、账号状态、权限等等,接下来,Spring Security框架会自动判断账号的状态(例如是否启用或禁用)、验证密码(在UserDetails
中的密码与登录表单中的密码是否匹配)等,从而决定此次是否登录成功!
所以,对于开发者而言,在以上方法中只需要完成“根据用户名返回匹配的用户详情”即可!例如:
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("用户名:{}", s);
// 假设正确的用户名是root,匹配的密码是1234
if (!"root".equals(s)) {
log.debug("此用户名没有匹配的用户数据,将返回null");
return null;
}
log.debug("用户名匹配成功!准备返回此用户名匹配的UserDetails类型的对象");
UserDetails userDetails = User.builder()
.username(s)
.password("1234")
.disabled(false) // 账号状态是否禁用
.a***ountLocked(false) // 账号状态是否锁定
.a***ountExpired(false) // 账号状态是否过期
.credentialsExpired(false) // 账号的凭证是否过期
.authorities("这是一个临时使用的山寨的权限!!!") // 权限
.build();
log.debug("即将向Spring Security返回UserDetails类型的对象:{}", userDetails);
return userDetails;
}
}
以上代码中,用User.builder()开启它的构建者模式, .build()表示构建完了。这是一个链式写法,先有个builder()在执行 .build()就可以创建这个对象。创建的过程中就传入例如密码、账号状态、权限等相关信息。
当项目中存在UserDetailsService
类型的对象后,启动项目时,控制台不会再提示临时使用的UUID密码!并且,user
账号也不可用! 用的就是自己配的 .username(s) .password("1234")这个。
另外,Spring Security框架认为所有的密码都是必须显式的经过某种算法处理过的,如果使用的密码是明文(原始密码例如1234这种),也必须明确的指出!例如,使用没加密的原始密码在Security的配置类中添加配置NoOpPasswordEncoder
这种密码编码器告诉Security是没有加密的,不然会报错:
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
此时尝试登录,输入用户名root,密码1234,登录成功,输入错误的用户名提示以下为null,这是因为这个null是在用户名不对的时候我们给它的。
如果用户名是输入的root,密码故意输出会提示:
如果把禁用打开,输入正确的用户名密码也会显示用户已失效:
使用数据库中的账号登录
需要将UserDetailsServiceImpl
中的实现改为“根据用户名查询数据库中的用户信息”!需要执行的SQL语句大致是:
select username, password, enable from ams_admin where username=?
在pojo.vo.AdminLoginInfoVO
类:
@Data
@A***essors(chain = true)
public class AdminLoginInfoVO implements Serializable {
private String username;
private String password;
private Integer enable;
}
在AdminMapper
接口中添加抽象方法:
AdminLoginInfoVO getLoginInfoByUsername(String username);
在AdminMapper.xml
中配置SQL:
<!-- AdminLoginInfoVO getLoginInfoByUsername(String username); -->
<select id="getLoginInfoByUsername" resultType="***.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO">
SELECT
username, password, enable
FROM
ams_admin
WHERE
username=#{username}
</select>
在AdminMapperTests
中编写并执行测试:
@Test
void getStandardById() {
String username = "root";
Object queryResult = mapper.getLoginInfoByUsername(username);
System.out.println("根据【username=" + username + "】查询数据完成,结果:" + queryResult);
}
然后,在UserDetailsServiceImpl
中调整原来的实现,改成:
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("Spring Security框架自动调用了UserDetailsServiceImpl.loadUserByUsername()方法,用户名:{}", s);
// 根据用户名从数据库中查询匹配的用户信息
AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);
if (loginInfo == null) {
log.debug("此用户名没有匹配的用户数据,将返回null");
return null;
}
log.debug("用户名匹配成功!准备返回此用户名匹配的UserDetails类型的对象");
UserDetails userDetails = User.builder()
.username(loginInfo.getUsername())
.password(loginInfo.getPassword())
.disabled(loginInfo.getEnable() == 0) // 账号状态是否禁用
.a***ountLocked(false) // 账号状态是否锁定
.a***ountExpired(false) // 账号状态是否过期
.credentialsExpired(false) // 账号的凭证是否过期
.authorities("这是一个临时使用的山寨的权限!!!") // 权限
.build();
log.debug("即将向Spring Security返回UserDetails类型的对象:{}", userDetails);
return userDetails;
}
为了得到较好的运行效果,应该在数据表中插入一些新的测试数据,例如:
因为目前配置的密码编码器是NoOpPasswordEncoder
,所以,本次测试运行时,使用的账号在数据库的密码应该是明文密码!
关于密码编码器
Spring Security定义了PasswordEncoder
接口,可以有多种不同的实现,此接口中的抽象方法主要有:
// 对原密码进行编码,返回编码后的结果(密文)
String encode(String rawPassword);
// 验证密码原文(第1个参数)和密文(第2个参数)是否匹配
boolean matches(String rawPassword, String encodedPassword);
常见的对密码进行编码,实现“加密”效果所使用的算法主要有:
-
MD(Message Digest)系列:MD2 / MD4 / MD5
-
SHA(Secure Hash Algorithm)系列:SHA-1 / SHA-256 / SHA-384 / SHA-512
-
BCrypt
-
SCrypt
目前,推荐使用的算法是BCrypt
算法!在Spring Security框架中,也提供了BCryptPasswordEncoder
类,其基本使用:
public class BCryptTests {
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
@Test
void encode() {
String rawPassword = "123456";
System.out.println("原文:" + rawPassword);
for (int i = 0; i < 5; i++) {
String encodedPassword = passwordEncoder.encode(rawPassword);
System.out.println("密文:" + encodedPassword);
}
}
// 原文:123456
// 密文:$2a$10$YOW67gn1jGQsNd1lWFOktuxGEK3Ai4obSCo6m0o0zP3YA4iTm0QoS
// 密文:$2a$10$AoGlKthb1ZKzTAng5ssX6OUwN8.tC9junqbYhtF0POkr.XdFuoEWy
// 密文:$2a$10$wgBhSmnoFQ.LdvFCLd8lyOSsHuGVIpVYKW8.bW4yt2kBMYqG1G.5u
// 密文:$2a$10$OIiWGSjFH02Vr9khLEQnG.s2rGowkotMV14TThAgJK8KQm.WQq6pm
// 密文:$2a$10$DluGioTO7Z***0hmwDz8Ld.4Uyp2hIIZ/PcGhFCVd1P3FuSukqJN36
@Test
void matches() {
String rawPassword = "123456";
System.out.println("原文:" + rawPassword);
String encodedPassword = "$2a$10$wgBhSmnoFQ.LdvFCLd8lyOSsHuGVIpVYKW8.bW4yt2kBMYqG1G.5u";
System.out.println("密文:" + encodedPassword);
boolean result = passwordEncoder.matches(rawPassword, encodedPassword);
System.out.println("匹配结果:" + result);
}
}
关于BCrypt算法,其典型特征有:
-
使用同样的原文,每次得到的密文都不相同
-
BCrypt算法在编码过程中,使用了随机的“盐”(salt)值,所以,每次编码结果都不同
-
编码结果中保存了这个随机的盐值,所以,并不影响验证是否匹配
-
-
运算效率极为低下,可以非常有效的避免暴力破解
-
可以通过构造方法传入
strength
值,增加强度(默认为10
),表示运算过程中执行2的多少次方的哈希运算 -
此特征是MD系列和SHA家庭的算法所不具备的特征
-
另外,SCrypt算法的安全性比BCrypt还要高,但是,执行效率比BCrypt更低,通常,由于BCrypt算法已经能够提供足够的安全强度,所以,目前,使用BCrypt是常见的选择。
使用BCrypt算法
只需要在Security配置类中将密码编码器换成BCryptPasswordEncoder
即可:
接下来,便可以使用数据库中那些密码是密文的账号测试登录:
(注意:专业名词上BCrypt算法以及上文提到的MD等都不是加密算法,加密算法是能加密还能解密的,而这些算法都是单向加密不可逆和还原的。登录的时候只能用数据库的密文和传递进来的密文做匹配,是不能验证原密码的。)
在以上案例中还不能使用post请求,需要以下:
Spring Security框架设计了“防止伪造的跨域攻击”的防御机制,所以,默认情况下,自定义的POST请求是不可用的,简单的解决方案就是在Spring Security的配置类中禁用这个防御机制即可,例如:
关于伪造的跨域攻击
伪造的跨域攻击:此类攻击原理是利用服务器端对客户端浏览器的“信任”来实现的!目前,主流的浏览器都是多选项卡模式的,假设在第1个选项卡中登录了某个网站,在第2个选项卡也打开这个网站的页面,就会被当作是已经登录的状态!基于这种特征,假设在第1个选项卡中登录了某个网上银行,在第2个选项卡中打开了某个坏人的网站(不是网上银行的网站),但是,在这个坏人的网站的页面中隐藏了一个使用网上银行进行转账的请求,这个请求在坏人的网站的页面刚刚打开时就自动发送出去了(自动发送:方法很多,例如将URL设置为某个不显示的<img>
标签的src
值),由于在第1个选项卡中已经登录了网上银行,从第2个选项卡中发出的请求也会被视为已经登录网上银行的状态,这就实现了一种攻击行为!当然,以上只是举例,真正的银行转账不会这么简单,例如还需要输入密码、手机验证码等等,但是,这种模式的攻击行为是确实存在的,由于使用另一个网站(坏人的网站)偷偷的实现的攻击,所以,称之为“伪造的跨域攻击”!
典型的防御手段:在Spring Security框架中,默认就开启了对于“伪造跨域攻击”的防御机制,其做法是在所有POST表单中隐藏一个具有“唯一性”的“随机值”,例如UUID值,当客户端提交请求时,必须提交这个UUID值,如果未提交,则服务器端将其直接视为攻击行为,将拒绝处理此请求!以Spring Security默认的登录表单为例:
当把防御机制禁用后,这个数值也就没有了。
提示:此前“如果在打开登录页面后重启过服务器端,则第1次的输入是无效的”,也是因为这种防御机制,当打开登录页,服务器端生成了此次使用的UUID,但重启服务器后,服务器不再识别此前生成的UUID,所以,第1次的输入是无效的!
目前以上已经实现Spring Security框架它默认带来的效果,解决了认证和授权的问题,最主要的用它来处理登录。但目前还不够,还需要实现前后端分离的登录。
使用前后端分离的登录
Spring Security框架自带了登录页面和退出登录页面,不是前后端分离的,则不可以与自行开发的前端项目中的登录页面进行交互,如果要改为前后端分离的模式,需要:
-
不再启用服务器端Spring Security框架自带的登录页面和退出登录页面
-
在配置类中不再调用
http.formLogin()
即可
-
-
使用控制器接收客户端的登录请求
-
自定义Param类,封装客户端将提交的用户名和密码,在控制器类中添加接收登录请求的方法
-
-
注意:需要将此请求配置在“白名单”中(不能登录之后在登录)
使用Service处理登录的业务
-
在接口中声明抽象方法,并在实现类中重写此方法
-
具体的验证登录,仍可以由Spring Security框架来完成,调用
AuthenticationManager
(认证管理器)对象的authenticate()
方法即可,则Spring Security框架会自动基于调用方法时传入的用户名来调用UserDetailsService
接口对象的loadUserByUsername()
方法,并得到返回的UserDetails
对象,然后,自动判断账号状态、对比密码等等-
可以在Spring Security的配置类中重写
authenticationManagerBean()
方法,并在此方法上添加@Bean
注解,则可以在任何所需要的位置自动装配AuthenticationManager
类型的数据,注意:不要使用authenticationManager()
方法,此方法在某些场景(例如某些测试等)中可能导致死循环,最终内存溢出
-
调用AuthenticationManager
(认证管理器)对象的authenticate()
方法,传入authentication这个参数。
点开Authentication 发现,也是一个接口
而Authentication实现类是
它需要传入参数 , 有两套构造方法,第一套第一个参数是用户名,第二个是密码。通过传入的参数取出用户名和密码。
以上:根据取出的用户名和密码创建了用户认证对象authentication ,用于去调用认证管理器AuthenticationManager的认证方法authenticate()。
最后验证登录成功。
完成后,重启项目,可以通过API文档的调试功能来测试登录,如果使用无法登录的账号信息,会在服务器端的控制台看到对应的异常:
-
用户名不存在
org.springframework.security.authentication.InternalAuthenticationServiceException: UserDetailsService returned null, which is an interface contract violation
-
密码错误
org.springframework.security.authentication.BadCredentialsException: 用户名或密码错误
-
账号被禁用
org.springframework.security.authentication.DisabledException: 用户已失效
可以在全局异常处理器中添加处理以上异常的方法,通常,在处理时,不会严格区分“用户名不存在”和“密码错误”这2种错误,也就是说,无论是这2种错误中的哪一种,一般提示“用户名或密码错误”即可,以进一步保障账号安全!
关于以上用户名不存在、密码错误时对应的异常,其继承结构是:
AuthenticationException
-- BadCredentialsException // 密码错误
-- AuthenticationServiceException
-- -- InternalAuthenticationServiceException // 用户名不存在
则可以在处理异常的方法上,在@ExceptionHandler
注解中指定需要处理的2种异常,并且,使用这2种异常公共的父类作为方法的参数,(如果光使用父类作为参数,父类下的其他异常也会被处理,所以要指定要处理的两种异常)例如:
// 如果@ExceptionHandler没有配置参数,则以方法参数的异常为准,来处理异常
// 如果@ExceptionHandler配置了参数,则只处理此处配置的异常
@ExceptionHandler({
InternalAuthenticationServiceException.class,
BadCredentialsException.class
})
public JsonResult handleAuthenticationException(AuthenticationException e) {
// 暂不关心方法内部的代码
}
在实际处理时,需要先在ServiceCode
中添加新的枚举值,以表示以上错误的状态码:
然后,在全局异常处理器中添加处理异常的方法:
// 如果@ExceptionHandler没有配置参数,则以方法参数的异常为准,来处理异常
// 如果@ExceptionHandler配置了参数,则只处理此处配置的异常
@ExceptionHandler({
InternalAuthenticationServiceException.class,
BadCredentialsException.class
})
public JsonResult handleAuthenticationException(AuthenticationException e) {
log.warn("程序运行过程中出现了AuthenticationException,将统一处理!");
log.warn("异常:", e);
String message = "登录失败,用户名或密码错误!";
return JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED, message);
}
@ExceptionHandler
public JsonResult handleDisabledException(DisabledException e) {
log.warn("程序运行过程中出现了DisabledException,将统一处理!");
log.warn("异常:", e);
String message = "登录失败,账号已经被禁用!";
return JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED_DISABLE, message);
}
以上只能算验证已经完成了,还不能算登录已经成功,因为在判断用户名和密码对了以后,还需要把相关的信息比如用户,把它放进例如session里面去,回头判断有没有登录的标准,就是看session有没有这个信息,有就是登录了,没有就是没登录。所以登录不是判断用户名密码就结束,还需要把信息留下来,下次在来访问的时候才知道你是谁。不仅仅是验证的过程。
关于认证的标准
Spring Security为每个客户端分配了一个SecurityContext
(可称之为“Security上下文”),并且,会根据在SecurityContext
中是否存在认证信息来判断当前请求是否已经通过认证!即:
-
如果在
SecurityContext
中存在有效的认证信息,则视为“已通过认证” -
如果在
SecurityContext
中没有有效的认证信息,则视为“未通过认证”
所以,在验证登录成功后,需要将认证信息存入到SecurityContext
中,否则,所开发的登录功能是没有意义的!
其实调用AuthenticationManager
(认证管理器)对象的authenticate()
方法时是可以接收到一个返回值的,可以获取到认证结果。
使用SecurityContextHolder
的getContext()
静态方法可以获取当前客户端对应的SecurityContext
对象!
打印认证方法返回的结果
以上认证方法返回的结果例如:
UsernamePasswordAuthenticationToken [
Principal=org.springframework.security.core.userdetails.User [
Username=root,
Password=[PROTECTED],
Enabled=true,
A***ountNonExpired=true,
credentialsNonExpired=true,
A***ountNonLocked=true,
Granted Authorities=[这是一个临时使用的山寨的权限!!!]
],
Credentials=[PROTECTED],
Authenticated=true,
Details=null,
Granted Authorities=[这是一个临时使用的山寨的权限!!!]
]
其实,以上数据是基于UserDetailsSerivce
实现类中loadUserByUsername()
返回的UserDetails
对象来创建的!
后续整个Spring Security在登录之后每次发请求的时候就可以重SecurityContext的到这个数据,从而识别你的身份。
以上算是实现了一个登录的完整功能,但是还有一个小的问题,比如在登录了的时候,服务端重启了,此时登录的信息就没了,此时在没有登录信息的时候去访问那些必须要登录的请求。会得到一个403错误。所以以下:
未通过认证时拒绝访问
当未通过认证(Spring Security从SecurityContext
中未找到认证信息)时,尝试访问那些需要授权的资源(不在白名单中的,需要先登录才可以访问的资源),在没有启用http.formLogin()
时,默认将响应403
错误!
需要在Spring Security的配置类中进行处理:
首先用http去这个方法
这个方法需要传进去的参数的类型是AuthenticationEntryPoint,点开后发现也是一个接口。
有两种方式,可以自己写个类去实现,但这个本身是一次性的使用,因为这个类只用在配置里,而配置本身是一次性的代码,所以可以用匿名内部类来写。
这里我们需要向客户端去响应一个错误说你还没有登录,那么可以直接用response去响应,比如通过一个输出流-写出文本-关流响应一个简单内容:
但是现在响应的内容太过简单,可以响应一个message内容进去。
此时响应出现显示为一堆问号,这是因为java原始的服务器端的问题,默认使用的是ISO-8859-1这个编码格式,这种格式是不支持中文的。
要在文档响应之前,设置编码格式,例如:
此时显示就没有问题了:
但此时任然不符合我们的设计需求。我们因该响应给客户端的是一个json结果,而不是一个字符串而已,需要更改文档类型前半截:
在写入一个json格式的字符串(格式可以复制,手敲累容易出错):
得到显示json的结果:
现在代码恶心在需要自己去拼json这个结果,最终我们要响应的还是和之前成功处理请求和处理异常时得到是一样的结果,它依然是一个json格式的数据,只不过我们之前处理请求,处理异常返回JsonResult就可以,为什么返回JsonResult的对象最终响应是一个json的数据是因为springMVC框架帮我们做了数据格式的转换,转换成json格式的字符串。但现在不能转,它不在springMVC的范围之内。
则需要人为创建JSON格式的结果!可以借助fastjson
工具进行处理,这是一款可以实现对象与JSON格式字符串相互转换的工具!需要添加依赖:
<fastjson.version>1.2.75</fastjson.version>
<!-- fastjson:实现对象与JSON的相互转换 -->
<dependency>
<groupId>***.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>${fastjson.version}</version>
</dependency>
便可以用这个工具做以下调整:
最终:
目前,登录算做好了,对Security使用难得部分已经过去了,下面是一些往后推进会设计的问题:
识别当事人(Principal)
当事人:当前提交请求的客户端的身份数据 。
当事人是一种身份数据,作用是,比如你登录一款软件,这个软件得知道你是谁,不然就无法做相关的操作,例如登录之后你要修改自己的密码,首先它得知道你是谁,然后再去改你的密码。这份表示你到底是谁的这个数据其核心,我们就把它叫做当事人。
当通过登录的验证后,AuthenticationManager
的authenticate()
方法返回的Authentication
对象中,就包含了当事人信息!例如:
UsernamePasswordAuthenticationToken [
Principal=org.springframework.security.core.userdetails.User [
Username=root,
Password=[PROTECTED],
Enabled=true,
A***ountNonExpired=true,
credentialsNonExpired=true,
A***ountNonLocked=true,
Granted Authorities=[这是一个临时使用的山寨的权限!!!]
],
Credentials=[PROTECTED],
Authenticated=true,
Details=null,
Granted Authorities=[这是一个临时使用的山寨的权限!!!]
]
数据里面的Principal这些数据就是当事人。
由于已经将以上认证结果存入到SecurityContext
中,则可以在后续任何需要识别当事人的场景中,获取当事人信息!
Spring Security提供了非常便利的获取当事人的做法,在控制器类中的处理请求的方法的参数列表中,可以声明当事人类型的参数(这里的user就是当时返回的userDetails,即可以说它是userDetails类型也可以说是User类型):
并在参数上添加@AuthenticationPrincipal
注解即可,例如找到管理员的controller:
上面添加@ApiIgnore是因为 写user有一个问题是API文档会以为你这个是请求参数,会在API文档中看到很多参数,调试里面也会有很多输入框,需要加上这个注解来忽略。
此时这个user是有值的
它的值就是在登录成功后返回的当事人数据:
也就是这一截:
Principal=org.springframework.security.core.userdetails.User [
Username=root,
Password=[PROTECTED],
Enabled=true,
A***ountNonExpired=true,
credentialsNonExpired=true,
A***ountNonLocked=true,
Granted Authorities=[这是一个临时使用的山寨的权限!!!]
]
就可以通过get拿到当时人的信息:
完成以上代码后,重启项目,可以在API文档中使用各个账号尝试登录并访问以上“查询管理员列表”,可以看到日志中输出了当次登录的账号的用户名,例如:
通过以上做法,虽然可以获取当事人信息,但是,无论是
UserDetails
还是User
类型,可以获取的数据信息较少,且不包含当前登录的用户的ID,通常并不满足开发需求!
需要记住:当前在控制器类中处理请求的方法中注入的当事人数据,就是UserDetailsService
接口的实现类中返回的数据!
而里面的数据来自于loginInfo
loginInfo是从数据库查出来的
所以如果需要获取当事人的ID,需要:
在AdminLoginInfoVO
中添加ID属性
修改Mapper层的getLoginInfoByUsername()
,需要查询管理员ID
现有的UserDetails
的实现类User
并不支持ID属性,需要自定义类实现UserDetails
接口,或者,自定义类继承自User
类,在自定义类中扩展出所需的各种属性,例如ID
因为它本身给了我们user类
点开后发现user实现了UserDetails类
所以我们自定义继承user相对于也实现了UserDetails,最终也可以作为这个方法的返回值。
在项目的根包下创建security.AdminDetails
类,继承自User
类,添加基于父类的构造方法,并扩展出ID属性:
然后只用第二个多的构造方法,第一个可以去掉,第二个包含了第一个所有的参数,还有账户启动状态等必要的信息。
但同时也用不完第二个构造方法里面的所有参数,我们需要把自己的构造方法中不用的参数去掉,同时,在调用父类的构造方法的时候需要这个参数,我们在给个固定的值传过去就好了。
扩展出id属性,并给构造参数加上id传进来给值。回头还需要被这个值取出来,但是不能用@Data,因为Lombok需要在父类也就是user类有一个默认的无参构造方法,但是user没有。所以添加@Getter注解。
在UserDetailsService
中返回数据时,改为返回自定义类的对象,其中将包含ID等属性值
里面的自定义的传参会略有不用,之前判断账号是否禁用的==0,因为当时方法叫做disabled禁用,而自己的属性的启用,就用==1判断。
添加权限用集合,以下:
最终代码如下:
在控制器类中处理请求的方法中,注入的当事人类型改为自定义类型
以上实现了可以登录登录后也知道你是谁的功能,登录的效果就差不多了,而Spring Security还有一个重要的功能就是权限,我们可以区分不同的账户它有什么操作权限,使得某些用户可以做特定的事情。如果要去判端当前这个人有没有权限去做这个事情,第一件事是把现在给的山寨权限换成数据库里的真实权限。
实现根据权限限制访问
首先,需要在管理员登录时,明确此管理员的权限,则需要在Mapper层实现“根据用户名查询管理员的登录信息,且需要包含此管理员对应的各权限”,需要执行的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';
然后,修改现有的查询功能,需要先在AdminLoginInfoVO
类中添加新的属性,用于存放“权限列表”:
然后,调整AdminMapper.xml
中的配置:
<!-- AdminLoginInfoVO getLoginInfoByUsername(String username); -->
<select id="getLoginInfoByUsername" resultMap="LoginInfoResultMap">
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=#{username}
</select>
<!-- resultMap标签:指导MyBatis封装查询结果 -->
<!-- resultMap标签的id属性:自定义名称,也是select标签上使用resultMap属性的值 -->
<!-- resultMap标签的type属性:封装查询结果的类型的全限定名 -->
<resultMap id="LoginInfoResultMap"
type="***.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO">
<!-- id标签:配置主键的列与属性的对应关系 -->
<!-- result标签:配置普通的列与属性的对应关系 -->
<!-- collection标签:配置List集合类型的属性与查询结果中的数据的对应关系 -->
<!-- collection标签的ofType属性:集合中的元素类型,取值为类型的全限定名 -->
<id column="id" property="id"/>
<result column="username" property="username"/>
<result column="password" property="password"/>
<result column="enable" property="enable"/>
<collection property="permissions" ofType="String">
<!-- constructor标签:通过构造方法来创建对象 -->
<constructor>
<!-- arg标签:配置构造方法的参数,如果构造方法有多个参数,依次使用多个此标签 -->
<arg column="value"></arg>
</constructor>
</collection>
</resultMap>
补充解释(关于使用resultMap标签):
[SpringBoot]xml文件里写SQL用resultMap标签_万物更新_的博客-CSDN博客
配置完成后,可以通过测试进行检验,查询结果例如:
根据【username=super_admin】查询数据完成,结果:
AdminLoginInfoVO(
id=2,
username=super_admin,
password=$2a$10$N.ZOn9G6/YLFixAOPMg/h.z7pCu6v2XyFDtC4q.jeeGm/TEZyj15C,
enable=1,
permissions=[/pms/product/read, /pms/product/add-new, /pms/product/delete, /pms/product/update, /pms/brand/read, /pms/brand/add-new, /pms/brand/delete, /pms/brand/update, /pms/category/read, /pms/category/add-new, /pms/category/delete, /pms/category/update, /pms/picture/read, /pms/picture/add-new, /pms/picture/delete, /pms/picture/update, /pms/album/read, /pms/album/add-new, /pms/album/delete, /pms/album/update]
)
基于方法的权限检查
以上loginInfo已经有真实的权限信息,从中get出真实权限,遍历加到权限集合里面去,加进去后,返回的userDetails就有真正的权限信息了。
当有了真实的权限以后,接下来就可以对所有的访问加上权限的限制,就是某些人可以干什么,某些人不可以干什么。要实现这样的效果需要做两件事情。
第一件事情,找到配置类, 开启权限的检查机制
接下来就可以做访问什么需要什么权限,例如必须具有管理员权限的值的人,才可以查看权限列表。
加上下面这个注解后就表示你不光要登录,你的认证信息的权限列表里面必须要包含hasAuthority里面的这个值,才能够做这次的访问,如果不包含这个权限,就访问不了。
提示:以上使用@PreAuthorize
注解检查权限时,此注解可以添加在任何方法上!例如Controller中的方法,或Service中的方法等等,由于当前项目中,客户端的请求第一时间都是交给了Controller,所以,更适合在Controller方法上检查权限!
当访问不包含所需的权限时, Spring Security给了我们以下这个异常:
有异常在全局异常处理器里面处理异常:
在ServiceCode
中添加新的业务状态码表示“无此权限”:
[异常]401和403的区分_万物更新_的博客-CSDN博客
然后,在全局异常处理器中添加处理以上异常的方法:
以上权限做好以后,还需要给它添加Token功能,这样每次客服端在访问过一次之后,都不用在继续登陆。
[java]关于Session&关于Token&关于JWT_万物更新_的博客-CSDN博客
添加Token
首先添加Token-JWT的依赖项:
父项目添加版本管理:
父项目添加依赖:
子项目添加依赖:
添加好依赖以后做两个测试,一个生成JWT的测试,一个解析JWT 的测试:
生成JWT:
// 不太简单的、难以预测的字符串
String secretKey = "jhdSfkkjKJ3831HdsDkdfSA9jklJD749Fhsa34fdsKf08dfjFhkdfs";
@Test
void generate() {
Map<String, Object> claims = new HashMap<>();
claims.put("id", 9527);
claims.put("name", "张三");
String jwt = Jwts.builder()
// Header
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
// Payload
.setClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + 3 * 60 * 1000))//设置有效期,防止一直用.
// Verify Signature
.signWith(SignatureAlgorithm.HS256, secretKey)
// 生成
.***pact();
System.out.println(jwt);
}
备注:
基于它的做法,我们可以自己传进去一个值:
解析JWT
// 不太简单的、难以预测的字符串
String secretKey = "jhdSfkkjKJ3831HdsDkdfSA9jklJD749Fhsa34fdsKf08dfjFhkdfs";
@Test
void parse() {
String jwt = "eyJhbGciOiJIUzI1NiIsInR5***I6IkpXVCJ9.eyJuYW1lIjoi5byg5LiJIiwiaWQiOjk1MjcsImV4***I6MTY4NDkwODUwMn0.tBo7YKRqQv6TG2cf5jeu7nNjUim5X8H6pKLF1LrYuKI";
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
Long id = claims.get("id", Long.class);
String name = claims.get("name", String.class);
System.out.println("id = " + id);
System.out.println("name = " + name);
}
备注:
点进Claims可以看到本质是一个map
获取往里面放的值,直接给的是object,因为map的value被定义死了是object,取出也是object
但在这里Claims在原有的map之上,get方法是有扩展的,传入的第二个参数就是你的目标类型是什么,这样传进去是什么类型得到的就是什么类型。
以下是会这块会出现的异常,列举出来,回头需要全局处理。
如果尝试解析的JWT已经过期,会出现异常:
io.jsonwebtoken.ExpiredJwtException: JWT expired at 2023-05-24T12:02:38Z. Current time: 2023-05-24T14:04:35Z, a difference of 7317175 milliseconds. Allowed clock skew: 0 milliseconds.
如果解析JWT时使用的secretKey有误,会出现异常:
io.jsonwebtoken.SignatureException: JWT signature does not match locally ***puted signature. JWT validity cannot be asserted and should not be trusted.
如果解析JWT的数据格式错误,会出现异常:
io.jsonwebtoken.MalformedJwtException: JWT strings must contain exactly 2 period characters. Found: 1
补充:
注意:在不知晓secretKey的情况下,也可以解析出JWT中的数据(例如将JWT数据粘贴到官网),只不过验证签名是失败的,所以,不要在JWT中存放敏感信息(比如密码,手机号码,身份证号码等)!
验证签名是失败的就是说就算你知道里面的数据,但是我会告诉你不可信,比如id是9527但是也不要相信id就是9527,因为它很有可能是一个伪造的JWT,因为验证签名失败了。所以JWT的secretKey的价值是防止被伪造,而不是防止被解析出来,它不能做到这一点。
经过上面的测试,接下来就要在项目中使用JWT识别用户的身份了
在项目中使用JWT识别用户的身份
核心流程
在项目中使用JWT识别用户的身份,至少需要:
-
当验证登录成功时,生成JWT数据,并响应到客户端去,是“卖票”的过程
-
当验证登录成功后,不再需要(没有必要)
-
当验证登录成功时,生成JWT数据,并响应到客户端去,是“卖票”的过程
-
当验证登录成功后,不再需要(没有必要)将认证结果存入到
SecurityContext
中 ,之前是这样的:
-
-
-
当客户端提交请求时,需要获取客户端携带的JWT数据,并尝试解析,解析成功后,再将相关信息存入到
SecurityContext
中去,(因为之前我们说Security去检验这个账号或者说这次客户端的访问到底是不是一个已认证的状态,就只是去看SecurityContext里面有没有东西,所以一旦解析成功之后,还是要把相关信息往SecurityContext里面放,然后就没了,后续说他有没有登录啊,有没有权限啊不是这里管的事,是Security去做后续的处理
)是“检票”的过程-
可以调整Spring Security使用Session的策略,改为不使用Session,则不会将
SecurityContext
存入到Session中(不存在Session里面的好处是它就只作用在这一次请求中,这次请求结束了SecurityContext就没了,当下次在过来的时候就又有了,结束了又没了。。。所以SecurityContext里面的认证信息只作用于当次那一次而已,在没有调整之前是基于session的,意味着如果session的有效期是15分钟,那你把认证信息存上下文里面,那这个上下文的有效时间就是15分钟,15分钟之内一直存在这个数据了,如果有效期是30分钟,那就会存在30分钟,在这个30分钟里面肯定是会有浪费的时间的,内存里面存这个信息就会浪费了,并且在你重新来访之后时间又会重新调整为30分钟,所以会有很长时间的浪费
-
验证登录成功时响应JWT
需要调整的代码大致包括:
-
在
IAdminService
中,将login()
方法的返回值类型改为String
类型,重写的方法作同样的修改
-
在
AdminServiceImpl
中,验证登录成功后,生成此管理员的信息对应的JWT(把上文测试里面生成JWT的代码拿过来做修改),并返回
-
在
AdminController
中,处理登录时,调用Service方法时获取返回的JWT,并响应到客户端去
解析客户端携带的JWT
客户端提交若干种不同的请求时,可能都会携带JWT,对应的,在服务器,处理若干种不同的请求时,也都需要尝试接收并解析JWT,则应该使用过滤器(Filter)组件进行处理!
[web]关于过滤器Filter_万物更新_的博客-CSDN博客
其实,Spring Security框架也使用了许多不同的过滤器来解决各种问题,为了保证解析JWT是有效的,解析JWT的代码必须运行在Spring Security的某些过滤器之前,则接收、解析JWT的代码也必须定义在过滤器中!
提示:过滤器(Filter)是Java服务器端应用程序的核心组件之一,它是最早接收到请求的组件!过滤器可以对请求选择“阻止”或“放行”!同一个项目中,允许存在若干个过滤器,形成“过滤器链(Filter Chain)”,任何请求必须被所有过滤器都“放行”,才会被控制器或其它组件所处理!
按照之前的方法,实现javax.servlet的过滤器接口,让后重写doFilter方法。
但是重写方法需要对类型进行强转,比较麻烦,不太好用。
我们这次选择去继承Spring系列框架提供的OncePerRequestFilter这个类。
这个类是一个抽象类,这个类继承自GenericFilterBean这个类。
而GenericFilterBean这个类实现了Filter这个接口, 所以继承OncePerRequestFilter这个类也算是实现了过滤器接口的。
继承spring这个框架提供的OncePerRequestFilter这个类已经帮我们做了强转了,就不用我们自己强转了。
所以在项目的根包下创建filter.JwtAuthorizationFilter
类,继承自OncePerRequestFilter
类,并添加@***ponent
注解:
@Slf4j
@***ponent
public class JwtAuthorizationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.debug("JwtAuthorizationFilter开始执行……");
// 放行
filterChain.doFilter(request, response);
}
}
添加@***ponent
注解把它标记成组件是因为通过注入,把解析JWT的代码必须运行在Spring Security的某些过滤器之前。
到此可以测试通过API登录请求常看第一步过滤器有没有生效:
关于携带JWT,根据业内惯用的做法,客户端会将JWT放在请求头(Request Header)中的Authorization属性中,在Knife4j的API文档中,可以:
关于过滤器的初步实现:
/**
* JWT过滤器,解决的问题:接收JWT,解析JWT,将解析得到的数据创建为认证信息并存入到SecurityContext
*/
@Slf4j
@***ponent
public class JwtAuthorizationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.debug("JwtAuthorizationFilter开始执行……");
// 根据业内惯用的做法,客户端会将JWT放在请求头(Request Header)中的Authorization属性中
String jwt = request.getHeader("Authorization");
log.debug("客户端携带的JWT:{}", jwt);
// 判断客户端是否携带了有效的JWT
if (!StringUtils.hasText(jwt)) {
// 如果JWT无效,则放行,并reture
filterChain.doFilter(request, response);
return;
}
// TODO 当前类和AdminServiceImpl中都声明了同样的secretKey变量,是不合理的
// TODO 解析JWT过程中可能出现异常,需要处理
// 尝试解析JWT
String secretKey = "jhdSfkkjKJ3831HdsDkdfSA9jklJD749Fhsa34fdsKf08dfjFhkdfs";
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
Long id = claims.get("id", Long.class);
String username = claims.get("username", String.class);
System.out.println("id = " + id);
System.out.println("username = " + username);
// TODO 需要考虑使用什么数据作为当事人
// TODO 需要使用真实的权限
// 创建认证信息
Object principal = username; //当事人 可以是任何类型,暂时使用用户名
Object credentials = null; //凭证 本次不需要
Collection<GrantedAuthority> authorities = new ArrayList<>();//权限
authorities.add(new SimpleGrantedAuthority("山寨权限"));
Authentication authentication = new UsernamePasswordAuthenticationToken(
principal, credentials, authorities);
// 将认证信息存入到SecurityContext中
SecurityContext securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(authentication);
// 放行
filterChain.doFilter(request, response);
}
}
因为上面代码中当事人是username,此时参数再用AdminDetails 声明是不对的,此处暂时去掉。
需要注意:由于Spring Security的SecurityContext
默认是基于Session的,所以,当携带JWT成功登录访问过后,在SecurityContext
中就已经有了认证信息,并且,在Session的有效期内,即使后续不携带JWT,Spring Security也能基于Session找到SecurityContext
并读取到认证信息,并不在需要登录就能访问的,这可能与设计初衷并不相符!
可以将Spring Security使用(创建)Session的策略改为“完全不使用Session”,需要在Spring Security的配置类中添加配置:
备注:
1.用StringUtils.hasText的方法
用StringUtils.hasText的方法可以同时判断,不能为空,不能为null,和包含文本。
包含文本即不是空白就是包含文本:
2.关于 Object credentials = null本此不需要凭证,因为之前凭证的表现是密码,而放在上下文里的认证信息作用是回头框架来识别出你是谁,有什么权限,这个过程是不需要使用密码的。
关于认证信息中的当事人
pring Security框架并不介意你使用什么类型作为认证信息(Authentication
)中的当事人(principal
)!
在项目中,到底使用什么类型作为当事人,可以自行考虑,主要考虑的因素就是:当你需要注入当事人数据的时候,你希望能够得到哪些数据!
在项目的根包下创建security.LoginPrincipal
作为自定义的当事人类型:
并且,在解析JWT成功后,在过滤去使用此类型作为当事人来创建认证信息:
后续,在Controller中,就可以通过@AuthenticationPrincipal
来注入自定义的当事人数据,例如:
接着处理一个小问题,因为在生成和解析JWT的时候对需要用到secretKey这个值,并且这个值相同,如果不相同就会签名失败,所以一个完全相同的代码写两遍是不合理的,有两种解决方案,第一种是专门写一个类去调取,第二个是写在application.yml文件里面,它们的区别是在application.yml里面需要读取在应用,有一个读取的过程,在类里面是直接应用的,从执行效率来说肯定是在类里面更快一些,但由于这个值需要甲方来定(为了防止伪造相关问题),所以必须写在application.yml里面。
关于secretKey必须有4位以上,否则都会被视为空值报错
以上权限还是一个假的权限,需要换成真的权限,目前我们就用把权限放在JWT中,然后再从JWT中取出权限的方式。(以替换在数据库里查的方式,因为从数据库里查数据是一个效率低下的方式,其一需要连接,传递SQL,然后准备,准备好了编译执行,执行好了在给个结果一个过程。其二,数据库里面的数据存在硬盘里面,硬盘是一个存储效率非常低效的硬件。同时这段代码只要有客户端来访就会执行这段代码,发生的非常高频率,所以不能选择连接数据库这么低效的做法)
把集合放进JWT里面。
从JWT取出权限列表
这样的取出方式看似语法没有问题,但会出现类型转换错误。
因为在这一步,它获取出来的是LinkedHashMap,但是LinkedHashMap不能强制转其他类型,为什么获取的是LinkedHashMap类型呢,因为API不知道你要获取什么类型,给你处理为了LinkedHashMap。因为是集合加泛型也没有办法向获取id一样在后面第二个参数加上Long.class来指定返回的类型。
所以这里需要换一个做法,在生成JWT的时候不往里面放集合里,改为放Json,
可以放Json是因为我们有添加fastjson的依赖,实现对象和Json相互转换的依赖。
在从JWT 取出权限的时候也取出Json字符串,然后用fastjson转成集合
以上就实现真实权限的功能了。
注意:此方式也不是最优解决方案。
接下来处理解析JWT时可能出现的异常,往常我们是在全局异常处理器处理的,但是在这里不行,因为解析JWT是在过滤器里面做的,全局异常处理器只能处理controller抛出的异常。
处理解析JWT时的异常
由于解析JWT是在过滤器组件中执行的,而过滤器是最早处理请求的组件,此时,控制器(Controller)还没有开始处理这次的请求,则全局异常处理器也无法处理解析JWT时出现的异常(全局异常处理器只能处理控制器抛出的异常)!这里使用最原始的try...catch处理
首先,在ServiceCode
中补充新的状态码:
ERR_JWT_EXPIRED(60000),
ERR_JWT_MALFORMED(60100),
ERR_JWT_SIGNATURE(60200),
然后,在JwtAuthorizationFilter
中,使用try...catch
包裹尝试解析JWT的代码:
// 尝试解析JWT
response.setContentType("application/json; charset=utf-8");
Claims claims = null;
try {
claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
} catch (MalformedJwtException e) {
String message = "非法访问!";
log.warn("程序运行过程中出现了MalformedJwtException,将向客户端响应错误信息!");
log.warn("错误信息:{}", message);
JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_MALFORMED, message);
String jsonString = JSON.toJSONString(jsonResult);
PrintWriter printWriter = response.getWriter();
printWriter.println(jsonString);
printWriter.close();
return;
} catch (SignatureException e) {
String message = "非法访问!";
log.warn("程序运行过程中出现了SignatureException,将向客户端响应错误信息!");
log.warn("错误信息:{}", message);
JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_SIGNATURE, message);
String jsonString = JSON.toJSONString(jsonResult);
PrintWriter printWriter = response.getWriter();
printWriter.println(jsonString);
printWriter.close();
return;
} catch (ExpiredJwtException e) {
String message = "您的登录信息已经过期,请重新登录!";
log.warn("程序运行过程中出现了ExpiredJwtException,将向客户端响应错误信息!");
log.warn("错误信息:{}", message);
JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_EXPIRED, message);
String jsonString = JSON.toJSONString(jsonResult);
PrintWriter printWriter = response.getWriter();
printWriter.println(jsonString);
printWriter.close();
return;
} catch (Throwable e) {
String message = "服务器忙,请稍后再试!【在开发过程中,如果看到此提示,应该检查服务器端的控制台,分析异常,并在解析JWT的过滤器中补充处理对应异常的代码块】";
log.warn("程序运行过程中出现了Throwable,将向客户端响应错误信息!");
log.warn("异常:", e);
JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_UNKNOWN, message);
String jsonString = JSON.toJSONString(jsonResult);
PrintWriter printWriter = response.getWriter();
printWriter.println(jsonString);
printWriter.close();
return;
}
注意:
以上代码中只有response.setContentType("application/json; charset=utf-8");这串代码可以提到最上面给每一个catch复用。 PrintWriter printWriter = response.getWriter();是不可以的:
把printWriter 放在上面会导致本该在正常成功访问的时候会报状态异常错误,说getWriter在本次调用中已经被占用了。原因是我们服务端向客户端响应就是用printWriter 来响应的,然后你在上图中拿到了getWriter输出流,控制器那边就拿不到响应成功的输出流了,以至于控制器没有办法去响应。
以上JWT就差不多了,以下在和前端结合的时候还需要实现的一些功能。
处理复杂请求的跨域问题
当客户端提交请求时,在请求头中配置了特定的属性(例如Authorization,带了JWT的时候
),则这个请求会被视为“复杂请求”:
对于复杂请求,浏览器会先对服务器端发送OPTIONS
类型的请求(也是和get,post一样的请求方式,OPTIONS请求的目的是试一下服务器是不是好的,是不是可以接受
),以执行预检(PreFlight),如果预检通过,才会执行本应该发送的请求。
然后会看到它的请求就需要给它配置白名单已通过。
在Spring Security的配置类中,可以在配置对请求授权时,将所有OPTIONS
类型的请求全部直接许可,例如:
或者,调用参数对象的cors()
方法也可以,例如:
提示:对于复杂请求的预检,是浏览器的行为,并且,当某个请求通过预检后,浏览器会缓存此结果,后续再次发出此请求时,不会再次执行预检。
实现单点登录,以下
单点登录
SSO(Single Sign On):单点登录,表示在集群或分布式系统中,客户端只需要在某1个服务器上完成登录的验证,后续,无论访问哪个服务器,都不需要再次重新登录!常见的实现手段主要有:共享Session,使用Token。
目前,如果希望客户端在csmall-passport
中登录后,在csmall-product
中也能够被识别身份、权限,需要:
-
复制依赖项:
spring-boot-starter-security
、jjwt
、fastjson
-
复制
LoginPrincipal
-
复制
ServiceCode
,覆盖此前的文件
-
复制
application-dev.yml
中的自定义的配置
-
复制
JwtAuthorizationFilter
-
复制
SecurityConfiguration,并更改导包
-
删除
PasswordEncoder
的@Bean
方法 -
删除
AuthenticationManager
的@Bean
方法 -
删除“白名单”中管理员登录的URL地址
-
完成后,在csmall-product
项目中,也可以通过@AuthenticationPrincipal
来注入当事人数据,也可以使用@PreAuthorize
来配置访问权限,这些都是通的。