阅读更多关于 Angular、TypeScript、Node.js/Java 、Spring 等技术文章,欢迎访问我的个人博客 —— 全栈修仙之路
在 Spring Boot 集成 Spring Security 这篇文章中,我们介绍了如何在 Spring Boot 项目中快速集成 Spring Security,同时也介绍了如何更改系统默认生成的用户名和密码。接下来本文将基于 Spring Boot 集成 Spring Security 这篇文章中所创建的项目,进一步介绍在 Spring Security 中如何实现自定义用户认证。
本项目所使用的开发环境及主要框架版本:
<?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>
首先创建一个 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; // 表示用户是否启用 }
接着配置 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(); } }
自定义 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 表示详细的用户信息,这个接口涵盖了一些必要的用户信息字段,具体的实现类对它进行了扩展。以上方法的具体作用如下:
介绍完上述内容,下面我们来创建一个 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")); } }
在 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 对象。
在 Spring Security 中 DefaultLoginPageGeneratingFilter 过滤器会为我们生成默认登录界面:
相信很多小伙伴都 “看不惯” 这个页面,下面我们就来对这个页面进行 “整容”。
// com/semlinker/controller/HomeController.java @Controller public class HomeController { @GetMapping("/") public String index() { return "index"; } }
// com/semlinker/controller/UserController.java @Controller public class UserController { @GetMapping("/login") public String login() { return "login"; } }
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Semlinker修仙之路首页 </title> </head> <body> <h3>欢迎您来到Semlinker修仙之路首页</h3> </body> </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>
在创建完登录页之后,还需要在 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 地址,若一切顺利的话,你将看到以下界面:
(页面来源于 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 调试模式,所以我们来看一下控制台输出的异常信息:
通过上图可以发现 /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 中的 AuthenticationSuccessHandler
和 AuthenticationFailureHandler
这两个接口或继承 SimpleUrlAuthenticationSuccessHandler
或 SimpleUrlAuthenticationFailureHandler
类来实现自定义登录成功和登录失败的处理逻辑。
这里我们选用继承 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)); } }
同样我们也选用继承 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())); } }
最后要让自定义处理登录成功和失败逻辑生效,还需要在 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