飞天班第13节:SpringSecurity权限控制

2020/03/25

1、SpringSecurity简介

前言

Web开发中,安全一直是十分重要的一个环节,也属于非功能性需求,市面上比较知名的安全框架:

  • Shiro,用的多,功能强大

  • SpringSecurity,与Spring无缝结合,十分的方便,基本功能全都有,支持分布式。

    用户认证(账号密码)与 授权验证(url请求接口权限)的 安全框架,基于RBAC(角色的权限控制)对用户的访问权限进行控制,核心思想是通过一系列的filter chain 来进行拦截过滤的。使用起来就是继承WebSecurityConfigurerAdapter进行权限配置

今天我们就来学习SpringSecurity,学习思路:看官网简介,快速阅读官方文档

官方地址:https://spring.io/projects/spring-security

分不清authentication 和authentization?

举个例子说明一下:

你要登机,你需要出示你的身份证和机票,身份证是为了证明你张三确实是你张三,这就是authentication(认证);而机票是为了证明你张三确实买了票可以上飞机,这就是authorization(授权)。

在从系统网站方面举个场景:

  • authentication(认证)

    你要登陆论坛,输入用户名张三,密码1234,密码正确,证明你张三确实是张三 ;

  • authorization(授权)

    再check用户张三是个版主,所以有权限加删别人的帖

快速阅读官方文档

1、点击Reference Doc 找到Getting Spring Security,发现依赖<

2、点击Project Modules

3、点击Servlet Applications,找到Spring Boot Auto Configuration

Spring Boot automatically:

  • Enables Spring Security’s default configuration, which creates a servlet Filter as a bean named springSecurityFilterChain. This bean is responsible for all the security (protecting the application URLs, validating submitted username and passwords, redirecting to the log in form, and so on) within your application.

    springboot 自动开启SpringSecurity的默认配置,创建一个叫s pringSecurityFilterChain的过滤器,负责你的应用的安全,包括保护应用url,验证用户名和密码,重定向到登录表单页面等
  • Creates a UserDetailsService bean with a username of user and a randomly generated password that is logged to the console.

    创建一个叫user的用户名,一个随机生成的密码在登录后在控制台输出
  • Registers the Filter with a bean named springSecurityFilterChain with the Servlet container for every request.

    springSecurityFilterChain会过滤每一个请求

Spring Boot is not configuring much, but it does a lot. A summary of the features follows:

  • Require an authenticated user for any interaction with the application

  • Generate a default login form for you

    生成一个默认的登录表单页面给你
  • Let the user with a username of user and a password that is logged to the console to authenticate with form-based authentication (in the preceding example, the password is 8e557245-73e2-4286-969a-ff57fe326336)

  • Protects the password storage with BCrypt

    使用BCrypt算法加密密码存储

    ,它可以降低破解密码的暴力攻击,快速了解BCrypt

小结

SpringSecurity 是通过Filter 来过认证和授权的,

Filter会先判断请求的用户是否是应用的用户(认证),再判断是否有权限访问请求的URL(授权),最后再通过DispatcherServlet分发器访问具体的Controller,获取数据。

我们需要了解SpringSecurity 内置的各种Filter

Alias Filter Class Namespace Element or Attribute
CHANNEL_FILTER ChannelProcessingFilter http/intercept-url@requires-channel
SECURITY_CONTEXT_FILTER SecurityContextPersistenceFilter http
CONCURRENT_SESSION_FILTER ConcurrentSessionFilter session-management/concurrency-control
HEADERS_FILTER HeaderWriterFilter http/headers
CSRF_FILTER CsrfFilter http/csrf
LOGOUT_FILTER LogoutFilter http/logout
X509_FILTER X509AuthenticationFilter http/x509
PRE_AUTH_FILTER AbstractPreAuthenticatedProcessingFilter Subclasses N/A
CAS_FILTER CasAuthenticationFilter N/A
FORM_LOGIN_FILTER UsernamePasswordAuthenticationFilter http/form-login
BASIC_AUTH_FILTER BasicAuthenticationFilter http/http-basic
SERVLET_API_SUPPORT_FILTER SecurityContextHolderAwareRequestFilter http/@servlet-api-provision
JAAS_API_SUPPORT_FILTER JaasApiIntegrationFilter http/@jaas-api-provision
REMEMBER_ME_FILTER RememberMeAuthenticationFilter http/remember-me
ANONYMOUS_FILTER AnonymousAuthenticationFilter http/anonymous
SESSION_MANAGEMENT_FILTER SessionManagementFilter session-management
EXCEPTION_TRANSLATION_FILTER ExceptionTranslationFilter http
FILTER_SECURITY_INTERCEPTOR FilterSecurityInterceptor http
SWITCH_USER_FILTER SwitchUserFilter N/A

4、找到Handling Security Exceptions

9.6. Handling Security Exceptions

The ExceptionTranslationFilter allows translation of AccessDeniedException and AuthenticationException into HTTP responses.

ExceptionTranslationFilter is inserted into the FilterChainProxy as one of the Security Filters.

1.ExceptionTranslationFilter 通知 FilterChain.doFilter 过滤

2.如果用户还没认证,那就走认证流程

3.如果是拒绝访问,那就抛异常给AccessDeniedHandler

5、springsecurity 我们重点关注

6、找到16. Java Configuration配置授权规则

找到 HttpSecurity ,

Thus far our WebSecurityConfig only contains information about how to authenticate our users. How does Spring Security know that we want to require all users to be authenticated? How does Spring Security know we want to support form based authentication?

Actually, there is a configuration class that is being invoked behind the scenes called WebSecurityConfigurerAdapter. It has a method called configure with the following default implementation:

spring security 是通过 WebSecurityConfigurerAdapter 配置类里的configure方法 配置授权规则 
protected void configure(HttpSecurity http) throws Exception {
    http
        .authorizeRequests(authorize -> authorize
            .anyRequest().authenticated()
        )
        .formLogin(withDefaults())
        .httpBasic(withDefaults());
}

上面是默认的生效规则:

  • 确保用户发起的所有应用请求都要认证
  • Allows users to authenticate with form based login
  • Allows users to authenticate with HTTP Basic authentication

我们可以自定义一个SecurityConfig类继承WebSecurityConfigurerAdapter,重写configure(HttpSecurity http)方法,定义自己的授权规则。

2、用户认证和授权详解

建一个简单的项目,没导入springsecurity,访问首页

我们想要不同用户访问不同level

导入依赖

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

我们先不编写配置类,如前面阅读官方文档,springboot已经自动帮我们做了一些配置,包括一个用户user和密码,还有登录页面,启动看后台生成了一个随机密码

浏览器访问任何请求都会跳到生成的登录页面,因为你还没有认证

我们使用user和生成的密码登录,就可以登录到首页了。

配置授权规则

新建一个configure类继承WebSecurityConfigurerAdapter,重写configure(HttpSecurity http)方法

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	// 定义授权规则
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		// htpp的方法基本分为4个类别:过滤器,登录注销规则,安全配置,OAuth2配置
		// 一般我们只配置规则即可
		// 首页允许所有人访问
		http.authorizeRequests().antMatchers("/").permitAll()
			.antMatchers("/level1/**").hasRole("vip1")
			.antMatchers("/level2/**").hasRole("vip2")
			.antMatchers("/level3/**").hasRole("vip3");
	}
}

访问首页/,不用登录,点击Level-1-2访问页面会报403错误Access Denied,因为你还没有认证登录。

添加formlogin,

http.formLogin();

这样没有认证访问就会跳到登录页了,使用user账号登录,点击Level-1-2 访问页面会报403错误 Forbidden,因为你还有没有授权,user账号没有vip1的角色。

点击formLogin进入源码

可以看到表单登录用户和密码的默认参数名是username和password

默认的登录页请求是/login(Get请求)

默认的提交登录请求是/login(Post请求)

认证失败默认会跳转/login?error(Get请求)

内存模式配置认证用户测试

从上面读formLogin的源码知道,我们可以重写configure(AuthenticationManagerBuilder auth)方法添加认证用户:

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	// 授权规则
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		// htpp的方法基本分为4个类别:过滤器,登录注销规则,安全配置,OAuth2配置
		// 一般我们只配置规则即可
		// 首页允许所有人访问
		http.authorizeRequests().antMatchers("/").permitAll()
			.antMatchers("/level1/**").hasRole("vip1")
			.antMatchers("/level2/**").hasRole("vip2")
			.antMatchers("/level3/**").hasRole("vip3");

//		表单登录用户和密码的默认参数名是username和password
//		默认的登录页请求是/login(Get请求)
//		默认的提交登录请求是/login(Post请求)
//		认证失败默认会跳转到/login?error(Get请求)
		http.formLogin();
	}

	// 认证用户
	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		// 内存中定义用户
		auth.inMemoryAuthentication()
				.withUser("jude").password("123456").roles("vip1","vip2","vip3")
				.and()
				.withUser("guest").password("123456").roles("vip1");
	}
}

重启用以上账号登录,

// 认证用户
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  // 内存中定义用户
  auth.inMemoryAuthentication()
    .withUser("jude").password(new BCryptPasswordEncoder(11).encode("123456")).roles("vip1","vip2","vip3")
    .and()
    .withUser("guest").password(new BCryptPasswordEncoder(11).encode("123456")).roles("vip1");
}

搜索密码接口PasswordEncoder,发现是一个接口,有很多实现类

了解加盐密码的通用加密方法

SpringSecurity推荐我们使用BCryptPasswordEncoder,

// 认证用户
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  // 内存中定义用户信息,指定加密方式,要前后一致
  auth.inMemoryAuthentication().passwordEncoder(new BCryptPasswordEncoder(11))
    .withUser("jude").password(new BCryptPasswordEncoder(11).encode("123456")).roles("vip1","vip2","vip3")
    .and()
    .withUser("guest").password(new BCryptPasswordEncoder(11).encode("123456")).roles("vip1");
}
}

这里我们可以从数据库读取用户信息放到内存,但还不是动态的,因为无论是授权规则(角色访问菜单url),还是认证用户(用户绑定角色)大多数业务场景都是需要在页面上动态配置的。

重启后用guest测试登录,成功。

添加error 403错误页面

访问level2页面:

用户认证信息从数据库读取

配置类SecurityConfig 继承WebSecurityConfigurerAdapter

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
   @Autowired
    UserServiceImpl userService;
  
  	//请求授权验证
    @Override
    protected void configure(HttpSecurity http) throws Exception {
          http.authorizeRequests()
                .antMatchers("/","/index","/favicon.ico","/bootstrap/**","/css/**","/editormd/**","/images/**","/js/**","/layer/**").permitAll()
                .antMatchers("/register","/toLogin","/login").permitAll()
                .antMatchers("/swagger-ui.html","/v2/api-docs","/swagger-resources/**").permitAll() // 默认UI的swagger请求
                .antMatchers("/doc.html").permitAll() // bootstrap-ui 的请求
                .antMatchers("/**").authenticated();   // 其他请求登录后可以访问
      
      // 登录配置
        http.formLogin()
                .usernameParameter("username")
                .passwordParameter("password")
                .loginPage("/toLogin")
                .loginProcessingUrl("/login") // 登陆表单提交请求
                .defaultSuccessUrl("/index"); // 设置默认登录成功后跳转的页面

        // 注销配置
        http.headers().contentTypeOptions().disable();
        http.headers().frameOptions().disable(); // 图片跨域
        http.csrf().disable();// 关闭csrf功能:跨站请求伪造,默认只能通过post方式提交logout请求
        http.logout().logoutSuccessUrl("/"); // 注销成功跳转到首页

        // 记住我配置
        http.rememberMe().rememberMeParameter("remember");
    } 
    
  	// 用户认证验证
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
       auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
    }

    // 密码加密方式
    @Bean
    public PasswordEncoder passwordEncoder(){
       return new BCryptPasswordEncoder();
    }
}  

userService 实现了org.springframework.security.core.userdetails.UserDetailsService

import org.springframework.security.core.userdetails.UserDetailsService;

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService,UserDetailsService {
    @Autowired
    UserService userService;
    @Autowired
    UserRoleService roleService;
    @Autowired
    HttpSession session;

    // 用户登录逻辑和验证处理
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        // 通过用户名查询用户
        User user = userService.getOne(new QueryWrapper<User>().eq("username", s));

        // 放入session
        session.setAttribute("loginUser",user);

        //创建一个新的UserDetails对象,最后验证登陆的需要
        UserDetails userDetails=null;
        if(user!=null){
            //System.out.println("未加密:"+user.getPassword());
            //String BCryptPassword = new BCryptPasswordEncoder().encode(user.getPassword());
            // 登录后会将登录密码进行加密,然后比对数据库中的密码,数据库密码需要加密存储!
            String password = user.getPassword();

            //创建一个集合来存放权限
            Collection<GrantedAuthority> authorities = getAuthorities(user);
            //实例化UserDetails对象
            userDetails=new org.springframework.security.core.userdetails.User(s,password,
                            true,
                            true,
                            true,
                            true, authorities);
        }
        return userDetails;
    }

    // 获取角色信息
    private Collection<GrantedAuthority> getAuthorities(User user){
        List<GrantedAuthority> authList = new ArrayList<GrantedAuthority>();
        UserRole role = roleService.getById(user.getRoleId());
        //注意:这里每个权限前面都要加ROLE_。否在最后验证不会通过
        authList.add(new SimpleGrantedAuthority("ROLE_"+role.getName()));
        return authList;
    }

动态配置URL权限

有两种方式实现,参考https://www.cnblogs.com/xiaoqi/p/spring-security-rabc.html

1、自定义AccessDecisionManager

官方的三个AccessDecisionManager都是基于AccessDecisionVoter来实现权限认证的,因此我们只需要自定义一个AccessDecisionVoter就可以了。

public class RoleBasedVoter implements AccessDecisionVoter<Object> {
    @Override
    public boolean supports(ConfigAttribute configAttribute) {
        return true;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }

    @Override
    public int vote(Authentication authentication, Object object, Collection<ConfigAttribute> attributes) {
        if(authentication == null) { // 用户认证信息为空
            return ACCESS_DENIED;   // 禁止访问 -1
        }
      	// 请求的URL
        FilterInvocation fi = (FilterInvocation) object;
        String url = fi.getRequestUrl();
        // 放行的url
        if(url.startsWith("/register") || url.startsWith("/login") || url.startsWith("/toLogin")){
            return ACCESS_GRANTED;
        }
        int result = ACCESS_ABSTAIN; // 放弃 0
        // 当前认证用户的角色权限集合,都是ROLE_ 开头
        Collection<? extends GrantedAuthority> authorities = extractAuthorities(authentication);

        // 从Redis或者DB动态加载当前url的访问ROLE,add到attributes,实现动态
        attributes.addAll(SecurityConfig.createList("ROLE_1","ROLE_2","ROLE_3"));

        for (ConfigAttribute attribute : attributes) {
            if(attribute.getAttribute()==null){
                continue;
            }
            if (this.supports(attribute)) {
                result = ACCESS_DENIED;

                // Attempt to find a matching granted authority
                for (GrantedAuthority authority : authorities) {
                    if (attribute.getAttribute().equals(authority.getAuthority())) {
                        return ACCESS_GRANTED; // 已授权 1
                    }
                }
            }
        }
        return result;
    }

    private Collection<? extends GrantedAuthority> extractAuthorities(
            Authentication authentication) {
        return authentication.getAuthorities();
    }
}

将RoleBasedVoter加入到配置类SecurityConfig

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    UserServiceImpl userService;

    //请求授权验证
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // .denyAll();    //拒绝访问
        // .authenticated();    //需认证通过
        // .permitAll();    //无条件允许访问
        // 访问权限
        http.authorizeRequests()
                .antMatchers("/","/index").permitAll()
                .antMatchers("/register","/login","/toLogin").permitAll()
                .antMatchers("/*").authenticated();
      
        http.authorizeRequests().accessDecisionManager(accessDecisionManager()); 

        // 登录配置
        http.formLogin()
                .usernameParameter("username")
                .passwordParameter("password")
                .loginPage("/toLogin")
                .loginProcessingUrl("/login") // 登陆表单提交请求
                .defaultSuccessUrl("/index"); // 设置默认登录成功后跳转的页面

        // 注销配置
        http.headers().contentTypeOptions().disable();
        http.headers().frameOptions().disable(); // 图片跨域
        http.csrf().disable();// 关闭csrf功能:跨站请求伪造,默认只能通过post方式提交logout请求
        http.logout().logoutSuccessUrl("/"); // 注销成功跳转到首页

        // 记住我配置
        http.rememberMe().rememberMeParameter("remember");
    }

    // 用户认证验证
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
    }

    // 密码加密方式
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AccessDecisionManager accessDecisionManager() {
        List<AccessDecisionVoter<? extends Object>> decisionVoters
                = Arrays.asList(
                new WebExpressionVoter(),
                new RoleBasedVoter(),
                new AuthenticatedVoter());
        return new UnanimousBased(decisionVoters);
    }

认证测试结果:

把权限调整一下

// 从Redis或者DB动态加载当前url的访问ROLE,add到attributes,实现动态
attributes.addAll(SecurityConfig.createList("ROLE_管理员","ROLE_2","ROLE_3"));

重启项目,重新访问localhost:8080/blog,有权限访问了(登录用户有“ROLE_管理员”的角色)

2、自定义权限校验类

看了官网的API文档有这么一段授权的说明

https://docs.spring.io/spring-security/site/docs/5.3.4.RELEASE/reference/html5/#authz-after-invocation-handling

根据官网新建类WebSecurity.java

public class WebSecurity {
    public boolean check(Authentication authentication, HttpServletRequest request) {
        if(authentication == null) { // 用户认证信息为空
            return false;   // 禁止访问 -1
        }
        // 请求的URI
        String uri = request.getRequestURI();

        // 当前认证用户的角色权限集合,都是ROLE_ 开头
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        // 从Redis或者DB动态加载当前访问的URI 查询对应的ROLE,add到attributes,实现动态
        List<ConfigAttribute> attributes = SecurityConfig.createList("ROLE_1","ROLE_2","ROLE_3");

        for (ConfigAttribute attribute : attributes) {
            if(attribute.getAttribute()==null){
                continue;
            }
            // Attempt to find a matching granted authority
            for (GrantedAuthority authority : authorities) {
                if (attribute.getAttribute().equals(authority.getAuthority())) {
                    return true; // 已授权 1
                }
            }
        }
        return false;
    }
}

bean webSecurity放到配置类SecurityConfig

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    UserServiceImpl userService;

    //请求授权验证
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        // .denyAll();    //拒绝访问
        // .authenticated();    //需认证通过
        // .permitAll();    //无条件允许访问
        // 访问权限
        http.authorizeRequests()
                .antMatchers("/","/index").permitAll()
                .antMatchers("/register","/login","/toLogin").permitAll()
                .antMatchers("/swagger-ui.html","/v2/api-docs","/swagger-resources/**").permitAll() // 默认UI的swagger请求
                .antMatchers("/doc.html").permitAll() // bootstrap-ui 的请求
                .antMatchers("/*").authenticated();   // 其他请求登录后可以访问

        http.authorizeRequests().antMatchers("/*").access("@webSecurity.check(authentication,request)");
        
        // 登录配置
        http.formLogin()
                .usernameParameter("username")
                .passwordParameter("password")
                .loginPage("/toLogin")
                .loginProcessingUrl("/login") // 登陆表单提交请求
                .defaultSuccessUrl("/index"); // 设置默认登录成功后跳转的页面

        // 注销配置
        http.headers().contentTypeOptions().disable();
        http.headers().frameOptions().disable(); // 图片跨域
        http.csrf().disable();// 关闭csrf功能:跨站请求伪造,默认只能通过post方式提交logout请求
        http.logout().logoutSuccessUrl("/"); // 注销成功跳转到首页

        // 记住我配置
        http.rememberMe().rememberMeParameter("remember");
    }

    // 用户认证验证
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
    }

    // 密码加密方式
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public WebSecurity webSecurity(){
        return new WebSecurity();
    }
}

认证测试结果:

把权限调整一下

 List<ConfigAttribute> attributes = SecurityConfig.createList("ROLE_管理员","ROLE_2","ROLE_3");

重启项目,重新访问localhost:8080/blog,有权限访问了(登录用户有“ROLE_管理员”的角色)

3、自定义SecurityMetadataSource

自定义FilterInvocationSecurityMetadataSource只要实现接口即可,在接口里从DB动态加载规则。为了复用代码里的定义,我们可以将代码里生成的SecurityMetadataSource带上,在构造函数里传入默认的FilterInvocationSecurityMetadataSource。

public class MyFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
    private FilterInvocationSecurityMetadataSource  superMetadataSource;
     private final AntPathMatcher antPathMatcher = new AntPathMatcher();
    
    // 这里的需要从DB或者Redis加载
    private  Map<String,String> urlRoleMap = new HashMap<String,String>();

    public MyFilterInvocationSecurityMetadataSource(FilterInvocationSecurityMetadataSource expressionBasedFilterInvocationSecurityMetadataSource){
        this.superMetadataSource = expressionBasedFilterInvocationSecurityMetadataSource;
        // TODO 从数据库加载权限配置,就是urlRoleMap
        urlRoleMap.put("/open/**","ROLE_ANONYMOUS");
        urlRoleMap.put("/health","ROLE_ANONYMOUS");
        urlRoleMap.put("/restart","ROLE_ADMIN");
        urlRoleMap.put("/demo","ROLE_USER");
    }

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
        FilterInvocation fi = (FilterInvocation) object;
        String url = fi.getRequestUrl();

        for(Map.Entry<String,String> entry:urlRoleMap.entrySet()){
            if(antPathMatcher.match(entry.getKey(),url)){ // 获取当前请求url的访问角色
                return org.springframework.security.access.SecurityConfig.createList(entry.getValue());
            }
        }

        // 返回代码定义的默认配置,
        return superMetadataSource.getAttributes(object);
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return FilterInvocation.class.isAssignableFrom(aClass);
    }
}

将MyFilterInvocationSecurityMetadataSource加入到SecurityConfiguration ,

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    UserServiceImpl userService;

    //请求授权验证
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 访问权限
        http.authorizeRequests()
                .antMatchers("/","/index").permitAll()
                .antMatchers("/register","/login","/toLogin").permitAll()
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O fsi) {
                        fsi.setSecurityMetadataSource(mySecurityMetadataSource(fsi.getSecurityMetadataSource()));
                        return fsi;
                    }
                });
      // 登录配置
        http.formLogin()
                .usernameParameter("username")
                .passwordParameter("password")
                .loginPage("/toLogin")
                .loginProcessingUrl("/login") // 登陆表单提交请求
                .defaultSuccessUrl("/index"); // 设置默认登录成功后跳转的页面

        // 注销配置
        http.headers().contentTypeOptions().disable();
        http.headers().frameOptions().disable(); // 图片跨域
        http.csrf().disable();// 关闭csrf功能:跨站请求伪造,默认只能通过post方式提交logout请求
        http.logout().logoutSuccessUrl("/"); // 注销成功跳转到首页

        // 记住我配置
        http.rememberMe().rememberMeParameter("remember");
    }
  
  // 用户认证验证
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
    }

    // 密码加密方式
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
  
    @Bean
    public MyFilterInvocationSecurityMetadataSource mySecurityMetadataSource(FilterInvocationSecurityMetadataSource filterInvocationSecurityMetadataSource) {
        MyFilterInvocationSecurityMetadataSource securityMetadataSource = new MyFilterInvocationSecurityMetadataSource(filterInvocationSecurityMetadataSource);
        return securityMetadataSource;
    }
}      

认证测试结果:

失败,静态资源也拦截,但是都没有校验权限的。

记住我

参考 https://cloud.tencent.com/developer/article/1540670

SpringSecurity过滤链如下图

进入登录页面,你输入用户名以及密码进行登录,Spring Security首先会进入UsernamePasswordAuthenticationFilter进行验证,如果认证成功它会调用一个叫RememberMeService的类(开启remember me 功能后),这个类会调用TokenRepository这个类,而这个类会将一个生成的Token写入数据库中,并且将Token存入cookie。下次请求就会带上name=”remember-me”,value=Token的cookie

当你下次再进入该网站的时候,SpringSecurity判断请求带有“remember-me”这个cookie,它会直接进入RemeberMeAuthticationFilter这个过滤器中,Cookie的值就是之前的Token,然后拿着这个Token通过RememberService到数据库中查找用户信息,如果存在该Token,则取出该Token对应的用户名以及其他信息放入UserDetailService,然后进入调用的URL。流程大概如下图:

实战

1)配置 PersistentTokenRepository 对象

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {    
  @Autowired
  private DataSource dataSource;

  @Bean
  UserDetailsService myUserDetailService() {
    return new MyUserDetailsService();
  }

  @Bean
  public PersistentTokenRepository persistentTokenRepository() {
    JdbcTokenRepositoryImpl persistentTokenRepository = new JdbcTokenRepositoryImpl();
    persistentTokenRepository.setDataSource(dataSource);
    return persistentTokenRepository;
  }
}

PersistentTokenRepository 为一个接口类,这里我们用的是数据库持久化,所以实际使用的 PersistentTokenRepository 实现类是 JdbcTokenRepositoryImpl,使用它的时候需要指定数据源,所以我们需要将已配置的 dataSource 对象注入到 JdbcTokenRepositoryImpldataSource 属性中。

2) 创建 persistent_logins 数据表

create table persistent_logins (
  username varchar(64) not null, 
  series varchar(64) primary key, 
	token varchar(64) not null, 
  last_used timestamp not null
)

3) 添加 remember me 复选框

打开 resources/templates 路径下的 login.html 登录页,添加 Remember Me 复选框:

<div class="form-field">
   Remember Me:<input type="checkbox" name="remember-me" value="true"/>
</div>

Remember Me 复选框的 name 属性的值必须为 “remember-me”,因为RememberMeConfigurer里的rememberMeParameter字段默认是“remember-me”

你可以修改在SecurityConfig配置类修改参数名

SecurityConfig配置类开启remeber me配置

// 记住我设置,当服务重启后,用户不会被拦截到登录页面也可以访问资源,客户端token是存在cookie中的,所以清除了cookie就要重新登录了
http.rememberMe()
  .tokenRepository(persistentTokenRepository()) // 配置token持久化仓库
  .tokenValiditySeconds(604800) // 过期时间七天,单位秒
  .userDetailsService(userConfig); // 自动登录逻辑处理

注销

授权规则里添加

// 开启注销,其实spring security默认已经开启的了
http.logout();

点击它的源码

首页添加我们的注销按钮

<a class="item" th:href="@{/logout}">
  <i class="address card icon"></i> 登录
</a>

发现其实SpringSecurity已经帮我们定义好了登录注销规则

关于默认的登出url,源码里这么说的

默认注销url是/logout,开启CSRF后必须是post请求,关闭CSRF后可以是get请求

// 注销,默认开启的注销功能
http.logout().logoutSuccessUrl("/");    // 注销成功跳转到首页

重启项目测试,登录和注销。

3、结合前端控制

Spring Security 和 Thymeleaf的结合

1.导入依赖

访问maven仓库 https://mvnrepository.com/search?q=thymeleaf&p=2

<!-- thymeleaf 和 springsecurity5的整合包,一定要与spring的版本对应-->
<dependency>
  <groupId>org.thymeleaf.extras</groupId>
  <artifactId>thymeleaf-extras-springsecurity5</artifactId>
  <version>3.0.4.RELEASE</version>
</dependency>

发现shiro和thymeleaf的整合包也有

2.修改前端页面

导入命名空间约束

<html lang="en"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
  .....

Thymeleaf-extras-spring security 的github地址https://github.com/thymeleaf/thymeleaf-extras-springsecurity

注意版本对应问题,上图已说明。

命名空间

<!--登录注销-->
<div class="right menu">

  <!--核心类:Authentication-->
  <!-- 如果未登录(认证)就显示登录按钮 -->
  <div sec:authorize="!isAuthenticated()">
    <a class="item" th:href="@{/login}">
      <i class="address card icon"></i> 登录
    </a>
  </div>

  <!-- 如果已登录,显示用户的信息 -->
  <div sec:authorize="isAuthenticated()">
    <a class="item">
      <!--<i class="address card icon"></i>-->
      用户名:<span sec:authentication="principal.username"></span> &nbsp;
      角色:<span sec:authentication="principal.authorities"></span>
    </a>
  </div>
  <div sec:authorize="isAuthenticated()">
    <a class="item" th:href="@{/logout}">
      <i class="address card icon"></i> 注销
    </a>
  </div>
</div>
....
<!--前端控制显示-->
<div sec:authorize="hasRole('vip1')">
  <div class="column">
    <div class="ui raised segment">
      <div class="ui">
        <div class="content">
          <h5 class="content">Level 1</h5>
          <hr>
          <div><a th:href="@{/level1/1}"><i class="bullhorn icon"></i> Level-1-1</a></div>
          <div><a th:href="@{/level1/2}"><i class="bullhorn icon"></i> Level-1-2</a></div>
          <div><a th:href="@{/level1/3}"><i class="bullhorn icon"></i> Level-1-3</a></div>
        </div>
      </div>
    </div>
  </div>
</div>

<div sec:authorize="hasRole('vip2')">
  <div class="column">
    <div class="ui raised segment">
      <div class="ui">
        <div class="content">
          <h5 class="content">Level 2</h5>
          <hr>
          <div><a th:href="@{/level2/1}"><i class="bullhorn icon"></i> Level-2-1</a></div>
          <div><a th:href="@{/level2/2}"><i class="bullhorn icon"></i> Level-2-2</a></div>
          <div><a th:href="@{/level2/3}"><i class="bullhorn icon"></i> Level-2-3</a></div>
        </div>
      </div>
    </div>
  </div>
</div>

<div sec:authorize="hasRole('vip3')">
  <div class="column">
    <div class="ui raised segment">
      <div class="ui">
        <div class="content">
          <h5 class="content">Level 3</h5>
          <hr>
          <div><a th:href="@{/level3/1}"><i class="bullhorn icon"></i> Level-3-1</a></div>
          <div><a th:href="@{/level3/2}"><i class="bullhorn icon"></i> Level-3-2</a></div>
          <div><a th:href="@{/level3/3}"><i class="bullhorn icon"></i> Level-3-3</a></div>
        </div>
      </div>
    </div>
  </div>
</div>

重启项目测试 ,访问首页

用guest 登录:

用Jude登录:

4、登录页定制和记住我

自定义登录页面 login.html:

<div class="ui segment">
  <div style="text-align: center">
    <h1 class="header">登录</h1>
  </div>

  <div class="ui placeholder segment">
    <div class="ui column very relaxed stackable grid">
      <div class="column">
        <div class="ui form">
          <!-- 提交请求记得修改! -->
          <form th:action="@{/login}" method="post">
            <div class="field">
              <label>Username</label>
              <div class="ui left icon input">
                <input type="text" placeholder="Username" name="username">
                <i class="user icon"></i>
              </div>
            </div>
            <div class="field">
              <label>Password</label>
              <div class="ui left icon input">
                <input type="password" name="password">
                <i class="lock icon"></i>
              </div>
            </div>
            <input type="checkbox" name="remember"> 记住我
            <input type="submit" class="ui blue submit button"/>
          </form>
        </div>
      </div>
    </div>
  </div>

  <div style="text-align: center">
    <div class="ui label">
      </i>注册
  </div>
  <br><br>
</div>
</div>

Controller 添加路由

@GetMapping("/login")
public String login(){
  return "login";
}

SecurityConfig 修改http指定登录页面请求

@Override
protected void configure(HttpSecurity http) throws Exception {
...    
http.formLogin()
			.loginPage("/login");

// 自定义的登录页需要配置 rememberMe 的参数名,就可以绑定到我们前端的!
// 记住我功能
http.rememberMe().rememberMeParameter("remember");
}

重启项目测试,

使用jude登录,勾选记住我,登录成功

会发现cookie中多一个remember-me,有效期是半个月,也就是说你只要不退出,半个月都不用登录了,浏览器每次请求都会把这个cookie带到服务端判断。

但点击“注销”退出报404

估计是自定义了/login请求,也必须要自定义/logout请求的原因。

关闭防csrf攻击功能

// 如果注销404,因为 Security 默认是防止 csrf 跨站伪请求!
 http.csrf().disable(); // 可能会让我们系统不安全

重启项目,测试注销登出成功。

但是这样不安全,百度百科CSRF:

跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click attack 或者 session riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法。跟跨网站脚本(XSS)相比,XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。

开启防CSRF攻击,把/logout 提交方法要改成用form表达 POST提交:

<div sec:authorize="isAuthenticated()">
  <!-- 将注销请求也改成post提交即可! -->
  <form th:action="@{/logout}" method="post">
    <button type="submit">注销</button>
    </form>
</div>

重启项目,测试:

这样就安全了。

5、整合JWT无状态验证

1、pom.xml导入依赖

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--JWT库-->
<dependency>
  <groupId>com.auth0</groupId>
  <artifactId>java-jwt</artifactId>
  <version>3.10.2</version>
</dependency>

2、自定义过滤器用来解析JWT信息

@Component
public class JWTAuthenticationFilter extends OncePerRequestFilter {
  @Autowired
  private JWTUtils jwt;
  @Autowired
  private MyUserDetailsService userDetailService;
  @Autowired
  private BCryptPasswordEncoder encoder;

  @Override
  protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
    //获取Http头中的Authentication
    String authentication = httpServletRequest.getHeader("Authentication");
    if (!StringUtils.isNullOrEmpty(authentication)){
      //解析JWT
      Map<String, Object> claims = jwt.parseToken(authentication);

      //如果解析后不为空,并且Security上下文中没有获取到验证信息(说明没有登录过,因为是无状态的,所以不会保存验证信息。)
      if (claims!=null && SecurityContextHolder.getContext().getAuthentication()==null){
        String username = (String) claims.get("username");
        String password = (String) claims.get("password");

        //重新经过userDetailService验证一次用户名和密码,因为JWT验证无法确保用户是否修改了密码,有被盗风险,或者可以加入IP验证。
        UserDetails userDetails = userDetailService.loadUserByUsername(username);
        if (userDetails!=null){
          if (encoder.matches(password,userDetails.getPassword())){
            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, userDetails.getPassword(), userDetails.getAuthorities());
            //token.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));

            //在上下文中写入jwt解析后的信息,经过UsernamePasswordAuthenticationFilter时就会认为验证通过                        
            SecurityContextHolder.getContext().setAuthentication(token);
          }
        }
      }
    }
    filterChain.doFilter(httpServletRequest, httpServletResponse);
  }
}

3、配置类SecurityConfig

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled=true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  // 密码加密方式
  @Bean
  public BCryptPasswordEncoder encoder(){
    return new BCryptPasswordEncoder();
  }

  // 配置AuthenticationManager
  @Override
  @Bean
  public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
  }

  // 用户信息服务,测试可以直接用memory方式
  @Autowired
  private MyUserDetailsService myUserDetailsService;

  // 自定义过滤器用来解析JWT信息
  @Autowired
  private JWTAuthenticationFilter jwtAuthenticationFilter;

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    final CorsConfiguration corsConfiguration = new CorsConfiguration();
    corsConfiguration.setAllowCredentials(true);
    corsConfiguration.addAllowedHeader("*");
    corsConfiguration.addAllowedOrigin("*");
    corsConfiguration.addAllowedMethod("*");
    corsConfiguration.setMaxAge(18000L);
    source.registerCorsConfiguration("/**", corsConfiguration);
    // 配置访问权限 关闭csrf 允许跨域请求
    http.authorizeRequests().mvcMatchers("/login**").permitAll().anyRequest().authenticated();
    http.csrf().disable();
    http.cors().configurationSource(source);
    http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    // 在Security里UsernamePasswordAuthenticationFilter之前,添加一个过滤器(自定义的)
    http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
  }
  
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
      /* 继承org.springframework.security.core.userdetails.UserDetailsService;UserDetailsService,重写方法从数据库读取用户的账目密码信息构成一个用户令牌*/
      auth.userDetailsService(myUserDetailsService).passwordEncoder(encoder());
      // 内存方式固定用户名密码构成用户令牌
      /*
      auth.inMemoryAuthentication()
      .withUser("test").password(bCryptPasswordEncoder().encode("test123"))
      .authorities("admin")
          .and().passwordEncoder(bCryptPasswordEncoder());
      */
    }
}

4、工具类JWTUtils 创建JWT token,解析token获取当前用户

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Component
public class JWTUtils {
  // 只有服务器上有的签名字符串
  public final Algorithm SIGN = Algorithm.HMAC256("dfds%*af13%^##fgfgs1");

  public String createToken(UserDetails user){
    Map<String,String> claims= new HashMap<>();
    claims.put("username", user.getUsername());
    claims.put("password", user.getPassword());
    return JWT.create()
      .withExpiresAt(new Date(System.currentTimeMillis() + 15 * 60 * 1000))  //设置过期时间
      .withClaim("principal",claims)
      .sign(SIGN);
  }

  public Map<String, Object> parseToken(String token){
    try{
      DecodedJWT verify = JWT.require(SIGN).build().verify(token);
      if (verify.getExpiresAt().after(new Date())){
        return verify.getClaim("principal").asMap();
      }
    }catch (JWTVerificationException ignored){
      return null;
    }
    return null;
  }
}

5、测试

@Controller
public class HomeController {
  @Autowired
  private JWTUtils jwt;

  @Autowired
  private AuthenticationManager authenticationManager;


  @RequestMapping("/login")
  @ResponseBody
  public String login(@RequestParam(value = "username") String username,@RequestParam(value = "password") String password) throws Exception {
    try {
      //这里使用authenticationManager验证,最终还会用到Config中设置的userDetailsService的loadUserByUsername方法
      //也可以直接用userDetailsService进行验证,反正只是为了封装JWT信息
      UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
      authenticationManager.authenticate(token);
    }catch (BadCredentialsException e){
      throw new Exception("账号密码错误",e);
    }
    return jwt.createToken(User.builder().username(username).password(password).authorities("Login").build());
  }

  @RequestMapping("/hello")
  @ResponseBody
  @PreAuthorize("hasAuthority('admin')")
  public String hello(){
    return "hello";
  }
}

@EnableGlobalMethodSecurity

注意在SecurityConfig添加了注解@EnableGlobalMethodSecurity开启了方法级别的安全认证,这个注解为我们提供了prePostEnabled 、securedEnabled 和 jsr250Enabled 三种不同的机制

  • prePostEnabled

    prePostEnabled = true 会解锁下面两个注解

    1) @PreAuthorize 在方法执行前进行验证

    2) @PostAuthorize 在方法执行后进行验证

    public interface UserService {
        List<User> findAllUsers();
      
        @PostAuthorize ("returnObject.type == authentication.name")
        User findById(int id);
          
    	//  @PreAuthorize("hasRole('ADMIN')") 必须拥有 ROLE_ADMIN 角色。
        @PreAuthorize("hasRole('ROLE_ADMIN ')")
        void updateUser(User user);
          
        @PreAuthorize("hasRole('ADMIN') AND hasRole('DBA')")
        void deleteUser(int id);
          
        // @PreAuthorize("principal.username.startsWith('Felordcn')") 用户名开头为 Felordcn 的用户才能访问。
        // @PreAuthorize("#id.equals(principal.username)") 入参 id 必须同当前的用户名相同。
        // @PreAuthorize("#id < 10") 限制只能查询 id 小于 10 的用户
        
      @PreFilter(filterTarget="ids", value="filterObject%2==0")
    	public void delete(List<Integer> ids, List<String> username)
    }
    

    常见的内置表达式如下:

    @PostAuthorize: 该注解使用不多,在方法执行后再进行权限验证。 适合验证带有返回值的权限。Spring EL 提供 返回对象能够在表达式语言中获取返回的对象returnObject。区别在于先执行方法。而后进行表达式判断。如果方法没有返回值实际上等于开放权限控制;如果有返回值实际的结果是用户操作成功但是得不到响应。允许方法调用,但是如果表达式计算结果为false,将抛出一个安全性异常。

  • secureEnabled

    在service方法上添加注解@Secured,在需要安全[角色/权限等]的方法上指定 @Secured,并且只有那些角色/权限的用户才可以调用该方法。

    注意.

    1) 不支持Spring EL表达式

    2) 指定的角色必须以ROLE_开头,只要其声明的角色集合(value)中包含当前用户持有的任一角色就可以访问

@Secured({"ROLE_user"})
void updateUser(User user);

@Secured({"ROLE_admin", "ROLE_user1"})
void updateUser();
  • jsr250Enabled

    启用 JSR-250 安全控制注解,这属于 JavaEE 的安全规范(现为 jakarta 项目),为true开启下面三个注解

    1) @DenyAll: 拒绝所有访问

    2) @RolesAllowed({“USER”, “ADMIN”}): 该方法只要具有”USER”, “ADMIN”任意一种权限就可以访问。这里可以省略前缀ROLE_,实际的权限可能是ROLE_ADMIN

    3) @PermitAll: 允许所有访问

Post Directory