fantasy

风色幻想

我与春风皆过客,你携秋水揽星河

fantasy

3899 字

前言

在开发项目时在安全框架选用上纠结了一段时间,整体来看,目前Javaweb中流行的安全框架主要有ShiroSpring security,其中shiro上手快、学习曲线平滑、权限控制细粒度大;Spring Security上手难(超麻烦)、学习曲线陡峭、权限控制细粒度小。最终考虑到我的项目主要基于Spring Boot搭建,与Spring Security有较高的契合度(同根同源),最终选用了Spring Security框架。本文主要介绍了Spring Security相关基本概念,后又结合实际实例给出了具体的实现方式。

介绍

Spring SecuritySpring框架的扩展框架,注重于解决项目的安全问题,是一个完善且周全的安全框架,它出自于Acegi Security框架,注重于为Java项目提供可定制的身份验证(Authentication)和授权(Authorization)等功能。其中身份验证是指为注册用户构建一个他所声明的主体的过程(主体一般是指用户,设备或可以在你系统中执行动作的其他系统);授权是指判断一个注册用户用户能否在我们的系统中执行某个操作,只有已验证的用户才能开始授权,身份的主体在身份验证过程建立。
在身份验证中,Spring Security框架的支持多种验证方法。这些认证方法大部分由第三方或相关的标准组织提供,另外Spring Security也默认提供自己的一套验证方法。总而言之,Spring Security目前已经支持所有这些技术集成的身份验证:

  • HTTP BASIC 认证头(基于 IETF RFC-based 标准)
  • HTTP Digest 认证头 (基于 IETF RFC-based 标准)
  • HTTP X.509 客户端证书交换 (IETF RFC-based 标准)
  • LDAP (一个非常常见的方法来跨平台认证需要,尤其是在大型环境)
  • Form-based authentication (用于简单的用户界面)
  • OpenID 认证
  • ·······

需求

现有一个基本网站,需要实现以下功能:

  • 网站有登录界面(/admin/login)、后台主页面(/admin/index)、网站统计页面(/admin/siteStatic
  • 管理员登陆时需要与数据库对比登录信息,登录成功跳转后台主页面,登录失败提示错误
  • 登录界面可以直接访问、后台主页面需要ROLE_ADMIN_CITY权限、网站统计页面需要ROLE_ADMIN_SUPER权限
  • 未登录时访问其他界面会强制跳转登录界面
  • 如果ROLE_ADMIN_CITY权限账号登陆访问统计界面则提示权限不足

其他说明:

  1. 本例中"管理员"与"用户"概念上完全等价
  2. 本例中包含了常用的使用方法,其中有些非必须,可根据自己需要删除功能

实现

依赖

本例基于Spring Boot框架(其他框架所需依赖不同),在使用Maven作为包管理工具时,仅需添加以下代码:

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

数据库实现

数据库使用RBAC配合实现,表的具体结构来自 RBAC模型的分析与实现,其中各表初始示例数据如下:

  • 用户表(admin用户表
  • 角色表(role角色表
  • 权限表(Permission权限表
  • 用户-角色关联表(User-Role用户-角色关联表
  • 角色-权限关联表(Role-Permission角色-权限关联表

Java实现

  1. Admin(用户)实体类
    首先创建管理员实体类,该类保存了用户名(username)、密码(password)等基本登陆信息。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Admin {
	private Integer id;	
	private String username;	
	private String password;	
	private String icon;	//头像
	private String city;	//所在城市
	private String nickName;	//昵称
	private String note;	//备注
	private Integer contactInfoId;	//联系方式id
	private Integer status;	//帐号启用状态:0->禁用;1->启用
	private Timestamp lastLoginTime;	//最后登陆时间
	private Timestamp createTime;	//创建时间
}

其次创建管理员DTO类,该类是登陆认证的直接对象,其中除了继承管理员类之外也实现了Spring SecurityUserDetails接口(Spring Security要求登陆实体类必须实现该接口)。UserDetails接口中必须认真实现的方法是getAuthorities,该方法用于返回当前用户的角色集合,其他方法主要用于判断当前用户是否符合额外要求,可直接return true;

@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class AdminDto extends Admin implements UserDetails {
    private ContactInfo contactInfo;
    private List<Role> roles;
    public boolean checkRole(String code) {
        for (Role role : roles) {
            if (role.getCode().equals(code)) {
                return true;
            }
        }
        return false;
    }
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 获取用户角色信息
        List<SimpleGrantedAuthority> simpleGrantedAuthorities = new ArrayList<>();
        for (Role role : roles) {
            simpleGrantedAuthorities.add(new SimpleGrantedAuthority(role.getCode()));
        }
        return simpleGrantedAuthorities;
    }
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    @Override
    public boolean isEnabled() {
        return true;
    }
}
  1. AdminAuthenticationProvider
    创建一个AdminAuthenticationProvider类,该类实现AuthenticationProvider接口,其中重要的方法是authenticate,该方法主要用于比对用户登陆口令是否与数据库对应的用户口令一致,匹配成功则通过认证,匹配失败则抛出认证错误。(可以同时注册多个Provider类,当进行登陆认证时,只要有一个Provider通过认证则全局通过认证)
@Component
public class AdminAuthenticationProvider implements AuthenticationProvider {
    @Autowired
    private AdminServImpl adminServ;
    /**
     * 进行身份认证
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 获取用户输入的用户名和密码
        String username = authentication.getName();
        String password = authentication.getCredentials().toString();
        // 获取封装用户信息的对象
        UserDetails userDetails = adminServ.loadUserByUsername(username);
        // 进行密码的比对
        BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
        boolean flag = bCryptPasswordEncoder.matches(password, userDetails.getPassword());
        // 校验通过
        if (flag){
            // 将权限信息也封装进去
            return new UsernamePasswordAuthenticationToken(userDetails,password,userDetails.getAuthorities());
        }
        // 验证失败返回 null
        throw new BadCredentialsException("密码错误!");
    }
    /**
     * 这个方法 确保返回 true 即可,
     */
    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}
  1. UrlFilterInvocationSecurityMetadataSource
    创建一个UrlFilterInvocationSecurityMetadataSource类,该类实现了FilterInvocationSecurityMetadataSource接口,其中重要函数getAttributes主要用于从数据库获取当前登陆用户访问的URL所要求的角色信息并返回,其他方法使用默认实现即可。
@Component
public class UrlFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
    @Autowired
    PermissionMapper permissionMapper;
    @Autowired
    RoleMapper roleMapper;
    @Autowired
    RolePermissionMapper rolePermissionMapper;
    @Autowired
    RolePermissionDtoMapper rolePermissionDtoMapper;
    /**
     * @Description: 返回该url所需要的用户权限信息
     * @Param: [o] 储存请求url信息
     **/
    @Override
    public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
        // 获取当前请求url
        String requestUrl = ((FilterInvocation) o).getRequestUrl();
        if (requestUrl.contains("?")){
            requestUrl = requestUrl.substring(0, requestUrl.indexOf("?"));
        }
        if (requestUrl.equals("/admin/login")) {
            return null;
        }
        Permission permission = new Permission();
        permission.setUri(requestUrl);
        // 数据库中该url的信息
        List<RolePermissionDto> rolePermissionDtoList = rolePermissionDtoMapper.selectDtoBySome(null, null, null, permission);
        List<String> roles = new ArrayList<>();
        // 若数据库没有该uri权限信息, 则默认需要 "ROLE_ADMIN_CITY" 权限
        if (CollectionUtil.isEmpty(rolePermissionDtoList)) {
            roles.add(Role.ROLE_ADMIN_CITY);
        }
        for (RolePermissionDto rolePermissionDto : rolePermissionDtoList) {
            Role role = rolePermissionDto.getRole();
            if (ObjectUtil.isNotEmpty(role)) {
                roles.add(role.getCode());
            }
        }
        return SecurityConfig.createList(roles.toArray(new String[roles.size()]));
    }
    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }
    @Override
    public boolean supports(Class<?> aClass) {
        return FilterInvocation.class.isAssignableFrom(aClass);
    }
}
  1. UrlAccessDecisionManager
    创建一个UrlAccessDecisionManager类,该类实现了AccessDecisionManager接口,其中重要函数decide主要用于判断当前登陆用户是否包含UrlFilterInvocationSecurityMetadataSource中要求的角色信息(参数collection存的是上节getAttributes返回的角色信息),若包含则通过验证,若不包含则抛出错误,其他方法使用默认实现即可。
@Component
public class UrlAccessDecisionManager implements AccessDecisionManager {
    /**
     * @Param: [当前登录用户的角色信息, 请求url信息, 当前请求需要的角色(可能有多个)]
     **/
    @Override
    public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException {
        // 遍历角色
        for (ConfigAttribute ca : collection) {
            // ① 当前url请求需要的权限
            String needRole = ca.getAttribute();
            // ② 当前用户所具有的角色
            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
            for (GrantedAuthority authority : authorities) {
                // 只要包含其中一个角色即可访问
                if (authority.getAuthority().equals(needRole)) {
                    return;
                }
            }
        }
        throw new AccessDeniedException("请联系管理员分配权限!");
    }
    @Override
    public boolean supports(ConfigAttribute configAttribute) {
        return true;
    }
    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}
  1. AdminSecurityConfiguration
    创建一个AdminSecurityConfiguration类,主要继承了WebSecurityConfigurerAdapter类,该类是Spring Security的总配置,所有的认证、鉴权功能只有通过此类注册才能生效。其中重要函数configure经过多次重载,不同的重载方法具有不同的功能、重要参数HttpSecurity包含了所有认证方法(方法介绍见附录)。
@Configuration
@Order(2)
public static class AdminSecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Autowired
    private AdminAuthenticationProvider adminAuthenticationProvider;
    // 获取访问url所需要的角色信息
    @Autowired
    private UrlFilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource;
    // 认证权限处理 - 将上面所获得角色权限与当前登录用户的角色做对比,如果包含其中一个角色即可正常访问
    @Autowired
    private UrlAccessDecisionManager urlAccessDecisionManager;
    // 自定义访问无权限接口时403响应内容
    @Autowired
    TestAuthenticationProvider testAuthenticationProvider;
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //将自定义验证类注册进去
        auth.authenticationProvider(testAuthenticationProvider);
        //加入数据库验证类,下面的语句实际上在验证链中加入了一个DaoAuthenticationProvider
        auth.authenticationProvider(adminAuthenticationProvider);
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 允许使用frame嵌套
        http.headers().frameOptions().sameOrigin()
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.ALWAYS);
        http
                .antMatcher("/admin/**")
                // 登陆界面、登陆表单地址、登陆成功跳转地址
                .formLogin().loginPage("/admin/login").loginProcessingUrl("/admin/loginInfo").defaultSuccessUrl("/admin/index").permitAll()
                // 登陆表单用户名和密码的name值
                .usernameParameter("username").passwordParameter("password")
                .and()
                .logout()
                // 注销地址、注销成功后跳转地址
                .logoutRequestMatcher(new AntPathRequestMatcher("/admin/logout")).logoutSuccessUrl("/admin/login")
                .and()
                .authorizeRequests()
                .antMatchers("/admin/**").authenticated()
                // 其他URL拦截
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O object) {
                        object.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource);
                        object.setAccessDecisionManager(urlAccessDecisionManager);
                        return object;
                    }
                })
        ;
    }
    @Override
    public void configure(WebSecurity webSecurity) {
        // 拦截白名单
        webSecurity.ignoring().antMatchers(
                "/**/notifyUrl",
                "/**/*.png",
                "/**/*.gif",
                "/**/*.ico",
                "/**/*.jpg",
                "/**/*.css",
                "/**/*.js");
    }
}

效果

  1. 登陆失败
    登陆失败
  2. 权限不足
    权限不足
  3. 权限足够
    权限足够

总结

Spring Security入门非常麻烦,权限控制细粒度小的代价就是需要大量的配置工作,其中一点错误都会导致系统运行失败,而且在使用的过程中,我发现它不容易处理多用户表(user表和admin表)同时权限控制的需求,我在强行配置好后出现了各种奇葩处理失败的问题(或许我太菜)。但总而言之,Spring Security作为Spring系列的开源框架,有不错的技术团队且能满足一般的权限控制需求,想要深入从事Javaweb开发的话值得学习一下。

附录

  1. HttpSecurity常用方法(官方文档)
方法 介绍
formLogin 配置基于表单的身份验证
logout 退出登录配置
anonymous 匿名用户配置(ROLE_ANONYMOUS)
antMatcher 仅对匹配成功的URL进行后续HttpSecurity配置
authenticationProvider 添加额外的AuthenticationProvider使用
requestMatchers 允许调用实现HttpSecurity的HttpServletRequest实例。
authorizeRequests 基于使用HttpServletRequest实现限制访问
sessionManagement 配置会话管理
rememberMe 配置“记住我”的验证
requestCache 允许配置请求缓存
csrf 添加CSRF支持,默认启用
oauth2Login 使用OAuth 2.0 and/or OpenID Connect 1.0提供者配置身份验证
addFilter 添加是框架中提供的过滤器的实例
openidLogin 用于基于 OpenId 的验证
headers 将安全标头添加到响应
cors 配置跨域资源共享

支付宝打赏二维码
微信打赏二维码

最新文章