本文演示如何实现 Spring Security 认证的定制开发。
一、新建 springboot工程。
二、添加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>spring.security</groupId>
<artifactId>spring-security</artifactId>
<version>1.0-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.4.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>
<mybatis.spring.version>1.3.2</mybatis.spring.version>
<swagger.version>2.8.0</swagger.version>
<jwt.version>0.9.1</jwt.version>
<fastjson.version>1.2.48</fastjson.version>
<pagehelper.version>4.1.6</pagehelper.version>
</properties>
<dependencies>
<!-- spring boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--swagger -->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>${swagger.version}</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>${swagger.version}</version>
</dependency>
<!-- spring security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>${jwt.version}</version>
</dependency>
<!-- fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>${fastjson.version}</version>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>compile</scope>
</dependency>
<!--pagehelper-->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper</artifactId>
<version>${pagehelper.version}</version>
</dependency>
<!-- freemarker -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
三、添加配置 application.properties
server.port=8081
server.tomcat.uri-encoding=UTF-8
#server.servlet.context-path=/
#spring.application.name=spring-security
#spring.aop.auto=true
#日志
logging.level.org.springframework.web=INFO
spring.freemarker.template-loader-path=classpath:/templates/
spring.freemarker.charset=utf-8
spring.freemarker.cache=false
spring.freemarker.expose-request-attributes=true
spring.freemarker.expose-session-attributes=true
spring.freemarker.expose-spring-macro-helpers=true
spring.freemarker.suffix=.ftl
四、启动器
package com.spring;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
/**
* 启动器
*/
@SpringBootApplication
@ComponentScan(basePackages = {"com.spring.security"})
public class SecurityApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityApplication.class, args);
}
}
五、实现真正 处理认证请求 的 AuthenticationProvider
package com.spring.security.auth;
import com.spring.security.model.User;
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 com.spring.security.model;
import lombok.Data;
import java.util.Set;
/**
* 用户模型
*/
@Data
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;
}
}
(2) getUser
方法根据用户登录时输入的用户名查找系统中匹配的用户信息,实际开发中通常是根据用户登录时输入的用户名在数据库中查找匹配的用户信息(DAO),或去其它第三方系统开放的认证接口中查找匹配的用户信息;
(3) authenticate
方法执行认证,注意在密码匹配部分直接使用明文,在实际开发中是不可能这样做的,后续如果有时间会专门写一篇有关 Spring Security 集成各种加密算法的方案。此方法中还使用了一个自定义异常 ConflictAccountException
标识账号冲突,实际上账号冲突通常是因为系统中账号唯一性逻辑出现了问题,是需要严格排查的,所以此处专门定义了一个异常:
package com.spring.security.auth;
import org.springframework.security.authentication.AccountStatusException;
public class ConflictAccountException extends AccountStatusException {
public ConflictAccountException(String msg) {
super(msg);
}
}
(4) supports
方法判断是否支持此类型认证,因为本文示例代码中只有一个 AuthenticationProvider
,所以设置为支持所有认证请求。
六、实现AuthenticationManager
package com.spring.security.auth;
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
返回的结果。ProviderManager
是 Spring Security 提供的 AuthenticationManager
默认实现,所以自定义的 AuthenticationManager
也可以直接继承 ProviderManager
。
七、实现 AbstractAuthenticationProcessingFilter
package com.spring.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 com.spring.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 com.spring.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
。
八、实现 AuthenticationEntryPoint
说明:此处为了方便直接使用 Spring Security 中的 org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint
类定义了一个对象,最主要的目的是设置登录页的 URL,如果想了解更多 AuthenticationEntryPoint
接口细节可以参考 LoginUrlAuthenticationEntryPoint
源码。
package com.spring.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");
}
}
九、覆盖默认的安全配置 WebSecurityConfigurerAdapter
package com.spring.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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()
// 跨域预检请求
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
// 登录URL
.antMatchers("/login").permitAll()
// swagger
.antMatchers("/swagger-ui.html").permitAll()
.antMatchers("/swagger-resources").permitAll()
.antMatchers("/v2/api-docs").permitAll()
.antMatchers("/webjars/springfox-swagger-ui/**").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>
元素。这个方法很重要,可以实现很多配置。
(4) swagger 配置
package com.spring.config;
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.ParameterBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.schema.ModelRef;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Parameter;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
/**
* Swagger配置
*/
@Configuration
@EnableSwagger2
public class SwaggerConfig {
@Bean
public Docket createRestApi(){
// 添加请求参数,我们这里把token作为请求头部参数传入后端
ParameterBuilder parameterBuilder = new ParameterBuilder();
List<Parameter> parameters = new ArrayList<Parameter>();
parameterBuilder.name("Authorization").description("令牌").modelRef(new ModelRef("string")).parameterType("header")
.required(false).build();
parameters.add(parameterBuilder.build());
return new Docket(DocumentationType.SWAGGER_2).apiInfo(apiInfo()).select().apis(RequestHandlerSelectors.any())
.paths(PathSelectors.any()).build().globalOperationParameters(parameters);
}
private ApiInfo apiInfo(){
return new ApiInfoBuilder().build();
}
}
十、使用springMVC集成 FreeMarker 演示 登录页面 及成功跳转
(1) Spring Boot 中 FreeMarker 基础配置,已在第二步添加。
(2) 定义 Controller 处理 请求 PageController
package com.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", method = RequestMethod.GET)
public String login() {
return "login";
}
}
(3) 在 resources/templates
目录下新建页面模板 /login/page,登录页 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>
(4) 登陆成功后的首页 index.ftl。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Index</title>
</head>
<body>
<p>登录成功,欢迎光临 !</p>
</body>
</html>
亲测可用,如图所示: