权限管理的核心功能:
- 认证(你是谁)
- 授权(你能干什么)
- 攻击防护(防止伪造身份)
基本原理
Spring Security 过滤器链,即一组 Filter 。每一个 Filter 负责处理一种认证方式 。
例如:
Username Password Authentication Filter 负责检查请求参数中是否有用户名和密码,如果有则进行认证,如果没有则“放过”,将请求交给下一个。如:Basic Authentication Filter 。
Basic Authentication Filter 负责检查请求的请求头中是否有 Basic 开头的 Authentication 信息,如果有则会取出并做 Base64 解码后取出其中的用户名密码作认证,如果没有则”放过 。
FilterSecurityInterceptor 是整个过滤器链的最后一环。它会根据请求无法满足的条件,抛出不同的异常。抛给了它前一个叫 Exception Translation Filter 的过滤器。
Exception Translation Filter 获取所抛出的异常后,会去引导用户去做相应处理。
pom.xml 全家桶
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-framework-bom</artifactId>
<version>${spring.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
pom.xml 自定义最小依赖
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
<version>${spring.version}</version>
</dependency>
1. 登录
1.1 核心配置(Part 1)
启用 Spring Security 的核心配置有四步:
- web.xml 中启用 springSecurityFilterChain,进行请求拦截
- spring-security.xml 中声明 spring security 相关配置
- 必须为 spirng-security 准备好一个密码加密器
- 提供当前登录用户密码和权限的“标准答案”
以下只使用/提供 Java 代码配置
/**
* 该类的存在相当于在 web.xml 中配置了 springSecurityFilterChain 去拦截所有请求
* 其中不再需要其他内容
*/
public class SecurityWebApplicationInitializer extends AbstractSecurityWebApplicationInitializer {
}
Spring Security 的 Java 代码配置类基本格式如下:
@Configuration
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
... // 对参数 http 调用其各种方式即为对 Spring Security 进行各种配置。
}
}
注意
,记得将该配置文件加入 WebAppInitializer 中。
Spring Security 有两种登录认证方式(显示内容,要求用户填写用户名密码):表单页 和 http-basic 。配置它们的方式为:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic(); // 或
http.formLogin(); // 两者二选一。
}
- .httpBasic() 方式会在浏览器中出现一个弹框
- .formLogin() 方式会跳转至 spring-security 内置的登录页
@Override
protected void configure(HttpSecurity http) throws Exception {
// 登录方式配置
http.formLogin()
// url拦截鉴权
http.authorizeRequests() // 开始“授权配置”
.anyRequest() // 任何请求
.authenticated(); // 都需要授权
/*
// 另一种写法
http.formLogin()
.and() // 逻辑上相当于分隔符
.authorizeRequests()
.anyRequest()
.authenticated();
*/
}
1.2 核心配置(Part 2)
在 Spring-security 中,用户信息的获取逻辑被封装在了 UserDetailsService 接口中。我们必须要实现这个接口,并在其中提供用户信息的“标准答案”。
该接口要求返回的 UserDetails 也是个接口,我们使用的是它的实现类 User 。User 的构造方法要求三个参数:用户名、密码、用户的权限的集合。
public class UserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return new User("tom", "123", AuthorityUtils.commaSeparatedStringToAuthorityList("ADMIN")); // 这里 admin 的大小写有区别
}
// 截止目前为止,配置仍没有结束。项目仍无法运行
}
密码的匹配工作是由 SpringSecurity 来做的,你只需要告诉它你获得的(例如从数据库中)的密码是什么即可。
UserDetails 中封装了用户登录过程中所需的全部信息:
- isAccountNonExpired(),账户是否过期
- isAccountNonLocked(),杭虎是否被锁(冻结)
- isCredentialsNonExpired(),密码是否过期
- isEnabled(),是否可用(假删除)
return new User("tom", "123456",
true, true, true, true,
AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
任何一个现实中的项目中,都不会将密码以明文的方式存储于数据库中。SpringSecurity 要求你提供/指定一个密码加密器。
SpringSecurity 使用 org.springframework.security.crypto.password.PasswordEncoder
对你提供的密码进行加密。该接口中有两个方法:加密方法,是否匹配方法。
加密方法(encode)方法用户注册功能在获得用户的密码后对其加密。
而另一个方法 matches 方法是由 Spring Security 调用的,它用该方法来比较登录密码和密码“标准答案”。
原本 Spring Security 中提供的不少内置的已实现的加密器,不过,Spring Security5 为了解耦,大量原有的加密器被标记为废弃,转由用户“额外”提供。仅剩的加密器还有:
- BCryptPasswordEncoder(官方推荐使用)
- Pbkdf2PasswordEncoder
- SCryptPasswordEncoder
@Configuration
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean("passwordEncoder")
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
// return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
...
}
}
另外,Spring Security 提供了一个叫 NoOpPasswordEncoder 是一个”无意义“加密器,它对原始密码没有做任何处理(现在也被标记为废弃)。
1.3 指定登录后的页面
登录成功后的跳转页面/路径有两种:
- 默认情况下,我们在登录成功后会返回到原本受限制的页面/请求。
- 但如果用户是直接请求登录页面,登录成功后默认情况下是跳转到当前应用的根路径,即欢迎页面。
// 登录页面配置
http.formLogin()
.defaultSuccessUrl("/success.jsp");
// .defaultSuccessUrl("/success.jsp", true);
通过 .defaultSuccessUrl()
可以指定上述第二种情况下的成功跳转页面。如果多加一个参数true,那么也就涵盖了第一种情况下的成功跳转页面。
类似的,通过 .failureForwardUrl()
可以指定登录失败时跳转的错误页面。
1.4 自定义登录页面和登录请求
@Configuration
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
...
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/login.html"); // 指定登录页面。
http.authorizeRequests()
.antMatchers("/login.html").permitAll() // 这句配置很重要,新手容易忘记。放开 login.html 的访问权
.anyRequest()
.authenticated();
}
}
在 SpringSecurity 中 form 表单方式的登录处理是由 UsernamePasswordAuthenticationFilter 处理的,在这个 filter 中
- 默认的登录请求 url 是 /login
- 默认的两个请求参数分别是 username 和 password
- 默认的请求方式是 post
要么你自定义的登录页面必须满足以上默认的条件,要么进行配置,手动指定。
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/sign-in.html")
.loginProcessingUrl("/login"); // 注意,此处路径中没有项目名作前缀
// .usernameParameter("username")
// .passwordParameter("password")
http.authorizeRequests()
.antMatchers("/sign-in.html").permitAll()
.anyRequest()
.authenticated()
http.csrf()
.disable(); // 关闭 csrf 功能
}
默认,Spring Security 开起了 CSRF Token 功能(跨站请求伪造攻击防护),因此,此时需要通过配置先关闭掉。
但是有时候(其实就是 Rest-ful 风格的API)中,你需要的并不是页面跳转,而是服务端返回 JSON 格式的数据,其中包含登录成功或失败信息。
1.5 Remember Me
Spring-Security 功能的基本步骤和原理:
- 用户请求会被 SpringSecurityFilterChain 中的 UsernamePasswordAuthenticationFilter 拦截处理
- 一旦用户认证成功,该 Filter 会去调用 RememberMeService ,而 RememberMeService 会生成一个 Token
- RememberMeService 一方面会回给浏览器一个包含了该 token 的 cookie ,另一个方面会将该 token 存入数据库表中
- 在数据库中,登录用户的用户名(username)和 Token 是一一对应的。
- 在以后的请求中,浏览器会携带包含了用户 token 的 cookie,而 RememberMeAuthenticationFilter,它负责从请求的提取该 Cookie 中的 token 并与数据库中的 token进行比较。
- 比对成功后,还需要进一步去调用 UserDetailsService 会执行登录操作,从而实现自动登录功能。
RememberMeService 向数据库写入用户的 Token 信息需要借助于 JdbcTokenRepository 接口及其实现类 JdbcTokenRepositoryImpl。 JdbcTokenRepositoryImpl 中有现成的数据库语句。
注意
,页面上的 checkbox 的 name 必须为 remember-me :
<input type="checkbox" id="remember-me" name="remember-me" />自动登录
在 spring-security.xml 中配置 Remember Me 功能核心配置有两步:
- 配置用于操作数据库存取用户 Token 的 Repository(即 Dao)
- 配置后续进行验证的 UserDetailService 。
@Override
protected void configure(HttpSecurity http) throws Exception {
// 登录页面配置
...
// 自动登录功能配置
http.rememberMe()
.tokenRepository(persistentTokenRepository()) // 步骤 1
.tokenValiditySeconds(60)
.userDetailsService(userDetailsService); // 步骤 2
// 鉴权配置
...
// 关闭 csrf 功能
...
}
由于涉及到数据库的操作,所以 spring-security.xml 中配置 remember-me 功能时,依赖于 spring-dao.xml 中的 database :
@Autowired
private DataSource dataSource;
另外,Spring-Security 是通过 JdbcTokenRepository 及其实现类 JdbcTokenRepositoryImpl 对数据库进行读写操作:
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl repository = new JdbcTokenRepositoryImpl();
repository.setDataSource(dataSource);
// repository.setCreateTableOnStartup(true); // 让 Spring-Security 创建表
return repository;
}
1.6 自定义登录成功/失败处理
有时候(特别是 Rest-ful 风格的 web 项目)登录成功和失败之后的“结果”并不是页面跳转,在前端发出 ajax 请求后,你的返回结果可能就只是一个证明验证成功或失败的 JSON 字符串(特别是登录失败的情况下)。
Spring-Security 提供了一种机制:你可以向 Spring-Security 配置一个 Handler,在验证成功(或失败)后,Spring-Security 来调用这个 Handler 中的指定方法。至于接下来是页面的跳转,还是返回 JSON 格式数据,完全取决于你所实现的这个 Handler 中的方法的执行结果。
以下以失败处理为例,成功处理与它类似。
1.6.1 定义 Handler
处理登录成功之后的后续 Handler 必须实现 AuthenticationSuccessHandler;处理登录失败之后的后续 Handler 必须实现 AuthenticationFailureHandler 。
经过注册后,
- Spring-Security 在验证用户登录成功之后,会去调用/执行 AuthenticationSuccessHandler 中的
onAuthenticationSuccess()
方法 - Spring-Security 在验证用户登录失败之后,会去调用/执行 AuthenticationFailureHandler 中的
onAuthenticationFailure()
方法
如果,你期望登录失败之后,是通过 转发 进行跳转显示失败页面,那么:
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response, AuthenticationException exception)
throws IOException, ServletException {
System.out.println("登录失败");
request.getRequestDispatcher("/failure.jsp").forward(request, response);
}
如果,你期望登录失败之后,是通过 重定向 进行跳转显示失败页面,那么:
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response, AuthenticationException exception)
throws IOException, ServletException {
System.out.println("登录失败");
response.sendRedirect("/xxx/failure.jsp");
}
如果,你期望登录失败之后,向客户端浏览器发送 JSON 格式数据,那么:
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response, AuthenticationException exception)
throws IOException, ServletException {
System.out.println("登录失败");
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("json 格式字符串");
}
考虑到 Spring MVC 默认是使用 jackson 库作 JSON 字符串的转换,我们可以在 spring-security.xml 配置一个 jackson 的 ObjectMapper 并在此处用于对象转JSON。
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response, AuthenticationException exception)
throws IOException, ServletException {
System.out.println("登录失败");
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(对象));
}
1.6.2 注册 Handler
@Configuration
@EnableWebSecurity
@ComponentScan("com.sxnd.authentication")
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
...
/*
@Autowired
@Qualifier("myAuthenticationSuccessHandler")
private AuthenticationSuccessHandler successHandler;
*/
@Autowired
@Qualifier("myAuthenticationFailureHandler") // Spring-Security 会有多个 FailureHandler 因此需要明确指明 id
private AuthenticationFailureHandler failureHandler;
@Bean("objectMapper")
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
...
@Override
protected void configure(HttpSecurity http) throws Exception {
// 登录页面配置
http.formLogin()
.loginPage("/sign-in.html")
.loginProcessingUrl("/login")
// .successHandler(successHandler)
.failureHandler(failureHandler);
...
}
}
1.7 退出(logout)
退出的配置所涉及的概念与登录配置类似:
http.logout()
.logoutUrl("/logout") // 触发退出功能的 url
.logoutSuccessUrl("/index.jsp") // 退出成功后的跳转页面
.invalidateHttpSession(true) // 让当前session失效,默认值就是true
.deleteCookies("JSESSIONID", ...) // 退出需要客户端删除的 cookie 名称,多个cookie之间以逗号分隔。
;
同样,logout 也有 Handler 机制,由你来指定退出成功后是进行页面跳转,还是发送 JSON 格式数据。
2. 鉴权
用户看不见,不等于不能访问:页面上不可见是用户体验问题,用户不能访问,是安全问题。
UserDetailsService 的 loadUserByUsername() 所返回的 User 对象的 authorities 参数决定了当前用户所拥有的权限。
权限表达式 | 说明 |
---|---|
permitAll() | 永远返回 true |
denyAll() | 永远返回 false |
anonymous() | 当前用户是匿名用户(anonymous)时返回 true |
rememberMe() | 当前用户是 rememberMe 用户时返回 true |
authentication | 当前用户不是匿名用户时,返回 true |
fullyAuthenticated | 当前用户既不是匿名用户,也不是 rememberMe 用户是,返回 true |
hasRole("role") | 当用户拥有指定身份时,返回 true |
hasAnyRole("role1","role2", ...) | 当用户返回指定身份中的任意一个时,返回 true |
hasAuthority("authority1") | 当用于拥有指定权限时,返回 true |
hasAnyAuthority("authority1", "authority2") | 当用户拥有指定权限中的任意一个时,返回 true |
hasIpAddress("xxx.xxx.x.xxx") | 发送请求的 ip 符合指定时,返回 true |
principal | 允许直接访问主体对象,表示当前用户 |
注意:hasRole()
会在参数字符串前加 ROLE_
,即,hashRole("ADMIN")
真正验证的身份是 ROLE_ADMIN
;而 hasAuthority()
则不会加任何前缀。
.antMatchers("/admin_role.jsp").hasRole("ADMIN") // 配置权限时,此处没有 ROLE_
.antMatchers("/root_role.jsp").hasRole("ROOT")
.antMatchers("/write_author.jsp").hasAuthority("write")
.antMatchers("/read_author.jsp").hasAuthority("read")
return new User("tom", "123456", AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_ADMIN, read")); // 但是权限本身是由 ROLE_ 的
通用的 RBAC(Role-Based Access Control) 数据模型
三张实体表 + 两张关系(中间)表
- 用户表
- 角色表
- 资源表
.anyRequest().access("@rbacService.hasPermission(request, authentication)")
@Component("rbacService")
public class RBACServiceImpl {
private AntPathMatcher matcher = new AntPathMatcher();
public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
Object principal = authentication.getPrincipal();
boolean permission = false;
if (principal instanceof UserDetails) {
String username = ((UserDetails) principal).getUsername();
Set<String> urls = new HashSet<>(); // 应该来源于数据库
for (String url : urls) { // url 中有可能有通配符 *,因此不要使用 equals 比较
if (matcher.match(url, request.getRequestURI())) {
permission = true;
break;
}
}
}
return permission;
}
}
SET FOREIGN_KEY_CHECKS = FALSE;
DROP TABLE IF EXISTS sys_users;
DROP TABLE IF EXISTS sys_roles;
DROP TABLE IF EXISTS sys_permissions;
DROP TABLE IF EXISTS sys_users_roles;
DROP TABLE IF EXISTS sys_roles_permissions;
CREATE TABLE sys_users (
id bigint auto_increment,
username varchar(100),
password varchar(100),
salt varchar(100),
locked bool default false,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE sys_roles (
id bigint auto_increment,
role varchar(100),
description varchar(100),
available bool default false,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE sys_permissions (
id bigint auto_increment,
permission varchar(100),
description varchar(100),
available bool default false,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE sys_users_roles (
user_id bigint,
role_id bigint,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES sys_users(id),
FOREIGN KEY (role_id) REFERENCES sys_roles(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE sys_roles_permissions (
permission_id bigint,
role_id bigint,
PRIMARY KEY (role_id, permission_id),
FOREIGN KEY (role_id) REFERENCES sys_role_permission(id),
FOREIGN KEY (permission_id) REFERENCES sys_permission(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
SET FOREIGN_KEY_CHECKS = TRUE;
select * from INFORMATION_SCHEMA.KEY_COLUMN_USAGE where TABLE_SCHEMA = 'scott';