Spring Security应用详解

一、Spring Security集成SpringBoot

Spring Boot提供spring-boot-starter-security用于开发Spring Security应用。

1.1 Spring容器配置

Spring Boot工程启动会自动扫描启动类所在包下的所有Bean,加载到Spring容器。

  1. Spring Boot配置文件application.yml
server:
  port: 8080
  servlet:
    context-path: /security-springboot

spring:
  application:
    name: security-springboot
  mvc:
    view:
      prefix: /WEB-INF/view/
      suffix: .jsp
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql:///oauth2?useUnicode=true&characterEncoding=utf8
    username: root
    password: root

1.2 Servlet Context配置

由于Spring Boot Starter自动装配机制,这里无需使用@EnableWebMvc@ComponentScan注解,WebConfig如下:

/**
 * 相当于springmvc.xml配置文件
 * 不需要使用自定义的拦截器,spring security已经做好了拦截处理
 */
@Configuration
public class WebConfig implements WebMvcConfigurer {

    /**
     * 默认URL根路径跳转到/login,此URL为spring security提供
     * @param registry
     */
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        // 使用spring security提供的login页面
        // registry.addViewController("/").setViewName("redirect:/login");
        registry.addViewController("/").setViewName("redirect:/login-view");
        registry.addViewController("/login-view").setViewName("login");
    }

}

1.3 安全配置

由于Spring Boot Starter自动装配机制,这里无需使用@EnableWebSecurity注解,WebConfig如下:

/**
 * @author pengjinsen
 * @EnableGlobalMethodSecurity(securedEnabled = true) 启用基于注解的安全性,可以使用@Secured注解
 */
@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 定义用户信息服务(查询用户信息)
     * @return
     */
    /*@Bean
    @Override
    protected UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("zhangsan").password("123").authorities("p1", "p3", "p4").build());
        manager.createUser(User.withUsername("lisi").password("456").authorities("p2").build());
        return manager;
    }*/


    /**
     * 密码编码器(采用什么方式比对)
     * @return
     */
    /*@Bean
    public PasswordEncoder passwordEncoder() {
        // 不需要对密码进行加密算法的简单比较器
        return NoOpPasswordEncoder.getInstance();
    }*/

    @Bean
    public PasswordEncoder passwordEncoder() {
        // 不需要对密码进行加密算法的简单比较器
        return new BCryptPasswordEncoder();
    }

    /**
     * 安全拦截机制(最重要)
     * @param http
     * @throws Exception
     */
    /*@Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/r/r1").hasAnyAuthority("p1") // 如果没有权限就是403-forbidden
                .antMatchers("/r/r2").hasAnyAuthority("p2")
                .antMatchers("/r/**").authenticated() // 拦截/r/**
                .anyRequest().permitAll() // 其他的放行
                .and()
                .formLogin() // 允许表单登录
                .successForwardUrl("/login-success"); // 自定义登录成功的页面地址
    }*/

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 屏蔽CSRF控制,即spring security不再限制CSRF(跨站请求伪造)
        http.csrf().disable()
                .authorizeRequests()
                // .antMatchers("/r/r1").hasAnyAuthority("p1") // 如果没有权限就是403-forbidden
                // .antMatchers("/r/r2").hasAnyAuthority("p2")
                .antMatchers("/r/**").authenticated() // 拦截/r/**
                .anyRequest().permitAll() // 其他的放行,不能放在前面,否则就全部都可以访问了
                .and()
                .formLogin() // 允许表单登录
                .loginPage("/login-view") // 登录页面
                .loginProcessingUrl("/login")
                .successForwardUrl("/login-success")
                // .permitAll(); // 自定义登录成功的页面地址
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 如果需要就创建一个session(默认)登录时
                .and()
                .logout()
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login-view?logout"); // 退出成功后重定向到登录页面

    }
}

1.4 测试

@RestController
public class LoginController {

    @PostMapping(value = "login-success", produces = {"text/plain;charset=UTF-8"})
    public String loginSuccess() {
        // 提示具体的用户名称登录成功
        String username = getUsername();
        return username + " 登录成功";
    }

    /**
     * 访问资源1
     * @return
     */
    @GetMapping(value = "/r/r1", produces = {"text/plain;charset=UTF-8"})
    @PreAuthorize("hasAuthority('p1')") // 拥有p1权限才可以访问
    // @PreAuthorize("hasAnyAuthority('p1', 'p3')") // 拥有p1或者p3任何一个都可以
    public String r1() {
        String username = getUsername();
        return username + " 访问资源1";
    }

    /**
     * 访问资源2
     * @return
     */
    @GetMapping(value = "/r/r2", produces = {"text/plain;charset=UTF-8"})
    @PreAuthorize("hasAuthority('p2')") // 拥有p2权限才可以访问
    public String r2() {
        String username = getUsername();
        return username + " 访问资源2";
    }

    /**
     * 获取当前用户信息
     * @return
     */
    private String getUsername() {
        String username = null;
        // 当前认证通过的用户身份信息
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (!authentication.isAuthenticated()) {
            username = "匿名";
            // return null;
        }
        // 用户身份
        Object principal = authentication.getPrincipal();
        if (principal instanceof UserDetails) {
            username = ((UserDetails) principal).getUsername();
        } else {
            username = principal.toString();
        }
        return username;
    }

}
登录测试

二、Spring Security工作原理

2.1 结构总览

Spring Security所解决的问题就是安全访问控制,而安全访问控制功能其实对所有进入系统的请求进行拦截,校验每个请求是否能够访问他所期望的资源。可以通过Filter或者AOP等技术实现,Spring Security对Web资源的保护就是靠Filter实现的,所以从这个Filter来入手,逐步深入Spring Security原理。
当初始化Spring Security时,会创建一个名为SpringSecurityFilterChain的Servlet过滤器,类型为org.springframework.security.web.FilterChainProxy,它实现了javax.servlet.Filter,因此外部的请求都会经过此类,下图是Spring Security过滤器链结构图:

Spring Security过滤器链结构图

FilterChainProxy是一个代理,真正起作用的是FilterChainProxy中的SecurityFilterChain所包含的各个Filter,同时这些Filter作为哦Bean被Spring管理,它是Spring Security核心,各有各的职责,但他们并不直接处理用户的认证,也不直接处理用户的授权,而是把它们交给了认证管理器(AuthenticationManager)和决策管理(AccessDecisionManager)进行处理,下图是FilterChainProxy相关类的UML图示。

FilterChainProxy UML

Spring Security功能的实现主要是由一系列过滤器链相互配合完成。

Spring Security过滤器链

下面介绍过滤器链中主要的几个过滤器及其作用:
SecurityContextPersistenceFilter这个Filter是整个拦截过程的入口和出口(也就是第一个和最后一个拦截器),会在请求开始时从配置好的SecurityContextRepository中获取SecurityContext,然后把它设置给SecurityContextHolder。在请求完成后将SecurityContextHolder持有的SecurityContext在保存到配置好的SecurityContextRepository,同时清除SecurityContextHolder所持有的SecurityContext
UsernamePasswordAuthenticationFilter用于处理劳资表单提交的认证。该表单必须提供对应的用户名和密码,其内部还有登录成功或失败后进行处理的AuthenticationSuccessHandlerAuthenticationFailureHandler,这些都可以根据需求做相关改变;
FilterSecurityInterceptor是用于保护web资源的,使用AccessDecisionManager,对当前用户进行授权访问;
ExceptionTranslationFilter能够捕获来自FilterChain所有的异常,并进行处理。但是它只会处理两类异常:AuthenticationExceptionAccessDeniedException,其他的异常他会继续抛出。

2.2 认证流程

认证流程

认证流程

认证过程

  1. 用户提交用户名、密码被SecurityFilterChain中的UsernamePasswordAuthenticationFilter过滤器获取到,封装为请求Authentication,通常情况下是UsernamePasswordAuthenticationToken这个实现类。
  2. 然后过滤器Authentication提交至认证管理器AuthenticationManager进行认证。
  3. 认证成功后,AuthenticationManager身份管理器返回一个被填满了信息的(包括上面提到的权限信息、身份信息、细节信息,单密码通常会被移除)Authentication实例。
  4. SecurityContextHolder安全上下文容器将第3步填满了信息的Authentication,通过SecurityContextHolder.getContext().setAuthentication(...),设置到其中。

可以看出AuthenticationManager接口(认证管理器)是认证相关的核心接口,也是发起认证的出发点,它的实现类为ProviderManager。而Spring Security支持多种认证方式,因此ProviderManager维护者一个List<AuthenticationProvider>列表,存放多种认证方式,最终实际的认证工作是由AuthenticationProvider完成的。web表单对应的AuthenticationProvider实现类为DaoAuthenticationProvider,它的内部又维护着一个UserDetailsService负责UserDetails的获取。最终AuthenticationProviderUserDetails填充至Authentication

2.3 AuthenticationProvider、UserDetailService

2.3.1 AuthenticationProvider

由前面的认证流程得知,认证管理器(AuthenticationManager)委托AuthenticationProvider完成认证工作。
AuthenticationProvider是一个接口,定义如下:

public interface AuthenticationProvider {
    Authentication authenticate(Authentication authentication) throws AuthenticationException;
    boolean supports(Class<?> authentication);
}

authenticate()方法定义了认证的实现过程,它的参数是一个Authentication,里面包含了登录用户所提交的用户名、密码等。而返回值也是一个Authentication,这个Authentication则是在认证成功后,将用户的权限及其他信息重新组装后生成的。

Spring Security中维护着一个List<AuthenticationProvider>列表,存放多种认证方式,不同的认证方式使用不同的AuthenticationProvider。如果使用用户名、密码登录时,使用AuthenticationProvider1,短信登录时使用AuthenticationProvider2等等。

每个AuthenticationProvider需要实现supports()方法来表明自己支持的认证方式,如果我们使用表单方式认证,在提交请求时Spring Security会生成UsernamePasswordAuthenticationToken,它是一个Authentication,里面封装着用户提交的用户名、密码信息。而对应的,哪个AuthenticationProvider来处理它?我们在DaoAuthenticationProvider的基类AbstractUserDetailsAuthenticationProvider中发现如下代码:

public boolean supports(Class<?> authentication) {
    return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}

也就是说,当问表单提交用户名密码时,Spring Security由DaoAuthenticationProvider处理
最后,来看一下Authentication(认证信息)的结构,它是一个接口,之前提到的UsernamePasswordAuthenticationToken就是他的实现类之一:

public interface Authentication extends Principal, Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();
    Object getCredentials();
    Object getDetails();
    Object getPrincipal();
    void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
  1. Authentication是Spring Security包中的接口,直接继承自Principal类,而Principal是位于java.security包中的。它表示着一个抽象主体身份,任何主体都有一个名称,因此包含一个getName()方法。
  2. getCredentials(),权限信息列表,默认是GrantedAuthority接口的一个实现类,通常是代表权限信息的一系列字符串。
  3. getCredentials(),凭证信息,用户输入的密码字符串,在认证后通常会被移除,用于保障安全。
  4. getDetails(),细节信息,web莹莹中的实现接口通常为WebAuthenticationDetails,它记录了访问者的ip地址和session的值。
  5. getPrincipal(),身份信息,大部分情况下返回的是UserDetails接口的实现类,UserDetails代表用户的详细信息,那从Authentication中取出来的UserDetails就是当前登录用户信息,它是框架中常用的接口之一。

2.3.1 UserDetailService

现在已经知道DaoAuthenticationProvider处理了web表单的认证逻辑,认证成功后即得到一个Authentication(UsernamePasswordAuthenticationToken实现),里面包含了身份信息(Principal)。这个身份信息就是一个Object,大多数情况下它是被强转为UserDetails对象。
DaoAuthenticationProvider中包含了一个UserDetailsService实例,他负责根据用户名提取用户信息UserDetails(包含密码),而后DaoAuthenticationProvider会去对比UserDetailsService提取的用户密码与用户提交的密码是否匹配作为认证成功的关键依据,因此可以通过自定义的UserDetailsService公开为Spring Bean来自定义身份验证。

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

很多人容易把DaoAuthenticationProviderUserDetailsService的职责搞混淆,其实UserDetailsService只负责从特定的地方(通常是数据库)加载用户信息,仅此而已。而DaoAuthenticationProvider的职责更大,它负责完成完整的认证流程,同时会把UserDetails填充至Authentication中。

UserDetails

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();
    String getPassword();
    String getUsername();
    boolean isAccountNonExpired();
    boolean isAccountNonLocked();
    boolean isCredentialsNonExpired();
    boolean isEnabled();
}

它和Authentication接口很类似,比如他们都拥有username、authorities,AuthenticationgetCredentials()UserDetails中的getPassword()需要区分对待,前者是用户提交的密码凭证,后者是用户实际存储的密码,认证其实就是对这两者的对比。Authentication中的getAuthorities()实际是由UserDetailsgetAuthorities()传递而形成的。还记得Authentication接口中的getDetails()方法吗?其中的UserDetails用户详细信息就是经过了AuthenticationProvider认证之后被填充的。
通过实现UserDetailsService和UserDetails,我们可以完成对用户信息获取方式以及用户信息字段的扩展。
Spring Security提供的InMemoryUserDetailsManager(内存认证),JdbcUserDetailsManager(jdbc认证)就是UserDetailsService的实现类,主要区别无非就是从内存还是数据库加载用户。

自定义UserDetailsService

@Service
public class SpringDataUserDetailsService implements UserDetailsService {

    /**
     * 根据账号查询用户信息
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 将来连接数据库根据账号查询用户信息
        // 登录账号
        System.out.println("username=" + username);
        // 暂时采用模拟的方式,构造一条存在数据库中的用户信息
        UserDetails userDetails = User.withUsername("zhangsan").password("123").authorities("p1").build();
        // BCryptPasswordEncoder
        // UserDetails userDetails = User.withUsername("zhangsan").password("$2a$10$NFSdBLI92t4iwGXkbsQ6GOgblxVV8Blj24Wt5LcQ9HWV8gj.eoEgO").authorities("p1").build();
        return userDetails;
    }
}

2.4 PasswordEncoder

2.4.1 认识PasswordEncoder

DaoAuthenticationProvider认证处理器通过UserDetailsService获取到UserDetails后,它是如何与请求Authentication中密码做对比呢?
Spring Security为了适应多种多样的加密类型,又做了抽象,DaoAuthenticationProvider通过PasswordEncoder接口的matches方法进行密码对比,而具体的密码对比细节取决于实现:

public interface PasswordEncoder {

    String encode(CharSequence rawPassword);

    boolean matches(CharSequence rawPassword, String encodedPassword);

    default boolean upgradeEncoding(String encodedPassword) {
        return false;
    }

而Spring Security提供很多内置的PasswordEncoder,能够开箱即用,使用某种PasswordEncoder只需要进行如下声明即可:

    /**
     * 密码编码器(采用什么方式比对)
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        // 不需要对密码进行加密算法的简单比较器
        return NoOpPasswordEncoder.getInstance();
    }

NoOpPasswordEncoder采用的是字符串匹配方式,不对密码进行加密比较处理,密码比较流程如下:

  1. 用户输入明文密码
  2. DaoAuthenticationProvider获取UserDetails(其中存储了用户的正确密码)
  3. DaoAuthenticationProvider使用PasswordEncoder对输入的密码和正确的密码进行校验,密码一致则校验通过,否则校验失败。

NoOpPasswordEncoder的校验规则是拿输入的密码和UserDetails中的正确密码进行字符串比较,字符串内容一致则校验通过,否则校验失败。

NoOpPasswordEncoder密码校验

实际项目中推荐使用BCryptPasswordEncoderPbkdf2PasswordEncoderSCryptPasswordEncoder等,感兴趣的可以看看他们具体的实现。

2.4.2 使用BCryptPasswordEncoder

  1. 配置BCryptPasswordEncoder
    在安全配置类中定义:
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
  1. 测试BCryptPasswordEncoder
@RunWith(SpringRunner.class)
@SpringBootTest
public class SecuritySpringBootApplicationTests {

    @Test
    public void contextLoads() {
    }

    @Test
    public void testBCrypt() {
        // 对原始密码加密
        // BCrypt.gensalt(): 系统自动生成的salt,也可以自己随便写一个,只要加密和解密一致即可
        String password = BCrypt.hashpw("secret", BCrypt.gensalt());
        // $2a$10$NFSdBLI92t4iwGXkbsQ6GOgblxVV8Blj24Wt5LcQ9HWV8gj.eoEgO
        // $2a$10$/Y86dyo8rSeC3L/2ZcwvoefTv7Hq4VzvF8wvXwTFQMCffsHw925o.
        // 每次都不一样,主要是BCrypt.gensalt()每次都不一样,每次校验都可以的
        System.out.println("password=============================== " + password);

        // 解密
        boolean checkpw0 = BCrypt.checkpw("secret", "$2a$10$mEJoLn1heHpt33FwIKrAzOZtmm9ceeBpjkR8M1096Cdmr0HnygWsi");
        boolean checkpw1 = BCrypt.checkpw("123", "$2a$10$NFSdBLI92t4iwGXkbsQ6GOgblxVV8Blj24Wt5LcQ9HWV8gj.eoEgO");
        boolean checkpw2 = BCrypt.checkpw("123", "$2a$10$/Y86dyo8rSeC3L/2ZcwvoefTv7Hq4VzvF8wvXwTFQMCffsHw925o.");
        System.out.println(checkpw0);
        System.out.println(checkpw1);
        System.out.println(checkpw2);
    }

}
测试BCryptPasswordEncoder

2.5 授权流程

Spring Security可以通过http.authorizeRequests()对web请求进行授权保护。Spring Security使用标准Filter建立了对web请求的拦截,最终实现对资源的授权访问,Spring Security的授权流程如下:

Spring Security授权流程

2.5.1 授权流程

  1. 拦截请求,已认证用户访问受保护的web资源将被SecurityFilterChain中的FilterSecurityInterceptor的子类拦截。
  2. 获取资源访问策略,FilterSecurityInterceptor会从SecurityMetadataSource的子类DefaultFilterInvocationSecurityMetadataSource获取要访问当前资源所需要的权限Collection<ConfigAttribute>
    SecurityMetadataSource其实就是读取访问策略的抽象,而读取的内容其实就是我们配置的访问规则:
    /**
     * 安全拦截机制(最重要)
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/r/r1").hasAnyAuthority("p1") // 如果没有权限就是403-forbidden
                .antMatchers("/r/r2").hasAnyAuthority("p2")
                .antMatchers("/r/**").authenticated() // 拦截/r/**
                .anyRequest().permitAll() // 其他的放行
                .and()
                .formLogin() // 允许表单登录
                .successForwardUrl("/login-success"); // 自定义登录成功的页面地址
    }
  1. 最后,FilterSecurityInterceptor会调用AccessDecisionManager进行授权决策,若决策通过,则允许访问资源,否则将禁止访问。
public interface AccessDecisionManager {
    /*
     * 通过传递来的参数来决定用户是否有访问对应受保护资源的权限
     * @param authentication: 要访问资源的访问者的身份
     * @param object: 要访问的受保护资源对象,web请求时对应FilterInvocation
     * @param configAttributes: 受保护资源的访问策略,通过SecurityMetadataSource获取
     */
    void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException;
}

decide接口就是用来鉴定当前用户是否有访问对应受保护资源的权限,decide方法的参数:

  • authentication: 要访问资源的访问者的身份。
  • object: 要访问的受保护资源对象,web请求时对应FilterInvocation
  • configAttributes: 受保护资源的访问策略,通过SecurityMetadataSource获取。

2.5.2 授权决策

AccessDecisionManager采用投票的方式来确定是否能够访问受保护的资源。

AccessDecisionManager UML

通过上图可知,AccessDecisionManager中包含的一系列AccessDecisionVoter将会被用来对Authentication是否有权访问受保护对象进行投票,AccessDecisionManager根据投票结果,做出最终决策。
AccessDecisionVoter是一个接口,定义了三个方法,具体结构如下:

public interface AccessDecisionVoter<S> {
    int ACCESS_GRANTED = 1;
    int ACCESS_ABSTAIN = 0;
    int ACCESS_DENIED = -1;
    boolean supports(ConfigAttribute attribute);
    boolean supports(Class<?> clazz);
    int vote(Authentication authentication, S object, Collection<ConfigAttribute> attributes);
}

vote()方法的返回结果是AccessDecisionVoter中定义的三个常量之一,ACCESS_GRANTED表示同意,ACCESS_ABSTAIN表示弃权,ACCESS_DENIED表示拒绝。如果AccessDecisionVoter不能判断当前Authentication是否拥有访问受保护资源的权限,则其vote()方法的返回值应当弃权ACCESS_ABSTAIN

Spring Security内置了三个基于投票的AccessDecisionManager的实现类,分别是AffirmativeBasedConsensusBasedUnanimousBased

AffirmativeBased逻辑

  1. 只要有AccessDecisionVoter的投票为ACCESS_GRANTED则同意用户进行访问;
  2. 如果全部弃权也表示通过;
  3. 如果没有一个人投赞成票,但是有人投反对票,则将抛出AccessDeniedException异常;

ConsensusBased逻辑

  1. 如果赞成票多余反对票则表示通过;
  2. 反过来,如果反对票多于赞成票则将抛出AccessDeniedException异常;
  3. 如果赞成票与反对票相等且不等于0,并且属性allowIfEqualGrantedDeniedDecisionstrue,则表示通过,否则抛出AccessDeniedException异常。参数allowIfEqualGrantedDeniedDecisions默认值为true
  4. 如果所有的AccessDecisionVoter都弃权了,则将视参数allowIfAllAbstainDecisions的值而定,如果为true则表示通过,否则将抛出AccessDeniedException异常,参数allowIfAllAbstainDecisions默认值为false

UnanimousBased逻辑
UnanimousBased的逻辑与另外两个有点不一样,另外两个会一次性把受保护对象的配置属性全部传递给AccessDecisionVoter进行投票,而UnanimousBased一次只传递一个ConfigAttributeAccessDecisionVoter进行投票。这也就意味着我们的AccessDecisionVoter的逻辑是只要传递进来的ConfigAttribute中有一个能够匹配则投赞成票,但是放到UnanimousBased中其投票结果就不一定是赞成了。具体逻辑如下:

  1. 如果受保护对象配置的某一个ConfigAttribute被任意的AccessDecisionVoter反对了,则将抛出AccessDeniedException异常;
  2. 如果有反对票,但是没有赞成票,则表示通过;
  3. 如果全部弃权了,则将视参数allowIfAllAbstainDecisions的值而定,true则通过,false则抛出异常AccessDeniedException

Spring Security也内置了一些投票者实现类,如RoleVoterAuthenticatedVoterWebExpressionVoter等,可以自行阅读源码学习。

三、自定义认证

Spring Security提供了非常好的认证扩展方法,比如:将用户信息存储到内存中,实际开发中用户信息通常在数据库,Spring Security可以从数据库读取用户信息,Spring Security还支持多种授权方法。

3.1 自定义登录页面

Spring Security快速上手 中,可能会想知道登录页面是从哪里来的?因为我们并没有提供任何的HTML或者JSP文件。Spring Security的默认配置没有明确设定一个登录页面的URL,因此Spring Security会根据启用的功能自动生成一个登录页面URL,并使用默认URL处理登录的提交内容,登录后跳转到默认的URL等等。尽管自动生成的登录页面方便快速启动和运行,但大多数应用程序都希望定义自己的登录页面。

3.1.1 认证登录页面

认证页面
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>用户登录</title>
</head>
<body>
    <form action="login" method="post">
<%--        <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}">--%>
        用户名:<input type="text" name="username"><br>
        密&nbsp;&nbsp;&nbsp;码:<input type="password" name="password"><br>
        <input type="submit" value="登录">
    </form>
</body>
</html>

3.1.2 配置认证登录页面

WebConfig中配置认证登录页面:

/**
 * 相当于springmvc.xml配置文件
 * 不需要使用自定义的拦截器,spring security已经做好了拦截处理
 */
@Configuration
public class WebConfig implements WebMvcConfigurer {

    /**
     * 默认URL根路径跳转到/login,此URL为spring security提供
     * @param registry
     */
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        // 使用spring security提供的login页面
        // registry.addViewController("/").setViewName("redirect:/login");
        registry.addViewController("/").setViewName("redirect:/login-view");
        registry.addViewController("/login-view").setViewName("login");
    }

}

3.1.3 安全配置

WebSecurityConfig中配置表单登录信息:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 屏蔽CSRF控制,即spring security不再限制CSRF(跨站请求伪造)
        http.csrf().disable()
                .authorizeRequests()
                // .antMatchers("/r/r1").hasAnyAuthority("p1") // 如果没有权限就是403-forbidden
                // .antMatchers("/r/r2").hasAnyAuthority("p2")
                .antMatchers("/r/**").authenticated() // 拦截/r/**
                .anyRequest().permitAll() // 其他的放行,不能放在前面,否则就全部都可以访问了
                .and()
                .formLogin() // 允许表单登录
                .loginPage("/login-view") // 登录页面
                .loginProcessingUrl("/login")
                .successForwardUrl("/login-success")
                // .permitAll(); // 自定义登录成功的页面地址
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 如果需要就创建一个session(默认)登录时
                .and()
                .logout()
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login-view?logout"); // 退出成功后重定向到登录页面

    }

(1)formLogin():允许表单登录;
(2)loginPage("/login-view"):指定我们自定定义的登录页面,Spring Security以重定向的方式跳转到/login-view
(3)loginProcessingUrl("/login"):指定登录处理的URL,也就是用户名、密码表单提交的目的路径;
(4)successForwardUrl("/login-success"):指定登录成功后跳转的URL;
(5)formLogin().permitAll():我们必须允许所有用户访问我们的登录页面,这个formLogin().permitAll()方法允许任意用户基于表单登录的所有URL。

3.1.4 测试

当用户没有登录认证时,访问系统资源会重定向到login-view页面,如输入http://localhost:8080/security-springboot/r/r1,会重定向到login-view页面:

登录认证页面

输入账号密码,点击登录报错:

403 Forbidden Error

发生以上问题是因为Spring Security为防止CSRF(Cross-Site Request Forgery跨站请求伪造)的发生,限制了除GET以外的大多数方法。
解决方式1,屏蔽CSRF控制,即Spring Security不再限制CSRF,配置WebSecurityConfig

    protected void configure(HttpSecurity http) throws Exception {
        // 屏蔽CSRF控制,即spring security不再限制CSRF(跨站请求伪造)
        http.csrf().disable()
              ...
    }

解决方式2,在login.jsp页面添加一个token,Spring Security会验证token,如果token合法则可以继续请求:

    <form action="login" method="post">
        <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}">
        用户名:<input type="text" name="username"><br>
        密&nbsp;&nbsp;&nbsp;码:<input type="password" name="password"><br>
        <input type="submit" value="登录">
    </form>
页面添加一个token

3.2 连接数据库认证

本节实现从数据库读取用户信息,根据前面的研究,需要重新定义UserDetailService,即可实现根据用户账号查询数据库。

3.2.1 创建数据库表

-- 创建数据库:oauth2
-- 关于关键字COLLATE参看:https://www.jianshu.com/p/f8707b8461d3
CREATE DATABASE oauth2 CHARACTER SET 'utf8' COLLATE 'utf8_general_ci';

-- 创建表:t_user
-- 关于ROW_FORMAT=DYNAMIC参看:https://www.cnblogs.com/wade-luffy/p/6289183.html
-- 关于USING BTREE参看:https://www.cnblogs.com/rxbook/p/10648796.html
CREATE TABLE `t_user` (
  `id` bigint(20) NOT NULL COMMENT '用户ID',
  `username` varchar(64) NOT NULL,
  `password` varchar(64) NOT NULL,
  `fullname` varchar(255) NOT NULL COMMENT '用户姓名',
  `mobile` varchar(11) DEFAULT NULL COMMENT '手机号',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC COMMENT '用户表'
t_user用户表

3.2.2 代码实现

1)application.yml

server:
  port: 8080
  servlet:
    context-path: /security-springboot

spring:
  application:
    name: security-springboot
  mvc:
    view:
      prefix: /WEB-INF/view/
      suffix: .jsp
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql:///oauth2?useUnicode=true&amp;characterEncoding=utf8
    username: root
    password: root

2)添加依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.47</version>
</dependency>

3)定义Dao
定义模型类型,在model包定义UserDto:

@Data
public class UserDto {
    private String id;
    private String username;
    private String password;
    private String fullname;
    private String mobile;
}

在Dao包中定义UserDao:

@Repository
public class UserDao {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    /**
     * 根据账号查询用户信息
     *
     * @param username 用户名
     * @return
     */
    public UserDto getUserByUsername(String username) {
        // 连接数据库查询用户信息
        String sql = "select id, username, password, fullname, mobile from t_user where username = ?";
        List<UserDto> userDtoList = jdbcTemplate.query(sql, new Object[]{username}, new BeanPropertyRowMapper<>(UserDto.class));
        if (CollectionUtils.isEmpty(userDtoList)) {
            return null;
        }
        return userDtoList.get(0);
    }

    /**
     * 根据用户的ID查询用户的权限
     * @param userId
     * @return
     */
    public List<String> findPermissionByUserId(String userId) {
        String sql = "select * from t_permission where id in (\n" +
                "    select permission_id from t_role_permission where role_id in (\n" +
                "        select role_id from t_user_role where user_id = ?\n" +
                "        )\n" +
                "    )";
        List<PermissionDto> list = jdbcTemplate.query(sql, new Object[]{userId}, new BeanPropertyRowMapper<>(PermissionDto.class));
        List<String> permissions = new ArrayList<>();
        // list.iterator().forEachRemaining(c -> permissions.add(c.getCode()));
        list.forEach(c -> permissions.add(c.getCode()));
        return permissions;
    }

}

四、会话管理

用户认证通过后,为了避免用户的每次操作都进行认证,可将用户的信息保存在会话中。Spring Security提供会话管理,认证通过后将身份信息存入SecurityContextHolder上下文,SecurityContext与当前线程进行绑定,方便获取用户身份。

4.1 获取用户身份

编写LoginController,实现/r/r1/r/r2的测试资源,并修改loginSuccess方法,注意getUsername方法,Spring Security获取当前登录用户信息的方法为SecurityContextHolder.getContext().getAuthentication()

@RestController
public class LoginController {

    @PostMapping(value = "login-success", produces = {"text/plain;charset=UTF-8"})
    public String loginSuccess() {
        // 提示具体的用户名称登录成功
        String username = getUsername();
        return username + " 登录成功";
    }

    /**
     * 访问资源1
     * @return
     */
    @GetMapping(value = "/r/r1", produces = {"text/plain;charset=UTF-8"})
    @PreAuthorize("hasAuthority('p1')") // 拥有p1权限才可以访问
    // @PreAuthorize("hasAnyAuthority('p1', 'p3')") // 拥有p1或者p3任何一个都可以
    public String r1() {
        String username = getUsername();
        return username + " 访问资源1";
    }

    /**
     * 访问资源2
     * @return
     */
    @GetMapping(value = "/r/r2", produces = {"text/plain;charset=UTF-8"})
    @PreAuthorize("hasAuthority('p2')") // 拥有p2权限才可以访问
    public String r2() {
        String username = getUsername();
        return username + " 访问资源2";
    }

    /**
     * 获取当前用户信息
     * @return
     */
    private String getUsername() {
        String username = null;
        // 当前认证通过的用户身份信息
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (!authentication.isAuthenticated()) {
            username = "匿名";
            // return null;
        }
        // 用户身份
        Object principal = authentication.getPrincipal();
        if (principal instanceof UserDetails) {
            username = ((UserDetails) principal).getUsername();
        } else {
            username = principal.toString();
        }
        return username;
    }

}
登录成功,获取用户名

4.2 会话控制

可以通过以下选项准确控制会话何时创建以及Spring Security如何与之交互:

会话控制

可以通过以下配置方式对该选项进行配置:

@Override
protected void configure(HttpSecurity http) throws Exception {
    // 屏蔽CSRF控制,即spring security不再限制CSRF(跨站请求伪造)
    // http.csrf().disable()
    http
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 如果需要就创建一个session(默认)登录时
            ...
}

默认情况下,Spring Security会为每个登录成功的用户创建一个Session,即ifRequired
若选用nerver,则指示Spring Security对登录成功的用户不创建Session了,但若你的应用程序在某地方新建了Session,那么Spring Security会用它。
若选用stateless,则指示Spring Security对登录成功的用户不创建Session了,你的应用程序也不允许建了Session,并且它会暗示不使用cookie,所以每个请求都需要重新进行身份验证。这种五状态结构适用于REST API及其无状态认证机制。

会话超时
可以在Servlet容器中设置Session的超时时间,如下设置Session有效期为3600s:
application.yml

server:
  port: 8080
  servlet:
    context-path: /security-springboot
    session:
      timeout: 1 # 设置Session超时时间,单位是分钟

Session超时之后,可以通过Spring Security设置跳转的路径:

http
    .sessionManagement()
    .invalidSessionUrl("/login-view?error=INVALID_SESSION")
    ...

invalidSessionUrl指传入的session无效。

超时路径跳转

安全会话cookie
我们可以使用httpOnly和secure标签来保护我们的会话cookie:

  • httpOnly:如果是true,那么浏览器脚本将无法访问cookie
  • secure:如果是true,则cookie将仅通过HTTPS连接发送

application.yml

server:
  port: 8080
  servlet:
    context-path: /security-springboot
    session:
      timeout: 1 # 设置Session超时时间,单位是分钟
      cookie:
        # http-only: true # 如果是true,那么浏览器脚本将无法访问cookie
        # secure: true # 如果是true,则cookie将仅通过HTTPS连接发送

五、自定义退出

5.1 退出

Spring Security默认实现了logout退出,访问/logout,果然退出功能Spring已经替我们做好了:

Spring退出页面

点击“Log out”退出此功,然后访问其他URL测试是否退出成功,会重定向到登录页面:

重定向到登录页面

5.2 自定义登出页面

WebSecurityConfig中配置:

      http
                ...
                .and()
                .logout()
                .logoutUrl("/logout") // 退出成功后默认是到Spring Security的退出去页面
                .logoutSuccessUrl("/login-view?logout"); // 退出成功后重定向到登录页面

当退出操作时,将发生:

  • 使HTTP Session无效
  • 清除SecurityContextHolder
  • 跳转到登录页面/login-view?logout

类似于配置登录功能,可以进一步自定义退出功能:

自定义退出功能

(1)提供系统退出支持,使用WebSecurityConfigurerAdapter会自动被应用。
(2)设置触发退出操作的URL,默认是/logout
(3)退出之后跳转的URL,默认是/login?logout
(4)定制的LogoutSuccessHandler,用于实现用户退出成功时的处理,如果指定了这个选项,那么logoutSuccessUrl()会被忽略。
(5)添加一个LogoutHandler,用于实现用户退出时的清理工作,默认SecurityContextLogoutHandler会被添加为最后一个LogoutHandler
(6)指定是否在退出时让HttpSession无效,默认值为true

注意:如果要让logoutGET请求下生效,必须关闭防止CSRF攻击csrf().disable()。如果开起了CSRF,必须使用POST方式请求/logout

LogoutHandler
一般来说,LogoutHandler的实现类被用来执行必要的清理,因而他们不应该抛出异常。
下面是Spring Security提供的一些实现:

  • PersistentTokenBasedRememberMeServices:基于持久化tokenRememberMe功能的相关清理
  • TokenBasedRememberMeServices:基于tokenRememberMe功能的相关清理
  • CookieClearingLogoutHandler:退出时Cookie的相关清理
  • CsrfLogoutHandler:负责在退出移除csrfToken
  • SecurityContextLogoutHandler:退出时SecurityContext的相关清理

链式API提供了调用相应的LogoutHandler实现的快捷方式,比如deleteCookies()

六、授权

授权的方式包括web授权和方法授权,web授权是通过URL连接进行授权,方法授权是通过方法拦截进行授权。他们会调用AccessDecisionManager进行授权决策,若为web授权则拦截器为FilterSecurityInterceptor;若为方法授权则拦截器为MethodSecurityInterceptor。如果同时通过web授权和方法授权,则先执行web授权,再执行方法授权,最后决策通过,则允许访问资源,否则将禁止访问。
类关系图如下:

授权类关系图

6.1 连接数据库查权限

6.1.1 数据库表创建

角色表:

CREATE TABLE `t_role` (
  `id` varchar(32) NOT NULL,
  `role_name` varchar(255) DEFAULT NULL,
  `description` varchar(255) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  `status` char(1) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `unique_role_name` (`role_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- 初始化数据
INSERT INTO t_role (id, role_name, description, create_time, update_time, status) VALUES ('1', '管理员', null, null, null, '');
INSERT INTO t_role (id, role_name, description, create_time, update_time, status) VALUES ('2', '普通用户', null, null, null, '');

用户角色关系表:

CREATE TABLE `t_user_role` (
  `user_id` varchar(32) NOT NULL,
  `role_id` varchar(32) NOT NULL,
  `create_time` datetime DEFAULT NULL,
  `creator` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- 初始化数据
INSERT INTO t_user_role (user_id, role_id, create_time, creator) VALUES ('1', '1', null, null);
INSERT INTO t_user_role (user_id, role_id, create_time, creator) VALUES ('2', '2', null, null);

权限表:

CREATE TABLE `t_permission` (
  `id` varchar(32) NOT NULL,
  `code` varchar(32) NOT NULL COMMENT '权限标识符',
  `description` varchar(64) DEFAULT NULL COMMENT '描述',
  `url` varchar(128) DEFAULT NULL COMMENT '请求地址',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- 初始化数据
INSERT INTO t_permission (id, code, description, url) VALUES ('1', 'p1', '测试资源1', '/r/r1');
INSERT INTO t_permission (id, code, description, url) VALUES ('2', 'p2', '测试资源2', '/r/r2');
INSERT INTO t_permission (id, code, description, url) VALUES ('3', 'p3', '测试资源3', '/r/r3');

角色权限关系表:

CREATE TABLE `t_role_permission` (
  `role_id` varchar(32) NOT NULL,
  `permission_id` varchar(32) NOT NULL,
  PRIMARY KEY (`role_id`,`permission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- 初始化数据
INSERT INTO t_role_permission (role_id, permission_id) VALUES ('1', '1');
INSERT INTO t_role_permission (role_id, permission_id) VALUES ('1', '3');
INSERT INTO t_role_permission (role_id, permission_id) VALUES ('2', '2');

6.1.2 修改UserDetailsService

修改Dao接口,增加根据账号查询用户信息、根据用户的ID查询用户的权限的接口方法:

@Repository
public class UserDao {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    /**
     * 根据账号查询用户信息
     *
     * @param username 用户名
     * @return
     */
    public UserDto getUserByUsername(String username) {
        // 连接数据库查询用户信息
        String sql = "select id, username, password, fullname, mobile from t_user where username = ?";
        List<UserDto> userDtoList = jdbcTemplate.query(sql, new Object[]{username}, new BeanPropertyRowMapper<>(UserDto.class));
        if (CollectionUtils.isEmpty(userDtoList)) {
            return null;
        }
        return userDtoList.get(0);
    }

    /**
     * 根据用户的ID查询用户的权限
     * @param userId
     * @return
     */
    public List<String> findPermissionByUserId(String userId) {
        String sql = "select * from t_permission where id in (\n" +
                "    select permission_id from t_role_permission where role_id in (\n" +
                "        select role_id from t_user_role where user_id = ?\n" +
                "        )\n" +
                "    )";
        List<PermissionDto> list = jdbcTemplate.query(sql, new Object[]{userId}, new BeanPropertyRowMapper<>(PermissionDto.class));
        List<String> permissions = new ArrayList<>();
        // list.iterator().forEachRemaining(c -> permissions.add(c.getCode()));
        list.forEach(c -> permissions.add(c.getCode()));
        return permissions;
    }

}

权限对象:

/**
 * 权限
 */
@Data
public class PermissionDto {

    private String id;
    private String code;
    private String description;
    private String url;

}

修改SpringDataUserDetailsService,调整loadUserByUsername方法,修改为查询数据用户信息的方式:

@Service
public class SpringDataUserDetailsService implements UserDetailsService {

    @Autowired
    private UserDao userDao;

    /**
     * 根据账号查询用户信息
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    /*@Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 将来连接数据库根据账号查询用户信息
        // 登录账号
        System.out.println("username=" + username);
        // 暂时采用模拟的方式,构造一条存在数据库中的用户信息
        // UserDetails userDetails = User.withUsername("zhangsan").password("123").authorities("p1").build();
        // BCryptPasswordEncoder
        UserDetails userDetails = User.withUsername("zhangsan").password("$2a$10$NFSdBLI92t4iwGXkbsQ6GOgblxVV8Blj24Wt5LcQ9HWV8gj.eoEgO")
                .authorities("p1").build();
        return userDetails;
    }*/


    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 将来连接数据库根据账号查询用户信息
        // 登录账号
        System.out.println("username=" + username);
        UserDto userByUsername = userDao.getUserByUsername(username);
        if (null == userByUsername) {
            // 如果用户查询不到,返回null,返回给provider,由provider来抛异常
            return null;
        }
        // 根据用户的ID查询用户的权限
        List<String> permissions = userDao.findPermissionByUserId(userByUsername.getId());
        String[] permissionArray = new String[permissions.size()];
        permissions.toArray(permissionArray);
        UserDetails userDetails = User.withUsername(userByUsername.getUsername()).password(userByUsername.getPassword())
                .authorities(permissionArray).build();
        return userDetails;
    }

}

6.2 web授权

在上面的例子中,我们完成了认证拦截,并对/r/**下的某些资源进行简单的权限保护,但是我们想进行灵活的权限控制应该怎么办呢?通过给http.authorizeRequests()添加多个子节点来定制需要处理的URL,如下:

http.authorizeRequests 子节点

(1)http.authorizeRequests()方法有多个子节点,每个matcher按照他们的声明顺序执行。
(2)指定“/r/r1”URL,需要拥有p1权限才能访问
(3)指定“/r/r2”URL,需要拥有p2权限才能访问
(4)指定“/r/r3”URL,需要拥有p1和p2权限才能访问
(5)指定除“r1”、“r2”、“r3”之外的“/r/**”资源,通过身份认证就能访问,这里使用SpEL(Spring Expression Language)表达式。
(6)剩余的尚未匹配的资源,不做保护。

注意
规则的顺序是重要的,更具体的规则应该写在前面。现在以/admin开始的所有内容都需要具有Admin角色的身份验证用户,即使是/admin/login路径(因为/admin/login已经被/admin/**规则匹配你,因此第二个规则被忽略)。

  .antMatchers("/admin/**").hasRole("ADMIN")
  .antMatchers("/admin/login").permitAll()
  ...

因此,登录页面的规则应该写在"/admin/**"规则之前,如:

  .antMatchers("/admin/login").permitAll()
  .antMatchers("/admin/**").hasRole("ADMIN")
  ...

保护URL常用的方法有:
authenticated()保护URL,需要用户登录;
permitAll()指定URL无需保护,一般用于静态资源文件;
hasRole(String role)限制单个角色访问,角色将被增加“ROLE_”,所以“ADMIN”将和“ROLE_ADMIN”进行比较;
hasAuthority(String authority)限制单个权限访问;
hasAnyRole(String... roles)允许多个角色访问;
access(String attribute)该方法使用SpEL表达式,所以可以创建复杂的限制;
hasIpAddress(String ipAddressExpression)限制IP地址或子网;

hasRole

6.3 方法授权

前面已经掌握了使用如何使用http.authorizeRequests()对web资源进行授权保护,从Spring Security2.0版本开始,它支持服务层方法的安全性校验。如:@PreAuthorize, @PostAuthorize, @Secured
可以在任何@Configuration实例上使用@EnableGlobalMethodSecurity注解来使用基于注解的安全性。

/**
 * @author pengjinsen
 * @EnableGlobalMethodSecurity(securedEnabled = true) 启用基于注解的安全性,可以使用@Secured注解
 */
@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

}

然后向方法(在类或者接口上)添加注解就会限制对该方法的访问,Spring Security的原生注解支持为该方法定义一组属性,这些将会传递给AccessDecisionManager以供它做出实际的决定:

public interface BankService {

    @Secured("IS_AUTHENTICATED_ANONYMOUSLY")
    Account readAccount(Long id);

    @Secured("IS_AUTHENTICATED_ANONYMOUSLY")
    Account[] findAccounts();

    @Secured("ROLE_TELLER")
    Account post(Account account, double amount);

}

以上配置标明readAccountfindAccounts可以匿名访问,底层使用WebExpressionVoter投票器;post方法需要有TELLER角色才能访问,底层使用RoleVoter投票器。

使用如下代码可启用prePost注解的支持:

 */
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

}

相应的Java代码如下:

/**
 * @author: Jay Mitter
 * @date: 2020-12-14 20:38
 * @description:
 */
public interface BankService {

    @PreAuthorize("isAnonymous()")
    Account readAccount(Long id);

    @PreAuthorize("isAnonymous()")
    Account[] findAccounts();

    @PreAuthorize("hasAnyAuthority('p_transfer') and hasAnyAuthority('p_read_account')")
    Account post(Account account, double amount);

}

以上配置标明readAccountfindAccounts可以匿名访问,post方法需要有p_transfer和p_read_account权限才能访问,底层使用WebExpressionVoter投票器;
在Controller中的应用:

@RestController
public class LoginController {

    @PostMapping(value = "login-success", produces = {"text/plain;charset=UTF-8"})
    public String loginSuccess() {
        // 提示具体的用户名称登录成功
        String username = getUsername();
        return username + " 登录成功";
    }

    /**
     * 访问资源1
     * @return
     */
    @GetMapping(value = "/r/r1", produces = {"text/plain;charset=UTF-8"})
    @PreAuthorize("hasAuthority('p1')") // 拥有p1权限才可以访问
    // @PreAuthorize("hasAnyAuthority('p1', 'p3')") // 拥有p1或者p3任何一个都可以
    public String r1() {
        String username = getUsername();
        return username + " 访问资源1";
    }

    /**
     * 访问资源2
     * @return
     */
    @GetMapping(value = "/r/r2", produces = {"text/plain;charset=UTF-8"})
    @PreAuthorize("hasAuthority('p2')") // 拥有p2权限才可以访问
    public String r2() {
        String username = getUsername();
        return username + " 访问资源2";
    }

    /**
     * 获取当前用户信息
     * @return
     */
    private String getUsername() {
        String username = null;
        // 当前认证通过的用户身份信息
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (!authentication.isAuthenticated()) {
            username = "匿名";
            // return null;
        }
        // 用户身份
        Object principal = authentication.getPrincipal();
        if (principal instanceof UserDetails) {
            username = ((UserDetails) principal).getUsername();
        } else {
            username = principal.toString();
        }
        return username;
    }

}

推荐阅读更多精彩内容