在 Spring Security Authentication (认证)原理初探 中已经介绍 Spring Security 实现认证的大致原理,本文演示如何实现 Spring Security 认证的定制开发。
先回顾一下 Spring Security 实现认证的流程:
① AbstractAuthenticationProcessingFilter
基于收到的身份信息(用户名、密码)构造一个 Authentication
对象;
② AbstractAuthenticationProcessingFilter
将 Authentication
传递给 AuthenticationManager
;
③ AuthenticationManager
有一个 AuthenticationProvider
列表,将 Authentication
委托给列表中的 AuthenticationProvider
处理认证请求;
④ AuthenticationProvider
依次对 Authentication
进行认证处理,如果认证不通过则抛出一个异常(注意对抛出的异常有类型要求)或直接返回 null,如果所有 AuthenticationProvider
都返回 null,则 AuthenticationManager
抛出 ProviderNotFoundException
异常;
⑤ 如果认证通过会返回一个填充完全的 Authentication
,这个对象最终会被放入 SecurityContextHolder
,后续用于授权功能。
为了尽可能展示整个认证流程,需要自定义实现最主要的几个接口,包括:
AbstractAuthenticationProcessingFilter
AuthenticationManager
AuthenticationProvider
1 新建 Spring Boot 工程
参看:
-> IntelliJ IDEA 创建 Spring Boot 工程
-> Spring Boot 单元测试
2 添加 Spring Security 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
POM 文件全部内容:
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>learn.spring.security</groupId>
<artifactId>spring_boot_security</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>spring_boot_security</name>
<description>Demo project for Spring Security</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.1.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
3 首先,实现真正处理认证请求的 AuthenticationProvider
package learn.spring.security.config;
import learn.spring.security.domain.User;
import learn.spring.security.exception.ConflictAccountException;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.stream.Collectors;
@Component
public class CustomAuthenticationProvider
implements AuthenticationProvider {
@Bean
public List<User> preloadUsers() {
return Arrays.asList(new User("user1", "password1", true, false, false),
new User("user2", "password2", false, false, false),
new User("user3", "password3", true, true, false),
new User("user4", "password4", true, false, true));
}
private List<User> getUser(String username) {
return preloadUsers().stream().filter(user -> user.getUsername().equals(username)).collect(Collectors.toList());
}
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
// 获取用户登录时输入的用户名
String username = authentication.getName();
// 根据用户名查询系统中的用户信息
List<User> users = getUser(username);
// 如果用户列表为 null,说明查找用户功能出现异常,抛出 AuthenticationServiceException
if (Objects.isNull(users)) {
throw new AuthenticationServiceException(String.format("Searching user[%s] occurred error!", username));
}
// 如果用户列表为空,说明没有匹配的用户,抛出 UsernameNotFoundException
if (users.size() == 0) {
throw new UsernameNotFoundException(String.format("No qualified user[%s]!", username));
}
// 如果用户列表中不止一个匹配用户,说明系统中用户唯一性逻辑存在问题,抛出 ConflictAccountException
if (users.size() > 1) {
throw new ConflictAccountException(String.format("Conflict user[%s]", username));
}
// 获取用户列表中唯一的用户对象
User user = users.get(0);
// 如果用户没有设置启用或禁用状态,或者用户被设为禁用,则抛出 DisabledException
Optional<Boolean> enabled = Optional.of(user.getEnabled());
if (!enabled.orElse(false)) {
throw new DisabledException(String.format("User[%s] is disabled!", username));
}
// 如果用户没有过期状态或过期状态为 true 则抛出 AccountExpiredException
Optional<Boolean> expired = Optional.of(user.getExpired());
if (expired.orElse(true)) {
throw new AccountExpiredException(String.format("User[%s] is expired!", username));
}
// 如果用户没有锁定状态或锁定状态为 true 则抛出 LockedException
Optional<Boolean> locked = Optional.of(user.getLocked());
if (locked.orElse(true)) {
throw new LockedException(String.format("User[%s] is locked!", username));
}
// 如果用户登录时输入的密码和系统中密码匹配,则返回一个完全填充的 Authentication 对象
if (user.getPassword().equals(authentication.getCredentials().toString())) {
return new UsernamePasswordAuthenticationToken(authentication, authentication.getCredentials(), new ArrayList<>());
}
// 如果密码不匹配则返回 null(此处可以抛异常,试具体应用场景而定)
return null;
}
@Override
public boolean supports(Class<?> aClass) {
return true;
}
}
说明:
(1) preloadUsers
方法用于预置用户信息。实际开发中用户信息通常存储在关系型数据库,本文主要目的是演示 Spring Security Authentication (认证)的定制开发过程,省略了数据库相关功能,直接在内存中定义了四个预置的用户数据,包括一个正常用户、一个已被禁用的用户、一个过期用户和一个已锁定用户,以便后续演示不同用户的登录认证结果。此方法中的 User 属于实际业务对象类型,可以根据实际业务场景定制,示例代码:
package learn.spring.security.domain;
import java.util.Set;
public class User {
private String username;
private String password;
private Boolean enabled;
private Boolean expired;
private Boolean locked;
private Set<String> roles;
public User() {
}
public User(String username, String password, Boolean enabled, Boolean expired, Boolean locked) {
this.username = username;
this.password = password;
this.enabled = enabled;
this.expired = expired;
this.locked = locked;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public Boolean getEnabled() {
return enabled;
}
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}
public Boolean getExpired() {
return expired;
}
public void setExpired(Boolean expired) {
this.expired = expired;
}
public Boolean getLocked() {
return locked;
}
public void setLocked(Boolean locked) {
this.locked = locked;
}
public Set<String> getRoles() {
return roles;
}
public void setRoles(Set<String> roles) {
this.roles = roles;
}
@Override
public String toString() {
return "User{" + "username='" + username + '\'' + ", password='" + password + '\'' + ", enabled=" + enabled +
", expired=" + expired + ", locked=" + locked + ", roles=" + roles + '}';
}
}
(2) getUser
方法根据用户登录时输入的用户名查找系统中匹配的用户信息,实际开发中通常是根据用户登录时输入的用户名在数据库中查找匹配的用户信息(DAO),或去其它第三方系统开放的认证接口中查找匹配的用户信息;
(3) authenticate
方法执行认证,注意在密码匹配部分直接使用明文,在实际开发中是不可能这样做的,后续如果有时间会专门写一篇有关 Spring Security 集成各种加密算法的方案。此方法中还使用了一个自定义异常 ConflictAccountException
标识账号冲突,实际上账号冲突通常是因为系统中账号唯一性逻辑出现了问题,是需要严格排查的,所以此处专门定义了一个异常:
package learn.spring.security.exception;
import org.springframework.security.authentication.AccountStatusException;
public class ConflictAccountException extends AccountStatusException {
public ConflictAccountException(String msg) {
super(msg);
}
}
(4) supports
方法判断是否支持此类型认证,因为本文示例代码中只有一个 AuthenticationProvider
,所以设置为支持所有认证请求。
4 其次,实现 AuthenticationManager
package learn.spring.security.config;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.ProviderNotFoundException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;
import java.util.Objects;
@Component
public class CustomAuthenticationManager
implements AuthenticationManager {
private final AuthenticationProvider authenticationProvider;
public CustomAuthenticationManager(
AuthenticationProvider authenticationProvider) {
this.authenticationProvider = authenticationProvider;
}
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Authentication result = authenticationProvider.authenticate(authentication);
if (Objects.nonNull(result)) {
return result;
}
throw new ProviderNotFoundException("Authentication failed!");
}
}
说明:
(1) 自定义的 AuthenticationManager
有一个 AuthenticationProvider
属性,通过构造器注入了上一步中自定义的 AuthenticationProvider
实例;
(2) AuthenticationManager
只有一个方法 authenticate
,将接收的 Authentication
对象传递给 AuthenticationProvider
实例认证,认证返回结果为 null 则抛出 ProviderNotFoundException
,否则直接返回 AuthenticationProvider
返回的结果。在 Spring Security Authentication (认证)原理初探 中我曾提过,ProviderManager
是 Spring Security 提供的 AuthenticationManager
默认实现,所以自定义的 AuthenticationManager
也可以直接继承 ProviderManager
。
5 然后,实现 AbstractAuthenticationProcessingFilter
package learn.spring.security.config;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Objects;
@Component
public class CustomAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public CustomAuthenticationFilter(
AuthenticationManager authenticationManager, AuthenticationFailureHandler authenticationFailureHandler,
AuthenticationSuccessHandler authenticationSuccessHandler) {
super(new AntPathRequestMatcher("/login", "POST"));
this.setAuthenticationManager(authenticationManager);
this.setAuthenticationFailureHandler(authenticationFailureHandler);
this.setAuthenticationSuccessHandler(authenticationSuccessHandler);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
/*
// 添加验证码校验功能
String captcha = request.getParameter("captcha");
if (!checkCaptcha(captcha)) {
throw new AuthenticationException("Invalid captcha!");
}
*/
String username = request.getParameter("username");
String password = request.getParameter("password");
username = Objects.isNull(username) ? "" : username.trim();
password = Objects.isNull(password) ? "" : password;
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
return this.getAuthenticationManager().authenticate(authRequest);
}
}
说明:
(1) 自定义的 AbstractAuthenticationProcessingFilter
通过构造器注入了上一步自定义的 AuthenticationManager
,除此之外还注入了一个 AuthenticationSuccessHandler
对象和一个 AuthenticationFailureHandler
对象;
(2) AuthenticationSuccessHandler
在登录认证成功后会被调用,自定义的 AuthenticationSuccessHandler
代码如下:
package learn.spring.security.config;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
public CustomAuthenticationSuccessHandler() {
}
@Override
public void onAuthenticationSuccess(
HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication)
throws IOException, ServletException {
httpServletResponse.sendRedirect("/index");
// 可以自定义登录成功后的其它动作,如记录用户登录日志、发送上线消息等
}
}
(3) AuthenticationFailureHandler
在登录认证失败后会被调用,自定义的 AuthenticationFailureHandler
代码如下:
package learn.spring.security.config;
import org.springframework.security.authentication.AccountExpiredException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
public CustomAuthenticationFailureHandler() {
}
/**
* 通过检查异常类型实现页面跳转控制
*/
@Override
public void onAuthenticationFailure(
HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e)
throws IOException, ServletException {
if (e instanceof UsernameNotFoundException) {
httpServletResponse.sendRedirect("/login/page?inexistent");
} else if (e instanceof DisabledException) {
httpServletResponse.sendRedirect("/login/page?disabled");
} else if (e instanceof AccountExpiredException) {
httpServletResponse.sendRedirect("/login/page?expired");
} else if (e instanceof LockedException) {
httpServletResponse.sendRedirect("/login/page?locked");
} else {
httpServletResponse.sendRedirect("/login/page?error");
}
}
}
(4) attemptAuthentication
方法同 org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
十分类似,唯一多出的注释掉的代码用于实现验证码校验功能,当然此处可以根据实际业务需求定制任意验证功能,有时间可以参考一下 UsernamePasswordAuthenticationFilter
的源码。自定义 AbstractAuthenticationProcessingFilter
也可以直接继承 UsernamePasswordAuthenticationFilter
。
5 至此,之前提到的几个主要接口和类都已自定义实现,除了这些接口和类外还需要实现一个十分重要的接口:AuthenticationEntryPoint
package learn.spring.security.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
@Configuration
public class CustomAuthenticationEntryPoint {
@Bean
public LoginUrlAuthenticationEntryPoint loginUrlAuthenticationEntryPoint() {
return new LoginUrlAuthenticationEntryPoint("/login/page");
}
}
说明:此处为了方便直接使用了 Spring Security 中的 org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint
类定义了一个对象,最主要的目的是设置登录页的 URL,如果想了解更多 AuthenticationEntryPoint
接口细节可以参考 LoginUrlAuthenticationEntryPoint
源码。
6 覆盖默认的安全配置
package learn.spring.security.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class CustomSecurityConfig
extends WebSecurityConfigurerAdapter {
private final AuthenticationEntryPoint authenticationEntryPoint;
private final AbstractAuthenticationProcessingFilter authenticationProcessingFilter;
public CustomSecurityConfig(AuthenticationEntryPoint authenticationEntryPoint, AbstractAuthenticationProcessingFilter authenticationProcessingFilter) {
this.authenticationEntryPoint = authenticationEntryPoint;
this.authenticationProcessingFilter = authenticationProcessingFilter;
}
@Override
protected void configure(AuthenticationManagerBuilder auth)
throws Exception {
auth.eraseCredentials(false);
}
@Override
public void configure(WebSecurity web)
throws Exception {
web.ignoring().antMatchers("/css/**", "/js/**", "/lib/**");
}
@Override
protected void configure(HttpSecurity http)
throws Exception {
http
.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint)
.and()
// 允许所有人访问 /login/page
.authorizeRequests().antMatchers("/login/page").permitAll()
// 任意访问请求都必须先通过认证
.anyRequest().authenticated()
.and()
// 启用 iframe 功能
.headers().frameOptions().disable()
.and()
// 将自定义的 AbstractAuthenticationProcessingFilter 加在 Spring 过滤器链中
.addFilterBefore(authenticationProcessingFilter, UsernamePasswordAuthenticationFilter.class);
}
}
说明:
(1) @EnableWebSecurity
注解禁用 Spring Boot 默认的 Security 配置,自定义扩展 WebSecurityConfigurerAdapter
的类并使用 @Configuration
注解可以实现定制的 Security 配置;
(2) 覆盖 public void configure(WebSecurity web)
方法,此方法主要实现 Web 层配置,一般用于实现不需要安全检查的目录,譬如存放静态文件(前端 JS / CSS 等)的目录;
(3) 覆盖 protected void configure(HttpSecurity http)
方法实现 Request 层的配置,对应 XML Configuration 中的 <http>
元素。这个方法很重要,可以实现很多配置,后续会针对具体使用场景进行专门讲解。
6 本文示例使用 Spring MVC 集成 FreeMarker 模板演示登录页面及成功后的跳转页面
(1) Spring Boot 中 FreeMarker 基础配置(application.yml)
spring:
freemarker:
cache: false
charset: UTF-8
check-template-location: true
content-type: text/html
enabled: true
suffix: .ftl
template-loader-path: classpath:/templates/
(2) 定义 Controller 处理 HTTP 请求
package learn.spring.security.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@Controller
public class PageController {
@RequestMapping(value = "/index", method = RequestMethod.GET)
public String index() {
return "index";
}
@RequestMapping(value = "/login/page", method = RequestMethod.GET)
public String login() {
return "login";
}
}
(3) 在 resources/templates
目录下新建页面模板
登录页 login.ftl
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login</title>
</head>
<body>
<p>This is login page</p>
<form action="/login" method="post">
<input name="${_csrf.parameterName}" type="hidden" value="${_csrf.token}">
<table>
<tr>
<th>用户名:</th>
<td><input type="text" id="username" name="username"></td>
</tr>
<tr>
<th>密码:</th>
<td><input type="password" id="password" name="password"></td>
</tr>
<tr>
<th>验证码:</th>
<td><input type="text" id="captcha" name="captcha"></td>
</tr>
</table>
<input type="submit" value="登录">
</form>
</body>
</html>
登录成功后展示的首页 index.ftl
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Index</title>
</head>
<body>
<p>This is index page!</p>
</body>
</html>
7 测试
运行工程,在浏览器中输入 http://localhost:8080/index
后回车直接跳转到 http://localhost:8080/login/page
,因为只有 /login/page
是可以不受拦截任意访问的,其他访问都需要先通过登录认证。
鉴于本文已经够长了,此处就不再截图演示,有兴趣的童鞋可以照着文中示例自己写一遍,运行并测试各种正常或异常场景。
网友评论