认证流程简析
AuthenticationProvider 定义了 Spring Security 中的验证逻辑,我们来看下 AuthenticationProvider 的定义:
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
boolean supports(Class<?> authentication);
}
可以看到,AuthenticationProvider 中就两个方法:
authenticate 方法用来做验证,就是验证用户身份。 supports 则用来判断当前的 AuthenticationProvider 是否支持对应的 Authentication。 这里又涉及到一个东西,就是 Authentication。
- Spring Security 中有一个非常重要的对象叫做 Authentication,我们可以在任何地方注入 Authentication 进而获取到当前登录用户信息
- Authentication 本身是一个接口,它实际上对 java.security.Principal 做的进一步封装
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
Object getCredentials();
Object getDetails();
Object getPrincipal();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
可以看到,这里接口中的方法也没几个,我来大概解释下:
- getAuthorities 方法用来获取用户的权限。
- getCredentials 方法用来获取用户凭证,一般来说就是密码。
- getDetails 方法用来获取用户携带的详细信息,可能是当前请求之类的东西。
- getPrincipal 方法用来获取当前用户,可能是一个用户名,也可能是一个用户对象。
- isAuthenticated 当前用户是否认证成功。
- Authentication 作为一个接口,它定义了用户,或者说 Principal 的一些基本行为
- 我们最常用的就是
UsernamePasswordAuthenticationToken
了,而每一个 Authentication 都有适合它的AuthenticationProvider
去处理校验。例如处理UsernamePasswordAuthenticationToken
的AuthenticationProvider
是DaoAuthenticationProvider。
所以大家在 AuthenticationProvider
中看到一个 supports
方法,就是用来判断 AuthenticationProvider
是否支持当前 Authentication。
在一次完整的认证中,可能包含多个 AuthenticationProvider,而这多个
AuthenticationProvider
则由 ProviderManager
进行统一管理,
- 看一下 DaoAuthenticationProvider,因为这是我们最常用的一个,当我们使用用户名/密码登录的时候,用的就是它,DaoAuthenticationProvider 的父类是 AbstractUserDetailsAuthenticationProvider,我们就先从它的父类看起:
public abstract class AbstractUserDetailsAuthenticationProvider implements AuthenticationProvider, InitializingBean, MessageSourceAware {
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName();
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
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;
}
}
}
try {
preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException exception) {
if (cacheWasUsed) {
cacheWasUsed = false;
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
else {
throw exception;
}
}
postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
return createSuccessAuthentication(principalToReturn, authentication, user);
}
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class
.isAssignableFrom(authentication));
}
}
AbstractUserDetailsAuthenticationProvider
的代码还是挺长的,这里我们重点关注两个方法:authenticate 和 supports。
authenticate 方法就是用来做认证的方法,我们来简单看下方法流程:
- 首先从 Authentication 提取出登录用户名。
- 然后通过拿着 username 去调用 retrieveUser 方法去获取当前用户对象,
- 这一步会调用我们自己在登录时候的写的 loadUserByUsername 方法,所以这里返回的 user 其实就是你的登录对象,
- 接下来调用 preAuthenticationChecks.check 方法去检验 user 中的各个账户状态属性是否正常,例如账户是否被禁用、账户是否被锁定、账户是否过期等等。
- additionalAuthenticationChecks 方法则是做密码比对的,好多小伙伴好奇 Spring Security 的密码加密之后,是如何进行比较的,看这里就懂了,因为比较的逻辑很简单,我这里就不贴代码出来了。但是注意,additionalAuthenticationChecks 方法是一个抽象方法,具体的实现是在
AbstractUserDetailsAuthenticationProvider
的子类中实现的,也就是 DaoAuthenticationProvider。这个其实很好理解,因为AbstractUserDetailsAuthenticationProvider
作为一个较通用的父类,处理一些通用的行为,我们在登录的时候,有的登录方式并不需要密码,所以 additionalAuthenticationChecks 方法一般交给它的子类去实现,在 DaoAuthenticationProvider 类中,additionalAuthenticationChecks 方法就是做密码比对的,在其他的 AuthenticationProvider 中,additionalAuthenticationChecks 方法的作用就不一定了。 - 最后在 postAuthenticationChecks.check 方法中检查密码是否过期。
- 接下来有一个 forcePrincipalAsString 属性,这个是是否强制将 Authentication 中的 principal 属性设置为字符串,这个属性我们一开始在 UsernamePasswordAuthenticationFilter 类中其实就是设置为字符串的(即 username),
- 但是默认情况下,当用户登录成功之后, 这个属性的值就变成当前用户这个对象了。之所以会这样,就是因为 forcePrincipalAsString 默认为 false,不过这块其实不用改,就用 false,这样在后期获取当前用户信息的时候反而方便很多。
- 最后,通过 createSuccessAuthentication 方法构建一个新的 UsernamePasswordAuthenticationToken。
- supports 方法就比较简单了,主要用来判断当前的 Authentication 是否是 UsernamePasswordAuthenticationToken。
由于 AbstractUserDetailsAuthenticationProvider
已经把 authenticate 和 supports 方法实现了,所以在 DaoAuthenticationProvider 中,我们主要关注 additionalAuthenticationChecks 方法即可:
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
if (authentication.getCredentials() == null) {
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
}
}
大家可以看到,additionalAuthenticationChecks 方法主要用来做密码比对的,逻辑也比较简单,就是调用 PasswordEncoder 的 matches 方法做比对,如果密码不对则直接抛出异常即可。
正常情况下,我们使用用户名/密码登录,最终都会走到这一步
而 AuthenticationProvider 都是通过
ProviderManager#authenticate
方法来调用的。由于我们的一次认证可能会存在多个 AuthenticationProvider,所以,在
ProviderManager#authenticate
方法中会逐个遍历 AuthenticationProvider 并调用他们的 authenticate 方法做认证
自定义认证思路
- 登录请求是调用
AbstractUserDetailsAuthenticationProvider#authenticate
方法进行认证的 - 在该方法中,又会调用到
DaoAuthenticationProvider#additionalAuthenticationChecks
方法做进一步的校验,去校验用户登录密码。 - 我们可以自定义一个
AuthenticationProvider
代替DaoAuthenticationProvider
,并重写它里边的additionalAuthenticationChecks
方法,在重写的过程中,加入验证码的校验逻辑即可。
常见的手机号码动态登录,也可以使用这种方式来认证。
kaptcha
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
@Bean
Producer verifyCode() {
Properties properties = new Properties();
properties.setProperty("kaptcha.image.width", "150");
properties.setProperty("kaptcha.image.height", "50");
properties.setProperty("kaptcha.textproducer.char.string", "0123456789");
properties.setProperty("kaptcha.textproducer.char.length", "4");
Config config = new Config(properties);
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
// 验证码图片的宽高、字符库以及生成的验证码字符长度
@RestController
public class VerifyCodeController {
@Autowired
Producer producer;
@GetMapping("/vc.jpg")
public void getVerifyCode(HttpServletResponse resp, HttpSession session) throws IOException {
resp.setContentType("image/jpeg");
String text = producer.createText();
session.setAttribute("verify_code", text);
BufferedImage image = producer.createImage(text);
try(ServletOutputStream out = resp.getOutputStream()) {
ImageIO.write(image, "jpg", out);
}
}
}
生成验证码图片,并将生成的验证码字符存入
HttpSession
中。注意这里我用到了try-with-resources
,可以自动关闭流,有的小伙伴可能不太清楚,可以自己搜索看下。接下来我们来自定义一个
MyAuthenticationProvider
继承自 DaoAuthenticationProvider,并重写additionalAuthenticationChecks
方法:
public class MyAuthenticationProvider extends DaoAuthenticationProvider {
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
HttpServletRequest req = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String code = req.getParameter("code");
String verify_code = (String) req.getSession().getAttribute("verify_code");
if (code == null || verify_code == null || !code.equals(verify_code)) {
throw new AuthenticationServiceException("验证码错误");
}
super.additionalAuthenticationChecks(userDetails, authentication);
}
}
- 在 additionalAuthenticationChecks 方法中:
- 首先获取当前请求,注意这种获取方式,在基于 Spring 的 web 项目中,我们可以随时随地获取到当前请求,获取方式就是我上面给出的代码。
- 从当前请求中拿到 code 参数,也就是用户传来的验证码。
- 从 session 中获取生成的验证码字符串。
- 两者进行比较,如果验证码输入错误,则直接抛出异常。
- 最后通过 super 调用父类方法,也就是 DaoAuthenticationProvider 的 additionalAuthenticationChecks 方法,该方法中主要做密码的校验。
- MyAuthenticationProvider 定义好之后,接下来主要是如何让 MyAuthenticationProvider 代替 DaoAuthenticationProvider。
- 前面我们说,所有的 AuthenticationProvider 都是放在 ProviderManager 中统一管理的,所以接下来我们就要自己提供 ProviderManager
- 然后注入自定义的 MyAuthenticationProvider,这一切操作都在 SecurityConfig 中完成:
- SecurityConfig
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Bean
MyAuthenticationProvider myAuthenticationProvider() {
MyAuthenticationProvider myAuthenticationProvider = new MyAuthenticationProvider();
myAuthenticationProvider.setPasswordEncoder(passwordEncoder());
myAuthenticationProvider.setUserDetailsService(userDetailsService());
return myAuthenticationProvider;
}
@Override
@Bean
protected AuthenticationManager authenticationManager() throws Exception {
ProviderManager manager = new ProviderManager(Arrays.asList(myAuthenticationProvider()));
return manager;
}
@Bean
@Override
protected UserDetailsService userDetailsService() {
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
manager.createUser(User.withUsername("javaboy").password("123").roles("admin").build());
return manager;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/vc.jpg").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.successHandler((req, resp, auth) -> {
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
out.write(new ObjectMapper().writeValueAsString(RespBean.ok("success", auth.getPrincipal())));
out.flush();
out.close();
})
.failureHandler((req, resp, e) -> {
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
out.write(new ObjectMapper().writeValueAsString(RespBean.error(e.getMessage())));
out.flush();
out.close();
})
.permitAll()
.and()
.csrf().disable();
}
}
- 我们需要提供一个 MyAuthenticationProvider 的实例,创建该实例时,需要提供 UserDetailService 和 PasswordEncoder 实例。
- 通过重写
authenticationManager
方法来提供一个自己的AuthenticationManager,实际上就是
ProviderManager,在创建
ProviderManager
时,加入自己的myAuthenticationProvider。
- 这里为了简单,我将用户直接存在内存中,提供一个
UserDetailsService
实例即可。 - 最后就简单配置一下各种回调即可,另外记得设置
/vc.jpg
任何人都能访问。
获取用户信息
@RestController
@RequestMapping("sec")
public class SecurytiController {
@RequestMapping("info")
public Map<String, Object> getDetails() {
Map<String, Object> rmap = new HashMap<>();
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
WebAuthenticationDetails details = (WebAuthenticationDetails) authentication.getDetails();
System.out.println(details);
rmap.put("info", details);
return rmap;
}
}