⒈添加Spring Boot Security Starter依赖
<dependency> <groupId>org.springframework.bootgroupId> <artifactId>spring-boot-starter-securityartifactId> dependency>
当Spring Boot的自动装配检测到Spring Secutity出现在了类路径中,将会初始化一些基本的安全配置。
此时启动应用,应用将会弹出一个HTTP Basic认证对话框并提示你进行认证,用户名为user,密码是随机生成的。它会被写入到应用的日志文件中。输入了正确的用户名和密码后,你就有权限访问应用了。
Spring Security自动装配提供了以下安全特性:
- 所有的HTTP请求路径都需要认证【这包括你的首页】
- 不需要特定的角色和权限
- 没有登录页面
- 认证过程是通过HTTP Basic认证对话框实现的
- 系统只有一个用户,用户名为user。
⒉配置Spring Security
基于XML来配置Spring Security不在本文的讨论之内,建议移步其他文章。
基于Java来配置Spring Security首先你需要继承Spring 提供的适配器
package cn.coreqi.config; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; @Configuration @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { }
此时,Spring的上下文容器中注册了我们自己的Security配置,Spring Security发生了改变,现在不再是提示HTTP Basic认证的对话框,而是会展现一个登录表单。
3.配置Spring Security用户存储
Spring Security为配置用户存储提供了多个可选解决方案,包括:
- 基于内存的用户存储
- 基于JDBC的用户存储
- 以LDAP作为后端的用户存储
- 自定义用户详情服务
不管使用哪种用户存储,都可以通过 WebSecurityConfigurerAdapter 基础配置类中定义的configure()方法来进行配置,首先我们需要重写configure()方法。
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { super.configure(auth); }
1.基于内存的用户存储
Spring Security支持将用户信心存储在内存之中,假设我们的应用只有数量有限的几个用户,而且这些用户几乎不会发生变化,在这种情况下,将这些用户定义在内存中是非常理想的实现方式。
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication() .withUser("fanqi") .password("fanqisoft") .authorities("ROLE_ADMIN","ROLE_USER") .and() .withUser("zhangsan") .password("123456") .authorities("ROLE_USER"); }
对于测试和简单的应用来讲,基于内存的用户存储是很有用的,但是这种方式不能很方便的编辑用户。如果需要新增、移除或变更用户,那么你要对代码做出必要的修改,然后重新构建和部署应用。
2.基于JDBC的用户存储
用户信息通常会在关系型数据库中进行维护,基于JDBC的用户存储方案会更加合理一些。
@Autowired private DataSource dataSource; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.jdbcAuthentication() .dataSource(dataSource); }
在这里的configure()实现中,调用了AuthenticationManagerBuilder 的 jdbcAuthentication()方法。我们还必须要设置一个DataSource,这样它才能知道如何访问数据库。
重写默认的用户查询功能
虽然短短的两句话就足以让Spring Security使用JDBC进行用户存储,但它同样也对我们的数据库模式有了一定的要求。
让我们查看一下Spring Security JDBC用户存储下默认的实现【org.springframework.security.core.userdetails.jdbc.JdbcDaoImpl】,节选部分代码、
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_AUTHORITIES_BY_USERNAME_QUERY = "select g.id, g.group_name, ga.authority from groups g, group_members gm, group_authorities ga where gm.username = ? and g.id = ga.group_id and g.id = gm.group_id";
在第一个查询中,我们获取了用户的用户名、密码以及是否启用的信息,用来进行用户认证。
在第二个查询中,我们查找了所授予用户的权限,用来进行鉴权。
在第三个查询中,我们查找了用户作为群组的成员所授予的权限。
如果你能够在数据库中定义和填充满足这些查询的表,那么基本上就不需要再做什么额外的事情了。但是,也有可能你的数据库与上述的不一致,那么你会希望在查询上有更多的控制权。
@Autowired private DataSource dataSource; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.jdbcAuthentication() .dataSource(dataSource) .usersByUsernameQuery( "select username,password,enabled from t_user where username = ?" ) .authoritiesByUsernameQuery( "select username,authority from t_authoritie where username = ?" ) .groupAuthoritiesByUsername( "select g.id, g.group_name, ga.authority from groups g, group_members gm, group_authorities ga where gm.username = ? and g.id = ga.group_id and g.id = gm.group_id" ); }
将默认的SQL查询替换为自定义的设计时,很重要的一点就是要遵循查询的基本协议。所有查询都将用户名作为唯一的参数。认证查询会选取用户名、密码以及启用状态信息。权限查询会选取0行或者多行包含该用户名及其权限信息的数据。群组权限查询会选取0行或多行数据,每行数据中都会包含群组ID、群组名称以及权限。
指定密码转码器【现在还有人用明文保存密码?】
通过passwordEncoder()方法指定一个密码转码器(encoder)
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.jdbcAuthentication() .dataSource(dataSource) .passwordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder()); }
passwordEncoder()方法可以接收Spring Security中passwordEncoder接口的任意实现。
以下为Spring Security内置的常用passwordEncoder 接口实现。
BCryptPasswordEncoder |
使用bcrypt强哈希加密 |
NoOpPasswordEncoder |
不进行任何转码 |
Pbkdf2PasswordEncoder |
使用PBKDF2加密 |
SCryptPasswordEncoder |
使用scrypt哈希加密 |
StandardPasswordEncoder |
使用SHA-256哈希加密 |
如果内置的实现无法满足需求时,你甚至可以提供自定义的实现。PasswordEncoder接口非常简单:
public interface PasswordEncoder { String encode(CharSequence rawPassword); boolean matches(CharSequence rawPassword, String encodedPassword); default boolean upgradeEncoding(String encodedPassword) { return false; } }
无论使用哪种密码转码器,重要的是要理解数据库中的密码永远不会被解码。相反,用户在登录时输入的密码使用相同的算法进行编码,然后将其与数据库中编码的密码进行比较。比较是在 PasswordEncoder 的 matches() 方法中进行的。
3.以LDAP作为后端的用户存储
LDAP(Lightweight Directory Access Protocol,轻量级目录访问协议),为了配置Spring Security使用基于LDAP认证,我们可以使用ldapAuthentication() 方法。这个方法在功能上与 jdbcAuthentication() 类似。
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.ldapAuthentication() .userSearchFilter("(uid={0})") .groupSearchFilter("member={0}"); }
userSearchFilter() 和 groupSearchFilter() 方法用来为基础 LDAP 查询提供过滤条件,它们分别用于搜索用户和组。默认情况下,对于用户和组的基础查询都是空的,也就是表明搜索会在LDAP 层次结构的根目录进行搜索。但是我们可以通过指定查询基础来改变这个默认行为:
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.ldapAuthentication() .userSearchBase("ou=people") .userSearchFilter("(uid={0})") .groupSearchBase("ou=groups") .groupSearchFilter("member={0}"); }
userSearchBase() 方法为查找用户提供了基础查询。同样,groupSearchBase() 为查找组指定了基础查询。这个示例中我们声明用户应该在名为people的组织单元下搜索而不是从根开始,而组应该在名为groups的组织单元下搜索。
配置密码对比
基于 LDAP 身份认证的默认策略是进行绑定操作,直接通过 LDAP 服务器认证用户。另一种可选的方式是进行比对操作。这涉及将输入的密码发送到 LDAP 目录上,并要求服务器将这个密码与用户的密码进行比对。因为比对是在 LDAP 服务器中进行的,所以实际的密码是保密的。
如果你希望通过密码比对进行身份验证,可以通过声明 passwordCompare() 方法来实现:
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.ldapAuthentication() .userSearchBase("ou=people") .userSearchFilter("(uid={0})") .groupSearchBase("ou=groups") .groupSearchFilter("member={0}") .passwordCompare(); }
默认情况下,在登录表单中提供的密码将与用户 LDAP 条目中的 userPassword 属性值进行比对。如果密码被保存在不同的属性中,可以使用 passwordAttribute() 方法来指定密码属性的名称:
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.ldapAuthentication() .userSearchBase("ou=people") .userSearchFilter("(uid={0})") .groupSearchBase("ou=groups") .groupSearchFilter("member={0}") .passwordCompare() .passwordEncoder(new BCryptPasswordEncoder()) .passwordAttribute("passcode"); }
在本例中,我们指定了要与给定密码进行比对的是“passcode”属性。另外,我们还可以指定密码转码器。在进行服务器端密码比对时,有一点非常好,那就是实际的密码在服务器端是私密的。但是进行尝试的密码还是需要通过网络线路传输到 LDAP 服务器上,这可能会被黑客所截获。为了防止这种情况,我们可以通过调用 passwordEncoder() 方法来指定加密策略。
在前面的示例中,密码使用 bcrypt 密码哈希函数加密,这需要 LDAP 服务器上的密码也使用 bcrypt 进行了加密。
引用远程 LDAP 服务器
到目前为止,我们忽略的一件事就是 LDAP 服务器和实际的数据在哪里?虽然已经将 Spring 配置为根据 LDAP 服务器进行身份验证,但是该服务器在哪里呢?
默认情况下,Spring Security 的 LDAP 身份验证将假设 LDAP 服务器监听本机的 33389端口。但是,如果 你的LDAP 服务器位于另一台机器上,那么可以使用 contextSource() 方法来配置这个地址:
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.ldapAuthentication() .userSearchBase("ou=people") .userSearchFilter("(uid={0})") .groupSearchBase("ou=groups") .groupSearchFilter("member={0}") .passwordCompare() .passwordEncoder(new BCryptPasswordEncoder()) .passwordAttribute("passcode") .and() .contextSource() .url("ldap://tacocloud.com:389/dc=tacocloud,dc=com"); }
contextSource() 方法会返回一个 ContextSourceBuilder对象,这个对象除了其他功能以外,还提供了 url() 方法来指定 LDAP 服务器的地址。
配置嵌入式的LDAP服务器
如果你没有 LDAP 服务器去做身份认证,Spring Security 还为我们提供了嵌入式的 LDAP 服务器。我们不再需要设置远程LDAP服务器的URL,只需通过 root() 方法指定嵌入式服务器的根前缀就可以了。
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.ldapAuthentication() .userSearchBase("ou=people") .userSearchFilter("(uid={0})") .groupSearchBase("ou=groups") .groupSearchFilter("member={0}") .passwordCompare() .passwordEncoder(new BCryptPasswordEncoder()) .passwordAttribute("passcode") .and() .contextSource() .root("dc=tacocloud,dc=com"); }
当 LDAP 服务器启动时,它会尝试从类路径下寻找 LDIF 文件来加载数据。LDIF(LDAP Data Interchange Format,LDAP 数据交换格式)是以文本文件展示 LDAP 数据的标准方式,每条记录可以有一行或多行,每行包含一个 name:value 配对信息,记录之间通过空行进行分隔。
如果你不想让 Spring 从整个类路径下寻找 LDIF 文件,那么可以通过调用 ldif() 方法来明确指定加载哪个 LDIF 文件:
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.ldapAuthentication() .userSearchBase("ou=people") .userSearchFilter("(uid={0})") .groupSearchBase("ou=groups") .groupSearchFilter("member={0}") .passwordCompare() .passwordEncoder(new BCryptPasswordEncoder()) .passwordAttribute("passcode") .and() .contextSource() .root("dc=tacocloud,dc=com") .ldif("classpath:users.ldif"); }
在这里,我们明确要求 LDAP 服务器从类路径根目录下的 users.ldif 文件中加载数据。如果你比较好奇,如下就是一个包含用户数据的LDIF 文件,我们可以使用它来加载嵌入式 LDAP 服务器:
dn: ou=groups,dc=tacocloud,dc=com objectclass: top objectclass: organizationalUnit ou: groups dn: ou=people,dc=tacocloud,dc=com objectclass: top objectclass: organizationalUnit ou: people dn: uid=buzz,ou=people,dc=tacocloud,dc=com objectclass: top objectclass: person objectclass: organizationalPerson objectclass: inetOrgPerson cn: Buzz Lightyear sn: Lightyear uid: buzz userPassword: password dn: cn=tacocloud,ou=groups,dc=tacocloud,dc=com objectclass: top objectclass: groupOfNames cn: tacocloud member: uid=buzz,ou=people,dc=tacocloud,dc=com
Spring Security 内置的用户存储非常方便,并且涵盖了最为常用的用户场景。但是,当你的 应用程序需要一些特殊的功能。当开箱即用的用户存储无法满足需求的时候,我们就需要创建和配置自定义的用户详情服务。
4.自定义用户认证
1.新建用户实体类并实现UserDetails接口
@Entity @Data @NoArgsConstructor(access=AccessLevel.PRIVATE, force=true) @RequiredArgsConstructor public class User implements UserDetails { private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy=GenerationType.AUTO) 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 state; private final String zip; 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; } }
我们的用户实体类实现了Spring Security的UserDetails接口
通过实现UserDetails 接口,我们能够提供更多信息给框架,比如用户都被授予了哪些权限以及用户的帐户是否启用。
getAuthorities() 方法应该返回用户被授予权限的一个集合。各种 is...Expired() 方法要返回一个boolean值,表明用户的帐户是否可用或过期。
2.定义了 User 实体后,现在可以定义repository接口了
public interface UserRepository extends CrudRepository{ User findByUsername(String username); }
3.创建用户详情接口【需要实现UserDetailsService接口】
@Service public class UserServiceImpl implements UserDetailsService { @Autowired private UserRepository userRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByUsername(username); if (user != null) { return user; } throw new UsernameNotFoundException("User '" + username + "' not found"); } }
4.将自定义的用户详情服务与Spring Security配置在一起。
@Qualifier("userServiceImpl") @Autowired private UserDetailsService userDetailsService; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService); }
在这里, 我们只是简单的调用userDetailsService()方法,并将自动装配到Spring Security Config中的UserDetailsService实例传递了进去即可连接到Spring Security的自定义认证服务。
像基于JDBC的认证一样,我们可以(也应该)配置一个密码转码器,这样在数据库中的密码将是转码过的。我们首先需要声明一个PasswordEncoder类型的Bean,然后通过调用passwordEncoder()方法将它注入到用户详情服务中:
@Qualifier("userServiceImpl") @Autowired private UserDetailsService userDetailsService; @Bean public PasswordEncoder encoder(){ return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService) .passwordEncoder(encoder()); }
⒊保护Web请求
当配置完上面的内容后,你会发现你的应用必须获取身份认证才可以正常访问,而我们应用中的一些页面(例如主页、登录页面及注册页面等等)并不需要身份认证便可以访问,为了配置这些安全性规则,需要介绍一下WebSecurityConfigurerAdapter 的另一个 configure() 方法:
@Override protected void configure(HttpSecurity http) throws Exception { ... }
这个 configure() 方法接受一个 HttpSecurity 对象,可以使用该对象来配置如何在 web 级别处理安全性。我们可以使用 HttpSecurity 配置的功能包括:
-
在为某个请求提供服务之前,需要预先满足特定的条件
-
配置自定义登录页面
-
支持用户退出应用
-
配置跨站请求伪造保护
配置 HttpSecurity常见的需求就是拦截请求以确保用户具有适当的权限。
1. 保护请求
我们需要确保只有经过身份认证的用户才能对“/design”和“orders”发起请求,而其他请求则对所有用户均可用。
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/design", "/orders") .hasRole("ROLE_USER") .antMatchers("/", "/**").permitAll(); }
对 authorizeRequests() 的调用会返回一个对象(ExpressionInterceptUrlRegistry),基于它我们可以指定 URL 路径和这些路径的安全需求。本示例中,我们指定了两条安全规则:
-
具备ROLE_USER 权限的用户才能访问 “/design” 和 “/orders”
-
其他的请求允许所有用户访问
这些规则的顺序是很重要的。声明在前面的安全规则比后面声明的规则有更高的优先级。如果我们交换这两个安全规则的顺序,那么所有的请求都会有permitAll()的规则,对“ /design ”和 “/orders ”声明的规则就不会生效 了。
在声明请求路径的安全需求时,hasRole() 和 permitAll() 方法只是众多方法中的两个。下表中列出了所有可用的方法。
方法 | 能够做什么 |
access(String) |
如果给定的 SpEL 表达式计算结果为 true,就允许访问 |
anonymous() | 允许匿名用户访问 |
authenticated() | 允许认证过的用户访问 |
denyAll() | 无条件拒绝所有访问 |
fullyAuthenticated() | 如果用户是完整认证的(不是通过Remember-me功能认证的), 则允许访问 |
hasAnyAuthority(String...) | 如果用户具备给定权限中的某一个,则允许访问 |
hasAnyRole(String...) |
如果用户具备给定角色中的某一个,则允许访问 |
hasAuthority(String) |
如果用户具备给定权限,则允许访问 |
hasIpAddress(String) |
如果请求来自给定 IP ,则允许访问 |
hasRole(String) |
如果用户具备给定角色,则允许访问 |
not() |
拒绝任何其他访问方法【对其他访问方法的结果求反】 |
permitAll() |
无条件允许访问 |
rememberMe() | 如果用户是通过Remember-me功能认证的,则允许访问 |
上表中的大多数方法为请求处理提供了基本的安全规则,但是它们是自我限制的,也就是只能支持由这些方法所定义的安全规则。除此之外,我们还可以使用 access() 方法,通过为其提供 SpEL 表达式来声明更丰富的安全规则。Spring Security 扩展了 SpEL,包含了多个安全相关的值和函数,如下表所示。
安全表达式 | 计算结果 |
authentication |
用户的认证对象 |
denyAll | 结果始终为false【通常值为 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【通常值为 true】 |
principal |
用户的 pricipal 对象 |
我们可以看到,上表中的大多数安全表达式扩展都对应于上上表中的类似方法。实际上,借助 access() 方法和 hasRole() 、 permitAll 表达式,我们可以按如下方式重写 configure()。
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/design", "/orders") .access("hasRole('ROLE_USER')") .antMatchers("/","/**").access("permitAll"); }
看上去,这似乎没什么大不了的。毕竟,这些表达式只是模拟了我们之前通过方法调用已经完成的事情。但是表达式可以更加灵活。例如,假设(基于某种疯狂的原因)我们只允许具有 ROLE_USER 权限的用户在周二创建新的用户;你可以重写表达式如下:
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/design", "/orders") .access("hasRole('ROLE_USER') && " + "T(java.util.Calendar).getInstance().get("+ "T(java.util.Calendar).DAY_OF_WEEK) == " + "T(java.util.Calendar).TUESDAY") .antMatchers("/", "/**").access("permitAll"); }
我们可以使用SpEL实现各种各样的安全性限制。
2.创建自定义的登录页
为了替换Spring Security内置的登录页面,首先需要告诉 Spring Security 自定义登录页面的路径。这可以通过调用传入到 configure() 的 HttpSecurity 对象的 formLogin() 方法来实现:
@Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/design", "/orders") .access("hasRole('ROLE_USER')") .antMatchers("/", "/**").access("permitAll") .and() .formLogin() .loginPage("/login"); }
请注意,在调用formLogin()之前,我们通过and()方法将这一部分的配置与前面的配置连接在一起。and()方法表示我们已经完成了授权相关的配置,并且要添加一些其他的HTTP配置。在开始新的配置区域时,我们可以多次调用and()。
在这个连接之后,我们调用formLogin()开始配置自定义的登录表单。在此之后,对loginPage()的调用声明了我们提供的自定义登录页面的路径。当Spring Security断定用户没有认证并且需要登录的时候,它就会将用户重定向到该路径。
默认情况下,Spring Security会在“/login”路径监听登录请求并获取用户名为username和密码为password的请求参数,但这些都是可配置的。举例来说,如下的配置自定义了路径和请求参数的名称:
@Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/design", "/orders") .access("hasRole('ROLE_USER')") .antMatchers("/", "/**").access("permitAll") .and() .formLogin() .loginPage("/login") .loginProcessingUrl("/authenticate") .usernameParameter("user") .passwordParameter("pwd"); }
在这里,我们指定 Spring Security 应该监听对 /authenticate 的请求以处理登录信息的提交。此外,用户名和密码字段已被指定为 user 和 pwd。
默认情况下,登录成功之后,用户将会被导航到Spring Security决定让用户登录之前的页面。如果用户直接访问登录页,那么登录成功之后用户将会被导航至根目录(例如,主页)。但是,我们可以通过指定默认的成功页来更改这种行为:
@Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/design", "/orders") .access("hasRole('ROLE_USER')") .antMatchers("/", "/**").access("permitAll") .and() .formLogin() .loginPage("/login") .defaultSuccessUrl("/design"); }
按照这个配置,用户直接导航至登录页并且成功登录之后将会被定向到"/design"页面。
另外,我们还可以强制用户在登录成功之后统一访问设计页面,即便用户在登陆之前正在访问其他页面,在登录之后也会被定向到设计页面,这可以通过为defaultSuccessUrl方法传递第二个参数true来实现:
@Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/design", "/orders") .access("hasRole('ROLE_USER')") .antMatchers("/", "/**").access("permitAll") .and() .formLogin() .loginPage("/login") .defaultSuccessUrl("/design", true); }
3.退出登录
退出和登录是同等重要的,为了启用退出功能,我们只需在HttpSecurity对象上调用logout方法:
@Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/design", "/orders") .access("hasRole('ROLE_USER')") .antMatchers("/", "/**").access("permitAll") .and() .formLogin() .loginPage("/login") .defaultSuccessUrl("/design", true) .and() .logout() .logoutSuccessUrl("/"); }
这样会搭建一个安全过滤器,该过滤器会拦截对“/logout”的请求。所以,为了提供退出功能,我们需要为应用的视图添加一个退出表单和按钮。
<form method="POST" th:action="@{/logout}"> <input type="submit" value="Logout"/> form>
当用户点击按钮的时候,他们的session将会被清理,这样他们就退出应用了。默认情况下,用户会被重定向到登录页面,这样他们可以重新登录。但是,如果你想要将他们导航到不同的页面,那么可以调用logoutSuccessUrl()指定退出后的不同页面
4.防止跨站请求伪造
跨站请求伪造(Cross-Site Request Forgery,CSRF)是一种常见的安全攻击。它会让用户在一个恶意设计的 web 页面上填写信息,这个页面会自动(通常是秘密地)将表单以攻击受害者的身份提交到另外一个应用上。例如,用户看到一个来自攻击者的Web站点的表单,这个站点会自动将数据POST到用户银行Web站点的URL上(这个站点可能设计的很糟糕,无法防御这种类型的攻击),实现转账的操作。用户可能根本不知道发生了攻击,直到他们发现账号上的钱已经不翼而飞。
为了防止这种类型的攻击,应用可以在展现表单的时候生成一个 CSRF token,并将该 token 放在隐藏域中,然后将其临时存储起来,以便后续在服务器上使用。在提交表单的时候,token 将和表单的其他数据一起发送至服务器。然后服务器拦截请求,并将其与最初生成的 token 进行对比。如果 token 匹配,则允许继续执行请求。否则,表单肯定是由恶意网站渲染的,因为它不知道服务器所生成的token。
幸运的是,Spring Security 有内置的 CSRF 保护。更幸运的是,它是默认启用的,不需要显式地配置它。我们唯一需要做的就是确保应用中的每个表单都要有一个名为 _csrf 的字段,该字段包含 CSRF token。
Spring Security 甚至进一步简化了将token放到请求的 “_csrf ”属性中这一任务。在 Thymeleaf 模板中,我们可以按照如下的方式在隐藏域中渲染 CSRF token:
<input type="hidden" name="_csrf" th:value="${_csrf.token}"/>
如果你使用 Spring MVC 的 JSP 标签库或者Spring Security的 Thymeleaf 方言,那么甚至都不用明确包含这个隐藏域(这个隐藏域会自动生成)
在 Thymeleaf 中,只需确保
<form method="POST" th:action="@{/login}" id="loginForm">
我们也可以禁用 CSRF 支持,CSRF 防护很重要,而且在表单中很容易实现,所以没有理由禁用它,但如果你坚持禁用它,那么可以通过调用 disable()来实现:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/design", "/orders")
.access("hasRole('ROLE_USER')")
.antMatchers("/", "/**").access("permitAll")
.and()
.formLogin()
.loginPage("/login")
.defaultSuccessUrl("/design", true)
.and()
.logout()
.logoutSuccessUrl("/")
.and()
.csrf()
.disable();
}
再次强调,不要禁用 CSRF 保护,特别是对于生产环境中的应用程序。
⒋获取系统中已登录的用户信息
通常,仅仅知道用户已登录是不够的,我们一般还需要知道他们是谁,这样才能优化体验。
我们有多种方式确定用户是谁,常用的方式如下:
- 注入Principal对象到控制器方法中
- 注入Authentication对象到控制器方法中
- 使用@AuthenticationPrincipal注解来标注方法
- 使用SecurityContextHolder来获取安全上下文
1.注入Principal对象到控制器方法中
@PostMapping public User getLoginUser(Principal principal){ User user = userRepository.findByUsername(principal.getName()); return user; }
这种方式能够正常运行,但是它会在与安全无关的功能中掺杂安全性的代码,我们可以修改这个方法,让它不再接受Principal参数,而是接受Authentication对象作为参数:
2.注入Authentication对象到控制器方法中
@PostMapping public User getLoginUser(Authentication authentication){ User user = null; if(authentication.getPrincipal() instanceof User){ user = (User) authentication.getPrincipal(); } return user; }
有了Authentication对象之后,我们就可以调用getPrincipal()来获取principal对象,在本例中,也就是一个User对象。需要注意,getPrincipal()返回的是java.util.Object,所以我们需要将其转换成User。
3.使用@AuthenticationPrincipal注解来标注方法
最整洁的方案可能是在该方法中直接接受一个User对象,不过我们需要为其添加@AuthenticationPrincipal注解,这样它才会变成认证的principal。
@PostMapping public User getLoginUser(@AuthenticationPrincipal User user){ return user; }
@AuthenticationPrincipal非常好的一点在于它不需要类型转换(上面的Authentication则需要进行类型转换),同时能够将安全相关的代码仅仅局限于注解本身。
4.使用SecurityContextHolder来获取安全上下文
还有另外一种方式能够识别当前认证用户是谁,但是这种方式有点麻烦,它会包含大量安全性相关的代码。我们可以从安全上下文中获取一个Authentication对象,然后像下面这样获取它的principal:
@PostMapping public User getLoginUser(){ Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); User user = (User)authentication.getPrincipal(); return user; }
尽管这个代码片段充满了安全性相关的代码,但是它与前面的其他方法相比有一个优势:它可以在应用的任何地方使用,而不仅仅是在控制器的处理器方法中,这使得它非常适合在较低级别的代码中使用。