Spring Security 认证
Spring Security 认证是指通过 Spring Security 框架提供的机制来验证用户的身份,确保用户是经过授权的合法用户。认证过程通常包括以下几个步骤:
1. 用户登录
- 提交登录信息:用户通过登录表单提交用户名和密码等登录信息。
- 过滤器处理:登录请求通常会被
UsernamePasswordAuthenticationFilter
过滤器捕获和处理。
2. 认证过程
- 创建认证对象:过滤器会创建一个
UsernamePasswordAuthenticationToken
对象,包含用户的登录信息。 - 调用认证管理器:将认证对象传递给
AuthenticationManager
进行认证。 - 认证提供者处理:
AuthenticationManager
会委托给一个或多个AuthenticationProvider
来处理具体的认证逻辑。例如,DaoAuthenticationProvider
会从用户数据存储中获取用户信息,并与提交的密码进行比对. - 认证结果:
- 成功:如果认证成功,
AuthenticationProvider
会返回一个包含用户权限信息的Authentication
对象。 - 失败:如果认证失败,会抛出一个认证异常,如
BadCredentialsException
等.
- 成功:如果认证成功,
3. 认证结果处理
- 成功处理:如果认证成功,
Authentication
对象会被存储到SecurityContextHolder
中,表示用户已成功登录。此时,用户可以访问受保护的资源。 - 失败处理:如果认证失败,通常会重定向到登录页面,并显示错误信息,提示用户重新登录.
4. 安全上下文
- SecurityContextHolder:Spring Security 使用
SecurityContextHolder
来存储当前用户的认证信息。它是一个线程局部存储,确保每个请求都能访问到当前用户的认证信息. - SecurityContext:
SecurityContextHolder
中存储的是SecurityContext
对象,它包含Authentication
对象,表示当前用户的认证状态和权限信息.
5. 认证方式
Spring Security 支持多种认证方式,包括:
- 表单登录:使用用户名和密码进行登录。
- HTTP Basic Authentication:通过 HTTP 基本认证头进行认证。
- OAuth2:使用 OAuth2 认证机制,如通过第三方服务进行认证.
- JWT:使用 JSON Web Tokens 进行无状态认证等.
通过这些步骤和机制,Spring Security 能够有效地实现用户的身份验证和授权管理,确保系统的安全性.
1. 认证流程原理
1.1. 认证授权流程
SpringSecurity是基于Filter实现认证和授权,底层通过FilterChainProxy代理去调用各种Filter(Filter链),Filter通过调用AuthenticationManager完成认证 ,通过调用AccessDecisionManager完成授权。流程如下图:
1.2. Security过滤器链
我们知道,SpringSecurity是通过很多的过滤器链共同协作完成认证,授权的流程,SpringSecurity中核心的过滤器链如下:
SecurityContextPersistenceFilter
这个filter是整个filter链的入口和出口,请求开始会从SecurityContextRepository中获取SecurityContext对象并设置给SecurityContextHolder。在登录请求完成后将SecurityContextHolder持有的SecurityContext再保存到配置好的SecurityContextRepository中,同时清除SecurityContextHolder中的SecurityContext
UsernamePasswordAuthenticationFilter
默认拦截“/login”登录请求,处理表单提交的登录认证,将请求中的认证信息包括username,password等封装成UsernamePasswordAuthenticationToken,然后调用AuthenticationManager的认证方法进行认证。
BasicAuthenticationFilter
基本认证,httpBasic登录,弹出登录框登录
RememberAuthenticationFilter
记住我
RememberMeAuthenticationFilter 是 Spring Security 中用于实现“记住我”功能的一个过滤器。其主要作用是当用户没有登录而直接访问资源时,从 cookie 中获取用户信息,如果能够识别出用户提供的 remember me cookie,则用户无需重新输入用户名和密码,即可直接登录系统。
AnonymousAuthenticationFilter
匿名Filter,用来处理匿名访问的资源,如果SecurityContext中没有Authentication,就会创建匿名的Token(AnonymousAuthenticationToken),然后通过SecurityContextHodler设置到SecurityContext中。
ExceptionTranslationFilter
用来捕获FilterChain所有的异常,进行处理,但是只会处理 AuthenticationException和AccessDeniedException异常,其他的异常会继续抛出。
FilterSecurityInterceptor
用来做授权的Filter,通过父类(AbstractSecurityInterceptor.beforeInvocation)调用AccessDecisionManager.decide方法对用户进行授权。
1.3. Security相关概念
AuthenticationToken
所有提交给AuthenticationManager的认证请求都会被封装成一个Token的实现,比如最容易理解的UsernamePasswordAuthenticationToken,其中包含了用户名和密码。
AuthenticationManager
用户认证的管理类,所有的认证请求(比如login)都会通过提交一个token给AuthenticationManager的authenticate()方法来实现认证。AuthenticationManager会调用AuthenticationProvider.authenticate进行认证。认证成功后,返回一个包含了认证信息的Authentication对象。
AuthenticationProvider.authenticate
认证的具体实现类,一个provider是一种认证方式的实现,比如提交的用户名密码。我是通过和DB中查出的user记录做比对实现的,那就有一个DaoProvider;如果我是通过CAS请求单点登录系统实现,那就有一个CASProvider。按照Spring一贯的作风, 主流的认证方式它都已经提供了默认实现,比如DAO、LDAP、CAS、OAuth2等。 前面讲了AuthenticationManager只是一个代理接口,真正的认证就是由AuthenticationProvider来做的。一个AuthenticationManager可以包含多个Provider,每个provider通过实现一个support方法来表示自己支持那种Token的认证。AuthenticationManager默认的实现类是ProviderManager。
UserDetailService
用户的认证通过Provider来完成,而Provider会通过UserDetailService拿到数据库(或内存)中的认证信息然后和客户端提交的认证信息做校验。虽然叫Service,但是我更愿意把它认为是我们系统里经常有的UserDao。
SecurityContext
当用户通过认证之后,就会为这个用户生成一个唯一的SecurityContext,里面包含用户的认证信息Authentication。通过SecurityContext我们可以获取到用户的标识Principle和授权信息GrantedAuthrity。在系统的任何地方只要通过SecurityHolder.getSecruityContext()就可以获取到SecurityContext。在Shiro中通过SecurityUtils.getSubject()到达同样的目的
1.4. SpringSecurity认证流程原理
-
请求过来会被过滤器链中的UsernamePasswordAuthenticationFilter拦截到,请求中的用户名和密码被封装成UsernamePasswordAuthenticationToken(Authentication的实现类)
-
过滤器将UsernamePasswordAuthenticationToken提交给认证管理器(AuthenticationManager)进行认证。
-
AuthenticationManager委托AuthenticationProvider(DaoAuthenticationProvider)进行认证,AuthenticationProvider通过调用UserDetailsService获取到数据库中存储的用户信息(UserDetails),然后调用passwordEncoder密码编码器对UsernamePasswordAuthenticationToken中的密码和UserDetails中的密码进行比较
-
AuthenticationProvider认证成功后封装Authentication并设置好用户的信息(用户名,密码,权限等)返回
-
Authentication被返回到UsernamePasswordAuthenticationFilter,通过调用SecurityContextHolder工具把Authentication封装成SecurityContext中存储起来。然后UsernamePasswordAuthenticationFilter调用AuthenticationSuccessHandler.onAuthenticationSuccess做认证成功后续处理操作
-
最后SecurityContextPersistenceFilter通过SecurityContextHolder.getContext()获取到SecurityContext对象然后调用SecurityContextRepository将SecurityContext存储起来,然后调用SecurityContextHolder.clearContext方法清理SecurityContext。
注意:SecurityContext是一个和当前线程绑定的工具,在代码的任何地方都可以通过SecurityContextHolder.getContext()获取到登陆信息。
2. 定义认证流程
2.1.添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 1.mybatis-plus整合SpringBoot的依赖-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.1</version>
</dependency>
<!-- 2.mysql的驱动依赖-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.23</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
2.2 配置
2.2.1配置mybatis-plus,代码生成器生成对应代码。
2.2.2编写SpringSecurity配置类。
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
}
(1)定义密码编码器
案例中,密码一值是明文的,我们指定的密码编码器是 NoOpPasswordEncoder ,这个是不加密的,但是在生产环境中我们数据库中的密码肯定是密文,所以我们需要指定密码的编码器,SpringSecurity在认证时会调用我们指定的密码编码器进行认证。
BCryptPasswordEncoder是SpringSecurity内部提供的编码器,他的好处在于多次对相同的明文加密出来的密文是不一致的,但是多次加密出来的不同密文确有能检查通过,这种方式增加了密码的安全性,测试代码如下:
public class PasswordTest {
@Test
public void testPassword(){
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
String enPass = bCryptPasswordEncoder.encode("123");
System.out.println(enPass);
System.out.println(bCryptPasswordEncoder.matches("123", enPass));
}
}
在配置类中定义编码器如下:
@Bean
public PasswordEncoder passwordEncoder(){
//return NoOpPasswordEncoder.getInstance();
return new BCryptPasswordEncoder();
}
(2)授权规则配置
//授权规则配置
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests() //授权配置
.antMatchers("/login").permitAll() //登录路径放行
.anyRequest().authenticated() //其他路径都要认证之后才能访问
.and().formLogin() // 允许表单登录
// .loginPage("/login.html").loginProcessingUrl("/login") // 自定义登录页面和登录逻辑路径
.and().logout().logoutUrl("/logout").permitAll() //自定义登出路径
.and().csrf().disable(); //关闭跨域伪造检查
}
2.3. 定义UserDetailsService
- UserDetailsService
是SpringSecurity提供用来获取认证用户信息(用户名,密码,用户的权限列表)的接口,我们可以实现该接口,复写loadUserByUsername(username)方法加载我们数据库中的用户信息。
- UserDetails
UserDetails是SpringSecurity用来封装用户认证信息,权限信息的对象,我们使用它的实现类User封装用户信息并返回,我们这里从数据库查询用户名。
- 创建类UserDetailServiceImpl实现UserDetailsService接口
/**
* 用来提供给security的用户信息的service,
* 我们需要复写 loadUserByUsername 方法返回数据库中的用户信息
*/
@Service
public class UserDetailServiceImpl implements UserDetailsService {
/**
* 加载数据库中的认证的用户的信息:用户名,密码,用户的权限列表
* @param username: 该方法把username传入进来,我们通过username查询用户的信息
(密码,权限列表等)然后封装成 UserDetails进行返回 ,交给security 。
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if (StringUtils.isBlank(username)){
throw new UsernameNotFoundException("用户名不能为空");
}
LambdaQueryWrapper<SysUser> sysUserLambdaQueryWrapper = new LambdaQueryWrapper<>();
sysUserLambdaQueryWrapper.eq(SysUser::getUsername,username);
SysUser sysUser = sysUserMapper.selectOne(sysUserLambdaQueryWrapper);
if (Objects.isNull(sysUser)){
throw new UsernameNotFoundException("用户名不存在");
}
List<GrantedAuthority> permissions = new ArrayList<>();
//密码是基于BCryptPasswordEncoder加密的密文
//User是security内部的对象,UserDetails的实现类 ,用来封装用户的基本信息(用户名,密码,权限列表)
return new User(username,sysUser.getPassword(),permissions);
}
}
Provider会调用UserDetailsService 获取认证信息,这里自定义的UserDetailsService实现类,复写了loadUserByUsername方法,根据用户名查询数据库中的认证信息和当前用户的权限信息,封装成User返回。
**注意:**这里定义了UserDetailSerice后,WebSecurityConfig中不再需要定义UserDetailService的Bean,需要移除
2.4. 自定义登录逻辑
-
添加jwt依赖
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>
-
导入jwt工具类
package com.gcxy.utils; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.util.Date; import java.util.HashMap; import java.util.Map; /** * Jwt处理Token工具类 * * @author fpf * @since 2024-12-23 */ @Component public class JwtTokenUtil { /** * 用户名的key */ private static final String CLAIM_KEY_USERNAME = "sub"; /** * JWT的创建时间 */ private static final String CLAIM_KEY_CREATED = "created"; /** * 秘钥 */ @Value("${jwt.secret}") private String secret; /** * token失效时间 */ @Value("${jwt.expiration}") private Long expiration; /** * 根据用户信息生成token * // * @param userDetails 用户信息 * @return 生成的token */ public String generateToken(String username) { // 定义token中存储数据的载荷 Map<String, Object> claims = new HashMap<>(); // 1. 用户名 claims.put(CLAIM_KEY_USERNAME, username); // 2. 签发时间 claims.put(CLAIM_KEY_CREATED, new Date()); // 签发token return generateToken(claims); } /** * 根据载荷生成token * * @param claims 载荷 * @return 生成的token */ private String generateToken(Map<String, Object> claims) { return Jwts.builder() // 1. 设置载荷 .setClaims(claims) // 2. 设置失效时间 .setExpiration(generateExpirationDate()) // 3. 设置签名 .signWith(SignatureAlgorithm.HS512, secret) // 签发token .compact(); } /** * 生成token失效时间 * * @return token的失效时间 */ private Date generateExpirationDate() { // token失效时间:当前系统时间 + 自己定义的时间 return new Date(System.currentTimeMillis() + expiration * 1000); } /** * 从token中获取用户名 * * @param token token * @return 当前token中存储的用户名 */ public String getUserNameFromToken(String token) { String userName; try { // 从token中获取到载荷 Claims claims = getClaimsFromToken(token); // 通过载荷获取到用户名 userName = claims.getSubject(); } catch (Exception e) { // 如果出现异常则将 userName 设置为空 userName = null; } return userName; } /** * 从token中获取载荷 * * @param token token * @return token中的载荷 */ private Claims getClaimsFromToken(String token) { Claims claims = null; try { claims = Jwts.parser() // 解密的秘钥 .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } catch (Exception e) { e.printStackTrace(); } return claims; } /** * 判断token是否可以被刷新 * * @param token token * @return token是否可以被刷新 */ public boolean canRefresh(String token) { return !isTokenExpired(token); } /** * 判断token是否失效 * * @param token token * @return 当前token是否失效 */ private boolean isTokenExpired(String token) { Date expiredDate = getExpiredDateFromToken(token); // 如果失效时间是在当前时间之前肯定是失效的 return expiredDate.before(new Date()); } /** * 获取token中的失效时间 * * @param token token * @return 当前token中的失效时间 */ public Date getExpiredDateFromToken(String token) { // 从当前token中获取载荷 Claims claims = getClaimsFromToken(token); // 从载荷中返回失效时间 return claims.getExpiration(); } /** * 刷新token * * @param token 用户携带的 token * @return 刷新后的 token */ public String refreshToken(String token) { // 从当前token中获取载荷 Claims claims = getClaimsFromToken(token); // 更新载荷中的失效时间改成当前时间 claims.put(CLAIM_KEY_CREATED, new Date()); // 重新生成token return generateToken(claims); } /** * 判断token是否有效 * * @param token 用户携带的 token * @return token 是否有效 */ public boolean validateToken(String token, String username) { // 通过token是否已经过期、荷载中的用户属性与userDetails中的用户属性是否一致 String userName = getUserNameFromToken(token); return userName.equals(username) && !isTokenExpired(token); } }
-
配置jwt对应参数
jwt: secret: 123456 expiration: 604800
-
SpringSecurity配置类
http.authorizeRequests() // 放行登录接口 .antMatchers("/login").permitAll() // 其他请求需要认证 .anyRequest().authenticated() .and() // 不通过Session获取SecurityContext .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and().csrf().disable();
-
controller中编写登录接口
@Autowired private SysUserServiceImpl sysUserService; //登录成功后重定向地址 @RequestMapping("/login") @ResponseBody public String login(@RequestBody UserLoginDTO userLoginDTO){ return sysUserService.login(userLoginDTO); }
-
编写参数对象UserLoginDTO
package com.gcxy.dto; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; import lombok.experimental.Accessors; /** * 用户登录参数 * * @author fpf * @since 2024-12-23 */ @Data @Accessors(chain = true) @ApiModel(value = "用户登录对象", description = "") public class UserLoginDTO { @ApiModelProperty(value = "用户名", required = true) private String username; @ApiModelProperty(value = "密码", required = true) private String password; }
-
编写登录的业务实现
/** * <p> * 服务实现类 * </p> * * @author fpf * @since 2024-12-23 */ @Service public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements ISysUserService { @Autowired private UserDetailServiceImpl userDetailsService; @Autowired private PasswordEncoder passwordEncoder; @Autowired private JwtTokenUtil jwtTokenUtil; public String login(UserLoginDTO userLoginDTO) { // 获取到UserDetails UserDetails userDetails = userDetailsService.loadUserByUsername(userLoginDTO.getUsername()); // 如果userDetails为空或密码匹配不一致 if (null == userDetails || !passwordEncoder.matches(userLoginDTO.getPassword(), userDetails.getPassword())) { throw new UsernameNotFoundException("用户名或密码错误"); } String token = jwtTokenUtil.generateToken(userDetails.getUsername()); // 登录成功后将token返回给前端 return token; } }
2.5.Jwt过滤实现
- 定义JWT过滤器
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Resource
private UserDetailsService userDetailsService;
@Override
public void doFilterInternal(HttpServletRequest servletRequest, HttpServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
// 跳过/login请求
if ("/login".equals(servletRequest.getRequestURI())) {
filterChain.doFilter(servletRequest, servletResponse);
return;
}
// 1. 从当前请求中获取到授权数据
String token = servletRequest.getHeader("Authorization");
// 2. 判断获取到的请求头中是否存在token
if (StringUtils.isBlank(token)) {
responseWrite(servletResponse,"请携带token");
return;
}
// 从token中获取到当前登录用户的用户名
String username = jwtTokenUtil.getUserNameFromToken(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// 执行登录操作
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.validateToken(token, username)) {
// 设置认证信息
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(servletRequest));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
// 放行
filterChain.doFilter(servletRequest, servletResponse);
}
private static void responseWrite(HttpServletResponse servletResponse,String message) throws IOException {
// 设置编码格式
servletResponse.setCharacterEncoding("UTF-8");
servletResponse.setContentType("application/json");
PrintWriter out = servletResponse.getWriter();
out.write(message);
out.flush();
out.close();
}
}
-
SpringSecurity配置类中添加过滤器相关配置
@Autowired private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; //授权规则配置 @Override protected void configure(HttpSecurity http) throws Exception { ······ http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); }
2.6. 自定义登出
SpringSecurity提供了默认的退出处理,可以在Security配置类中通过.and().logout().permitAll(); 使用默认的退出路径“/logout” ,如果我们需要自定义退出路径,可以通过如下方式指定:
.and().logout().logoutUrl("/mylogout").permitAll() //自定义登出路径
.logoutSuccessHandler(new MyLogoutHandler()) //登出后处理器-可以做一些额外的事情
.invalidateHttpSession(true); //登出后session无效
如有侵权,联系删除