关于Spring Security框架
Spring Security框架主要解决了认证与授权相关的问题。
1. 添加Spring Boot Security依赖
在csmall-passport
项目中添加依赖项:
<!-- Spring Boot Security的依赖项,用于处理认证与授权 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
当添加了以上依赖项后,当前项目会:
- 此依赖项中包含
BCryptPasswordEncoder
类,可以用于处理密码加密 - 所有请求都是必须通过认证的,在没有通过认证之前,任何请求都会被重定向到Spring Security内置的登录页面
- 可以使用
user
作为用户名,使用启动项目时随机生成的UUID密码来登录 - 当登录成功后,会自动重定向到此前尝试访问的页面
- 当登录成功后,所有GET的异步请求允许访问,但POST的异步请求不允许访问(403错误)
- 可以使用
当添加依赖后,在浏览器中尝试访问时还可能出现以下错误:
org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the header value "Idea-c968a669=03021799-4633-4321-9d0d-11b7ee08f588; username=é»æ±å; JSESSIONID=120F9329E0CE7AF9E052A302EFE494F2" is not allowed.
此错误是浏览器的问题导致的,更换浏览器即可。
2. 关于BCryptPasswordEncoder
BCrypt算法是用于对密码进行加密处理的,在spring-boot-starter-security
中包含了BCryptPasswordEncoder
,可以实现编码、验证:
public class BCryptTests {
@Test
public void encode() {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String rawPassword = "123456";
System.out.println("原文:" + rawPassword);
for (int i = 0; i < 50; i++) {
String encodedPassword = passwordEncoder.encode(rawPassword);
System.out.println("密文:" + encodedPassword);
}
// 密文:$2a$10$H7neseWrkpdCQiW6R4bJyeXaU.nowsFZZz.iO4HCLzFScz.FdpDSG
// 密文:$2a$10$DoQQSh9eAxDRVKADzQ.Q8Oa4QqcpMUR9UmKyptop3i0mwsdfS.wyC
// 密文:$2a$10$tZCa3YIYehg5B9VESrDOWeoBAX3aX4f.Ioc4awtiY/vwihGmD.xQG
// 密文:$2a$10$9qx53wQEF0XjSjKattwEw.mFayMvjxLnZmPnRO5V1DnZvKuCLrVQG
// 密文:$2a$10$dmGQK7iwTd9Mbwa/mxzABeBHezbqyGpqwmxUobwelQDlRuW4oHS9e
}
@Test
public void matches() {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String rawPassword = "123456";
String encodedPassword = "$2a$10$H7neseWrkpdCQiW6R4bJyeXaU.nowsFZZz.iO4HCLzFScz.FdpDSG";
boolean matches = passwordEncoder.matches(rawPassword, encodedPassword);
System.out.println("原文:" + rawPassword);
System.out.println("密文:" + encodedPassword);
System.out.println("验证:" + matches);
}
}
BCrypt算法默认使用了随机盐值,所以,即使使用相同的原文,每次编码产生的密文都是不同的!
BCrypt算法被刻意设计为慢速的,所以,可以非常有限的避免穷举式的暴力破解!
3. 关于Spring Security的配置类
在Spring Boot项目中,在根包下创建config.SecurityConfiguration
类,作为Spring Security的配置类,需要继承自WebSecurityConfigurerAdapter
类,并重写其中的方法进行配置:
package cn.tedu.csmall.passport.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
/**
* Spring Security配置类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Slf4j
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
public SecurityConfiguration() {
log.debug("创建配置类对象:SecurityConfiguration");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 如果不调用父类方法,默认所有请求都不需要通过认证,可以直接访问
// super.configure(http);
// 白名单
String[] urls = {
"/favicon.ico",
"/doc.html",
"/**/*.js",
"/**/*.css",
"/swagger-resources",
"/v2/api-docs"
};
// 将防止伪造跨域攻击的机制禁用
http.csrf().disable();
// 提示:关于请求路径的配置,如果同一路径对应多项配置规则,以第1次配置的为准
http.authorizeRequests() // 管理请求授权
.mvcMatchers(urls) // 匹配某些路径
.permitAll() // 直接许可,即可不需要通过认证即可访问
.anyRequest() // 除了以上配置过的以外的其它所有请求
.authenticated(); // 要求是“已经通过认证的”
// 启用登录表单
// 当未认证时:
// -- 如果启用了表单,会自动重定向到登录表单
// -- 如果未启用表单,则会提示403错误
http.formLogin();
}
}
4. 关于伪造的跨域攻击
伪造的跨域攻击(CSRF)主要是基于服务器端对浏览器的信任,在多选项卡的浏览器中,如果在X选项卡中登录,在Y选项卡中的访问也会被视为“已登录”。
在Spring Security框架中,默认开启了“防止伪造的跨域攻击”的机制,其基本做法就是在POST请求中,要求客户端提交其随机生成的一个UUID值,例如,(在没有禁用防止伪造跨域攻击时)在Spring Security的登录页面中有:
<input name="_csrf" type="hidden" value="b6dc65f8-e0cf-4907-bdaf-a5f19b759f93" />
以上代码中的value
值就是一个UUID值,是前次GET请求时由服务器端响应的,服务器端会要求客户端携带此UUID来访问,否则,就会将请求视为伪造的跨域攻击行为!
关于登录账号
默认情况下,Spring Security框架提供了默认的用户名user
和启动时随机生成UUID密码,如果需要自定义登录账号,可以自定义类,实现UserDetailsService
接口,重写接口中的如下方法:
UserDetails loadUserByUsername(String username);
Spring Security框架在处理认证时,会自动根据提交的用户名(用户在登录表单中输入的用户名)来调用以上方法,以上方法应该返回匹配的用户详情(UserDetails
类型的对象),接下来,Spring Security会自动根据用户详情(UserDetails
对象)来完成认证过程,例如判断密码是否正确等。
可以在根包下创建security.UserDetailsServiceImpl
类,在类上添加@Service
注解,实现UserDetailsService
接口,重写接口中定义的抽象方法:
package cn.tedu.csmall.passport.security;
import lombok.extern.slf4j.Slf4j;
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 {
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("Spring Security框架自动调用UserDetailsServiceImpl中的loadUserByUsername方法,参数:{}", s);
// 假设正确的用户名 / 密码分别是 root / 1234
if ("root".equals(s)) {
UserDetails userDetails = User.builder()
.username("root")
.password("1234")
.disabled(false)
.accountLocked(false) // 此项目未设计“账号锁定”的机制,固定为false
.accountExpired(false) // 此项目未设计“账号过期”的机制,固定为false
.credentialsExpired(false) // 此项目未设计“凭证锁定”的机制,固定为false
.authorities("暂时给出的假的权限标识") // 权限
.build();
return userDetails;
}
return null;
}
}
完成后,重启项目,首先,可以在启动日志中看到,Spring Security框架不再生成随机的UUID密码。
在Spring Security处理认证时,还会自动装配Spring容器中的密码编码器(PasswordEncoder
),如果Spring容器中并没有密码编码器,则无法验证密码是否正确,当使用了正确的用户名尝试登录时,服务器端将报告错误:
java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
可以在SecurityConfiguration
中添加@Bean
方法,来配置所需的密码编码器:
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
提示:以上使用的密码编码器是无操作的密码编码器(No Operation),不会对密码进行加密处理,是不推荐的,所以,此类被声明为已过期,在IntelliJ IDEA中,此类的名称会有删除线。
当添加了密码编码器后,再次启用项目,尝试登录:
- 当用户名错误时,会提示
UserDetailsService
返回了是null
- 当用户名正确,但密码错误时,会提示登录失败
- 当用户名、密码均正确时,将成功登录
也可以将以上NoOpPasswordEncoder
换成BCryptPasswordEncoder
,例如:
@Bean
public PasswordEncoder passwordEncoder() {
// return NoOpPasswordEncoder.getInstance();
return new BCryptPasswordEncoder();
}
如果修改,则UserDetails
对象中封装的密码也必须是与此密码编码器符合的,即必须是BCrypt算法加密的结果,例如:
使用前后端分离的登录模式
目前的登录是由Spring Security提供了登录表单,然后由自定义的UserDetailsServiceImpl
获取对应的用户信息,并由Spring Security完后后续的认证过程,以此来实现的,这不是前后端分离的开发模式,因为依赖于Spring Security提供的登录表单,例如csmall-web-client
或其它客户端根本没有办法像服务器端发送登录请求!
要实现前后端分离的登录模式,需要:
- 使用控制器接收来自客户端的登录请求
- 创建
AdminLoginDTO
封装客户端提交的用户名、密码 - 所设计的登录请求的URL必须添加到“白名单”
- 创建
- 使用Service处理登录认证
- 调用
AuthenticationManager
的authenticate()
方法处理认证- 可以通过重写配置类中的
authenticationManagerBean()
方法,并添加@Bean
注解来得到
- 可以通过重写配置类中的
- 调用
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("Spring Security框架自动调用UserDetailsServiceImpl中的loadUserByUsername方法,参数:{}", s);
// 假设正确的用户名 / 密码分别是 root / 1234
if ("root".equals(s)) {
UserDetails userDetails = User.builder()
.username("root")
.password("$2a$10$DoQQSh9eAxDRVKADzQ.Q8Oa4QqcpMUR9UmKyptop3i0mwsdfS.wyC")
// 后续代码没有调整……
【SecurityConfiguration】
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
关于“登录”的判断标准
在Spring Security框架中,对于“登录”(通过认证)的判断标准是:在SecurityContext
(Security上下文)中是否存在Authentication
对象(认证信息),如果存在,Spring Security框架会根据Authentication
对象识别用户的身份、权限等,如果不存在,则视为“未登录”。
在默认情况下,Spring Security框架也是基于Session来处理(读写)用户的信息的。
关于Session
HTTP协议本身是无状态协议,无法保存用户信息,即:某客户端第1次访问了服务器端,可能产生了某些数据,此客户端再次访问服务器端时,服务器端无法识别出这个客户端是此前曾经来访的客户端。
为了能够识别客户端的身份,当某客户端第1次向服务器端发起请求时,服务器端将向客户端响应一个JSESSIONID数据,其本质是一个UUID数据,在客户端后续的访问中,客户端会自动携带此JSESSIONID,以至于服务器端能够识别此客户端的身份。同时,在服务器端,还是一个Map
结构的数据,此数据是使用JSESSIONID作为Key的,所以,每个客户端在服务器端都有一个与之对应在的在此Map
中的Value
,也就是Session数据!
提示:UUID是全球唯一的,从设计上,它能够保证在同一时空中的唯一性。
由于Session的运作机制,决定了它必然存在缺点:
- 默认不适用于集群或分布式系统,因为Session是内存中的数据,所以,默认情况下,Session只存在于与客户端交互的那台服务器上,如果使用了集群,客户端每次请求的服务器都不是同一台服务器,则无法有效的识别客户端的身份
- 可以通过共享Session等机制解决
- 不适合长时间保存数据,因为Session是内存中的数据,并且,所有来访的客户端在服务器端都有对应的Session数据,就必须存在Session清除机制,如果长期不清除,随着来访的客户端越来越多,将占用越来越多的内存,服务器将无法存储这大量的数据,通常,会将Session设置为15分钟或最多30分钟清除
Token
Token:票据、令牌
由于客户端种类越来越多,目前,主流的识别用户身份的做法都是使用Token机制,Token可以理解为“票据”,例如现实生活中的“火车票”,某客户端第1次请求服务器,或执行登录请求,则可视为“购买火车票”的行为,当客户端成功登录,相当于成功购买了火车票,客户端的后续访问应该携带Token,相当于乘坐火车需要携带购票凭证,则服务器端可以识别客户端的身份,相当于火车站及工作人员可以识别携带了购买凭证的乘车人。
与Session最大的区别在于:Token是包含可识别的有效信息的!对于需要获取信息的一方而言,只需要具备读取Token信息的能力即可。
Session机制中客户端需要携带的JSESSIONID本身上是UUID,此数据只具有唯一性,并不是有意义的数据,真正有意义的数据是服务器端内存中的Session数据。
所以,Token并不需要占用较多的内存空间,是可以长时间,甚至非常长时间保存用户信息的!
JWT
JWT:JSON Web Token
JWT是一种使用JSON格式来组织数据的Token。
生成与解析JWT
需要添加依赖项:
<!-- JJWT(Java JWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
关于生成JWT与解析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 {
String secretKey = "a9F8ujGFDhjgvfd3SA90ukEDS";
@Test
public void generate() {
Date date = new Date(System.currentTimeMillis() + 5 * 60 * 1000);
Map<String, Object> claims = new HashMap<>();
claims.put("id", 9527);
claims.put("username", "liucangsong");
String jwt = Jwts.builder()
// Header
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
// Payload
.setClaims(claims)
// Signature
.setExpiration(date)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
System.out.println(jwt);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjY3ODc3ODg5LCJ1c2VybmFtZSI6ImxpdWNhbmdzb25nIn0.Txpj_kcLpkpUoEYA94pLCM3H807UnOEqN_r0c005I44
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjY3ODc5MDM2LCJ1c2VybmFtZSI6ImxpdWNhbmdzb25nIn0.gMlHQiSbbWnf5cIBi0p4V9bz05QHRaq3rNC8e_4yfpE
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjY3ODc5ODAxLCJ1c2VybmFtZSI6ImxpdWNhbmdzb25nIn0.fjPvR0ibgNKoTp6U-1fCOcMoAVMRkAQ1yr4C2fvf6YQ
}
@Test
public void parse() {
String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjY3ODc5ODAxLCJ1c2VybmFtZSI6ImxpdWNhbmdzb25nIn0.fjPvR0ibgNKoTp6U-1fCOcMoAVMRkAQ1yr4C2fvf6YQ";
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);
}
}
当尝试解析JWT时,可能会出现以下错误:
- 如果JWT已过期,会抛出
ExpiredJwtException
,例如:
io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-11-08T11:24:49Z. Current time: 2022-11-08T11:38:01Z, a difference of 792152 milliseconds. Allowed clock skew: 0 milliseconds.
- 如果JWT数据有误,会抛出
MalformedJwtException
,例如:
io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"alg":"HS7#�$�uB'
- 如果JWT签名不匹配,会抛出
SignatureException
,例如:
io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
登录成功时返回JWT
在处理登录时,当用户登录成功,应该向客户端返回JWT数据,以至于客户端下次提交请求时,可以携带JWT来访问服务器端!
首先,需要在通过认证(登录成功)后,生成JWT数据,并返回!在Spring Security框架中,AuthenticationManager
调用authenticate()
方法时,如果通过认证,会返回Authentication
接口类型的对象,本质上是UsernamePasswordAuthenticationToken
类型,此类型中的pricipal
属性就是通过认证的用户信息,也是UserDetailsService
中的loadUserByUsername()
方法返回的结果,例如:
UsernamePasswordAuthenticationToken [
Principal=org.springframework.security.core.userdetails.User [
Username=root,
Password=[PROTECTED],
Enabled=true,
AccountNonExpired=true,
credentialsNonExpired=true,
AccountNonLocked=true,
Granted Authorities=[暂时给出的假的权限标识]
],
Credentials=[PROTECTED],
Authenticated=true,
Details=null,
Granted Authorities=[暂时给出的假的权限标识]
]
所以,可以在处理认证的代码后再添加读取认证结果、生成JWT的代码:
@Override
public void login(AdminLoginDTO adminLoginDTO) {
log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
// 执行认证
Authentication authentication
= new UsernamePasswordAuthenticationToken(
adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
Authentication authenticateResult
= authenticationManager.authenticate(authentication);
log.debug("认证通过,认证管理器返回:{}", authenticateResult);
// 从认证结果中获取所需的数据,将用于生成JWT
Object principal = authenticateResult.getPrincipal();
log.debug("认证结果中的当事人类型:{}", principal.getClass().getName());
User user = (User) principal;
String username = user.getUsername();
// 生成JWT数据时,需要填充装到JWT中的数据
Map<String, Object> claims = new HashMap<>();
// claims.put("id", 9527);
claims.put("username", username);
// 以下是生成JWT的固定代码
String secretKey = "a9F8ujGDhjgFvfEd3SA90ukDS";
Date date = new Date(System.currentTimeMillis() + 5 * 24 * 60 * 60 * 1000L);
String jwt = Jwts.builder()
// Header
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
// Payload
.setClaims(claims)
// Signature
.setExpiration(date)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
log.debug("生成的JWT:{}", jwt);
}
接下来,需要将IAdminService
接口中定义的“登录”方法的返回值类型修改为String
:
/**
* 管理员登录
*
* @param adminLoginDTO 封装了管理员的用户名和密码的对象
* @return 登录成功后生成的匹配的JWT
*/
String login(AdminLoginDTO adminLoginDTO);
并且修改其实现,并返回JWT。
然后,调整控制器中处理登录请求的方法:
// http://localhost:9081/admins/login
@ApiOperation("管理员登录")
@ApiOperationSupport(order = 50)
@PostMapping("/login")
public JsonResult<String> login(AdminLoginDTO adminLoginDTO) {
log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginDTO);
String jwt = adminService.login(adminLoginDTO);
return JsonResult.ok(jwt);
}
完成后,重启项目,在API文档中调试,使用正确的用户名、密码登录,响应结果中将包含对应的JWT数据,并且,此JWT数据可以在此前编写的测试方法中尝试解析(注意:务必保证生成JWT和解析JWT使用的secretKey
是相同的)。
识别客户端的身份
基于Spring Security框架的特征“依据SecurityContext
中的认证信息来判定当前是否已经通过认证”,所以,客户端应该在得到JWT之后,携带JWT向服务器端提交请求,而服务器端应该尝试解析此JWT,并且从中获取用户信息,用于创建认证对象,最后,将认证对象存入到SecurityContext
中,剩下的就可以交由框架进行处理了,例如判断是否已经通过认证等。
由于若干个不同的请求都需要识别客户端的身份(即解析JWT、创建认证对象、将认证对象存入到SecurityContext
),所以,应该通过能够统一处理的组件来处理JWT,同时,此项任务必须在Spring Security的过滤器之前执行,则此项任务只能通过自定义过滤器来处理!
则在项目的根包下创建filter.JwtAuthorizationFilter
类:
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.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.Collection;
import java.util.List;
/**
* <p>JWT过滤器</p>
*
* <p>此JWT的主要作用:</p>
* <ul>
* <li>获取客户端携带的JWT,惯用做法是:客户端应该通过请求头中的Authorization属性来携带JWT</li>
* <li>解析客户端携带的JWT,并创建出Authentication对象,存入到SecurityContext中</li>
* </ul>
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
public static final int JWT_MIN_LENGTH = 113;
public JwtAuthorizationFilter() {
log.info("创建过滤器对象:JwtAuthorizationFilter");
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.debug("JwtAuthorizationFilter开始执行过滤……");
// 获取客户端携带的JWT
String jwt = request.getHeader("Authorization");
log.debug("获取客户端携带的JWT:{}", jwt);
// 检查是否获取到了基本有效的JWT
if (!StringUtils.hasText(jwt) || jwt.length() < JWT_MIN_LENGTH) {
// 对于无效的JWT,直接放行,交由后续的组件进行处理
log.debug("获取到的JWT被视为无效,当前过滤器将放行……");
filterChain.doFilter(request, response);
return;
}
// 尝试解析JWT
log.debug("获取到的JWT被视为有效,准备解析JWT……");
String secretKey = "a9F8ujGDhjgFvfEd3SA90ukDS";
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
// 获取JWT中的管理员信息
String username = claims.get("username", String.class);
// 处理权限信息
List<GrantedAuthority> authorities = new ArrayList<>();
GrantedAuthority authority = new SimpleGrantedAuthority("这是一个假权限");
authorities.add(authority);
// 创建Authentication对象
Authentication authentication
= new UsernamePasswordAuthenticationToken(
username, null, authorities);
// 将Authentication对象存入到SecurityContext
log.debug("向SecurityContext中存入认证信息:{}", authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
// 过滤器链继续向后传递,即:放行
log.debug("JWT过滤器执行完毕,放行!");
filterChain.doFilter(request, response);
}
}
并且,为了保证此过滤器在Spring Security的过滤器之前执行,还应该在SecurityConfiguration
中,先自动装配此过滤器对象:
@Autowired
private JwtAuthorizationFilter jwtAuthorizationFilter;
然后,在void configurer(HttpSecurity http)
方法中补充配置:
// 将JWT过滤器添加到Spring Security框架的过滤器链中
http.addFilterBefore(jwtAuthorizationFilter,
UsernamePasswordAuthenticationFilter.class);
至此,简单的登录处理已经完成,客户端或API文档可以通过 /admins/login
登录,以获取JWT数据,并且,在后续的访问中,如果携带了JWT数据,将可以正常访问,否则,将无权访问!
目前,还存在需要解决的问题:
- 生成和解析JWT的
secretKey
不应该分别定义在2个类中 - 解析JWT可能出现异常,但尚未处理
- 认证信息中的“当事人”是使用
username
表示的,不包含此管理员的id
,不便于实现后续的需求 - 认证信息中权限目前是假数据
- 前端还没有结合起来
前端登录
在前端的登录页面中,当服务器端响应登录成功后,应该将服务器端响应的JWT数据保存下来,可以使用localStorage
来保存数据,例如:
let url = 'http://localhost:9081/admins/login';
console.log('url = ' + url);
let formData = this.qs.stringify(this.ruleForm);
console.log('formData = ' + formData);
this.axios.post(url, formData).then((response) => {
let responseBody = response.data;
if (responseBody.state == 20000) {
this.$message({
message: '登录成功!',
type: 'success'
});
let jwt = responseBody.data;
console.log('登录成功,服务器端响应JWT:' + jwt);
localStorage.setItem('jwt', jwt); // 使用localStorage保存数据
console.log('已经将JWT保存到localStorage');
} else {
console.log(responseBody.message);
this.$message.error(responseBody.message);
}
});
后续,当需要此数据时,可以通过localStorage.getItem(key)
来获取此前存入的数据。
在需要携带JWT的请求中,可以调用axios
对象的create()
方法来配置请求头,并使用此方法返回的axios
对象向服务器端提交请求,例如:
loadAdminList() {
console.log('loadAdminList');
console.log('在localStorage中的JWT数据:' + localStorage.getItem('jwt'));
let url = 'http://localhost:9081/admins';
console.log('url = ' + url);
this.axios
// ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 以下是携带JWT提交请求的关键代码 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
.create({'headers': {'Authorization': localStorage.getItem('jwt')}})
.get(url).then((response) => {
let responseBody = response.data;
this.tableData = responseBody.data;
});
}
需要注意:当客户端的异步请求定义了请求头中的Authorization
时,在服务器端,在SecurityConfiguration
类的void configurer(HttpSecurity http)
方法中,需要添加以下配置:
http.cors();
否则客户端将出现跨域错误!
关于复杂请求的PreFlight
PreFight:预检
当客户端提交的请求自定义了请求头,且请求头中的属性不是常规属性时(例如Authorization
就不是常规属性),这类请求会被视为复杂请求,就会触发预检(PreFlight)机制,浏览器会自动向对应的URL提交一个OPTIONS
类型的请求,如果此请求被正常响应(即HTTP响应码为200
),才可以正常提交原本的请求,否则,视为预检失败,会提示跨域错误。
需要注意:预检是基于浏览器缓存的,如果某个请求对应的URL曾经预检通过,则后续再次提交请求时不会执行预检!
在服务器端的SecurityConfiguration
中,在重写的void configurer(HttpSecurity http)
方法中,配置请求认证时,对所有OPTIONS
请求直接放行,即可解决预检不通过导致的跨域错误,例如:
http.authorizeRequests()
// ↓↓↓↓↓ 对所有OPTIONS请求直接放行 ↓↓↓↓↓
.mvcMatchers(HttpMethod.OPTIONS, "/**")
.permitAll()
.mvcMatchers(urls)
.permitAll()
.anyRequest()
.authenticated();
或者,更简单一点,直接调用参数http
的cors()
方法,则Spring Security会自动启用一个CorsFilter
,这是Spring Security专门用于处理跨域问题的过滤器,也会对OPTIONS
请求放行,所以,实现的效果是完全相同的!
注意:以上解决方案并不能取代目前使用WebMvcConfiguration
解决的跨域问题!
使用配置文件自定义JWT参数
生成和解析JWT都需要使用到secretKey
,并且,这2处使用到的secretKey
值必须是完全相同的!所以,应该使用一个公共的位置来配置secretKey
的值,由于此值应该允许被客户(软件的使用者)修改,则应该将此值定义的配置文件中(不推荐定义在某个类)。
则可以在application-dev.yml
中添加自定义配置:
# 当前项目中的自定义配置
csmall:
# JWT相关配置
jwt:
# 生成和解析JWT时使用的secretKey
secret-key: a9F8ujGDhjgFvfEd3SA90ukDS
# JWT的有效时长,以分钟为单位
duration-in-minute: 14400
提示:当在.yml
或.properties
中添加配置后(无论是否为自定义配置),当加载时,会将这些配置读取到Spring框架内置的Environment
对象中,另外,操作系统的配置和JVM配置也会自动读取到Environment
中,且配置文件中的配置的优先级是最低的(会被覆盖),使用@Value
读取值,其实是从Environment
中读取的,并不是直接从配置文件中读取的!
添加配置后,在生成JWT时使用,即在AdminServiceImpl
中声明2个全局属性,通过@Value
注解为这2个属性注入配置的值:
@Value("${csmall.jwt.secret-key}")
private String secretKey;
@Value("${csmall.jwt.duration-in-minute}")
private long durationInMinute;
在生成JWT时,就可以直接使用这2个属性了!
另外,在JwtAuthorizationFilter
也应该使用同样的做法,应用secretKey
的配置!
处理解析JWT时可能出现的异常
当前项目中使用过滤器解析JWT,而过滤器是JAVA EE项目中最早接收到请求的组件,此时其它组件(例如Controller)均未开始处理此请求,所以,如果过滤器在解析JWT时出现异常,Controller是无法“知晓”的,则全局异常处理器也无法处理这些异常,只能在过滤器中使用try...catch
语法处理。
处理异常后的响应应该是JSON格式的,当前项目中一直在使用JsonResult
表示响应的结果,但是,由于过滤器解析JWT时,Spring MVC的相关组件尚未运行,无法自动将JsonResult
对象转换成JSON格式的字符串,所以,需要先在项目中添加依赖项,用于将对象转换成JSON格式的字符串:
<!-- fastjson:实现对象与JSON的相互转换 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.75</version>
</dependency>
然后,在ServiceCode
中添加新的枚举值:
public enum ServiceCode {
// 此前已有的枚举值
// ↓↓↓↓↓ 新增的枚举值 ↓↓↓↓↓
/**
* 错误:JWT签名错误
*/
ERR_JWT_SIGNATURE(60000),
/**
* 错误:JWT数据格式错误
*/
ERR_JWT_MALFORMED(60100),
/**
* 错误:JWT已过期
*/
ERR_JWT_EXPIRED(60200);
// 其它代码
最后,在JwtAuthorizationFilter
中,使用try...catch
包裹解析JWT的代码,并处理相关异常:
// 尝试解析JWT
log.debug("获取到的JWT被视为有效,准备解析JWT……");
response.setContentType("application/json; charset=utf-8");
Claims claims = null;
try {
claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
} catch (SignatureException e) {
log.debug("解析JWT时出现SignatureException");
String message = "非法访问!";
JsonResult<Void> jsonResult = JsonResult.fail(
ServiceCode.ERR_JWT_SIGNATURE, message);
String jsonResultString = JSON.toJSONString(jsonResult);
PrintWriter writer = response.getWriter();
writer.println(jsonResultString);
return;
} catch (MalformedJwtException e) {
log.debug("解析JWT时出现MalformedJwtException");
String message = "非法访问!";
JsonResult<Void> jsonResult = JsonResult.fail(
ServiceCode.ERR_JWT_MALFORMED, message);
String jsonResultString = JSON.toJSONString(jsonResult);
PrintWriter writer = response.getWriter();
writer.println(jsonResultString);
return;
} catch (ExpiredJwtException e) {
log.debug("解析JWT时出现ExpiredJwtException");
String message = "登录信息已过期,请重新登录!";
JsonResult<Void> jsonResult = JsonResult.fail(
ServiceCode.ERR_JWT_EXPIRED, message);
String jsonResultString = JSON.toJSONString(jsonResult);
PrintWriter writer = response.getWriter();
writer.println(jsonResultString);
return;
} catch (Throwable e) {
log.debug("解析JWT时出现Throwable,需要开发人员在JWT过滤器补充对异常的处理");
e.printStackTrace();
String message = "你有异常没有处理,请根据服务器端控制台的信息,补充对此类异常的处理!!!";
PrintWriter writer = response.getWriter();
writer.println(message);
return;
}
将登录的管理员的id封装到认证信息中
在根包下创建security.AdminDetails
类,继承自User
类,并在类中扩展声明Long id
属性:
package cn.tedu.csmall.passport.security;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import java.util.Collection;
/**
* 管理员详情类,是Spring Security框架的loadUserByUsername()的返回结果
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Setter
@Getter
@ToString(callSuper = true)
@EqualsAndHashCode
public class AdminDetails extends User {
private Long id;
public AdminDetails(Long id,
String username,
String password,
boolean enabled,
Collection<? extends GrantedAuthority> authorities) {
super(username, password, enabled, true,
true, true, authorities);
this.id = id;
}
}
在UserDetailsService
中,当需要返回UserDetails
对象时,返回以上自定义的对象:
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private AdminMapper adminMapper;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("Spring Security框架自动调用UserDetailsServiceImpl中的loadUserByUsername方法,参数:{}", s);
AdminLoginInfoVO admin = adminMapper.getLoginInfoByUsername(s);
log.debug("从数据库中根据用户名【{}】查询管理员信息,结果:{}", s, admin);
if (admin == null) {
log.debug("没有与用户名【{}】匹配的管理员信息,即将抛出BadCredentialsException", s);
String message = "登录失败,用户名不存在!";
throw new BadCredentialsException(message);
}
List<GrantedAuthority> authorities = new ArrayList<>();
GrantedAuthority authority = new SimpleGrantedAuthority("这是一个假权限");
authorities.add(authority);
AdminDetails adminDetails = new AdminDetails(
admin.getId(),
admin.getUsername(),
admin.getPassword(),
admin.getEnable() == 1,
authorities);
log.debug("即将向Spring Security框架返回UserDetails对象:{}", adminDetails);
return adminDetails;
}
}
至此,当用户成功登录后,AuthenticationManager
的authenticate()
返回的认证信息中的当事人(Principal)就是以上返回的AdminDetails
,其中是包含id
和用户名等信息的!在处理认证后,可以得到这些信息,并用于生成JWT。
在AdminServiceImpl
的login()
方法:
// 从认证结果中获取所需的数据,将用于生成JWT
Object principal = authenticateResult.getPrincipal();
log.debug("认证结果中的当事人类型:{}", principal.getClass().getName());
AdminDetails adminDetails = (AdminDetails) principal;
String username = adminDetails.getUsername();
Long id = adminDetails.getId(); // 新增
// 生成JWT数据时,需要填充装到JWT中的数据
Map<String, Object> claims = new HashMap<>();
claims.put("id", id); // 新增
claims.put("username", username);
至此,当用户成功登录后,得到的JWT中是包含了id
和username
的!
要将id
和username
同时封装到认证信息中,由于认证信息中的当事人只是1个数据,如果要将id
和username
这2个数据都封装进去,就需要自定义类,在类定义这2个属性,此类的对象将是最终存入到认证信息中的当事人:
package cn.tedu.csmall.passport.security;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginPrincipal implements Serializable {
private Long id;
private String username;
}
在JwtAuthorizationFilter
中:
// 获取JWT中的管理员信息
String username = claims.get("username", String.class);
Long id = claims.get("id", Long.class); // 新增
// 处理权限信息
// 省略相关代码
// 创建Authentication对象
LoginPrincipal loginPrincipal = new LoginPrincipal(id, username); // 新增
Authentication authentication
= new UsernamePasswordAuthenticationToken(
loginPrincipal, null, authorities);
// ↑↑↑↑↑ 调整 ↑↑↑↑↑
// 将Authentication对象存入到SecurityContext
log.debug("向SecurityContext中存入认证信息:{}", authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
在处理请求时识别当前登录的用户身份
在任何处理请求的方法的参数列表中,都可以添加@AuthenticationPrincipal LoginPrincipal loginPrincipal
参数,Spring Security框架会自动从上下文(SecurityContext
)中获取认证信息中的当事人,作为此参数的值!所以,在处理请求时,可以知晓当前登录的用户的id
、username
,例如:
// http://localhost:9081/admins
@ApiOperation("查询管理员列表")
@ApiOperationSupport(order = 420)
@GetMapping("")
public JsonResult<List<AdminListItemVO>> list(
// ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 新增 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
@ApiIgnore @AuthenticationPrincipal LoginPrincipal loginPrincipal) {
log.debug("开始处理【查询管理员列表】的请求,无参数");
log.debug("当前登录的当事人:{}", loginPrincipal);
List<AdminListItemVO> list = adminService.list();
return JsonResult.ok(list);
}
提示:以上@ApiIgnore
注解用于避免API文档中提示要求客户端提交id
和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='wangkejing';
要保证执行认证时能够查询到管理员的基本信息和权限,需要:
- 在
AdminLoginInfoVO
中添加属性,表示此管理员的权限 AdminMapper.java
接口中的抽象方法不必调整- 在
AdminMapper.xml
中调整SQL语句,及如何封装查询结果
先在AdminLoginInfoVO
中添加:
/**
* 权限列表
*/
private List<String> permissions;
然后在AdminMapper.xml
中调整配置:
<!-- AdminLoginInfoVO getLoginInfoByUsername(String username); -->
<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>
<!-- 在1对多的查询中,List属性需要使用collection标签来配置 -->
<!-- collection标签的property属性:封装查询结果的类型中的属性名,即List的属性名 -->
<!-- collection标签的ofType属性:List的元素数据类型,取值为类型的全限定名 -->
<!-- collection标签的子级:如何将查询结果中的数据封装成ofType类型的对象 -->
<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="String">
<constructor>
<arg column="value"/>
</constructor>
</collection>
</resultMap>
接下来,应该将管理员的权限存入到SecurityContext
中!需要:
- 在
UserDetailsServiceImpl
中返回的对象中需要包含真实的权限信息 - 在
AdminServiceImpl
中,认证通过后,从返回的结果中获取权限信息,并将其转换为JSON格式的字符串,存入到JWT中 - 在
JwtAuthorizationFilter
中,解析JWT成功后,获取权限信息对应的JSON字符串,并将其反序列化为Collection<? extends GrantedAuthority>
格式,并存入到Authentication
中,进而存入到SecurityContext
中
以上全部完成后,就可以开始配置权限了!需要先在配置类(强烈建议SecurityConfiguration
)上添加@EnableGlobalMethodSecurity(prePostEnabled = true)
注解,以启用方法级别的权限检查!然后,可以选择将配置检查的配置添加在控制器中处理请求的方法上(其实也可以添加在其它组件的自定义方法上),例如:
@ApiOperation("添加管理员")
@ApiOperationSupport(order = 100)
@PreAuthorize("hasAuthority('/ams/admin/add-new')") // 新增
@PostMapping("/add-new")
public JsonResult<Void> addNew(AdminAddNewDTO adminAddNewDTO) {
log.debug("开始处理【添加管理员】的请求,参数:{}", adminAddNewDTO);
adminService.addNew(adminAddNewDTO);
return JsonResult.ok();
}
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiYXV0aG9yaXRpZXNKc29uU3RyaW5nIjoiW3tcImF1dGhvcml0eVwiOlwiL2Ftcy9hZG1pbi9hZGQtbmV3XCJ9LHtcImF1dGhvcml0eVwiOlwiL2Ftcy9hZG1pbi9kZWxldGVcIn0se1wiYXV0aG9yaXR5XCI6XCIvYW1zL2FkbWluL3JlYWRcIn0se1wiYXV0aG9yaXR5XCI6XCIvYW1zL2FkbWluL3VwZGF0ZVwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvYWxidW0vYWRkLW5ld1wifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvYWxidW0vZGVsZXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9hbGJ1bS9yZWFkXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9hbGJ1bS91cGRhdGVcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL2JyYW5kL2FkZC1uZXdcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL2JyYW5kL2RlbGV0ZVwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvYnJhbmQvcmVhZFwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvYnJhbmQvdXBkYXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9jYXRlZ29yeS9hZGQtbmV3XCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9jYXRlZ29yeS9kZWxldGVcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL2NhdGVnb3J5L3JlYWRcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL2NhdGVnb3J5L3VwZGF0ZVwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvcGljdHVyZS9hZGQtbmV3XCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9waWN0dXJlL2RlbGV0ZVwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvcGljdHVyZS9yZWFkXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9waWN0dXJlL3VwZGF0ZVwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvcHJvZHVjdC9hZGQtbmV3XCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9wcm9kdWN0L2RlbGV0ZVwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvcHJvZHVjdC9yZWFkXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9wcm9kdWN0L3VwZGF0ZVwifV0iLCJleHAiOjE2Njg4NTE1MjgsInVzZXJuYW1lIjoicm9vdCJ9.PZw1dwiPP7uDgGf-GQsPdahmLmq1oLC3tPsP0M2K0bM
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwiYXV0aG9yaXRpZXNKc29uU3RyaW5nIjoiW3tcImF1dGhvcml0eVwiOlwiL3Btcy9hbGJ1bS9hZGQtbmV3XCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9hbGJ1bS9kZWxldGVcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL2FsYnVtL3JlYWRcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL2FsYnVtL3VwZGF0ZVwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvYnJhbmQvYWRkLW5ld1wifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvYnJhbmQvZGVsZXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9icmFuZC9yZWFkXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9icmFuZC91cGRhdGVcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL2NhdGVnb3J5L2FkZC1uZXdcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL2NhdGVnb3J5L2RlbGV0ZVwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvY2F0ZWdvcnkvcmVhZFwifSx7XCJhdXRob3JpdHlcIjpcIi9wbXMvY2F0ZWdvcnkvdXBkYXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9waWN0dXJlL2FkZC1uZXdcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL3BpY3R1cmUvZGVsZXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9waWN0dXJlL3JlYWRcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL3BpY3R1cmUvdXBkYXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9wcm9kdWN0L2FkZC1uZXdcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL3Byb2R1Y3QvZGVsZXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9wcm9kdWN0L3JlYWRcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL3Byb2R1Y3QvdXBkYXRlXCJ9XSIsImV4cCI6MTY2ODg1MTYxNywidXNlcm5hbWUiOiJzdXBlcl9hZG1pbiJ9.J7NUoItrFePJpmcV8FLl4Pcb1IcAca31nczMfOdiN74
单点登录(SSO)
SSO:Single Sign On,单点登录,表现为客户端只需要在某1个服务器上通过认证,其它服务器也可以识别此客户端的身份!
单点登录的实现手段主要有2种:
-
使用Session机制,并共享Session
spring-boot-starter-data-redis
结合spring-session-data-redis
-
使用Token机制
- 各服务器需要有同样的解析JWT的代码
当前,csmall-passport
中已经使用JWT,则可以在csmall-product
项目中也添加Spring Security框架和解析JWT的代码,则csmall-product
项目也可以识别用户的身份、检查权限。
需要做的事:
-
添加依赖
spring-boot-starter-security
jjwt
fastjson
-
复制
ServiceCode
覆盖csmall-product
原本的文件 -
复制
GlobalExceptionHandler
覆盖csmall-product
原本的文件 -
复制
application-dev.yml
中关于JWT的secretKey的配置- 关于JWT有效时长的配置,可以复制,但暂时用不上
-
复制
LoginPrincipal
到csmall-product
中,与csmall-passport
相同的位置 -
复制
JwtAuthorizationFilter
-
复制
SecurityConfiguration
- 删除
PasswordEncoder
的@Bean
方法 - 删除
AuthenticationManager
的@Bean
方法 - 应该删除白名单中的
/admins/login
- 删除