简介
Spring Security是一个能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案的安全框架(简单说是对访问权限进行控制 )。
它提供了一组可以在Spring应用上下文中配置的Bean,充分利用了Spring IOC,DI和AOP功能,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全控制编写大量重复代码的工作。
应用的安全性包括:用户认证(Authentication)和用户授权(Authorization)两个部分
用户认证:验证某个用户是否为系统中的合法主体,也就是说用户能否访问该系统 。
用户授权:验证某个用户是否有权限执行某个操作
框架搭建
环境要求: SpringBoot、SpringSecurity、MySQL
工程搭建:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.5.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
@SpringBootApplication
public class SpringsecuritydemoApplication {
public static void main(String[] args) {
SpringApplication.run(SpringsecuritydemoApplication.class, args);
}
}
框架分析
浏览器,访问http://localhost:8080,发现会跳转到第一个登录页面,Spring Security已经默认做了一些配置,并且创建一个简单的登录页面 ,那这个页面是怎么来的?通过跟踪源码来一探究竟。
Spring Security 官网文档链接:
https://docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#jc-oauth2login
跳转登录页面
通过查看文档发现,WebSecurityConfigurerAdapter 提供的默认的配置 ,这个抽象类中,提供了一个方法formLogin(),内容如下:
protected void configure(HttpSecurity http) throws Exception {
logger.debug("Using default configure(HttpSecurity). If subclassed this will potentially override subclass configure(HttpSecurity).");
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin().and()
.httpBasic();
}
public FormLoginConfigurer<HttpSecurity> formLogin() throws Exception {
return getOrApply(new FormLoginConfigurer<>());
}
查看formLogin()源码,跳转到HttpSecurity类中,这个方法返回一个 FormLoginConfigurer<HttpSecurity>类型,再继续来看这个FormLoginConfigurer,在FormLoginConfigurer中有个initDefaultLoginFilter()方法
private void initDefaultLoginFilter(H http) {
DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = http
.getSharedObject(DefaultLoginPageGeneratingFilter.class);
if (loginPageGeneratingFilter != null && !isCustomLoginPage()) {
loginPageGeneratingFilter.setFormLoginEnabled(true);
loginPageGeneratingFilter.setUsernameParameter(getUsernameParameter());
loginPageGeneratingFilter.setPasswordParameter(getPasswordParameter());
loginPageGeneratingFilter.setLoginPageUrl(getLoginPage());
loginPageGeneratingFilter.setFailureUrl(getFailureUrl());
loginPageGeneratingFilter.setAuthenticationUrl(getLoginProcessingUrl());
}
}
这个方法,初始化一个默认登录页的过滤器,可以看到第一句代码,默认的过滤器是DefaultLoginPageGeneratingFilter ,进入过滤器中 :
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
boolean loginError = isErrorPage(request);
boolean logoutSuccess = isLogoutSuccess(request);
if (isLoginUrlRequest(request) || loginError || logoutSuccess) {
String loginPageHtml = generateLoginPageHtml(request, loginError,
logoutSuccess);
.........
return;
}
chain.doFilter(request, response);
}
可以看到,如果没有配置login页,这个过滤器会被创建,然后看doFilter()方法,登录页面的配置是通过generateLoginPageHtml()方法创建的,再来看这个方法 :
private String generateLoginPageHtml(HttpServletRequest request, boolean loginError,
boolean logoutSuccess) {
String errorMsg = "Invalid credentials";
if (loginError) {
HttpSession session = request.getSession(false);
if (session != null) {
AuthenticationException ex = (AuthenticationException) session
.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
errorMsg = ex != null ? ex.getMessage() : "Invalid credentials";
}
}
StringBuilder sb = new StringBuilder();
sb.append("<!DOCTYPE html>\n"
+ "<html lang=\"en\">\n"
+ " <head>.......");
String contextPath = request.getContextPath();
if (this.formLoginEnabled) {
sb.append(" <form class=\"form-signin\" method=\"post\" action=\"" + contextPath + this.authenticationUrl + "\">\n"
+ "........");
}
if (openIdEnabled) {
sb.append(" <form name=\"oidf\" class=\"form-signin\" method=\"post\" action=\"" + contextPath + this.openIDauthenticationUrl + "\">\n"
.......
}
if (oauth2LoginEnabled) {
sb.append("<h2 class=\"form-signin-heading\">Login with OAuth 2.0</h2>");
.......
for (Map.Entry<String, String> clientAuthenticationUrlToClientName : oauth2AuthenticationUrlToClientName.entrySet()) {
72 .........
}
sb.append("</table>\n");
}
sb.append("</div>\n");
sb.append("</body></html>");
return sb.toString();
}
至此,默认登录页及配置,已经可以清楚了 。
用户名和密码分析
在项目启动的日志中,可以发现有这样一条信息 :
2019-05-20 16:26:24.846 INFO 9032 --- [ main] .s.s.UserDetailsServiceAutoConfiguration :
Using generated security password: 5baa53a8-2ff7-4fea-9e10-185b5f640dad
可以看到,自动配置类是UserDetailsServiceAutoConfiguration,密码是 :5baa53a8-2ff7-4fea-9e10-185b5f640dad,现在知道了密码,那用户名是什么还不知道,进入到 UserDetailsServiceAutoConfiguration去看看:
在这个 UserDetailsServiceAutoConfiguration 类的描述中可以知道,这个类是设置一些 Spring Security 相关默认的自动配置,把InMemoryUserDetailsManager 中得user 和 password 信息设置为默认得用户和密码,可以通过提供的AuthenticationManager、AuthenticationProvider 或者 UserDetailsService 的 bean 来覆盖默认的自动配置信息:
@Bean
@ConditionalOnMissingBean(
type = "org.springframework.security.oauth2.client.registration.ClientRegistrationRepository")
@Lazy
public InMemoryUserDetailsManager inMemoryUserDetailsManager(
SecurityProperties properties,
ObjectProvider<PasswordEncoder> passwordEncoder) {
SecurityProperties.User user = properties.getUser();
List<String> roles = user.getRoles();
return new InMemoryUserDetailsManager(User.withUsername(user.getName())
.password(getOrDeducePassword(user, passwordEncoder.getIfAvailable()))
.roles(StringUtils.toStringArray(roles)).build());
}
private String getOrDeducePassword(SecurityProperties.User user,
PasswordEncoder encoder) {
String password = user.getPassword();
if (user.isPasswordGenerated()) {
logger.info(String.format("%n%nUsing generated security password: %s%n",
user.getPassword()));
}
if (encoder != null || PASSWORD_ALGORITHM_PATTERN.matcher(password).matches()) {
return password;
}
return NOOP_PASSWORD_PREFIX + password;
}
可以看到, 日志输出的密码是通过inMemoryUserDetailsManager()方法获取,返回一个新的带有UserDetials信息参数构造的InMemoryUSerDetailsManager对象 ,第一个参数为:User.withUsername(user.getName()),其中user 对象是上面SecurityProperties.User类型 的,通过SecurityProperties 对象中获取的 ,看下SecurityProperties类 :
@ConfigurationProperties(prefix = "spring.security")
public class SecurityProperties {
private User user = new User();
public User getUser() {
return this.user;
}
public static class User {
private String name = "user";
private String password = UUID.randomUUID().toString();
}
}
通过配置文件中的,前缀为spring.security 的配置可以改变默认配置信息,再看看SecurityProperties 的 getUser()方法 ,通过一步步的跟踪,发现默认的用户名是user 。
框架核心过滤器
想要对WEB资源进行保护,最好的办法就是Filter,想要对方法进行保护,最好的办法就是AOP,SpringSecurity在我们进行用户认证和授权的时候,会通过各种各样的拦截器来控制权限的访问,从而实现安全。SpringSecurity常见的过滤器有:
Filter | 含义 |
---|---|
WebAsyncManagerIntegrationFilter | 异步 , 提供了对securityContext和WebAsyncManager的集成 |
SecurityContextPersistenceFilter | 同步 , 从配置的SecurityContextRepository而不是request中获取信息存到SecurityContextHolder,并且当请求结束清理contextHolder时将值存回repository中(默认使用HttpSessionSecurityContextRepository).在该过滤器中每一个请求仅执行一次,该filter需在任何认证处理机制其作用之前执行。认证处理机制如basic,cas等期望在执行时从SecurityContextHolder中获取SecurityContext |
HeaderWriterFilter | 是一个向HttpServletResponse写入http请求头的约定 |
CsrfFilter | 通过使用同步token模式来进行csrf防护 |
LogoutFilter | 记录用户的退出 |
RequestCacheAwareFilter | 用于用户登录成功后,重新恢复因为登录被打断的请求 , 请求信息被保存到cache中 |
SecurityContextHolderAwareRequestFilter | 包装请求对象request |
AnonymousAuthenticationFilter | 是在UsernamePasswordAuthenticationFilter、BasicAuthenticationFilter、RememberMeAuthenticationFilter这些过滤器后面的,所以如果这三个过滤器都没有认证成功,则为当前的SecurityContext中添加一个经过匿名认证的token,但是通过servlet的getRemoteUser等方法是获取不到登录账号的。因为SecurityContextHolderAwareRequestFilter过滤器在AnonymousAuthenticationFilter前面 |
SessionManagementFilter | 管理session |
ExceptionTranslationFilter | 处理过滤器链抛出的所有AccessDeniedException和AuthenticationException异常 |
FilterSecurityInterceptor | 通过实现了filter来增加http资源的安全性。这个安全拦截器需要FilterInvocationSecurityMedataSource |
UsernamePasswordAuthenticationFilter | 登陆用户密码验证过滤器 ,基于用户名和密码的认证逻辑 |
BasicAuthenticationFilter | 处理一个http请求的basic认证头,将结果放入SecurityContextHolder |
DefaultLoginPageGeneratingFilter | 当一个用户没有配置login页面时使用。仅当跳转到login页面时用到 |
核心组件
Authentication
public String getCurrentUsername() {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof UserDetails) {
return ((UserDetails) principal).getUsername();
}
if (principal instanceof Principal) {
return ((Principal) principal).getName();
}
return String.valueOf(principal);
}
public String getCurrentUsername() {
return SecurityContextHolder.getContext().getAuthentication().getName();
}
SecurityContextHolder
AuthenticationManager
AuthenticationProvider
Authentication authenticate(Authentication authentication) throws AuthenticationException;
UserDetailsService
GrantedAuthority
认证过程梳理
SpringBoot整合SpringSecurity
<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.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.0.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.24</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
CREATE TABLE `user` (
`userId` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(100) DEFAULT NULL,
`password` varchar(200) DEFAULT NULL,
`phone` varchar(20) DEFAULT NULL,
PRIMARY KEY (`userId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
@Data
public class User implements UserDetails {
private Long userId;
private String username;
private String password;
private String phone;
private List<GrantedAuthority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
# 访问端口号
server.port=8890
# 数据库相关配置
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/securitydemo
spring.datasource.username=root
spring.datasource.password=123456
# MyBatis的相关配置
# 映射文件位置
mybatis.mapper-locations=classpath:mapper/*.xml
# 输出SQL执行语句
mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
login.html:
<form th:action="@{/login}" method="post">
<input type="text" id="username" name="username" placeholder="手机号"/>
<br/>
<input type="password" id="password" name="password" placeholder="密码"/>
<br/>
<p th:if="${param.authError}" style="color: red">用户名或者密码错误</p>
<br/>
<button type="submit">登录</button>
</form>
index.html:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>首页面</title>
</head>
<body>
<h3>登录成功</h3>
<br/>
<form th:action="@{/user/logout}" method="post" id="logoutForm">
<button type="submit" form="logoutForm">注销</button>
</form>
</body>
</html>
@Controller
public class LoginController {
@Autowired
private IUserService userService;
@GetMapping("/user/toLogin")
public String toLogin(){
return "login";
}
@PostMapping("/user/logout")
public String logout(){
return "login";
}
}
@Configuration
@EnableWebSecurity
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/user/toLogin").permitAll()
.and()
.formLogin()
.loginProcessingUrl("/login")
.failureHandler(authFailHandler())
.and();
}
@Autowired
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(authProvider());
}
@Bean
public MyAuthProvider authProvider(){
return new MyAuthProvider();
}
@Bean
public MyAuthFailHandler authFailHandler(){
return new MyAuthFailHandler();
}
}
public class MyAuthProvider implements AuthenticationProvider {
@Autowired
private IUserService userService;
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String name = authentication.getName();//获取表单提交的用户名
String passwordForm = (String) authentication.getCredentials();//获取表单输入的密码
User user = userService.queryUserByName(name);
if (user==null) {
throw new AuthenticationCredentialsNotFoundException("authError");
}
if(bCryptPasswordEncoder.matches(passwordForm, user.getPassword())){
return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
}else{
throw new BadCredentialsException("authError");
}
}
@Override
public boolean supports(Class<?> authentication) {
return true;
}
}
public class MyAuthFailHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
super.setDefaultFailureUrl("/user/toLogin?"+exception.getMessage());
super.onAuthenticationFailure(request, response, exception);
}
}
public interface IUserService {
public User queryUserByName(String username);
}
@Service
public class UserServiceImpl implements IUserService {
@Autowired
UserDao userDao;
@Override
public User queryUserByName(String username) {
User user = userDao.queryUserByName(username);
return user;
}
}
<mapper namespace="com.mysecurity.dao.UserDao">
<select id="queryUserByName" resultType="com.mysecurity.entity.User">
select * from user where username=#{username}
</select>
</mapper>
public interface UserDao {
public User queryUserByName(String username);
}