前言
本文讲解一下Spring Security如何使用,阅读本文之前最好先阅读一下我的另一篇文章,讲的是 Spring Security基础的源码https://blog.csdn.net/a771664696/article/details/141790526,基于上一篇源码的理解再来看如何正确的使用Spring Security,先附上代码的gitee地址,https://gitee.com/henryht/spring-security-demo.git
目的
首先,明确一下本文讲解的目的是搭建一个简易的Spring Security框架,为业务服务提供权限认证。先看一个非常简单的图,
-
前端先通过用户名密码调用后端的登录接口,登录成功后,后端返回一串token给到前端
-
当前端发起业务请求时再Header中携带这个token,后端会对这个业务请求进行拦截,并验证token的合法性
实际项目的接口如下几个图所示:
-
调用/auth/login接口获取token
-
拿到token之后,通过header中携带token完成认证,访问测试接口
回顾
在开始讲解之前,先来回顾一下上一篇文章的内容。还是这个经典的图,本文我们将对下面的几个成员按照真实的业务需求进行重写
登录源码分析
自定义过滤器CustomLoginUsernamePasswordFilter
按照上文的流程图,首先我们需要重写的是过滤器UsernamePasswordAuthenticationFilter,这个过滤器之前也是介绍了,主要负责的拦截登录的请求,然后将请求处理后交由后续的角色进行登录的流程操作
/**
* 用户登录过滤器
*/
public class CustomLoginUsernamePasswordFilter extends UsernamePasswordAuthenticationFilter {
private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER =
new AntPathRequestMatcher(SecurityConstants.LOGIN_URL, "POST");
public CustomLoginUsernamePasswordFilter(CustomProvider customProvider) {
this.setRequiresAuthenticationRequestMatcher(DEFAULT_ANT_PATH_REQUEST_MATCHER);
this.setAuthenticationManager(new ProviderManager(customProvider));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("custom Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
username = (username != null) ? username : "";
username = username.trim();
String password = obtainPassword(request);
password = (password != null) ? password : "";
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
/**
* 重写授权成功方法,返回token
*/
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws IOException {
UserDetails userDetails = (UserDetails) authResult.getPrincipal();
//存入redis,返回生产的uuid token
String token = UUID.randomUUID().toString().replaceAll("-", "");
// 返回token
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(token);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException {
SecurityContextHolder.clearContext();
// 返回token
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("登录失败");
}
}
这里我们自定义了一个CustomLoginUsernamePasswordFilter过滤器,继承了UsernamePasswordAuthenticationFilter类,然后重写了一些方法,首先是attemptAuthentication方法,这里其实并没有做太多改造。
但为什么这里也重写了这个方法呢?首先看看这个方法是在干嘛,这个方法首先从HttpServletRequest中拿到username和password,封装成一个UsernamePasswordAuthenticationToken对象,然后交由权限认证管理器处理。平时我们在和前端约定传值的时候不一定是按照这个方式来传的,比如有的可能就不使用form表单提交的形式,所以这里我们使用的时候可以按照自己的方式来传值
这里我们还重写了2个方法,一个是successfulAuthentication,这个方法之前讲过,在父类抽象类中它的作用是登录成功做一个重定向到指定的url页面,但是这里我们登录实际的需求是返回一串token给前端,这里正常来说需要存入redis中用于后续登录合法性校验,本文的demo就不做实现。
另一个方式是unsuccessfulAuthentication,这里也是同样的道理,它默认的实现是登录失败跳转登录失败页面,但因为我们是前后端分离,这里就是直接返回和前端约定的登录失败的code和提示即可。
权限认证过滤器ProviderManager
本文并没有对ProviderManager进行重写,因为这个权限认证过滤器的核心逻辑是去筛选一个符合条件的Provider进行处理
自定义权限认证处理器CustomProvider
这里自定义了CustomProvider用来替代DaoAuthenticationProvider来完成我们自己的逻辑。在这个权限认证处理器,核心方法是authenticate
-
这里我们可以进行一些自定义的日志打印
-
从自定义的customUserDetailService(这个也需要我们自定义)中获取UserDetails用于验证用户合法性,用户名密码等
-
通过自定的PasswordEncoder对密码进行加解密的验证,这里我使用的是md5的形式,大家也可以根据自己的需求对面进行不同形式的加解密验证,如AES等。
-
密码校验通过封装一个完整的UsernamePasswordAuthenticationToken返回。
@Component
public class CustomProvider implements AuthenticationProvider {
Logger logger = LoggerFactory.getLogger(CustomProvider.class);
private final PasswordEncoder passwordEncoder;
private final CustomUserDetailService customUserDetailService;
public CustomProvider(PasswordEncoder passwordEncoder,
CustomUserDetailService customUserDetailService) {
this.passwordEncoder = passwordEncoder;
this.customUserDetailService = customUserDetailService;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
logger.info("come into CustomProvider authentication {} ", authentication);
//匹配账号密码
String username = authentication.getName();
String password = (String) authentication.getCredentials();
UserDetails userDetails = customUserDetailService.loadUserByUsername(username);
if (userDetails == null) {
throw new BadCredentialsException("user does not exist");
}
if (!passwordEncoder.matches(password, userDetails.getPassword())) {
throw new BadCredentialsException("wrong password");
}
// 返回一个认证对象
return new UsernamePasswordAuthenticationToken(userDetails, passwordEncoder.encode(password), userDetails.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}
用户处理器CustomUserDetailService
我们知道Spring Security默认使用的是,InMemoryUserDetailsManager,但是我们生产环境不可能只用内存的形式来处理用户信息,所以我要通过创建一个新的CustomUserDetailService来替代它。
这个类其实大家应该会比较眼熟了,因为网上很多的文章都会去自己实现这个UserDetailsService这个接口。
看了本文之后相信大家也不仅能够知道实现它能干嘛,而且也能够了解为什么要实现它,以及它的整个调用链路。
该接口就只有一个方法loadUserByUsername,那么来看看这个方法内到底需要做什么
-
通过方法传入的username(账号)去数据中查询用户信息,这里我屏蔽了数据库的查询细节,因为是demo所以这里直接代码写死
-
authorities为权限相关,本文暂不做深入讲解
-
查询完成后封装一个自定义的CustomUser用户对象返回,这里对象返回给上一层的CustomProvider,就可以为它提供密码校验的能力了
@Component
public class CustomUserDetailService implements UserDetailsService {
Logger logger = LoggerFactory.getLogger(CustomUserDetailService.class);
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
logger.info("come into CustomProvider authentication {} ", username);
// query from db
String userName = "henry";
List<GrantedAuthority> authorities = new ArrayList<>();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority("ROLE_ADMIN");
authorities.add(simpleGrantedAuthority);
return new CustomUser(userName, SecureUtil.md5("123456"), authorities);
}
}
对于CustomUser这里也是需要去实现它的UserDetails接口,这里面也是Spring Security抽象的一些行为
比如getPassword获取用户密码, getUsername查询用户账号,isAccountNonExpired账号是否过期等,总之按照它的规范来就行
public class CustomUser implements UserDetails {
private String username;
private String password;
List<GrantedAuthority> authorities;
public CustomUser(String username, String password, List<GrantedAuthority> authorities) {
this.username = username;
this.password = password;
this.authorities = authorities;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.username;
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return false;
}
public void setUsername(String username) {
this.username = username;
}
public void setPassword(String password) {
this.password = password;
}
public void setAuthorities(List<GrantedAuthority> authorities) {
this.authorities = authorities;
}
}
到这里登录流程就已经完结。
上面我们重写了很多Spring Security的类或者叫做组件吧,还需要把他们注入生效,这里需要一个配置类去创建它们。
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Resource
private CustomProvider customProvider;
@Bean
public PasswordEncoder passwordEncoder() {
//自定义密码加解密
return new CustomPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
//关闭csrf
http.csrf().disable()
.anonymous().disable()
// 禁用session
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
//添加provider
.authenticationProvider(customProvider)
.authorizeHttpRequests(authorizationManagerRequestMatcherRegistry -> {
authorizationManagerRequestMatcherRegistry.antMatchers(SecurityConstants.LOGIN_URL).permitAll();
authorizationManagerRequestMatcherRegistry.antMatchers(SecurityConstants.LOGOUT_URL).permitAll();
authorizationManagerRequestMatcherRegistry.anyRequest().authenticated();
//可配置其他的静态访问资源
})
//添加登录拦截器
.addFilterAt(new CustomLoginUsernamePasswordFilter(customProvider), UsernamePasswordAuthenticationFilter.class)
//在CustomLoginUsernamePasswordFilter之前去执行
.addFilterBefore(new CustomRequestUsernamePasswordFilter(), CustomLoginUsernamePasswordFilter.class)
//退出登录
.logout().logoutUrl(SecurityConstants.LOGOUT_URL).addLogoutHandler(new CustomLogoutHandler());
}
}
鉴权源码分析
如上文所说,登录成功之后,我们会返回一个uuid用作token返回给前端。那么前端只需要把这个token按照一定的规则传给后端就可以进行账号登录的合法性验证了。
一般来说都是通过在Header中传入一个参数Authorization,然后以Bearer开头,空格加token进行传递。
所以这里我们需要个过滤器来拦截进入后端的每一个请求,从而达到校验用户登录的目的,那我们来看看这个拦截器做了什么
-
判断是否登录接口,如果是则默认放行,登录接口不用做校验
-
从header中解析出token
-
根据token去redis中查找是否存在,并取出redis中存储的用户信息,校验合法性
-
将从redis中取出的用户信息封装成UsernamePasswordAuthenticationToken放入SecurityContextHolder中。
public class CustomRequestUsernamePasswordFilter extends OncePerRequestFilter {
Logger logger = LoggerFactory.getLogger(CustomRequestUsernamePasswordFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (request.getRequestURI().contains(SecurityConstants.LOGIN_URL)) {
//白名单放行,如果是/auth/login则证明是登录的接口,不需要校验token
filterChain.doFilter(request, response);
return;
}
String tokenHeader = request.getHeader("Authorization");
if (!StringUtils.hasText(tokenHeader)) {
// 返回token
//正常这里要返回封装的result类
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("认证失败");
return;
}
String token = tokenHeader.replace("Bearer ", "");
logger.info("token is {}", token);
// 校验token合法性
//... 省略逻辑 这里从redis中拿出
// token续期
//... 省略逻辑 这里每次访问都将redis中的token续期
// 将redis中存放的用户信息,放入SecurityContextHolder中
UserDetails userDetails = new CustomUser("henry", "", null);
// 放入SecurityContextHolder中,这一步很重要,否则spring security无法认证成功,会被AuthorizationFilter拦截器拦住
SecurityContextHolder.getContext()
.setAuthentication(new UsernamePasswordAuthenticationToken(userDetails, null, null));
filterChain.doFilter(request, response);
}
}
总结
本文其实也只是一个很简单的demo,但是基本的Spring Security的使用思路基本也都涵盖了。这里也留一个问题,在微服务中,又要怎么来处理呢?留到下一篇文章再讲解