Spring Security自定义用户认证

Scroll Down

Spring Boot集成Spring Security中搭建了一个简单的Spring Boot集成Spring Security项目,在项目中,认证的用户名和密码都是Spring Security生成的。Spring Security支持自定义认证,例如处理用户信息获取逻辑,自定义登录表单以及登录成功或者失败后的逻辑。

自定义认证

自定义认证需要实现Spring Security提供的UserDetailService 接口,改接口只有一个抽象方法 loaduserByUsername ,源代码如下:

public interface UserDetailsService {
	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

loadUserByUsername 方法返回一个UserDetails 对象,该对象是一个接口,用于描述用户信息的方法,源代码如下:

public interface UserDetails extends Serializable {

	Collection<? extends GrantedAuthority> getAuthorities();

	String getPassword();

	String getUsername();

	boolean isAccountNonExpired();

	boolean isAccountNonLocked();


	boolean isCredentialsNonExpired();

	boolean isEnabled();
}

参数含义如下:

  • getAuthorities() 获取用户包含的权限,返回权限集合,权限是一个继承了GrantedAuthority的对象;
  • getPassword() getUsername()获取用户名和密码
  • isAccountNonExpired() 用于判断账户是否未过期,未过期返回true反之返回false;
  • isAccountNonLocked() 方法用于判断账户是否未锁定;
  • isCredentialsNonExpired() 用于判断用户凭证是否没过期,即密码是否未过期;
  • isEnabled() 方法用于判断用户是否可用

在项目中,可以自定义UserDetail是接口的实现类,也可以直接使用Spring Security提供的UserDetails接口的实现类User。
接下来创建用户实体类,用户存放模拟的用户数据(项目从数据库获取,这里直接创建模拟)

public class UserEntity implements Serializable {
    private String username;
    private String password;
    private boolean accountNonExpired = true;
    private boolean accountNonLocked = true;
    private boolean credentialsNonExpired = true;
    private boolean enabled = true;

    //set/get
}

创建 MyUserDetailService 实现 UserDetailsService:

@Configuration
public class MyUserDetailService implements UserDetailsService {

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //模拟用户,实际从数据库获取
        UserEntity userEntity = new UserEntity();
        userEntity.setUsername(username);
        userEntity.setPassword(this.passwordEncoder.encode("123456"));
        //输出加密过后的密码
        System.out.println(userEntity.getPassword());
        return new User(username, userEntity.getPassword(), userEntity.isEnabled(),
                userEntity.isAccountNonExpired(), userEntity.isCredentialsNonExpired(),
                userEntity.isAccountNonLocked(), AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

此外注入了 PasswordEncoder 对象,该对象用于密码加密,注入前需要手动配置。我们在 SecurityConfig 中配置:

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

PasswordEncoder 是一个密码加密接口,BCryptPasswordEncoder是它的实现类,可以根据需求自定义类实现PasswordEncoder。不过BCryptPasswordEncoder也非常的强大,对于相同的密码加密后可以说能成不同的密文。启动项目访问http://localhost:8080/login,密码为123456,用户名不定,多次登录操作,可以看到控制台打印密文是不一样的:

$2a$10$TGGIkrt3Za7hpMiPAiWLU.ttil23au6aNKwYwFGZEGQqbF3JtyXKK
$2a$10$fXI0xP65c.AyGk8aBlbyeub5fmu9Tae6q6BRXuBLeVt4uy0B4VsUG
$2a$10$vUYcrQJCeoCisXpTWs3.zOVLUwTP74CL4p8zNbCNCpjXrTWMwxBAW

替换默认登录页

默认登录页面过于简陋,也满足不了项目的需求,因此可以自定义一个登录页面。为了方便起见,直接在src/main/resources/resources目录下定义一个login.html(可以直接访问,不需要Controller跳转)

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
  
</head>
<body>
<form action="/login" method="POST">
    <div class="form-group">
        <label for="username">username</label>
        <input type="text" class="form-control" id="username" name="username">
    </div>
    <div class="form-group">
        <label for="password">Password</label>
        <input type="password" class="form-control" id="password" name="password">
    </div>
    <button type="submit">Submit</button>
</form>
</body>
</html>

自己设计好登录页面后,就需要让Security跳转到自己定义的页面,在SecurityConfig 配置文件中添加一些配置即可:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage("/login.html") //指定跳转登录页面请求的URL
                .loginProcessingUrl("/login") //对应登录页面form表单的action='/login'
                .and()
                .authorizeRequests()    //授权配置
                .antMatchers("/login.html").permitAll() //表示跳转登录页面不进行拦截,否则会进如无限循环
                .anyRequest()   //所有请求
                .authenticated();//都要认证
    }
  • loginPage("/login.html") 指定跳转到登录页面的请求URL
  • loginProcessingUrl("/login") 对应登录页面form表单的action='/login'
  • authorizeRequests() 授权配置
  • antMatchers("/login.html").permitAll() 表示跳转登录页面不进行拦截,否则会进如无限循环
  • anyRequest() 所有请求
  • authenticated() 都要认证

这是重新访问 http://localhost:8080/hello, 会跳转到自定义的界面:(界面过于简单,没加css)
zdy_login.png
输入用户名密码进行登录操作,会发现登录页刷新,这时需要把CSRF攻击防御关了,修改 SecurityConfig 的 config:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .formLogin()
                .loginPage("/login.html") //指定跳转登录页面请求的URL
                .loginProcessingUrl("/login") //对应登录页面form表单的action='/login'
                .and()
                .authorizeRequests()    //授权配置
                .antMatchers("/login.html").permitAll() //表示跳转登录页面不进行拦截,否则会进如无限循环
                .anyRequest()   //所有请求
                .authenticated();//都要认证
    }

重启项目登录就正常了(在Security4.X版本时,不会刷新表单,而是会直接提示CSRF错误)。

假如现在有这样一个需求:在未登录的情况下,当用户访问html资源的时候跳转到登录页,否则返回JSON格式数据,状态码为401。

要实现这个功能我们将loginPage的URL改为/authentication/require,并且在antMatchers方法中加入该URL,让其免拦截:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .formLogin()
                .loginPage("/authentication/require") //指定跳转登录页面请求的URL
                .loginProcessingUrl("/login") //对应登录页面form表单的action='/login'
                .and()
                .authorizeRequests()    //授权配置             .antMatchers("/authentication/require").permitAll() //表示跳转登录页面不进行拦截,否则会进如无限循环
                .anyRequest()   //所有请求
                .authenticated();//都要认证
    }

创建控制器 BrowserSecurityController 处理这个请求:

@RestController
public class BrowerSecurityController {
    private RequestCache requestCache = new HttpSessionRequestCache();
    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @GetMapping("/authentication/require")
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    public String requireAuth(HttpServletRequest request, HttpServletResponse response) throws IOException {
        SavedRequest savedRequest = requestCache.getRequest(request, response);
        if(savedRequest != null){
            String targetUrl = savedRequest.getRedirectUrl();
            if(StringUtils.endsWithIgnoreCase(targetUrl, ".html")){
                redirectStrategy.sendRedirect(request, response, "/login.html");
            }
        }
        return "访问的资源需要认证,请您先登录认证";
    }
}
  • HttpSessionRequestCache Spring Security提供的用于缓存请求的对象,通过调用它的getRequest方法可以获取到本次请求的HTTP信息
  • DefaultRedirectStrategy的sendRedirect为Spring Security提供的用于处理重定向的方法。

上面代码获取了引发跳转的请求,根据请求是否以.html为结尾来对应不同的处理方法。如果是以.html结尾,那么重定向到登录页面,否则返回”访问的资源需要身份认证!”信息,并且HTTP状态码为401(HttpStatus.UNAUTHORIZED)。

处理认证成功和失败

Spring Security 有默认的处理认证成功和失败的方法。当用户登录成功时,页面会跳转会引发登录的请求,比如在未登录的情况下访问http://localhost:8080/hello,页面会跳转到登录页,登录成功后再跳转回来;登录失败时则是跳转到Spring Security默认的错误提示页面。下面我们通过一些自定义配置来替换这套默认的处理机制。

自定义认证成功逻辑

改变默认的认证成功逻辑,需要实现 org.springframework.security.web.authentication.AuthenticationSuccessHandler 接口的 onAuthenticationSuccess 方法:

@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Autowired
    private ObjectMapper mapper;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write(mapper.writeValueAsString(authentication));
    }
}

其中Authentication参数既包含了认证请求的一些信息,比如IP,请求的SessionId等,也包含了用户信息,即前面提到的User对象。通过上面这个配置,用户登录成功后页面将打印出Authentication对象的信息。

将自定义的配置加入Security中,还的在SecurityConfig的configure中配置:


@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .formLogin()
                .loginPage("/authentication/require") //指定跳转登录页面请求的URL
//                .loginPage("/login.html") //指定跳转登录页面请求的URL
                .loginProcessingUrl("/login") //对应登录页面form表单的action='/login'
                .successHandler(customAuthenticationSuccessHandler)  //处理认证成功的逻辑
                .and()
                .authorizeRequests()    //授权配置
                .antMatchers("/login.html").permitAll() //表示跳转登录页面不进行拦截,否则会进如无限循环
                .antMatchers("/authentication/require").permitAll() //表示跳转登录页面不进行拦截,否则会进如无限循环
                .anyRequest()   //所有请求
                .authenticated();//都要认证
    }
}

把CustomAuthenticationSuccessHandler 注入进来,并通过successHandler 方法进行配置,重启项目登录会输出一下JSON信息:

{
    "authorities":[
        {
            "authority":"admin"
        }
    ],
    "details":{
        "remoteAddress":"0:0:0:0:0:0:0:1",
        "sessionId":null
    },
    "authenticated":true,
    "principal":{
        "password":null,
        "username":"t",
        "authorities":[
            {
                "authority":"admin"
            }
        ],
        "accountNonExpired":true,
        "accountNonLocked":true,
        "credentialsNonExpired":true,
        "enabled":true
    },
    "credentials":null,
    "name":"t"
}

除此之外,我们也可以在登录成功后自定义页面的跳转,修改CustomAuthenticationSuccessHandler :

@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Autowired
    private ObjectMapper mapper;
    private RequestCache requestCache = new HttpSessionRequestCache();
    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        SavedRequest savedRequest = requestCache.getRequest(request, response);
        redirectStrategy.sendRedirect(request, response, savedRequest.getRedirectUrl());
//        redirectStrategy.sendRedirect(request, response, "/index");
    }
}

通过上面配置,登录成功后页面将跳转回引发跳转的页面。如果想指定跳转的页面,比如跳转到/index,可以将savedRequest.getRedirectUrl()修改为/index。然后在TestController中定义一个处理该请求的方法:

    @GetMapping("/index")
    private Object index(Authentication authentication){
        return authentication;
    }

Authentication 是获取认证的对象信息,并返回给页面。
auth_index.png

自定义认证失败逻辑

和自定义认证成功处理逻辑类似,自定义认证失败处理逻辑需要实现 org.springframework.security.web.authentication.AuthenticationFailureHandler 的 onAuthenticationFailure方法:

@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {

    }
}

onAuthenticationFailure方法的AuthenticationException参数是一个抽象类,Spring Security根据登录失败的原因封装了许多对应的实现类。不同的失败原因对应不同的异常,比如用户名或密码错误对应的是BadCredentialsException,用户不存在对应的是UsernameNotFoundException,用户被锁定对应的是LockedException等。
例如在登录失败后返回失败信息,如下处理:

@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write(mapper.writeValueAsString(exception.getMessage()));
    }
}

状态码定义为500(HttpStatus.INTERNAL_SERVER_ERROR.value()),即系统内部异常。

同样的,我们需要在BrowserSecurityConfig的configure中配置它:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;
    @Autowired
    private CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .formLogin()
                .loginPage("/authentication/require") //指定跳转登录页面请求的URL
//                .loginPage("/login.html") //指定跳转登录页面请求的URL
                .loginProcessingUrl("/login") //对应登录页面form表单的action='/login'
                .successHandler(customAuthenticationSuccessHandler)  //处理认证成功的逻辑
                .failureHandler(customAuthenticationFailureHandler) //处理认证失败的逻辑
                .and()
                .authorizeRequests()    //授权配置
                .antMatchers("/login.html").permitAll() //表示跳转登录页面不进行拦截,否则会进如无限循环
                .antMatchers("/authentication/require").permitAll() //表示跳转登录页面不进行拦截,否则会进如无限循环
                .anyRequest()   //所有请求
                .authenticated();//都要认证
    }
}

重启项目,密码输入错误后,页面显示如下:
pass_error.png