基于 Spring Security 搭建用户权限系统(二) - 自定义配置

说明

本文的目的是如何基于 Spring Security 去扩展实现一个基本的用户权限模块, 内容会覆盖到 Spring Security 常用的配置.

文中涉及到的业务代码是不完善的, 甚至会存在逻辑上的漏洞, 业务部分请自行思考完善.

一、脱离框架实现认证和鉴权

Spring Security、Shiro 这种所谓的安全框架, 其核心作用就是 认证鉴权, 当不使用这些框架时, 比较常规的实现方式如下:

  • 定义一个登录接口用于验证用户身份 (认证)
  • 定义一个过滤器验证用户的访问权限 (鉴权)

伪代码

下面贴出的是核心代码(有删减)

认证

// 登录接口
class LoginController {

    @PostMapping("/login")
	public Object login(HttpServletRequest request, HttpServletResponse response) {
    	// 获取登录用户名和密码
        String username = request.getParameter("username");
        String password = request.getParameter("password");
        
        // 忽略非空校验
        
        // 根据用户名查询数据库
        User user = userDao.selectByUsername(username);
        if(user == null) {
        	return "用户名不存在, 返回登录页";
        }
        // 验证密码
        if(!password.equals(user.getPassword)) {
            return "密码不正确, 返回登录页";
        }
        
        return "登录成功";
    }
}

鉴权

// 权限过滤器
class PermissionFilter implements Filter {

	public void doFilter(HttpServletRequest request, HttpServletResponse response) {
        // 如果是登录请求, 直接放行
        String url = request.getRequestUrl();
        if("/login".equals(url)) {
        	return "放行";
        }
        
        // 从 session 中取出用户信息(包含用户名、权限等关键信息)
        User user = session.get('user');
		
        // 如果 user == null, 表示未登录
        if user == null {
        	return "跳转至登录页";
        }
        
        // 判断用户是否有当前请求的访问权限
        if(user.getPermissions().contains(url)) {
            return "放行";
        }
        
       	return "没有访问权限!"
        
    }

}

以上便是一个基本的、固定思维的 认证和 **鉴权 **的思路, 它的缺点是 可移植性扩展性不高. 改造难度类似于给一台苹果笔记本换内存条一样.

二、基于框架实现认证和鉴权

Spring Security 在扩展性上给我们带来的体验就像给一台普通台式机换内存条, 基本上就是买个内存条, 然后一插一拔就完事了.

它把第一种实现方式中的涉及到的可变因素, 都提供出了对应的配置项, 因此, 实现认证和鉴权就跟组装变形金刚一样.

下面来分析下, 如果要对上述的 伪代码进行配置抽取, 至少都需要配置什么:

  1. 登录接口

    1. 我们需要配置一个登录接口的 url, 以及获取表单数据的参数名称, 例如: usernamepassword
    2. 如何从某种存储介质(redismysqlmemory等)中取出用户信息(涉及到从哪里取, 怎么取).
  2. 权限过滤器

    1. 配置 url 的访问规则, 比如 /user 必须具有 admin 角色的用户才能访问(涉及到如何访问规则, 以及如何匹配这些规则.)

下面看一下 Spring Security 是如何配置上面提到的内容的:

自定义配置

首先需要了解的是 Spring Security 是有自己的默认配置的

  • 默认提供了登录页, 通过 http://localhost:port/login 访问
  • 默认接收登录表单的字段是 usernamepassword
  • 默认提供了默认用户名 user 和 密码 (项目启动时, 会在控制台打印), 存放于内存中.

一、覆盖默认配置类

前面说到, Spring Security 有自己的默认配置, 如果要自定义这些配置, 我们就要通过某种方式, 去覆盖重写原有的配置, Spring Security 为我们提供了相应的入口 :

  1. 定义一个 WebSecurityConfig 配置类, 添加 @EnableWebSecurity 注解
  2. 继承 WebSecurityConfigurerAdapter
  3. 重写对应的方法(下面会重点提到)
// 第一步: 添加注解
@EnableWebSecurity
// 第二步: 继承配置类
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
	// TODO 第三步: 重写方法
}

二、配置表单登录

上一步, 我们已经找到了重写配置的切入点, 下面就接着上一步, 去真正自定义配置

  1. 告诉 Spring Security 如何接收表单中的 **用户名 **和 密码, 也就是配置表单参数名称, 默认是 usernamepassword, 如果不需要, 可以不配置
  2. 默认提供的有登录页, 如果需要自定义页面, 则要通过 loginPage("/login.html") 指定页面路径
  3. 默认登录处理接口请求路径是 /login, 可以通过 loginProcessingUrl() 指定
  4. 需要关闭 csrf, 这里不多解释该名词, 自行了解.
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    
    // 如果需要自定义登录页, 需要增加如下配置, 不让静态资源走框架过滤器
    // 当然也有其它方式可以做到, 我们采用这种推荐方式
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/login.html");
        // 如果需要排除其他静态资源, 参考如下:
        //web.ignoring().antMatchers("/login.html", "/static/**");
    }
    
	@Override
    protected void configure(HttpSecurity http) throws Exception {
       
        // 配置表单登录
        http.formLogin()
                // 第一步: 配置如何接收表单参数
                .usernameParameter("username")
                .passwordParameter("password")
                // 第二步: 配置登录页路径, 这里指定的是 /resource/static/login.html
                .loginPage("/login.html")
                // 第三步: 指定表单提交的 url
                .loginProcessingUrl("/login");
        
        // 第四步: 关闭 csrf
        http.csrf().disable();
    }
}

三、配置读取用户数据源

在第二步中, 相当于配置了前后端交互的规则, 而接下来就要考虑后端的具体处理逻辑. 当后端接收到页面表单提交的参数后, 需要做什么:

  1. 根据用户名查询用户信息, 用户信息可能存在内存, 文件 或者 数据库中
  2. 根据存储方式的不同, 取数据的业务逻辑也大不相同
  3. 取出用户信息后,需要比较表单传来的密码和查询出来的密码是否一致.
  4. 如果密码是通过加密的方式进行存储, 那么还需要对表单提交的密码进行加密, 然后再比较

下面来看一下 Spring Security 是如何处理上述的需求的:

  1. 用户发起登录请求后, Spring Security 会自动调用 UserDetailsService 的 ``loadUserByUsername(username) 方法获取 **用户信息, **然后该方法会返回一个 UserDetails 对象,
  2. 取用户信息的逻辑可以通过重写loadUserByUsername方法, 我们需要把查询出来的用户信息 封装到UserDetails中.
  3. 取出用户信息后, Spring Security 也会自己去比较密码, 这一步也不需要我们去介入.
  4. Spring Security 内置了几种密码加密方式, 通过配置 Bean 的方式, 注入到框架中.

**于是有了如下步骤: **

  1. 定义 Spring Security 自己的 UserDetails 用户类
  2. 定义 处理查询用户的业务类 MyUserDetailsService
  3. MyUserDetailsService 通过配置的方式注入到 Spring Security 中
// 第一步:userDetail 包含了用户的详细信息, 需要按照 Spring Security 提供的接口实现
public class MyUserDetails implements UserDetails {
	// 省略部分代码
}

// 第二步: 查询用户信息
@Service
public class MyUserDetailsService implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private UserRoleMapper userRoleMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        
        // 省略查询用户信息(角色、权限等)
		
       	// 封装到 MyUserDetails 中
        return new MyUserDetails(xxxxxxxxxxx);
    }
}


@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    
    // 第三步: 配置 UserDeatilsService
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(new MyUserDetailsService());
    } 
    
    
    // 如果需要自定义登录页, 需要增加如下配置, 不让静态资源走框架过滤器
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/login.html");
        // 如果需要排除其他静态资源, 参考如下:
        //web.ignoring().antMatchers("/login.html", "/static/**");
    }
    
	@Override
    protected void configure(HttpSecurity http) throws Exception {
        // 关闭跨站攻击
        http.csrf().disable();

        // 配置表单登录
        http.formLogin()
                .usernameParameter("username")
                .passwordParameter("password")
                .loginProcessingUrl("/login")
                .loginPage("/login.html");
    }
    
}

小结

至此, 其实已经完成了用户认证的过程, 我们主要做了以下事情:

  1. 配置访问登录页的路径
  2. 让 Spring security 框架在接收到登录请求后, 知道怎么接收用户名和密码
  3. 根据用户名, 去查询数据库, 获取到用户完整信息, 封装到 UserDetails 对象中
  4. 最后, 由 Spring Security 自动完成根据 UserDetails 验证用户身份的过程

四、配置动态权限

认证过后, 就是访问权限的控制, 即: 根据当前请求的 url, 判断用户是否具有访问权限

  • 如果有访问权限, 就放行
  • 如果没有权限, 就拦截

当访问一个 url 时, 你需要告诉 Spring Security, 访问这个 url 需要什么条件:
比如: /user 必须是 admin 角色才能访问, 亦或者是 admin 用户, 这个可以自定义实现.

下面的例子就以角色作为条件:

  1. 获取访问 **/user** 路径时, 需要的角色

    • 角色和权限是多对多关系, 一个权限 url ( /user) 可以被多个角色访问
    • 查询出所有权限跟角色的对应关系, 比如 **/user**** **可以被 adminsuperadmin 角色访问
    • 然后根据当前 **请求的 url **去查询权限表中是否配置有该 url
    • 如果匹配到返回对应的角色, 比如 (adminsuperadmin)
    • 如果匹配不到, 那么允许匿名访问
@Component
public class MyFilterInvocationSecurityMetadataSource implements
        FilterInvocationSecurityMetadataSource {

    @Autowired
    private MenuMapper menuMapper;

    @Override
    public Collection<ConfigAttribute> getAttributes(Object o) throws
            IllegalArgumentException {
        
        AntPathMatcher antPathMatcher = new AntPathMatcher();
        
        // 获取请求的 url
        String requestUrl = ((FilterInvocation) o).getRequestUrl();
        
        // 获取所有菜单以及对应的角色
        List<Menu> menus = menuService.getAllMenusWithRole();
        
        for (Menu menu : menus) {
            
           	// 如果查询到需要的条件, 就返回
            if (antPathMatcher.match(menu.getUrl(), requestUrl)) {
                List<Role> roles = menu.getRoles();
                String[] str = new String[roles.size()];
                for (int i = 0; i < roles.size(); i++) {
                    str[i] = roles.get(i).getName();
                }
                // 返回
                return SecurityConfig.createList(str);
            }
        }
        // 如果匹配不到,就表示该 url 不需要任何权限, Anonymous 是自己定义的
        return SecurityConfig.createList("Anonymous");
    }

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

    @Override
    public boolean supports(Class<?> aClass) {
        return FilterInvocation.class.isAssignableFrom(aClass);
    }
}
  1. 判断用户是否有权限

在上一步中, 我们拿到了访问一个 url 需要哪些角色, 下面就需要告诉 Spring Security 如何基于这些条件做出抉择( **放行 or 拒绝通行 **)

  • 拿到上一步获取到的条件(即: 要求的角色), 开始遍历
  • 如果当前登录用户具备这样的角色, 就放行
  • 如果可以匿名访问, 也放行
  • 否则拒绝放行
@Component
public class MyAccessDecisionManager implements AccessDecisionManager {

    @Override
    public void decide(Authentication authentication, Object o,
                       Collection<ConfigAttribute> collection) throws AccessDeniedException,
            AuthenticationException {
		// 这里获取到访问当前 url 需要的条件
        Iterator<ConfigAttribute> iterator = collection.iterator();
        //         
        while (iterator.hasNext()) {
            
            ConfigAttribute ca = iterator.next();
            // 获取
            String needRole = ca.getAttribute();
            // 如果可以匿名访问, 就放行
            if ("Anonymous".equals(needRole)) {
                return;
            }
           
            
            //当前用户所具有的权限
            Collection<? extends GrantedAuthority> authorities =
                    authentication.getAuthorities();
            
            for (GrantedAuthority authority : authorities) {
                if (authority.getAuthority().equals(needRole)) {
                    return;
                }
            }
        }

        throw new AccessDeniedException("权限不足!");

    }

    @Override
    public boolean supports(ConfigAttribute attribute) {
        return true;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return true;
    }
}
  1. 配置动态权限

将第一步 和 第二步 的配置告诉 Spring Security

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private MyFilterInvocationSecurityMetadataSource
            myFilterInvocationSecurityMetadataSource;

    @Autowired
    private MyAccessDecisionManager myAccessDecisionManager;

    @Autowired
    private MyUserDetailsService myUserDetailsService;

    @Autowired
    private MyAuthenticationFailureHandler myAuthenticationFailureHandler;

    @Autowired
    private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationAccessDeniedHandler authenticationAccessDeniedHandler;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(myUserDetailsService);

    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/login.html", "/static/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 关闭跨站攻击
        http.csrf().disable();
        
        // 配置表单登录
        // 配置表单登录
        http.formLogin()
                .usernameParameter("username")
                .passwordParameter("password")
                .loginProcessingUrl("/login")
                .loginPage("/login.html");
		
        // 配置动态权限
        http.authorizeRequests()
                .withObjectPostProcessor(
                        new ObjectPostProcessor<FilterSecurityInterceptor>() {
                            @Override
                            public <O extends FilterSecurityInterceptor> O postProcess(O o) {
                                o.setSecurityMetadataSource(myFilterInvocationSecurityMetadataSource);
                                o.setAccessDecisionManager(myAccessDecisionManager);
                                return o;
                            }
                        });

        // 关闭 csrf 
        http.csrf().disable();

    }
}

五、修改 Spring Security 拦截行为

前面提到, 如果用户未登录, Spring Security 会跳转至登录页, 如果鉴权失败, 会跳转至 403 forbidden 页面. 这些也是可以通过重写对应的方法去实现的, 详情请参考:

  • Spring security 入门 - 03 自定义登录成功后的处理逻辑

附: 完整代码

关注点请放在 Spring Security 配置部分, 业务部分请自行完善, 当然, 后续自己也会基于此代码搭建一个完整的用户权限系统.

https://github.com/nimo10050/spring-security-sample/tree/master/spring-security-06

你可能感兴趣的:(springsecurity)