在上一篇入门原理中,我们介绍了Spring Security的主要流程是 认证 和 授权。这一篇主要介绍其中的 认证流程 ,并完成一次认证流程。
说明: 本系列的文章使用的是 springBoot 2.7.11 (springboot 2x 最后一个稳定版本),对应的 Spring Security 是 5.7.8
完整认证流程
完整认证流程
security 配置
-
继承
WebSecurityConfigurerAdapter在该类注释中有相关配置示例可以参考,但spring目前已经不推荐这种方式了!
//默认登录方式配置是在 SpringBootWebSecurityConfiguration 中
//@Configuration
public class DeprecatedSecurity extends WebSecurityConfigurerAdapter {
@Autowired
private SecurityProperties securityProperties;
/**
* 配置url 规则 ,登录方式等
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//访问 admin 需要鉴权且用户需要有Admin角色 ,
// 所有url都需要鉴权
http.authorizeRequests().antMatchers("/admin/**").hasAnyRole("Admin")
.anyRequest().authenticated()
.and()
.formLogin().and()
.httpBasic();
}
/**
* 配置 用户角色
* @param auth
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser(securityProperties.getUser().getName()).password("{noop}".concat(securityProperties.getUser().getPassword())).roles("Admin");
// super.configure(auth);
}
/**
* 配置用户服务
* 如何获取用户
* @return
*/
@Override
protected UserDetailsService userDetailsService() {
return super.userDetailsService();
}
- 手动实现
SecurityFilterChain
其中关于 httpSecurity的一些配置项也做了详细的注释
@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig {
@Autowired
private AuthenticationConfiguration configuration;
@SneakyThrows
@Bean
public AuthenticationManager authenticationManagerBean() {
AuthenticationManager authenticationManager = configuration.getAuthenticationManager();
return authenticationManager;
}
/**
* url拦截规则
* 这个拦截链是可以定义多个bean的
*
* @param http
* @return
* @throws Exception
*/
@Bean
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
//使用由httpSecurityConfiguration 装配的 HttpSecurity 则会紧密的保持原有的过滤器
http.csrf().disable()//前后端分离项目要关闭 csrf
//登录接口只允许匿名访问(即未登录状态下才可以访问)
.authorizeRequests().antMatchers("/user/login").anonymous()
//登出接口需要鉴权才能访问
.antMatchers("/user/logout").authenticated()
//test 在任何情况下都可以访问
.antMatchers("/user/test", "/error").permitAll()
//denyAll 在任何情况下都不可以访问
.antMatchers("/user/denyAll").denyAll()
//其余接口都需要鉴权
.anyRequest().authenticated();
//formLogin 添加了 usernamePasswordAuthenticationFilter 登录页等信息
//.formLogin();
return http.build();
}
/**
* url放行规则
*
* @return
*/
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().antMatchers("/html/**");
}
/**
* 必须配置一种加密方式
*
* @return
*/
@Bean
public PasswordEncoder getPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
自定义加载用户信息
表单登录中需要验证账号密码,其中重要的过滤器就是 UsernamePasswordAuthenticationFilter ,下图就是该过滤器的执行过程。
表单登录的认证流程
其中UserDetailService 是一个接口,我们可以通过实现该接口的方式来重写加载用户信息的逻辑。
@Service
public class UserDetailServiceImpl implements UserDetailsService {
@Resource
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 查询用户信息
User user = userMapper.getUser(username);
if (Objects.isNull(user)) {
//抛出异常之后,后续拦截器会继续处理
throw new BizException("用户名或密码错误");
}
//todo 查询对应的权限信息
LoginUser loginUser = new LoginUser(user);
return loginUser;
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginUser implements UserDetails {
private User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
/**
* 账号是否未过期
* @return
*/
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* 账号是否未被锁定
* @return
*/
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* 账号是否凭证未过期
* @return
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 账号是否可用
* @return
*/
@Override
public boolean isEnabled() {
return true;
}
}
至此,我们基本上就实现了自己加载用户信息的方式。
自定义token 认证
现在很多应用都实行了前后端分离,使用 token的方式进行鉴权访问。下面我们讲解如何使用自定义token实现security验证。
设计方案
- 提供一个登录接口,来获取token
- Token工具类,生成和解析token (userId)
- 自定义Token过滤器,如果token校验通过,则封装一个已认证对象给其他过滤器继续处理
- 如果想降低数据库压力,引入redis保存/查询用户信息
- 一个登出接口
POM 文件
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
需要额外引入 jwt来生成和解析token
登录接口
- 既然自定义登录接口,则废弃security原有的登录页接口
/login 借助 security 的过滤器进行账户验证- 也可以自己查数据库验证
- 验证通过,则生成 jwt token
- 用户信息存入 redis(可选)
- 返回token
@RestController
@RequestMapping("/user")
public class LoginController {
@Autowired
private LoginService loginService;
/**
* 1.自定义登录接口:
* 借助 AuthenticationManager 调用 ProviderManager 的方法进行认证,如果认证通过生成 jwt
* 把用户信息存入redis
*
*/
@RequestMapping("/login")
public Result<Token> login(@RequestBody User user) {
return loginService.login(user);
}
}
@Service
public class LoginService {
@Autowired
private AuthenticationManager authenticationManager;
public Result<Token> login(User user) {
/** 构建 用户密码待验证token */
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
user.getUserName(),user.getPassword());
/** 直接将账号密码交给 认证器进行验证 */
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
if (Objects.isNull(authenticate)) {
throw new BizException("用户名或密码错误");
}
//认证通过 从认证结果中拿出 完整的User信息 使用userId 生成 jwt
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
Token token = JwtUtils.getJwtToken(loginUser.getUser());
//todo 将user 存入redis
return Result.success(token);
}
}
这里面有几个要注意的点:
- 在登录服务中,如果借助security进行账户校验,需要获取一个bean
AuthenticationManager,这个bean 需要通过以下方式获取
@Autowired
private AuthenticationConfiguration configuration;
@SneakyThrows
@Bean
public AuthenticationManager authenticationManagerBean() {
AuthenticationManager authenticationManager = configuration.getAuthenticationManager();
return authenticationManager;
}
- 在
LoginService中,我们需要根据 UserId 生成 token (为什么不用userName呢?其实也可以,但userName容易暴露,userId相对而言更安全一些)时,要使用鉴权过后的Authentication里去取LoginUser里的User,而不是将前端参数绑定出来的User对象。 - 生成
UsernamePasswordAuthenticationToken时不要用错构造函数,要用只有两个参数的构造函数,这样返回的认证对象是未认证 - 最后需要在 security 配置中放行 登录接口的url
token工具类
package com.example.domain.util;
import com.example.domain.entity.Token;
import com.example.domain.entity.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.util.StringUtils;
import java.util.Date;
public class JwtUtils {
private static final long expire = 1000 * 60 * 60 * 24;
private static final String SECRET = "ukc8BDbRigUDaY6pZFfWus2jZWLP26";
// private static final String SECRET = "ukc8BDbRigUDaY6pZFfWus2jZWLPHO";
private static final String TOKEN_ID = "id";
/**
* 生成Token
* @param user
* @return
*/
public static Token getJwtToken(User user) {
Date expireDate = new Date(System.currentTimeMillis() + expire);
String jwtToken = Jwts.builder()
.setHeaderParam("type", "jwt")
.setHeaderParam("alg", "HS2256")
.setSubject("lin-user")
.setIssuedAt(new Date())
.setExpiration(expireDate)
.claim(TOKEN_ID, user.getId())
.claim("userName", user.getUserName())
.signWith(SignatureAlgorithm.HS256, SECRET)
.compact();
return Token.builder()
.token(jwtToken)
.expireTime(expireDate)
.build();
}
/**
* 判断token是否存在与有效
* @Param jwtToken
*/
public static boolean checkToken(String jwtToken){
if (StringUtils.hasText(jwtToken)){
return false;
}
try{
//验证token
Jwts.parser().setSigningKey(SECRET).parseClaimsJws(jwtToken);
}catch (Exception e){
e.printStackTrace();
return false;
}
return true;
}
/**
* 根据token获取会员id
* @Param request
*/
public static Integer getUserId(String token){
Jws<Claims> claimsJws = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token);
Claims body = claimsJws.getBody();
return (Integer) body.get(TOKEN_ID);
}
}
自定义token过滤器
token 过滤器是用来校验token,然后转换成用户信息,进而封装出一个已认证的认证对象丢给其他过滤器。
很明显这个过滤器应该在 UsernamePasswordAuthenticationFilter 这个过滤器的前面。
流程:
- 获取token
- 解析 token 中的 userId
- 从redis中获取用户信息
- 存入 SecurityContextHolder
SecurityContextHolder 里面保存了一个绑定当前 request线程的 SecurityContext 对象,我们往这里面放Authentication对象可以让后续过滤器也可以取出来用。实际上security也就是这样操作的
现在,我们如何写这个过滤器呢? 自定义Filter 有很多种方式,比如
- implements Filter
- extends GenericFilter 但这些都会有个问题就是 Filter可能会重跑2次 (加入securityFilterChain 会执行一次,被 代理类引入servlet容器之后也会执行一次),如果这块不清楚可以翻看下前一张中关于容器部分。
推荐 继承 spring的
OncePerRequestFilter可以保证过滤器只会跑一次
@Component
public class TokenFilter extends OncePerRequestFilter {
@Resource
private UserMapper userMapper;
/**
* 1.获取token
* 2.解析 token 中的 userId
* 3.从redis中获取用户信息
* 4.存入 SecurityContextHolder
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("token");
if (!StringUtils.hasText(token)) {
//没token 当前过滤器直接放行 (后续由security其他过滤器进行处理)
filterChain.doFilter(request, response);
return;
}
Integer userId = JwtUtils.getUserId(token);
//根据userId 获取用户信息
User user = userMapper.getUserById(Long.valueOf(userId));
if (Objects.isNull(user)) {
throw new BizException("用户未登录");
}
// 存入 securityContextHolder
// 这个构造参数会执行 super.setAuthenticated(true) 表示已认证
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
user.getUserName(), user.getPassword(), null);
SecurityContextHolder.getContext().setAuthentication(authentication);
//todo 从redis中获取
//继续执行后续过滤器
filterChain.doFilter(request, response);
}
}
需要注意的事:
- token 这个字段应该是在http header 里,而不是在接口请求参数里。
- 构建的 authentication 要正确使用构造函数,返回一个已认证的对象
- 过滤器执行完token后,需要放行,让过滤器链继续跑
登出接口
用户使用token 进行登出操作。首先要通过security的验证流程,然后删除redis中的用户信息即可。
public Result<String> logout() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
//登出接口也需要鉴权 authentication不为null
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
Long userId = loginUser.getUser().getId();
//todo 从redis 中删除对应的用户信息
return Result.success("登出成功");
}
security 配置
既然使用token登录,则需要在SecurityConfig中进行以下配置
- 关闭 csrf
http.csrf().disable()
- 取消 session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
- 登录接口应该是支持匿名访问(未登录可以访问,已登录不可以访问)
.authorizeRequests().antMatchers("/user/login").anonymous()
- 登出接口需要鉴权访问
.antMatchers("/user/logout").authenticated()
- 将自定义的token过滤器加到
UsernamePasswordAuthenticationFilter前面
http.addFilterBefore(tokenFilter,UsernamePasswordAuthenticationFilter.class);