启用Spring Security
保护Spring应用的第一步就是将Spring Boot security starter依赖添加到构建文件中。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
只要添加了该依赖,当应用启动的时候,自动配置功能会探测到Spring Security出现在了类路径中,因此它就会初始化一些安全配置。这时,你可以尝试启动应用访问主页,应用会展示一个登录页面并提示你进行认证。用户名为user,密码会被写出应用的日志文件中。日志条目大致如下所示:
Using generated security password: 893043b6-a647-4f21-8787-70d25c6151a1
假设输入了正确的用户名和密码,你就有权限访问应用了。
通过将security 起步依赖添加到项目的构建文件中,我们得到了以下安全特性:
- 所有的HTTP请求路径都需要认证
- 不需要特定的角色和权限
- 系统只有一个用户,用户名为user
我们需要:
- 提供多个用户,并提供一个注册页面
-对不同的请求路径,执行不同的安全规则
配置Spring Security
基础安全配置
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
}
配置用户储存
Spring Security为配置用户储存提供了多个可选方案:
- 基于内存的用户存储
- 基于JDBC的用户存储
- 以LDAP作为后端的用户存储
- 自定义用户详情服务
不管使用那种用户存储,都可以通过覆盖WebSecurityConfigureAdapter基础配置类中定义的configure()方法进行配置。首先,我们可以将如下的方法添加到SecurityConfig类中:
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
...
}
}
现在,我们需要使用指定的AuthenticationManagerBuilder替换上面的省略号。以此来定义在认证过程中如何查找用户。
基于用户内存的用户存储
用户信息可以存储在内存之中。假设我们只有数量有限的几个用户,而且这些用户几乎不会发生改变,将这些用户定义成安全配置的一部分是非常简单的。
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
/*基于内存的用户存储*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("buzz")
.password(passwordEncoder().encode("123"))
.authorities("ROLE_USER");
}
}
AuthenticationManagerBuilder使用构造者(builder)风格的接口来构建认证环节。我们调用inMemoryAuthentication方法来指定用户信息。通过withUser()方法来配置用户而密码就是password,用户权限就是authorities方法。
需要注意的是,我们需要定义一个密码编码器,能够用正确方式解析密码。
基于JDBC的用户存储
用户信息通常会在关系型数据库中进行维护,基于JDBC的用户存储方案会更加合理一些。
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.jdbcAuthentication()
.dataSource(dataSource)
.usersByUsernameQuery(
"select username,password,enabled from Users "
+ "where username = ?"
)
.passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
DataSource dataSource;
@Autowired(required = false)
public SecurityConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
在这里的configure实现中,调用了AuthenticationManagerBuilder的jdbcAuthentication方法,我们还必须设置一个DataSource,这样它才能知道如何访问数据库。这里的DataSource是通过自动装配的技巧获得的。
Spring Security内部源码,展现了当查找用户时所执行的SQL查询语句:
public static final String DEF_USERS_BY_USERNAME_QUERY =
"select username,password,enabled " +
"from users " +
"where username = ?";
public static final String DEF_AUTHORITIES_BY_USERNAME_QUERY =
"select username,authority " +
"from authorities " +
"where username = ?";
public static final String DEF_GROUP_BY_USERNAME_QUERY =
"select g.id,g.goup_name,ga.authority " +
"from groups g,goup_members gm,group_authorities ga " +
"where username = ?" +
"and g.id = ga.group_id" +
"and g.id = gm.group_id";
在第一个查询中,我们获取了用户的用户名,密码以及是否启用的信息,用来进行用户认证。接下来的查询查找了用户所授予的权限。
如果你能够在数据库中定义和填充满徐这些查询的表,那么基本上就不需要在做什么。你可能会希望在查询上获得更多的控制权,你可以自定义用户查询。
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.jdbcAuthentication()
.dataSource(dataSource)
.usersByUsernameQuery(
"select username,password,enabled from Users "
+ "where username = ?"
)
.passwordEncoder(passwordEncoder());
}
这里只写了认证的查询语句。重要的是认证和授予权限必须具备,否则无法实现。所以群组认证非必须。
将默认的SQL查询替换成自定义的设计时,很重要的一点是要遵循查询的基本协议。所有查询都将用户名作为唯一的参数。认证查询会选取用户名,密码,以及启用权限。权限查询会选取零行或多行包含该用户名及其权限信息的数据。群组权限会选取零行或多行数据,每行数据都会包含群组ID,群组名称以及权限。
为了解决明文密码储存的问题,我们需要借助以下passwordEncoder方法指定一个密码转码器:
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
passwordEncoder方法可以接受SpringSecurity中PasswordEncoder接口的任意实现:
- BCryptPasswordEncoder:使用bcrypt强哈希加密
- NoOpPasswordEncoder:不进行任何转码
- Pbkdf2PassWordEncoder:使用PBKDF2加密
- SCryptPasswordEncoder:使用scrypt哈希加密
- StandardPasswordEncoder:使用SHA-256加密
上述代码中使用了NoOpPasswordEncoder,已被弃用,仅在开发中使用。
如果内置的加密方法无法满足要求,可以提供自定义的实现,PasswordEncoder接口:
public interface PasswordEncoder {
String encoder(CharSequence rawPassword);
boolean matches(CharSequence rawPassword,String encodedPassword);
}
无论使用哪一种密码转码器,数据库中密码是永远不会解码的。
用户在登陆时所采取的策略相反,输入的密码按照相同的算法进行转码,然后与数据库中已经转码过的密码进行比较,这个对比是在passwordEncoder的matches方法中进行的。
以LDAP作为后端的用户存储
自定义用户认证
使用Spring Data repository来存储用户。在此之前我们首先要创建领域对象。
定义用户领域对象和持久化
为了捕获用户信息,我们需要创建User类:
@Data
@Entity
@NoArgsConstructor(access = AccessLevel.PRIVATE,force = true)
@RequiredArgsConstructor
public class User implements UserDetails {
private static final long serialVersionID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private final String username;
private final String password;
private final String fullName;
private final String street;
private final String city;
private final String phoneNumber;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"));
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
除了定义了一些属性之外,User类还实现了Spring Security的UsersDetails接口。
通过实现UsersDetails接口,我们能够提供更多信息给框架,比如用户被授予了哪些权限,以及用户的账号是否可用。
getAuthorities方法应该返回用户被授予权限的一个集合。各种is…Expired方法要返回一个Boolean值,表明用户的账号是否可用或过期。
User实体定义完成之后,我们就可以定义repository接口了:
public interface UserRepository extends CrudRepository<User,Long> {
User findByUsername(String username);
}
我们额外定义了一个findByUsername方法以便于再用户详情服务中用到,根据用户名查找User。
创建用户详情服务
public interface UserDetailsService extends org.springframework.security.core.userdetails.UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
记住,该接口需要继承UserDetailsService接口
正如我们所看到的,这个接口的实现会得到一个用户的用户名。
因为我们的User类实现了UserDetails接口,并且UserRepository提供了findByUsername方法,所以它们非常适合用在UserDetailsService实现中。
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private UserRepository userRepository;
@Autowired
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username);
if(user != null) {
return user;
} else {
throw new UsernameNotFoundException(
"User " + username + "not found"
);
}
}
}
我们注意到UserDetailsServiceImpl上添加了@Service。这是Spring的另外一个构造性注解,它表明这个类要包含到Spring的组件扫描当中。Spring将会自动发现它并将它初始化为一个bean。
我们需要将这个自定义的用户详情服务与Spring Security配置在一起。
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
DataSource dataSource;
UserDetailsService userDetailsService;
@Autowired(required = false)
public SecurityConfig(DataSource dataSource,UserDetailsService userDetailsService) {
this.dataSource = dataSource;
this.userDetailsService = userDetailsService;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
/*基于自定义的用户存储*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
}
用户注册控制器
@RestController
@RequestMapping("/register")
public class RegistrationController {
private UserRepository userRepository;
private PasswordEncoder passwordEncoder;
public RegistrationController(UserRepository repository, PasswordEncoder passwordEncoder) {
this.userRepository = repository;
this.passwordEncoder = passwordEncoder;
}
@PostMapping
public String processRegistration(RegistrationForm registrationForm) {
userRepository.save(registrationForm.toUser(passwordEncoder));
return "test";
}
}
@Data
public class RegistrationForm {
private final String username;
private final String password;
public User toUser(PasswordEncoder passwordEncoder) {
return new User(username,passwordEncoder.encode(password));
}
}
保护Web请求
因为默认情况下,所有请求都需要认证,但是我们应该让所有用户都能够访问注册以及登录页面。为了配置这些安全性规则,需要用到其他的configure方法。
@Override
protected void configure(HttpSecurity http) throws Exception {
...
}
configure方法接受一个HttpSecurity对象,能够用来配置Web级别该如何处理安全性。我们可以使用HttpSecurity配置的功能包括:
- 在为某个请求提供服务之前,需要预先满足特定的条件;
- 配置自定义的登录页;
- 支持用户退出应用;
- 预防跨站请求伪造
配置HttpSecurity常见的需求就是拦截请求以确保用户具备适当的权限。
我们需要确保只有认证过的用户才能发起对"/taco"和"/test"的请求,而其他请求对所有均可用:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/taco","/test")
.hasRole("ADMIN")
.antMatchers("/","/**").permitAll();
}
对authorizeRequests的调用会返回一个对象(ExpressionInterceptUrlRegistry),基于它我们可以指定URL路径和这些路径的安全需求。
需要注意的是在定义User实体类时,我们给予的权限为”ROLE_ADMIN”,因为在configure中”ADMIN“会自动加上”ROLE_“前缀。
这些规则的顺序是很重要的,声明在前面的安全规则比后面声明的规则有更高的优先级。如果我们交换这两个安全规则的顺序,那么所有的请求都会有permitAll的规则,对"taco","test"声明的规则就不会生效了。
声明请求路径的安全需求的所有可用方法
方法 | 能够做什么 |
---|---|
access(String) | 如果给定的SpEL表达式计算结果为true,就允许访问 |
anonymous() | 允许匿名用户访问 |
authenticated() | 允许认证过的用户访问 |
denyAll() | 无条件拒绝所有访问 |
fullyAuthenticated() | 如果用户是完整认证的(不是通过Remember-me功能认证的),就允许访问 |
hasAnyAuthority(String…) | 如果用户具备给定权限中的某一个,就允许访问 |
hasAnyRole(String…) | 如果用户具备给定角色中的某一个,就允许访问 |
hasAuthority(String) | 如果用户具备给定权限,就允许访问 |
hasIpAddress(String) | 如果请求来自给定IP地址,就允许访问 |
hasRole(String) | 如果用户具备给定角色,就允许访问 |
not() | 对其他访问方法的结果求反 |
permitAll() | 无条件允许访问 |
remenberMe)() | 如果用户是通过remember-me功能认证的,就允许访问 |
我们还可以使用access方法,通过为其提供SpEL表达式来声明更丰富的安全规则。Spring Security拓展了SpEL,包含了多个安全相关的值和函数
安全表达式 | 计算结果 |
---|---|
authentication | 用户的认证对象 |
denyAll | 结果始终为false |
hasAnyRole(list of roles) | 如果用户被授予了列表中任意的指定角色,结果为true |
hasRole(role) | 如果用户被授予了指定的角色,结果为true |
hasIpAddress(IP Address) | 如果请求来自指定IP,结果为true |
isAnonymous() | 如果当前用户为匿名用户,结果为true |
isAuthenticated() | 如果当前用户进行了认证,结果为true |
isFullyAuthenticated() | 如果当前用户进行了完整认证(不是通过Remember-me功能进行的认证),结果为true |
isRememberMe() | 如果当前用户是通过Remember-me自动认证的,结果为true |
permitAll | 结果始终为true |
principal | 用户的principal对象 |
下面为上面配置HttpSecurity的SpEL版:
@Overrride
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/taco","/test")
.access("hasRole('ADMIN')")
.antMatchers("/","/**").access("permitAll");
}
我们可以使用SpEL实现各种各样的安全性限制。
创建自定义的登录页
为了替换内置的登录页,我们需要告诉Spring Security自定义登录页的路径是什么。这可以通过调用传入到configure()中的HttpSecurity对象的formLogin方法啊来实现:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/taco","/test")
.hasRole("ADMIN")
.antMatchers("/","/**").permitAll()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.defaultSuccessUrl("/taco")
.and()
.logout()
.permitAll();
}
在调用formlogin之前,我们通过and方法将这一部分的配置与前面的配置连接在一起。and方法表示我们已经完成了授权相关的配置,并且要添加一些其他的HTTP配置,我们可以多次调用and方法。
默认情况下,Spring Security会在”/login“路径监听登录请求并且预期的用户名和密码输入域的名称为username和password。但这都是可配置的,举例来说,如下配置了自定义的路径以及输入域的名称:
.and()
.formLogin()
.loginPage("/login")
.loginProcessingUrl("/authenticate")
.usernameParameter("user")
.passwordParameter("pwd")
在这里,我们声明Spring Security要监听”/authenticate“的请求来处理登录信息的提交。同时用户名和密码的字段应该是user和pwd。
默认情况下,登陆成功后,用户将会被导航到Spring Security决定让用户登陆之前的页面。如果用户直接访问登录页,那么登录成功之后用户将会被导航到根路径,但是我们可以指定默认的成功页也来更改这种行为:
and()
.formLogin()
.permitAll()
.defaultSuccessUrl("/taco")
如果我们想要强制要求用户登陆之后统一访问某页面,即使用户登录之前正在访问其他页面。我们可以使用defaultSuccessUrl的第二个参数true来实现:
and()
.formLogin()
.permitAll()
.defaultSuccessUrl("/taco", true)
退出
.and()
.logout()
.permitAll()
.logoutSuccessUrl("/")
启用退出功能,这样会搭建一个安全过滤器,该过滤器会拦截对”、logout“的请求。当用户退出的时候,他们的session将会被清理。
防止跨站请求伪造
跨站请求伪造(Cross-Site Request Forgery,CSRF)是一种常见的安全攻击,它会让用户在一个恶意的Web页面填写信息,然后自动(通常是秘密的)将表单以攻击受害者的身份提交到另外一个应用上。
为了防止这种类型的攻击,应用可以在展现表单的时候生成一个CSRF token,并放到隐藏域中,然后将其临时存储起来,以便后续在服务器上使用。在提交表单的时候,token将和其他的表单数据一起发送至服务器端。请求会被服务器拦截,并与最初生成的token对比,如果token匹配,那么请求将会允许处理;否则,表单肯定是由恶意网站渲染的,因为它不知道服务器生成的token。
比较幸运的是,Spring Security提供了内置的CSRF保护。更幸运的是,默认它就是启用的,我们不需要显式配置它,我们唯一需要做的就是确保应用中的每个表单都要有一个名为 “_csrf” 字段它会持有服务器所生成的token.
我们可以禁用
.and()
.csrf()
.disable();
了解用户是谁
我们有多种方式确定用户是谁,常用的方式如下:
- 注入Principal对象到控制器方法中,
- 注入Authentication对象到控制器方法中
- 使用SecurityContextHolder来获取安全上下文
- 使用@AuthenticationPrincipal注解来标注方法
我们可以接受一个Principal参数就能够从UserRepository中查找用户:
UserRepository userRepository;
public GetPrincipalController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@GetMapping
public String Order(Principal principal) {
return userRepository.findByUsername(principal.getName()).toString();
}
但这个在安全无关的代码中参杂安全性的代码,是不推荐的。我们可以让它不再接收Principal参数,而是接受Authentication对象作为参数:
UserRepository userRepository;
public GetPrincipalController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@GetMapping
public String Order(Authentication authentication) {
User user = (User) authentication.getPrincipal();
return user.toString();
}
有了Authentication对象之后,我们就可以通过调用getPrincipal来获取principal对象,需要注意的是getPrincipal返回的是java.util.Object,所以我们需要将他转换成User。
最整洁的方案就是直接接受一个User对象,不过我们需要为其添加@AuthenticationPrincipal注解,这样它才会变成认证的principal:
@GetMapping
public String Order(@AuthenticationPrincipal User user) {
return user.toString();
}
@AuthenticationPrincipal非常好的一点就是它不需要类型转换,同时能够将安全相关的怠慢仅限于注解本身。
还有一种方法能够识别当前的认证用户是谁,但是这种方式有点麻烦,包含大量安全性相关的代码:
我们可以从安全上下文中获取一个Authentication对象:
@GetMapping
public String Order() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
User user = (User) authentication.getPrincipal();
return user.toString();
}
但是它可以使用在应用程序任何地方,不仅仅只是控制器的处理方法中,还比较适合在较低级别的代码中使用。