关于Sp
Spring Security框架主要解决了认证与授权相关的问题。
认证信息:表示用户的身份的信息
认证:识别用户身份信息,具体可以表现为“登录”
授权:授予用户权限,使之可以进行某些访问,反之,如果用户没有得到相关授权,就不允许进行某些访问
Spring Security框架的依赖项
在Spring Boot项目中使用Spring Security时需要添加依赖项:
<!-- Spring Boot支持Spring Security的依赖项,用于处理认证与授权 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Spring Security的典型特征
当添加了spring-boot-starter-security
依赖项,会自带一系列的自动配置,当启动项目后,相比此前的项目,会有以下变化:
-
所有的请求(包括根本不存在的)都是必须要登录的,如果未登录,会自动跳转到框架自带的登录页面
-
默认的用户名是
user
,密码是启用项目时在控制台提示的一串UUID值-
登录时,如果在打开登录页面后重启过服务器端,应该刷新登录页面,否则,第1次输入并提交是无效的。
-
原因:当服务器重启后,由服务器生成的UUID值(该UUID值还应该保存在返回给客户端的登陆页面中)被清空了(Security本质还是Session),所以老页面中保存的UUID值已经失效了,当老登录页面带着UUID发来请求时,由于UUID失效,服务器会重新返回一个新的登录页面,新登录页面中保存者新的UUID值,新页面带着新的UUID发请求就没有问题了,具体表现是页面被刷新了一次,再一次发请求就可以成功了
-
-
当登录成功后,会自动跳转到此前尝试访问的URL
-
当登录成功后,可通过
/logout
退出登录 -
默认不接受普通的
POST
请求,如果提交POST
请求,会响应403
(Forbidden)-
具体原因参见后续的
CSRF
相关内容
-
关于Spring Security的配置
在项目的根包下创建SecurityConfiguration
类,作为Spring Security的配置类,继承自WebSecurityConfigurerAdpater
类,(高版本的Spring要求实现接口 WebSecurityConfigurer,继承的类也实现了该接口,归根结底还是要实现接口)并重写void configure(HttpSecurity http)
方法,在方法体中进行配置:
@Slf4j
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// super.configure(http); // 不要保留调用父类同名方法的代码,不要保留!不要保留!不要保留!注释掉后所有请求不会再限制,即所有请求都允许通过,没有登录表单
}
}
关于默认的登录表单
在配置类的void configure(HttpSecurity http)
方法中,在没有调用父级的同名方法时,默认是不启用登录表单的!
@Override
protected void configure(HttpSecurity http) throws Exception {
// 如果调用以下方法,当需要访问通过认证的资源,但是未通过认证时,将自动跳转到登录页面
// 如果未调用以下方法,将响应403
http.formLogin(); // 添加后发 /login 请求可以访问登录页面,其他请求可以正常访问,列如Api在线文档访问正常
// super.configure(http); // 不要保留调用父类同名方法的代码,不要保留!不要保留!不要保留!
}
关于请求的访问控制
@Override
protected void configure(HttpSecurity http) throws Exception {
// 白名单
// 使用1个星号,表示通配此层级的任意资源,例如:/admins/*,可以匹配:/admins/add-new、/admins/delete
// 但是,不可以匹配多个层级,例如:/admins/*,不可以匹配:/admins/9527/delete
// 使用2个连续的星号,表示通配若干层级的任意资源,例如:/admins/*,可以匹配:/admins/add-new、/admins/9527/delete
String[] urls = {
"/doc.html",
"/**/*.css",
"/**/*.js",
"/swagger-resources",
"/v2/api-docs",
};
// 基于请求的访问控制
http.authorizeRequests() // 对请求进行授权
.mvcMatchers(urls) // 匹配某些路径
.permitAll() // 直接许可,即不需要认证即可访问
.anyRequest() // 任意请求
.authenticated(); // 要求通过认证的
} // 添加该限制后请求不再像之前,除了白名单的请求外,其他请求均要登陆或者响应403(取决于是否写了http.formLogin())
使用临时的自定义的账号实现登录
在使用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;
}
使用数据库中的账号信息实现登录
然后,调整UserDetailsServiceImpl
中的实现:
@Autowired
private AdminMapper adminMapper;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("xxx");
AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);
if (loginInfo == null) {
return null;
}
UserDetails userDetails = User.builder()
.username(loginInfo.getUsername())
.password(loginInfo.getPassword()) // 期望是密文
.disabled(loginInfo.getEnable() == 0) // 账号禁用
.accountLocked(false) // 账号锁定
.accountExpired(false) // 账号过期
.credentialsExpired(false) // 凭证过期
.authorities("这是一个临时使用的山寨权限") // 权限
.build();
return userDetails;
}
由于数据库中的测试数据的密码都是密文的,例如:
$2a$10$N.ZOn9G6/YLFixAOPMg/h.z7pCu6v2XyFDtC4q.jeeGm/TEZyj15C
以上密文是通过BCrypt算法进行编码的结果!为了保证Spring Security能够正确的判断密码,需要将密码编码器改为BCrypt的密码编码器,例如:
@Bean
public PasswordEncoder passwordEncoder() {
// return NoOpPasswordEncoder.getInstance();
return new BCryptPasswordEncoder();
}
关于Token
Token:令牌,票据
Token机制是用于解决服务器端识别客户端身份的。
关于JWT
在项目中添加JJWT的依赖项:
<!-- JJWT(Java JWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
在项目中使用JWT识别用户的身份
当用户通过登录的验证后,服务器应该生成JWT数据,并响应到客户端
当通过验证后,不再需要(没有必要)将用户的认证信息存入到
SecurityContext
中当用户尝试执行需要通过认证的操作时,用户应该自主携带JWT,并且,服务器端应该尝试解析此JWT,从而验证JWT的真伪,并识别用户的身份,如果一切无误,再将用户的认证信息存入到
SecurityContext
中
登录成功后响应JWT
调整AdminServiceImpl
中的login()
的声明与实现:
@Override
public String login(AdminLoginInfoDTO adminLoginInfoDTO) {
log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginInfoDTO);
Authentication authentication = new UsernamePasswordAuthenticationToken(
adminLoginInfoDTO.getUsername(), adminLoginInfoDTO.getPassword());
Authentication authenticateResult
= authenticationManager.authenticate(authentication);
log.debug("认证通过!(如果未通过,过程中将抛出异常,你不会看到此条日志!)");
log.debug("认证结果:{}", authenticateResult);
log.debug("认证结果中的当事人:{}", authenticateResult.getPrincipal());
// 使用JWT机制时,登录成功后不再需要将认证信息存入到SecurityContext
// SecurityContext securityContext = SecurityContextHolder.getContext();
// securityContext.setAuthentication(authenticateResult);
// 需要存入到JWT中的数据
AdminDetails adminDetails = (AdminDetails) authenticateResult.getPrincipal();
Map<String, Object> claims = new HashMap<>();
claims.put("id", adminDetails.getId());
claims.put("username", adminDetails.getUsername());
// 生成JWT,以下代码是相对固定的
String secretKey = "gfd89uiKa89J043tAFrflkji9432kjfdsajm";
String jwt = Jwts.builder()
// Header:声明算法与Token类型
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
// Payload:数据,具体表现为Claims
.setClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + 10 * 24 * 60 * 60 * 1000))
// Verify Signature:验证签名
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
log.debug("生成了JWT数据:{}", jwt);
return jwt;
}
解析客户端携带的JWT
在项目的根包下创建filter.JwtAuthorizationFilter
类,继承自OncePerRequestFilter
类,并在类上添加@Component
注解
package cn.tedu.csmall.passport.filter;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class JwtAuthorizationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
}
}
然后,在过滤器的方法中接收JWT数据:
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.debug("JWT过滤器开始执行……");
// 根据业内惯用的做法,客户端提交的请求中的JWT应该存放于请求头(Request Header)中的名为Authorization属性中
String jwt = request.getHeader("Authorization");
log.debug("客户端携带的JWT:{}", jwt);
// 放行
filterChain.doFilter(request, response);
}
}
然后,还需要在SecurityConfiguration
中将此过滤器注册到Spring Security框架的过滤器链中:
解析JWT
/**
* <p>JWT过滤器</p>
*
* <p>此过滤器的主要作用</p>
* <ul>
* <li>接收客户端提交的请求中的JWT</li>
* <li>尝试解析客户端提交的请求中的有效JWT</li>
* <li>将解析成功得到的数据创建为Authentication对象,并存入到SecurityContext中</li>
* </ul>
*/
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
/**
* JWT的最小长度值
*/
public static final int JWT_MIN_LENGTH = 113;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.debug("JWT过滤器开始执行……");
// 根据业内惯用的做法,客户端提交的请求中的JWT应该存放于请求头(Request Header)中的名为Authorization属性中
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
String secretKey = "gfd89uiKa89J043tAFrflkji9432kjfdsajm";
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
Object id = claims.get("id");
Object username = claims.get("username");
log.debug("解析JWT结束,id={},username={}", id, username);
// 临时处理认证信息中的权限
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("这是一个山寨的权限!"));
// 创建Authentication对象
Object principal = username;
Object credentials = null;
Authentication authentication = new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
// 将Authentication对象存入到SecurityContext中
SecurityContext securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(authentication);
// 放行
filterChain.doFilter(request, response);
}
}
关于当事人
当事人信息中应该至少包含用户的ID和用户名,而认证信息(Authentication
)中的当事人(Principal
)的类型被设计为Object
,所以,你可以使用任何类型的数据作为当事人!则可以自定义类封装用户的ID和用户名!
在解析JWT时,将解析结果处理为期望的类型,例如:
// 尝试解析JWT
String secretKey = "gfd89uiKa89J043tAFrflkji9432kjfdsajm";
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
Long id = claims.get("id", Long.class); // 期望的类型
String username = claims.get("username", String.class); // 期望的类型
log.debug("解析JWT结束,id={},username={}", id, username);
然后,基于解析结果创建当事人对象:
// 创建当事人对象,用于存入到Authentication对象中
LoginPrincipal loginPrincipal = new LoginPrincipal();
loginPrincipal.setId(id);
loginPrincipal.setUsername(username);
然后,将当事人对象用于创建认证信息对象:
// 创建Authentication对象
Object principal = loginPrincipal; // 当事人对象
Object credentials = null;
Authentication authentication = new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
在控制器类中处理请求的方法中,当需要当事人数据时,注入LoginPrincipal
类型的参数即可:
@GetMapping("")
public JsonResult<List<AdminListItemVO>> list(
@ApiIgnore @AuthenticationPrincipal LoginPrincipal loginPrincipal) {
log.debug("开始处理【查询管理员列表】的请求,参数:无");
log.debug("当事人信息:{}", loginPrincipal);
log.debug("当事人信息中的ID:{}", loginPrincipal.getId());
log.debug("当事人信息中的用户名:{}", loginPrincipal.getUsername());
List<AdminListItemVO> list = adminService.list();
return JsonResult.ok(list);
}
处理权限
在项目中添加fastjson
依赖项:
<!-- fastjson:实现对象与JSON的相互转换 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.75</version>
</dependency>
则任何管理员成功登录后,得到的JWT中都将包含权限列表的信息!