SpringSecurity登录流程详解

前置准备

使用SpringSecurity框架之前,需要自定义配置类SecurityConfig,该配置类继承自WebSecurityConfigurerAdapter
SpringSecurity登录流程详解_第1张图片

在 Spring Security 中,很多对象都是手动 new 出来的,这些 new 出来的对象和容器没有任何关系。在接下来的登录流程中需要使用到AuthenticationManager 对象,所以需要重写authenticationManagerBean()方法,添加@Bean注解使其被容器管理

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
   return super.authenticationManagerBean();
}

另外,需要重写三个configure方法

SpringSecurity登录流程详解_第2张图片

void configure(AuthenticationManagerBuilder auth) 为身份认证接口,用来配置认证管理器AuthenticationManager。

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}

void configure(WebSecurity web) 用来配置 WebSecurity 。而 WebSecurity 是基于 Servlet Filter 用来配置 springSecurityFilterChain 。而 springSecurityFilterChain 又被委托给了 Spring Security 核心过滤器 Bean DelegatingFilterProxy 。 相关逻辑可以在 WebSecurityConfiguration 中找到。我们一般不会过多来自定义 WebSecurity ,使用较多的是其ignoring() 方法用来忽略 Spring Security 对静态资源的控制

@Override
public void configure(WebSecurity web) throws Exception {
    super.configure(web);
}
/**
 1. anyRequest          |   匹配所有请求路径
 2. access              |   SpringEl表达式结果为true时可以访问
 3. anonymous           |   匿名可以访问
 4. denyAll             |   用户不能访问
 5. fullyAuthenticated  |   用户完全认证可以访问(非remember-me下自动登录)
 6. hasAnyAuthority     |   如果有参数,参数表示权限,则其中任何一个权限可以访问
 7. hasAnyRole          |   如果有参数,参数表示角色,则其中任何一个角色可以访问
 8. hasAuthority        |   如果有参数,参数表示权限,则其权限可以访问
 9. hasIpAddress        |   如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
 10. hasRole            |   如果有参数,参数表示角色,则其角色可以访问
 11. permitAll          |   用户可以任意访问
 12. rememberMe         |   允许通过remember-me登录的用户访问
 13. authenticated      |   用户登录后可访问
*/
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
            // CSRF禁用,因为不使用session
            .csrf().disable()
            // 认证失败处理类,指定异常处理实现类
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
            // 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
            // 过滤请求
            .authorizeRequests()
            // 对于登录login 验证码captchaImage 允许匿名访问
            .antMatchers("/login", "/captchaImage").anonymous()
            .antMatchers(
                    HttpMethod.GET,
                    //添加前端所有静态资源路径
                    "/*.ttf",
                    ...
            ).permitAll()
            //添加公共api路径
            .antMatchers("/common/downloadByMinio**").permitAll()
            ...
            // 除上面外的所有请求全部需要鉴权认证
            .anyRequest().authenticated()
            .and()
            .headers().frameOptions().disable();
    //指定执行退出登录功能的LogoutSuccessHandler实现类
    httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
    // 添加JWT JwtAuthenticationTokenFilter到UsernamePasswordAuthenticationFilter之前,第二个参数是指定添加到哪个过滤器之前
    httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    // 添加CORS filter
    httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
    httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
}

此配置类中重点关注以下两个配置:
1、antMatchers("/login", "/captchaImage").anonymous()
对于登录和验证码路径请求,允许匿名访问

2、httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
添加自定义过滤器JwtAuthenticationTokenFilterUsernamePasswordAuthenticationFilter之前
(关于添加自定义过滤器JwtAuthenticationTokenFilter的作用,待分析完整个登录流程后详解)

SpringSecurity登录流程详解_第3张图片
以下为重点:

登录流程:
1、在首次登录时,前端发送 /login 请求,随后进入controller方法

@Resource
private AuthenticationManager authenticationManager;
    
public String login(String username, String password, String code) {
		// TODO 验证码验证过程中日志记录的异步线程思路留待后续填坑
        // 用户登录信息验证
        // authentication中封装用户名和密码
        Authentication authentication = null;
        try {
            //此处为核心代码,见下方详解!!!!!!!!!!!!!!!!
            authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
        } catch (Exception e) {
            e.printStackTrace();
            if (e instanceof BadCredentialsException) {
                throw new UserPasswordNotMatchException();
            } else {
                throw new CustomException(e.getMessage());
            }
        }
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        // 生成token
        return tokenService.createToken(loginUser);
    }

authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));

一、首先由AuthenticationManager接口中的authenticate()提供认证入口,该方法由其实现类ProviderManager实现:

  1. 阅读官方文档和源码可知,ProviderManager中的authenticate()方法会遍历AuthenticationProvider的实现类集合,根据每个实现类中的supports()方法来判断是否支持UsernamePasswordAuthenticationToken类型(默认使用该类封装用户名和密码)authentication的校验,默认由AbstractUserDetailsAuthenticationProvider提供此类型authentication的实现类
    (当Spring Security默认提供的实现类不能满足需求的时候可以扩展AuthenticationProvider集合中的实现类,并重写其supports(Class authentication)方法)
//AbstractUserDetailsAuthenticationProvider类中的supports方法
public boolean supports(Class<?> authentication) {
		return (UsernamePasswordAuthenticationToken.class
				.isAssignableFrom(authentication));
	}
//AbstractUserDetailsAuthenticationProvider类中的authenticate方法
public Authentication authenticate(Authentication authentication)
			throws AuthenticationException {
		Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
				() -> messages.getMessage(
						"AbstractUserDetailsAuthenticationProvider.onlySupports",
						"Only UsernamePasswordAuthenticationToken is supported"));

		// Determine username
		String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
				: authentication.getName();

		boolean cacheWasUsed = true;
		UserDetails user = this.userCache.getUserFromCache(username);

		if (user == null) {
			cacheWasUsed = false;

			try {
//2
				user = retrieveUser(username,
						(UsernamePasswordAuthenticationToken) authentication);
			}
			catch (UsernameNotFoundException notFound) {
				logger.debug("User '" + username + "' not found");

				if (hideUserNotFoundExceptions) {
					throw new BadCredentialsException(messages.getMessage(
							"AbstractUserDetailsAuthenticationProvider.badCredentials",
							"Bad credentials"));
				}
				else {
					throw notFound;
				}
			}

			Assert.notNull(user,
					"retrieveUser returned null - a violation of the interface contract");
		}

		try {
			preAuthenticationChecks.check(user);
//3
			additionalAuthenticationChecks(user,
					(UsernamePasswordAuthenticationToken) authentication);
		}
		postAuthenticationChecks.check(user);

		if (!cacheWasUsed) {
			this.userCache.putUserInCache(user);
		}

		Object principalToReturn = user;

		if (forcePrincipalAsString) {
			principalToReturn = user.getUsername();
		}

		return createSuccessAuthentication(principalToReturn, authentication, user);
	}
  1. AbstractUserDetailsAuthenticationProvider类中重写的authenticate()方法中,调用如下方法
    user = retrieveUser(username,(UsernamePasswordAuthenticationToken) authentication);以获取用户信息。该方法中又调用了loadUserByUsername()方法以用户名形式获取信息并进行封装对象,此时即可同步获取到用户对象的权限,并统一封装到UserDetails的自定义实现类中。后续的权限校验即可在该实现类对象中获取用户权限集合用于权限校验。
    另:此处一般会自定义实现类,实现UserDetailsService接口来重写loadUserByUsername()方法,从而可以在持久层中获取用户数据用于校验
@Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser user = userService.selectUserByUserName(username);
        return new LoginUser(user, permissionService.getMenuPermission(user));
    }
  1. 获取到用户数据后,调用additionalAuthenticationChecks()方法验证密码是否匹配,该方法由AbstractUserDetailsAuthenticationProvider实现类DaoAuthenticationProvider实现
protected void additionalAuthenticationChecks(UserDetails userDetails,
			UsernamePasswordAuthenticationToken authentication)
			throws AuthenticationException {
		if (authentication.getCredentials() == null) {
			logger.debug("Authentication failed: no credentials provided");

			throw new BadCredentialsException(messages.getMessage(
					"AbstractUserDetailsAuthenticationProvider.badCredentials",
					"Bad credentials"));
		}

		String presentedPassword = authentication.getCredentials().toString();

		if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
			logger.debug("Authentication failed: password does not match stored value");

			throw new BadCredentialsException(messages.getMessage(
					"AbstractUserDetailsAuthenticationProvider.badCredentials",
					"Bad credentials"));
		}
	}
  1. 若验证成功,则创建UsernamePasswordAuthenticationToken类型的Authentication对象,由于用户验证已经通过,所以查询了用户的权限列表,并作为形参注入,这里同时也是为了调用UsernamePasswordAuthenticationToken的三参构造,在该构造方法中设置了authenticated为true,即 将authentication设置为认证状态,后续无需再次认证;
protected Authentication createSuccessAuthentication(Object principal,
			Authentication authentication, UserDetails user) {
		UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(
				principal, authentication.getCredentials(),
				authoritiesMapper.mapAuthorities(user.getAuthorities()));
		result.setDetails(authentication.getDetails());

		return result;
	}
public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
			Collection<? extends GrantedAuthority> authorities) {
		super(authorities);
		this.principal = principal;
		this.credentials = credentials;
		super.setAuthenticated(true);
	}
  1. Authentication返回过程:
    1. DaoAuthenticationProvider类的retrieveUser方法通过loadUserByUsername获取到用户信息后返回一个UserDetails对象给到父类AbstractUserDetailsAuthenticationProvider的方法authenticate

    2. AbstractUserDetailsAuthenticationProvider拿到返回的UserDetails后,调用了return createSuccessAuthentication(principalToReturn, authentication, user);创建了一个可信的UsernamepasswordAuthenticationToken,并返回给了ProviderManagerauthenticate方法,
      authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));,此时的authentication是已验证过的。

    3. 接下来即可进行创建token,缓存token,返回token至前端等操作


至此,整个登录流程已经梳理完毕。但是流程中还有一些不足之处,例如在获取用户信息时,总是需要在request请求中获取token后,再获取用户的UUID,再从Redis缓存中获取到用户信息;
因此,我们可以增加一个自定义拦截器,在拦截器中利用token获取数据后,存入SpringSecurity提供的一个存储空间SecurityContextHolder,默认是使用ThreadLocal实现的,这样就保证了本线程内所有的方法都可以获得SecurityContext对象。

因此,想要存入SecurityContextHolder中,需要将用户信息先封装到Authentication对象中。

public interface SecurityContext extends Serializable {

	Authentication getAuthentication();

	void setAuthentication(Authentication authentication);
}

可以继续使用UsernamepasswordAuthenticationToken进行封装(注意,这里同样需要使用三参构造,以使authentication对象变成认证状态),由于仅需封装对象数据,所以形参列表中后两个参数可以为null。

完整代码如下:

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    @Autowired
    private TokenService tokenService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
            
//1. 
        LoginUser loginUser = tokenService.getLoginUser(request);
        if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())) {    

//2.
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, null);
            
//3.
		SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        chain.doFilter(request, response);
    }
}

你可能感兴趣的:(java,servlet,spring)