Spring Security单项目权限设计过程解析
为什么选择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
publicclassSecurityUserimplementsUserDetails{
@Autowired
privateSysRoleServicesysRoleService;
//用户登录名(注意此处的username和SysUser的loginName是一个值)
privateStringusername;
//登录密码
privateStringpassword;
//用户id
privateSysUsersysUser;
//该用户的所有权限
privateListsysMenuList;
/**构造函数*/
publicSecurityUser(SysUsersysUser){
this.username=sysUser.getLoginName();
this.password=sysUser.getPassword();
this.sysUser=sysUser;
}
publicSecurityUser(SysUsersysUser,ListsysMenuList){
this.username=sysUser.getLoginName();
this.password=sysUser.getPassword();
this.sysMenuList=sysMenuList;
this.sysUser=sysUser;
}
/**需要实现的方法*/
@Override
publicCollectiongetAuthorities(){
Listauthorities=newArrayList<>();
for(SysMenumenu:sysMenuList){
authorities.add(newSimpleGrantedAuthority(menu.getPerms()));
}
returnauthorities;
}
@Override
publicStringgetPassword(){
returnthis.password;
}
@Override
publicStringgetUsername(){
returnthis.username;
}
//默认账户未过期
@Override
publicbooleanisAccountNonExpired(){
returntrue;
}
//默认账户没有带锁
@Override
publicbooleanisAccountNonLocked(){
returntrue;
}
//默认凭证没有过期
@Override
publicbooleanisCredentialsNonExpired(){
returntrue;
}
//默认账户可用
@Override
publicbooleanisEnabled(){
returntrue;
}
}
这个类包含着某个请求者的信息,在Security中叫做主体。其中这个方法是必须实现的,可以获取用户的具体权限。我们这边权限的颗粒度达到了菜单级别,而不是很多开源项目中角色那级别,我觉得颗粒度越细越方便(个人觉得...)
/**
*Security要求需要实现的UserService类
**/
@Service
publicclassSecurityUserServiceimplementsUserDetailsService{
@Autowired
privateSysUserServicesysUserService;
@Autowired
privateSysMenuServicesysMenuService;
@Autowired
privateHttpServletRequesthttpServletRequest;
@Override
publicSecurityUserloadUserByUsername(StringloginName)throwsUsernameNotFoundException{
LambdaQueryWrappercondition=Wrappers.lambdaQuery().eq(SysUser::getLoginName,loginName);
SysUsersysUser=sysUserService.getOne(condition);
if(Objects.isNull(sysUser)){
thrownewUsernameNotFoundException("未找到该用户!");
}
LongprojectId=null;
try{
projectId=Long.parseLong(httpServletRequest.getHeader("projectId"));
}catch(Exceptione){
}
SysMenuModelsysMenuModel;
if(sysUser.getUserType()){
sysMenuModel=newSysMenuModel();
}else{
sysMenuModel=newSysMenuModel().setUserId(sysUser.getId());
}
sysMenuModel.setProjectId(projectId);
ListmenuList=sysMenuService.getList(sysMenuModel);
returnnewSecurityUser(sysUser,menuList);
}
}
显而易见,这个类实现了唯一的方法loadUserByUsername,从而可以拿到某用户的所有权限,并生成主体,在后面的filter中就可以见到他的作用了。
在看配置和filter之前,还有一个类需要说明一下,此类提供方法,可以让用户未登录、或者token失效的情况下进行统一返回。
@Component
publicclassSecurityAuthenticationEntryPointimplementsAuthenticationEntryPoint,Serializable{
privatestaticfinallongserialVersionUID=1L;
@Override
publicvoidcommence(HttpServletRequestrequest,HttpServletResponseresponse,
AuthenticationExceptionauthException)throwsIOException,ServletException{
response.sendError(HttpServletResponse.SC_UNAUTHORIZED,"token失效,请登陆后重试");
}
}
ok,接下来看配置,实现了WebSecurityConfigurerAdapter的SecurityConfig类,特别说明,本demo算是前后端分离的前提下写的,所以实现过多的方法,其实这个类可以实现三个方法。
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled=true)
publicclassSecurityConfigextendsWebSecurityConfigurerAdapter{
@Autowired
SecurityAuthenticationEntryPointsecurityAuthenticationEntryPoint;
@Autowired
SecurityFiltersecurityFilter;
@Override
protectedvoidconfigure(HttpSecurityhttp)throwsException{
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
publicclassSecurityFilterextendsOncePerRequestFilter{
@Autowired
SecurityUserServicesecurityUserService;
@Autowired
SysUserServicesysUserService;
@Autowired
SysUserTokenServicesysUserTokenService;
/**
*认证授权
**/
@Override
protectedvoiddoFilterInternal(HttpServletRequesthttpServletRequest,HttpServletResponsehttpServletResponse,
FilterChainfilterChain)throwsServletException,IOException{
log.info("访问的链接是:{}",httpServletRequest.getRequestURL());
try{
finalStringtoken=httpServletRequest.getHeader("token");
LambdaQueryWrappercondition=Wrappers.lambdaQuery().eq(SysUserToken::getToken,token);
SysUserTokensysUserToken=sysUserTokenService.getOne(condition);
if(Objects.nonNull(sysUserToken)){
SysUsersysUser=sysUserService.getById(sysUserToken.getUserId());
if(Objects.nonNull(sysUser)){
SecurityUsersecurityUser=securityUserService.loadUserByUsername(sysUser.getLoginName());
//将主体放入内存
UsernamePasswordAuthenticationTokenauthentication=
newUsernamePasswordAuthenticationToken(securityUser,null,securityUser.getAuthorities());
authentication.setDetails(newWebAuthenticationDetailsSource().buildDetails(httpServletRequest));
//放入内存中去
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
}catch(Exceptione){
log.error("认证授权时出错:{}",Arrays.toString(e.getStackTrace()));
}
filterChain.doFilter(httpServletRequest,httpServletResponse);
}
}
判断用户是否登录,就是从数据库中查看是否有未过期的token,如果存在,就把主体信息放进到项目的内存中去,特别说明的是,每个请求链结束,SecurityContextHolder.getContext()的数据都会被clear的,所以,每次请求的时候都需要set。
以上就完成了Security核心的创建,为了业务代码方便获取内存中的主体信息,我特意加了一个获取用户信息的方法
/**
*获取Security主体工具类
*@authorpjjlt
**/
publicclassSecurityUserUtil{
publicstaticSysUsergetCurrentUser(){
SecurityUsersecurityUser=(SecurityUser)SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if(Objects.nonNull(securityUser)&&Objects.nonNull(securityUser.getSysUser())){
returnsecurityUser.getSysUser();
}
returnnull;
}
}
业务代码
以上是Security核心代码,下面简单加两个业务代码,比如登录和某个接口的权限访问测试。
万物之源登录登出
首先,不被filter拦截的那三个方法注册、登录、登出,我都写在了moudle.controller.LoginController这个路径下,注册就不用说了,就是一个insertUser的方法,做好判断就好,密码通过AES加个密。
下面看下登录代码,controller层就不说了,反正就是个验参。
/**
*登录,返回登录信息,前端需要缓存
**/
@Override
@Transactional(rollbackFor=Exception.class)
publicJSONObjectlogin(SysUserModelsysUserModel)throwsException{
JSONObjectresult=newJSONObject();
//1.验证账号是否存在、密码是否正确、账号是否停用
WrappersysUserWrapper=Wrappers.lambdaQuery()
.eq(SysUser::getLoginName,sysUserModel.getLoginName()).or()
.eq(SysUser::getEmail,sysUserModel.getEmail());
SysUsersysUser=baseMapper.selectOne(sysUserWrapper);
if(Objects.isNull(sysUser)){
thrownewException("用户不存在!");
}
Stringpassword=CipherUtil.encryptByAES(sysUserModel.getPassword());
if(!password.equals(sysUser.getPassword())){
thrownewException("密码不正确!");
}
if(sysUser.getStatus()){
thrownewException("账号已删除或已停用!");
}
//2.更新最后登录时间
sysUser.setLoginIp(ServletUtil.getClientIP(request));
sysUser.setLoginDate(LocalDateTime.now());
baseMapper.updateById(sysUser);
//3.封装token,返回信息
Stringtoken=UUID.fastUUID().toString().replace("-","");
LocalDateTimeexpireTime=LocalDateTime.now().plusSeconds(expireTimeSeconds);
SysUserTokensysUserToken=newSysUserToken()
.setToken(token).setUserId(sysUser.getId()).setExpireTime(expireTime);
sysUserTokenService.save(sysUserToken);
result.putOpt("token",token);
result.putOpt("expireTime",expireTime);
returnresult;
}
首先验证下用户是否存在,登录密码是否正确,然后封装token,值得一提的是,我并没有从数据库(sysUserToken)中获取用户已经登录的token,然后更新过期时间的形式做登录,而是每次登录都获取新token,这样就可以做到多端登录了,后期还可以做账号登录数量的控制。
然后就是登出,删除库中存在的token
/**
*登出,删除token
**/
@Override
publicvoidlogout()throwsException{
Stringtoken=httpServletRequest.getHeader("token");
if(Objects.isNull(token)){
thrownewLoginException("token不存在",ResultEnum.LOGOUT_ERROR);
}
LambdaQueryWrappersysUserWrapper=Wrappers.lambdaQuery()
.eq(SysUserToken::getToken,token);
baseMapper.delete(sysUserWrapper);
}
权限验证
这边我维护了两个账号,一个是超级管理员majian,拥有所有权限。一个是普通人员_pjjlt,只有一些权限,我们看一下访问接口的效果。
我们访问的接口是moudle.controller.LoginController路径下的
@PreAuthorize("hasAnyAuthority('test')")
@GetMapping("test")
publicStringtest(){
return"test";
}
其中hasAnyAuthority('test')就是权限码
我们模拟用不同账号访问,就是改变请求header中的token值,就是登录阶段返回给前端的token。
首先是超级管理员验证
然后是普通管理员访问
接着没有登录(token不存在或者已过期)访问
demo地址
https://github.com/majian1994/easy-file-back
结束语
本文简单讲解了,主要是将Security相关的东西,具体实现角色的三要素,用户、角色、权限(菜单)可以看我的代码,都写完测完了,本来想写个文档管理系统,帮助我司更好的管理接口文档,but有位小伙伴找了一个不错的开源的了,所以这代码就成了我的一个小demo。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持毛票票。
声明:本文内容来源于网络,版权归原作者所有,内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎发送邮件至:czq8825#qq.com(发邮件时,请将#更换为@)进行举报,并提供相关证据,一经查实,本站将立刻删除涉嫌侵权内容。