共计 12718 个字符,预计需要花费 32 分钟才能阅读完成。
在 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 过滤器会为我们生成默认登录界面:
相信很多小伙伴都“看不惯”这个页面,下面我们就来对这个页面进行“整容”。
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 地址,若一切顺利的话,你将看到以下界面:
(页面来源于 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
类来实现自定义登录成功和登录失败的处理逻辑。
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
四、参考资源
- MrBird – Spring Security 自定义用户认证
- Woodwhale – SpringBoot + Spring Security 学习笔记(一)自定义基本使用及个性化登录配置