8

Spring Boot 集成 Spring Security 这篇文章中,我们介绍了如何在 Spring Boot 项目中快速集成 Spring Security,同时也介绍了如何更改系统默认生成的用户名和密码。接下来本文将基于 Spring Boot 集成 Spring Security 这篇文章中所创建的项目,进一步介绍在 Spring Security 中如何实现自定义用户认证。

阅读更多关于 Angular、TypeScript、Node.js/Java 、Spring 等技术文章,欢迎访问我的个人博客 —— 全栈修仙之路

一、自定义认证过程

本项目所使用的开发环境及主要框架版本:

  • java version "1.8.0_144"
  • spring boot 2.2.0.RELEASE
  • spring security 5.2.0.RELEASE

1.0 配置项目 pom.xml 文件

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.0.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.semlinker</groupId>
    <artifactId>custom-user-authentication</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>custom-user-authentication</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
      
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <!-- 省略spring-boot-starter-test、spring-security-test及spring-boot-devtools -->
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

1.1 自定义用户模型

首先创建一个 MyUser 类,用于存储模拟的用户信息(实际开发中一般从数据库中获取真实的用户信息):

// com/semlinker/domain/MyUser.java
@Data
public class MyUser implements Serializable {
    private static final long serialVersionUID = -1090551705063344205L;

    private String userName;
    private String password;
    private boolean accountNonExpired = true; // 表示账号是否未过期
    private boolean accountNonLocked = true; // 表示账号是否未锁定
    private boolean credentialsNonExpired = true; // 表示用户凭证未过期,比如用户密码
    private boolean enabled = true; // 表示用户是否启用
}

1.2 自定义 Security 配置类及 PasswordEncoder 对象

接着配置 PasswordEncoder 对象,顾名思义该对象用于密码加密。在下面的 UserDetailsService 服务中需要用到此对象,因此这里我们需要提前做好配置。PasswordEncoder 是一个密码加密接口,在 Spring Security 中有许多实现类,比如 BCryptPasswordEncoder、Pbkdf2PasswordEncoder 和 LdapShaPasswordEncoder 等。

当然我们也可以自定义 PasswordEncoder,但 Spring Security 中实现的 BCryptPasswordEncoder 功能已经足够强大,它对相同的密码进行加密后可以生成不同的结果,这样就大大提高了系统的安全性。即尽管系统中使用相同密码的某些用户不小心泄露了密码,也不会导致其他用户密码泄露。既然 BCryptPasswordEncoder 功能那么强大,我们肯定直接使用它,具体的配置方式如下:

// com/semlinker/config/WebSecurityConfig.java
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

1.3 自定义 UserDetailsService 服务

自定义 UserDetailsService 服务,需要实现 UserDetailsService 接口,该接口只包含一个 loadUserByUsername 方法,用于通过 username 来加载匹配的用户。当找不到 username 对应用户时,会抛出 UsernameNotFoundException 异常。UserDetailsService 接口的定义如下:

// org/springframework/security/core/userdetails/UserDetailsService.java
public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

loadUserByUsername 方法返回 UserDetails 对象,这里的 UserDetails 也是一个接口,它的定义如下:

// org/springframework/security/core/userdetails/UserDetails.java
public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();
    String getPassword();
    String getUsername();
    boolean isAccountNonExpired();
    boolean isAccountNonLocked();
    boolean isCredentialsNonExpired();
    boolean isEnabled();
}

顾名思义,UserDetails 表示详细的用户信息,这个接口涵盖了一些必要的用户信息字段,具体的实现类对它进行了扩展。以上方法的具体作用如下:

  • getPassword():用于获取密码;
  • getUsername():用于获取用户名;
  • isAccountNonExpired():用于判断账号是否未过期;
  • isAccountNonLocked():用于判断账号是否未锁定;
  • isCredentialsNonExpired():用于判断用户凭证是否未过期,即密码是否未过期;
  • isEnabled():用于判断用户是否可用。

介绍完上述内容,下面我们来创建一个 MyUserDetailsService 类并实现 UserDetailsService 接口,具体如下:

// com/semlinker/service/MyUserDetailsService.java
@Service
public class MyUserDetailsService implements UserDetailsService {
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        MyUser myUser = new MyUser();
        myUser.setUserName(username);
        myUser.setPassword(this.passwordEncoder.encode("hello"));

        // 使用Spring Security内部UserDetails的实现类User,来创建User对象
        return new User(username, myUser.getPassword(), myUser.isEnabled(),
                myUser.isAccountNonExpired(), myUser.isCredentialsNonExpired(),
                myUser.isAccountNonLocked(),
                AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

1.4 配置 UserDetailsService Bean 及配置 AuthenticationManagerBuilder 对象

在 Spring Security 中使用我们自定义的 MyUserDetailsService,还需要在 WebSecurityConfig 类中进行配置:

// com/semlinker/config/WebSecurityConfig.java
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    UserDetailsService myUserDetailService() {
        return new MyUserDetailsService();
    }

    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(myUserDetailService()).passwordEncoder(passwordEncoder());
    }
}

在以上 configure 方法中,我们配置了自定义的 MyUserDetailsService 和 PasswordEncoder 对象。

1.5 创建相关 Controller 及自定义登录页和首页

在 Spring Security 中 DefaultLoginPageGeneratingFilter 过滤器会为我们生成默认登录界面:

user-login-page.jpg

相信很多小伙伴都 “看不惯” 这个页面,下面我们就来对这个页面进行 “整容”。

HomeController 类
// com/semlinker/controller/HomeController.java
@Controller
public class HomeController {

    @GetMapping("/")
    public String index() {
        return "index";
    }

}
UserController 类
// com/semlinker/controller/UserController.java
@Controller
public class UserController {

    @GetMapping("/login")
    public String login() {
        return "login";
    }

}
index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Semlinker修仙之路首页 </title>
</head>
<body>
   <h3>欢迎您来到Semlinker修仙之路首页</h3>
</body>
</html>
login.html
<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <title>Semlinker修仙之路登录页</title>
</head>
<body>
<form class="login-form" method="post" action="/login">
    <h1>Login</h1>
    <div class="form-field">
        <i class="fas fa-user"></i>
        <input type="text" name="username" id="username" class="form-field" 
               placeholder=" " required>
        <label for="username">Username</label>
    </div>
    <div class="form-field">
        <i class="fas fa-lock"></i>
        <input type="password" name="password" id="password" class="form-field" 
               placeholder=" " required>
        <label for="password">Password</label>
    </div>
    <button type="submit" value="Login" class="btn">Login</button>
</form>
</body>
</html>

1.6 配置默认的登录页

在创建完登录页之后,还需要在 WebSecurityConfig 类中进行配置才能生效,对应的配置方式如下:

// com/semlinker/config/WebSecurityConfig.java
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    // 省略前面已设置的内容
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
            .loginPage("/login");
    }
}

完成上述配置后,我们来测试一下效果,首先启动 Spring Boot 应用,待启动完成后在浏览器中打开 http://localhost:8080/login 地址,若一切顺利的话,你将看到以下界面:

custom-login-page.jpg

(页面来源于 https://codepen.io/alphardex/...

接下来我们来执行登录操作,这里的用户名可以是任意的,密码是前面我们所设置的 hello。但当我们输入正确的用户名和密码点击登录之后,映入眼帘的却是以下的异常页面:

Whitelabel Error Page
This application has no explicit mapping for /error, so you are seeing this as a fallback.

Mon Oct 28 14:27:25 CST 2019
There was an unexpected error (type=Forbidden, status=403).
Forbidden

这是什么原因呢?为啥被禁止访问了,小伙伴们先别急,首先打开当前项目 src/main/resources/ 目录下的 application.properties 文件,然后输入以下配置信息:

logging.level.org.springframework.security.web.FilterChainProxy=DEBUG

待完成配置之后,重启一下应用,然后重新执行一次上述的登录操作。如果没猜错的话,你重新执行登录,输入的用户名和密码也没有错,但仍看见 Whitelabel Error Page 页面。其实刚才我们已经启用的 Security FilterChainProxy 的 DEBUG 调试模式,所以我们来看一下控制台输出的异常信息:

filter-chain-proxy-debug.jpg

通过上图可以发现 /login 请求,经过 CsrfFilter 过滤器就不再往下继续执行了。这里的 CsrfFilter 过滤器是用来处理跨站请求伪造攻击的过滤器,跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click attack 或者 session riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的 Web 应用程序上执行非本意的操作的攻击方法。

现在我们已经大致知道原因了,由于我们的登录页暂不需要开启 Csrf 防御,所以我们先把 Csrf 过滤器禁用掉:

// com/semlinker/config/WebSecurityConfig.java
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage("/login")
                .and().csrf().disable();
    }
}

更新完 WebSecurityConfig 配置类,再重新跑一次前面的登录流程,这次当你点击登录之后,你将会在当前页面看到欢迎您来到Semlinker修仙之路首页这行内容。

二、处理不同类型的请求

默认情况下,当用户通过浏览器访问被保护的资源时,会默认自动重定向到预设的登录地址。这对于传统的 Web 项目来说,是没有多大问题,但这种方式就不适用于前后端分离的项目。对于前后端分离的项目,服务端一般只需要对外提供返回 JSON 格式的 API 接口。

针对上述的问题,有如下一种方案可供参考。即根据请求是否以 .html 为结尾来对应不同的处理方法。如果是以 .html 结尾,那么重定向到登录页面,否则返回 ”访问的资源需要身份认证!” 信息,并且 HTTP 状态码为401(HttpStatus.UNAUTHORIZED)。

要实现上述的功能,我们先来定义一个 WebSecurityController 类,具体实现如下:

// com/semlinker/controller/WebSecurityController.java
@Slf4j
@RestController
public class WebSecurityController {
    // 原请求信息的缓存及恢复
    private RequestCache requestCache = new HttpSessionRequestCache();

    // 用于执行重定向操作
    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    /**
     * 默认的登录页,用于处理不同的登录认证逻辑
     *
     * @param request
     * @param response
     * @return
     */
    @RequestMapping("/authentication/require")
    @ResponseStatus(code = HttpStatus.UNAUTHORIZED)
    public String requireAuthenication(HttpServletRequest request, 
      HttpServletResponse response) throws Exception {
        SavedRequest savedRequest = requestCache.getRequest(request, response);
        if (savedRequest != null) {
            String targetUrl = savedRequest.getRedirectUrl();
            log.info("引发跳转的请求是:" + targetUrl);
            if (StringUtils.endsWithIgnoreCase(targetUrl, ".html")) {
                redirectStrategy.sendRedirect(request, response, "/login.html");
            }
        }

        return "访问的服务需要身份认证,请引导用户到登录页";
    }
}

接着将 formLogin 的默认登录页,修改为 /authentication/require,并通过 antMatchers 方法设置免拦截:

// com/semlinker/config/WebSecurityConfig.java
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage("/authentication/require")
                .and()
                .authorizeRequests()
                .antMatchers("/authentication/require", "/login.html").permitAll()
                .anyRequest().authenticated()
                .and().csrf().disable()
        ;
    }
}

同时也要修改一下前面定义的 UserController 类,让其支持 /login.html 路径映射:

// com/semlinker/controller/UserController.java
@Controller
public class UserController {

    @GetMapping({"login", "/login.html"})
    public String login() {
        return "login";
    }

}

完成上述调整后,到我们访问 http://localhost:8080/index 的时候,页面会自动跳转到 http://localhost:8080/authentication/require,并且输出 "访问的服务需要身份认证,请引导用户到登录页"。而当我们访问 http://localhost:8080/index.html 的时候,页面会跳转到登录页面。

三、自定义处理登录成功和失败逻辑

在前后端分离项目中,当用户登录成功或登录失败时,需要向前端返回相应的信息,而不是直接进行页面跳转。针对前后端分离的场景,可以利用 Spring Security 中的 AuthenticationSuccessHandlerAuthenticationFailureHandler 这两个接口或继承 SimpleUrlAuthenticationSuccessHandlerSimpleUrlAuthenticationFailureHandler 类来实现自定义登录成功和登录失败的处理逻辑。

3.1 自定义登录成功处理逻辑

这里我们选用继承 SimpleUrlAuthenticationSuccessHandler 类,来实现自定义登录成功处理逻辑:

// com/semlinker/handler/MyAuthenctiationSuccessHandler.java
@Slf4j
@Component
public class MyAuthenctiationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
      HttpServletResponse response, Authentication authentication)
        throws IOException, ServletException {

        log.info("登录成功");
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(authentication));
    }
}

3.2 自定义登录失败处理逻辑

同样我们也选用继承 SimpleUrlAuthenticationFailureHandler 类,来实现自定义登录失败处理逻辑:

// com/semlinker/handler/MyAuthenctiationFailureHandler.java
@Slf4j
@Component
public class MyAuthenctiationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Autowired
    private ObjectMapper objectMapper;

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

        log.info("登录失败");
        response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(exception.getMessage()));
    }
}

3.3 配置 MyAuthenctiationSuccessHandler 和 MyAuthenctiationFailureHandler

最后要让自定义处理登录成功和失败逻辑生效,还需要在 WebSecurityConfig 类中配置 FormLoginConfigurer 对象的 successHandler 和 failureHandler 属性,到目前为止 WebSecurityConfig 类的完整配置如下:

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private MyAuthenctiationFailureHandler myAuthenctiationFailureHandler;

    @Autowired
    private MyAuthenctiationSuccessHandler myAuthenctiationSuccessHandler;

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

    @Bean
    UserDetailsService myUserDetailService() {
        return new MyUserDetailsService();
    }

    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(myUserDetailService()).passwordEncoder(passwordEncoder());
    }

    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage("/login")
                .successHandler(myAuthenctiationSuccessHandler)
                .failureHandler(myAuthenctiationFailureHandler)
                .and()
                .authorizeRequests()
                .antMatchers("/authentication/require", "/login").permitAll()
                .anyRequest().authenticated()
                .and().csrf().disable()
        ;
    }
}

前面本文已经介绍了在 Spring Security 中实现自定义用户认证的流程,在学习过程中如果小伙伴们遇到其它问题的话,建议可以开启 FilterChainProxy 的 DEBUG 模式进行日志排查。

本文项目地址:Github - custom-user-authentication

四、参考资源

full-stack-logo


阿宝哥
15.8k 声望10.2k 粉丝

聚焦全栈,专注分享 Angular、TypeScript、Node.js/Java 、Spring 技术栈等全栈干货