Java杂货铺用Security做权限极简入门

16次阅读

共计 8721 个字符,预计需要花费 22 分钟才能阅读完成。

原来大多数单体项目都是用的 shiro,随着分布式的逐渐普及以及与 Spring 的天生自然的结合。Spring Security 安全框架越受大家的青睐。本文会教你用 SpringSecurity 设计单项目的权限,关于如何做分布式的权限,后续会跟进。

为什么选择 SpringSecurity?

现如今,在 JavaWeb 的世界里 Spring 可以说是一统江湖,随着微服务的到来,SpringCloud 可以说是 Java 程序员必须熟悉的框架,就连阿里都为 SpringCloud 写开源呢。(比如大名鼎鼎的 Nacos)作为 Spring 的亲儿子,SpringSecurity 很好的适应了了微服务的生态。你可以非常简便的结合 Oauth 做认证中心服务。本文先从最简单的单体项目开始,逐步掌握 Security。更多可达官方文档

准备

我准备了一个简单的 demo,具体代码会放到文末。提前声明,本 demo 没有用 JWT,因为我想把 token 的维护放到服务端,更好的维护过期时间。(当然,如果将来微服务认证中心的形式,JWT 也可以做到方便的维护过期时间,不做过多讨论)如果想了解 Security+JWT 简易入门,请戳

本项目结构如下

另外,本 demo 使用了 MybatisPlus、lombok。

核心代码

首先需要实现两个类,一个是 UserDetails 的实现类 SecurityUser,一个是 UserDetailsService 的实现类 SecurityUserService。

**
 * Security 要求需要实现的 User 类
 * */
@Data
public class SecurityUser implements UserDetails {
    @Autowired
    private SysRoleService sysRoleService;
    // 用户登录名(注意此处的 username 和 SysUser 的 loginName 是一个值)
    private String username;
    // 登录密码
    private String password;
    // 用户 id
    private SysUser sysUser;
    // 该用户的所有权限
    private List<SysMenu> sysMenuList;
    /** 构造函数 */
    public SecurityUser(SysUser sysUser){this.username = sysUser.getLoginName();
        this.password = sysUser.getPassword();
        this.sysUser = sysUser;
    }
    public SecurityUser(SysUser sysUser,List<SysMenu> sysMenuList){this.username = sysUser.getLoginName();
        this.password = sysUser.getPassword();
        this.sysMenuList = sysMenuList;
        this.sysUser = sysUser;
    }
    /** 需要实现的方法 */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {List<GrantedAuthority> authorities = new ArrayList<>();
        for(SysMenu menu : sysMenuList) {authorities.add(new SimpleGrantedAuthority(menu.getPerms()));
        }
        return authorities;
    }
    @Override
    public String getPassword() {return this.password;}
    @Override
    public String getUsername() {return this.username;}
    // 默认账户未过期
    @Override
    public boolean isAccountNonExpired() {return true;}
    // 默认账户没有带锁
    @Override
    public boolean isAccountNonLocked() {return true;}
    // 默认凭证没有过期
    @Override
    public boolean isCredentialsNonExpired() {return true;}
    // 默认账户可用
    @Override
    public boolean isEnabled() {return true;}
}

这个类包含着某个请求者的信息,在 Security 中叫做 主体。其中这个方法是必须实现的,可以获取用户的具体权限。我们这边权限的颗粒度达到了菜单级别,而不是很多开源项目中角色那级别,我觉得颗粒度越细越方便(个人觉得 …)

/**
 * Security 要求需要实现的 UserService 类
 * */
@Service
public class SecurityUserService implements UserDetailsService{

    @Autowired
    private SysUserService sysUserService;
    @Autowired
    private SysMenuService sysMenuService;
    @Autowired
    private HttpServletRequest httpServletRequest;

    @Override
    public SecurityUser loadUserByUsername(String loginName) throws UsernameNotFoundException {LambdaQueryWrapper<SysUser> condition = Wrappers.<SysUser>lambdaQuery().eq(SysUser::getLoginName, loginName);
        SysUser sysUser = sysUserService.getOne(condition);
        if (Objects.isNull(sysUser)){throw new UsernameNotFoundException("未找到该用户!");
        }
        Long projectId = null;
        try{projectId = Long.parseLong(httpServletRequest.getHeader("projectId"));
        }catch (Exception e){ }
        SysMenuModel sysMenuModel;
        if (sysUser.getUserType()){sysMenuModel = new SysMenuModel();
        }else {sysMenuModel = new SysMenuModel().setUserId(sysUser.getId());
        }
        sysMenuModel.setProjectId(projectId);
        List<SysMenu> menuList = sysMenuService.getList(sysMenuModel);
        return new SecurityUser(sysUser,menuList);
    }
}

显而易见,这个类实现了唯一的方法 loadUserByUsername,从而可以拿到某用户的所有权限,并生成主体,在后面的 filter 中就可以见到他的作用了。

在看配置和 filter 之前,还有一个类需要说明一下,此类提供方法,可以让用户未登录、或者 token 失效的情况下进行统一返回。

@Component
public class SecurityAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {

    private static final long serialVersionUID = 1L;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {response.sendError(HttpServletResponse.SC_UNAUTHORIZED,"token 失效,请登陆后重试");
    }
}

ok,接下来看配置,实现了 WebSecurityConfigurerAdapter 的 SecurityConfig 类,特别说明,本 demo 算是前后端分离的前提下写的,所以实现过多的方法,其实这个类可以实现三个方法,具体请戳。

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter{

    @Autowired
    SecurityAuthenticationEntryPoint securityAuthenticationEntryPoint;
    @Autowired
    SecurityFilter securityFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // 禁止 csrf
                .csrf().disable()
                // 异常处理
                .exceptionHandling().authenticationEntryPoint(securityAuthenticationEntryPoint).and()
                //Session 管理方式
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                // 开启认证
                .authorizeRequests()
                .antMatchers("/login/login").permitAll()
                .antMatchers("/login/register").permitAll()
                .antMatchers("/login/logout").permitAll()
                .anyRequest().authenticated();
        http
                .addFilterBefore(securityFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

异常处理就是上面那个类,Session 那几种管理方式我在那篇 Security+JWT 的文章中也有所讲解,比较简单,然后是几个不用验证的登录路径,剩下的都需要经过我们下面这个 filter。

@Slf4j
@Component
public class SecurityFilter extends OncePerRequestFilter {

    @Autowired
    SecurityUserService securityUserService;
    @Autowired
    SysUserService sysUserService;
    @Autowired
    SysUserTokenService sysUserTokenService;

    /**
     * 认证授权
     * */
    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
                                    FilterChain filterChain) throws ServletException, IOException {log.info("访问的链接是:{}",httpServletRequest.getRequestURL());
        try {final String token = httpServletRequest.getHeader("token");
            LambdaQueryWrapper<SysUserToken> condition = Wrappers.<SysUserToken>lambdaQuery().eq(SysUserToken::getToken, token);
            SysUserToken sysUserToken = sysUserTokenService.getOne(condition);
            if (Objects.nonNull(sysUserToken)){SysUser sysUser = sysUserService.getById(sysUserToken.getUserId());
                if (Objects.nonNull(sysUser)){SecurityUser securityUser = securityUserService.loadUserByUsername(sysUser.getLoginName());
                    // 将主体放入内存
                    UsernamePasswordAuthenticationToken authentication =
                            new UsernamePasswordAuthenticationToken(securityUser, null, securityUser.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
                    // 放入内存中去
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        }catch (Exception e){log.error("认证授权时出错:{}", Arrays.toString(e.getStackTrace()));
        }
        filterChain.doFilter(httpServletRequest, httpServletResponse);
    }
}

判断用户是否登录,就是从数据库中查看是否有未过期的 token,如果存在,就把主体信息放进到项目的内存中去,特别说明的是,每个请求链结束,SecurityContextHolder.getContext()的数据都会被 clear 的,所以,每次请求的时候都需要 set。

以上就完成了 Security 核心的创建,为了业务代码方便获取内存中的主体信息,我特意加了一个获取用户信息的方法

/**
 * 获取 Security 主体工具类
 * @author pjjlt
 * */
public class SecurityUserUtil {public static SysUser getCurrentUser(){SecurityUser securityUser = (SecurityUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        if (Objects.nonNull(securityUser) && Objects.nonNull(securityUser.getSysUser())){return securityUser.getSysUser();
        }
        return null;
    }
}

业务代码

以上是 Security 核心代码,下面简单加两个业务代码,比如登录和某个接口的权限访问测试。

万物之源登录登出

首先,不被 filter 拦截的那三个方法注册、登录、登出,我都写在了 moudle.controller.LoginController 这个路径下,注册就不用说了,就是一个 insertUser 的方法,做好判断就好,密码通过 AES 加个密。

下面看下登录代码,controller 层就不说了,反正就是个验参。

    /**
     * 登录,返回登录信息,前端需要缓存
     * */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public JSONObject login(SysUserModel sysUserModel) throws Exception{JSONObject result = new JSONObject();
        //1. 验证账号是否存在、密码是否正确、账号是否停用
        Wrapper<SysUser> sysUserWrapper = Wrappers.<SysUser>lambdaQuery()
                .eq(SysUser::getLoginName,sysUserModel.getLoginName()).or()
                .eq(SysUser::getEmail,sysUserModel.getEmail());
        SysUser sysUser = baseMapper.selectOne(sysUserWrapper);
        if (Objects.isNull(sysUser)){throw new Exception("用户不存在!");
        }
        String password = CipherUtil.encryptByAES(sysUserModel.getPassword());
        if (!password.equals(sysUser.getPassword())){throw new Exception("密码不正确!");
        }
        if (sysUser.getStatus()){throw new Exception("账号已删除或已停用!");
        }
        // 2. 更新最后登录时间
        sysUser.setLoginIp(ServletUtil.getClientIP(request));
        sysUser.setLoginDate(LocalDateTime.now());
        baseMapper.updateById(sysUser);
        // 3. 封装 token,返回信息
        String token = UUID.fastUUID().toString().replace("-","");
        LocalDateTime expireTime = LocalDateTime.now().plusSeconds(expireTimeSeconds);
        SysUserToken sysUserToken = new SysUserToken()
                .setToken(token).setUserId(sysUser.getId()).setExpireTime(expireTime);
        sysUserTokenService.save(sysUserToken);
        result.putOpt("token",token);
        result.putOpt("expireTime",expireTime);
        return result;
    }

首先验证下用户是否存在,登录密码是否正确,然后封装 token,值得一提的是,我并没有从数据库(sysUserToken)中获取用户已经登录的 token,然后更新过期时间的形式做登录,而是每次登录都获取新 token,这样就可以做到多端登录了,后期还可以做账号登录数量的控制。

然后就是登出,删除库中存在的 token

    /**
     * 登出,删除 token
     * */
    @Override
    public void logout() throws Exception{String token = httpServletRequest.getHeader("token");
        if (Objects.isNull(token)){throw new LoginException("token 不存在",ResultEnum.LOGOUT_ERROR);
        }
        LambdaQueryWrapper<SysUserToken> sysUserWrapper = Wrappers.<SysUserToken>lambdaQuery()
                .eq(SysUserToken::getToken,token);
        baseMapper.delete(sysUserWrapper);
    }

权限验证

这边我维护了两个账号,一个是超级管理员 majian,拥有所有权限。一个是普通人员_pjjlt,只有一些权限,我们看一下访问接口的效果。

我们访问的接口是 moudle.controller.LoginController 路径下的

@PreAuthorize("hasAnyAuthority('test')")
@GetMapping("test")
public String test(){return "test";}

其中 hasAnyAuthority(‘test’)就是权限码

我们模拟用不同账号访问,就是改变请求 header 中的 token 值,就是登录阶段返回给前端的 token。

首先是超级管理员验证

然后是普通管理员访问

接着没有登录(token 不存在或者已过期)访问

demo 地址

https://github.com/majian1994…

结束语

本文简单讲解了,主要是将 Security 相关的东西,具体实现角色的三要素,用户、角色、权限(菜单)可以看我的代码,都写完测完了,本来想写个文档管理系统,帮助我司更好的管理接口文档,but 有位小伙伴找了一个不错的开源的了,所以这代码就成了我的一个小 demo。最后的最后,可不可以放波公众号啊,以后打算公众号同步写文章。

正文完
 0