Spring Security框架
Spring Security主要解决了认证与授权相关的问题。
认证:判断某个账号是否允许访问某个系统,简单来说,就是验证登录
授权:判断是否允许已经通过认证的账号访问某个资源,简单来说,就是判断是否具有权限执行某项操作
1.认证
第一步,添加依赖
<!-- Spring Boot Security依赖项,用于处理认证与授权相关的问题 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
添加以上依赖项后,项目会发生以下变化,下面三点不重要,知道就行(Spring Boot中的Spring Security的默认行为):
-
所有的请求都是必须要登录才允许访问的,包括错误的URL
-
提供了默认的登录页面,当未登录时,会自动重定向到此登录页面
-
提供了临时的登录账号,用户名是
user
,密码是启动项目时在控制台中的UUID值(每次重启项目都会不同)
第二步,创建配置类,继承自WebSecurityConfigurerAdapter
类,在上添加@Configuration
注解,不要使用super
调用父类的此方法,自己配置
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
// 新增
@Autowired
private JwtAuthorizationFilter jwtAuthorizationFilter;
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean // 重要
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 处理“需要通过认证,但是实际上未通过认证就发起的请求”导致的错误
http.exceptionHandling().authenticationEntryPoint(new AuthenticationEntryPoint() {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
String message = "未检测到登录,请登录!(在开发阶段,看到此提示时,请检查客户端是否携带了JWT向服务器端发起请求)";
JsonResult<Void> jsonResult = JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED, message);
response.setContentType("application/json; charset=utf-8");
PrintWriter printWriter = response.getWriter();
printWriter.println(JSON.toJSONString(jsonResult));
printWriter.close();
}
});
http.csrf().disable();
// 白名单
// 使用1个星号,表示通配此层级的任意资源,例如:/admin/*,可以匹配 /admin/delete、/admin/add-new
// 但是,不可以匹配多个层级,例如:/admin/*,不可以匹配 /admin/9527/delete
// 使用2个连续的星号,表示通配任何层级的任意资源,例如:/admin/**,可以匹配 /admin/delete、/admin/9527/delete
String[] urls = {
"/doc.html",
"/**/*.js",
"/**/*.css",
"/swagger-resources",
"/v2/api-docs",
"/admin/login"
};
http.cors();
// http.authorizeRequests() // 配置URL的访问控制
//
// // 重要,以下2行代码表示:对所有OPTIONS类型的请求“放行”
// .mvcMatchers(HttpMethod.OPTIONS, "/**")
// .permitAll()
//
// .mvcMatchers(urls)
// .permitAll()
// .anyRequest()
// .authenticated();
// 配置URL的访问控制
http.authorizeRequests() // 配置URL的访问控制
.mvcMatchers(urls) // 匹配某些URL
.permitAll() // 直接许可,即:不需要通过认证就可以直接访问
.anyRequest() // 任何请求
.authenticated(); // 以上配置的请求需要是通过认证的
http.addFilterBefore(jwtAuthorizationFilter,
UsernamePasswordAuthenticationFilter.class);
}
}
1.在configure方法中对URL的访问控制配置
2.在configure方法中禁用“防止伪造的跨域攻击”这种防御机制,因为在“前后端分离”的项目中,并不适用
3.配置PasswordEncoder
对象到Spring容器中,因为Spring Security框架在处理认证时,会自动使用PasswordEncoder
中的matches()
方法将原文和密文进行对比,并且自带BCryptPasswordEncoder
类
4.在Spring Security的配置类中重写authenticationManagerBean()
方法,并在此方法上添加@Bean
注解,则Spring会自动调用此方法,得到AuthenticationManager
类型的对象,并保存在Spring容器中,后续,在处理登录功能时需要AuthenticationManager
时可以自动装配!
5.开启“基于方法的权限检查”,通过注解配置权限在控制器类中
6.处理未登录的错误
7.复杂请求的预检机制导致的跨域问题,Spring Security的配置类中,要么对所有OPTIONS
类型的请求“放行”,要么,通过http.cors();
第三步,Spring Security框架处理认证需要自定义类,实现UserDetailsService
接口,并保证此类是组件类就是添加@Service
注解
Spring Security框架会基于此实现类来处理认证
并且会自动使用登录表单提交过来的用户名来调用以上loadUserByUsername()
方法,并得到UserDetails
类型的对象,此对象中应该包含用户的相关信息,例如密码、账号状态等,接下来,Spring Security会自动使用登录表单提交过来的密码与UserDetails
中的密码进行对比,且判断账号状态,以决定此账号是否能够通过认证。
认证结果与loadUserByUsername的返回值有关,会被框架自动对比
@Service
@Slf4j
public class UserDetailService implements UserDetailsService {
@Autowired
private AdminMapper adminMapper;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("Spring Security调用了loadUserByUsername()方法,参数:{}", s);
AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);
log.debug("从数据库查询用户名【{}】匹配的信息,结果:{}", s, loginInfo);
if (loginInfo == null) {
return null; // 暂时
}
// ========== 重要 ===========
List<String> permissions = loginInfo.getPermissions();
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
for (String permission : permissions) {
SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permission);
authorities.add(authority);
}
AdminDetails adminDetails = new AdminDetails(loginInfo.getId(),
loginInfo.getUsername(),
loginInfo.getPassword(),
loginInfo.getEnable() == 1,
authorities);
log.debug("即将向Spring Security返回UserDetails对象:{}", adminDetails);
return adminDetails;
}
}
**提示:**当项目中存在UserDetailsService类型的组件对象时,Spring Security框架不再提供临时的账号(用户名为user密码为启动项目时的UUID值的账号)!
**注意:**Spring Security在处理认证时,要求密码必须经过加密码处理,即使你执意不加密,也必须明确的表示出来,在配置类中配置!
第四步,接受登录请求要写控制器类和service层
在service层中有两个操作,
在service层中需要自动装配AuthenticationManager
处理认证时,当通过认证,需要获取返回结果,并且,将返回结果存入到SecurityContext
中
SecurityContext
默认是基于Session的,也就是将Session存入,并且要返回jwt给客户端,以便于客户端进行登陆后的操作,登陆后的操作会在请求中携带jwt(Session和jwt是干什么的,自己搜,懒得讲)
1.添加依赖
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
2.在service中生成并返回jwt
@Service
@Slf4j
public class AdminServiceImpl implements IAdminService{
@Autowired
private AuthenticationManager authenticationManager;
@Value("${csmall.jwt.secret-key}")
private String secretKey;
@Value("${csmall.jwt.duration-in-minute}")
private Integer durationInMinute;
@Override
public String login(AdminLoginDTO adminLoginDTO) {
log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
// 执行认证
Authentication authentication = new UsernamePasswordAuthenticationToken(
adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
Authentication authenticationResult
= authenticationManager.authenticate(authentication);
log.debug("认证通过,认证结果:{}", authenticationResult);
log.debug("认证通过,认证结果中的当事人:{}", authenticationResult.getPrincipal());
// =========== 新增以下代码 ==========
// 将通过认证的管理员的相关信息存入到JWT中
// 准备生成JWT的相关数据
Date date = new Date(System.currentTimeMillis() + durationInMinute * 60 * 1000);
AdminDetails principal = (AdminDetails) authenticationResult.getPrincipal();
Map<String, Object> claims = new HashMap<>();
claims.put("id", principal.getId());
claims.put("username", principal.getUsername());
Collection<GrantedAuthority> authorities = principal.getAuthorities(); // 新增
String authoritiesJsonString = JSON.toJSONString(authorities); // 新增
claims.put("authoritiesJsonString", authoritiesJsonString); // 新增
// 生成JWT
String jwt = Jwts.builder()
// Header
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
// Payload
.setClaims(claims)
// Signature
.setExpiration(date)
.signWith(SignatureAlgorithm.HS256, secretKey)
// 完成
.compact();
// 返回JWT
return jwt;
}
}
第五步,登录后的请求需要解析jwt
根包下创建过滤器类,继承自OncePerRequestFilter
,并在类上添加组件注解@Component
解析JWT时的异常,需要被捕捉
过滤器是整个服务器端中最早接收到所有请求的组件,此时,控制器等其它组件尚未运行,则不可以使用此前的“全局异常处理器”来处理解析JWT时的异常(全局异常处理器只能处理控制器抛出的异常)
响应到客户端的结果,仍推荐使用JsonResult
封装相关信息,而响应到客户端的结果应该是JSON格式的,则需要将JsonResult
对象转换成JSON格式的字符串!
需要添加fastjson
依赖项
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.75</version>
</dependency>
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
@Value("${csmall.jwt.secret-key}")
private String secretKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 清除SecurityContext中的数据
SecurityContextHolder.clearContext();
// 根据业内惯用的做法,客户端应该将JWT保存在请求头(Request Header)中的名为Authorization的属性中
String jwt = request.getHeader("Authorization");
log.debug("尝试接收客户端携带的JWT数据,JWT:{}", jwt);
// 判断客户端是否提交了有效的JWT
if (!StringUtils.hasText(jwt) || jwt.length() < 113) {
// 直接放行
filterChain.doFilter(request, response);
// 【重要】终止当前方法的执行,不执行当前方法接下来的代码
return;
}
Claims claims = null;
response.setContentType("application/json; charset=utf-8");
try {
claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
} catch (SignatureException e) {
String message = "非法访问!";
log.warn("解析JWT时出现SignatureException,响应消息:{}", message);
JsonResult<Void> jsonResult
= JsonResult.fail(ServiceCode.ERR_JWT_SIGNATURE, message);
PrintWriter printWriter = response.getWriter();
printWriter.println(JSON.toJSONString(jsonResult));
printWriter.close();
return;
} catch (MalformedJwtException e) {
String message = "非法访问!";
log.warn("解析JWT时出现MalformedJwtException,响应消息:{}", message);
JsonResult<Void> jsonResult
= JsonResult.fail(ServiceCode.ERR_JWT_MALFORMED, message);
PrintWriter printWriter = response.getWriter();
printWriter.println(JSON.toJSONString(jsonResult));
printWriter.close();
return;
} catch (ExpiredJwtException e) {
String message = "您的登录信息已过期,请重新登录!";
log.warn("解析JWT时出现ExpiredJwtException,响应消息:{}", message);
JsonResult<Void> jsonResult
= JsonResult.fail(ServiceCode.ERR_JWT_EXPIRED, message);
PrintWriter printWriter = response.getWriter();
printWriter.println(JSON.toJSONString(jsonResult));
printWriter.close();
return;
}
Long id = claims.get("id", Long.class);
String username = claims.get("username", String.class);
String authoritiesJsonString = claims.get("authoritiesJsonString", String.class);
log.debug("从JWT中解析得到的管理员ID:{}", id);
log.debug("从JWT中解析得到的管理员用户名:{}", username);
log.debug("从JWT中解析得到的管理员权限列表JSON:{}", authoritiesJsonString);
// 将JSON格式的权限列表转换成Authentication需要的类型(Collection<GrantedAuthority>)
List<SimpleGrantedAuthority> authorities =
JSON.parseArray(authoritiesJsonString, SimpleGrantedAuthority.class);
// 基于解析JWT的结果创建认证信息
LoginPrincipal principal = new LoginPrincipal();
principal.setId(id);
principal.setUsername(username);
Object credentials = null; // 应该为null
Authentication authentication = new UsernamePasswordAuthenticationToken(
principal, credentials, authorities);
// 将认证信息存入到SecurityContext中
SecurityContext securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(authentication);
// 过滤器链继承向后执行,即:放行
// 如果没有执行以下代码,表示“阻止”,即此请求的处理过程到此结束,在浏览器中将显示一片空白
filterChain.doFilter(request, response);
}
}
上述代码中secretKey和duration-in-minute定义在配置文件(application.yml
系列文件)中,以便于统一管理此值,并且,客户可以修改此值(如果定义在.java
文件中,经过编译后,此值将无法修改)。
在访求处理过程中(特殊业务下),可能需要获取“当事人”的ID、用户名等数据
1.自定义数据类型表示“当事人”
2.在控制器中处理请求的方法的参数列表中,可以通过@AuthenticationPrincipal
注入LoginPrincipal
类型的参数,在处理请求的过程中,可以通过此参数获取当事人的具体数据
Security存在的异常,需要被全局异常处理器捕捉
用户名不存在
org.springframework.security.authentication.InternalAuthenticationServiceException: UserDetailsService returned null, which is an interface contract violation
密码错误
org.springframework.security.authentication.BadCredentialsException: 用户名或密码错误
账号被禁用
org.springframework.security.authentication.DisabledException: 用户已失效
无此权限的账号提交请求
org.springframework.security.access.AccessDeniedException: 不允许访问
2.授权
在登录成功后,将此登陆者的权限列表也写入到JWT中,在上述过滤器中的代码可以看到
在控制器类中的方法上添加注解就可以配置权限
@PreAuthorize("hasAuthority('/ams/admin/read')") // 配置权限
单点登录
指的是有两个服务端
第一个负责登录,第二个负责登陆后的请求
在第二个服务端有以下几步
- 添加依赖
spring-boot-starter-security
jjwt
fastjson
- 复制配置文件中关于JWT的自定义配置
- 更新
ServiceCode
中的枚举值 - 更新
GlobalExceptionHandler
中处理异常的方法 LoginPrincipal
JwtAuthorizationFilter
SecurityConfiguration
- 删除
PasswordEncoder
对应的@Bean
方法 - 删除
AuthenticationManager
对应的@Bean
方法 - 删除“白名单”中的
/admins/login
路径
- 删除
题外话:使用axios携带JWT发起请求
当需要携带JWT发起请求时,根据业内惯例,JWT应该放在请求头的Authorization
属性中,则使用axios时需要自定义请求头
// 以下调用的create()表示创建一个axios实例,此函数的参数是一个对象,用于表示新创建的axios的参数
this.axios .create({'headers': {'Authorization': localStorage.getItem('jwt')}}) .post()// 与此前使用相同,在此处调用get()或post()发起请求
其实也不难,知道自己要做什么就行了,就是这么简单