内网项目使用RBAC权限管理模式,一个角色上挂载了多个菜单信息,用户登录时根据用户账号查询用户所拥有的菜单,将菜单信息响应给前端进行渲染。此种方式对权限控制的粒度是最粗的,用户有这个菜单即认为用户可以访问这个菜单上的所有资源,并且用户访问菜单上的接口时没有对接口进行权限验证(即此接口用户是否有权限访问)。
这样就产生了一些问题:
1:如果“超级管理员”登录后,我把存在localStorage中超管的菜单信息拷贝出来,放在一个普通用户localStorage中,请求携带普通用户的token,前端是根据存放在localStorage中的菜单信息进行菜单渲染的,而后台接口并没有进行权限控制,那么普通用户拿到了超管的菜单,普通用户也就可能访问超管用户的菜单信息。
2:以“用户管理”菜单为例,用户管理有新增/查询/修改/删除用户的按钮,对于“超级管理员(角色)”和“普通用户(角色)”来说,超管拥有用户管理的所有权限,即可对用户进行增/删/查/改,而我只希望普通用户有查询/修改的权限,如果对这些“按钮”进行控制,可能需要在代码中做一些特殊处理,这样做这些特殊处理很不方便而且代码维护起来很麻烦,使用SpringSecurity我们对用户管理菜单的增/删/查/改接口分别指定不同的权限字符,在角色管理时不仅设置这个角色有哪些菜单,还要设置这个菜单下面要开放哪些功能(按钮)给用户。
如图:若依菜单管理&&角色管理
Token:token用来解决Http会话无状态,用户登录时生成一个token,一般Key的格式为前缀+唯一标识,Value为登录用户对象信息,一般通过token用来判断用户是否登录,且在后端中可以通过token在代码任意地方去Redis中取用户相关信息。
SpringSecurity:用于权限控制保护后台资源,即用户在访问某个后台接口时判断用户是否有访问某个接口的权限,SpringSecurity在进行认证授权需要用户信息时可以重token中取用户信息。
在若依前后端分离项目中权限分为目录、菜单、按钮(功能)权限,在数据库菜单表设计中
通过menu_type字段标识菜单类型(M目录、C菜单、F按钮),说明目录不需要任何权限字符。
用户登录成功后访问:
/getRouters:获取用户目录、菜单信息,即SQL中查询菜单表menu_type in('M','C')【用来渲染目
录/菜单】
/getInfo:获取用户信息、用户权限(目录、菜单、按钮权限字符)【用来控制菜单/按钮的显隐】
前端如何控制菜单下某个按钮的显隐:
以 系统管理 -》用户管理 -》用户新增(按钮)为例:
1:用户登录后访问/getRouters获取到用户的菜单信息,访问/getInfo获取到用户的所有权限字符
(目录、菜单、按钮)。将获取到的菜单和权限字符信息分别保存到前端。
2:登录后前端获取到菜单信息和权限字符信息,前端判断当前返回的菜单信息的权限字符是否包
含在调用的/getInfo获取的权限字符列表中(此处的菜单权限筛选其实是为了避免有人将其他角
色(如:超管)的菜单拷贝到前端存储,前端进行菜单渲染时展示了非当前用户的菜单),个人感
觉 这一步可做可不做,前端做了菜单筛选更能保证系统的安全性,前端不做菜单筛选就算普通
用户拿到了超管的菜单,访问超管菜单时后台接口也会重Redis中取出当前用户的菜单信息,进
行判断用户是否有权访问此菜单。
3:前端菜单渲染完毕,点击用户管理菜单,因为用户管理菜单下面的所有按钮都做了权限判断(即
前端为每个按钮都绑定了不同的权限字符,此权限字符与后端数据库中的权限字符保持一致) 如
用户新增按钮指定了system:user:add权限字符在用户管理菜单加载时,前端将此菜单下面的所
有按钮绑定的权限与/getInfo接口返回的权限字符列表进行匹配筛选,如“用户新增”按钮权限
字符包含于/getInfo接口返回的权限字符列表,即在用户管理菜单中显示用户新增按钮(其他按钮
都是如此判断显隐)
引用侵必删:
【项目实践】一文带你搞定Spring Security + JWT实现前后端分离下的认证授权 - 知乎
若依视频讲解地址: 8 跑后端_哔哩哔哩_bilibili
SpringSecurity的实质是一条过滤器链,SpringSecurity分为“认证”,“授权”两部分,认证
即是对用户身份的确认,授权即是用户能否访问某个资源的确认,认证和授权都是由自定义的
过滤器来实现认证授权的功能。
在Servlet过滤器链中,Spring Security向其添加了一个FilterChainProxy过滤器,这个代理过滤器会创建一套Spring Security自定义的过滤器链,然后执行一系列过滤器。
SpringSecurity默认创建的过滤器:
常见过滤器:
UsernamePasswordAuthenticationFilter:负责认证的过滤器
FilterSecurityInterceptor:负责授权的过滤器
LogoutFilter:登出过滤器
AnonymousAuthenticationFilter:匿名身份认证过滤器
ExceptionTranslationFilter:只处理认证/授权类异常过滤器
认证流程:
AuthenticationManager
UserDetailService
查询出UserDetails
PasswordEncoder
UserDetails
存入到Authentication
,将Authentication
存入到SecurityContext
AuthenticationEntryPoint
处理授权流程:
Spring Security的授权发生在FilterSecurityInterceptor过滤器中:
1:首先调用的是 SecurityMetadataSource,来获取当前请求的鉴权规则。
2:然后通过Authentication获取当前登录用户所有权限数据: GrantedAuthorit认证对象里存放权
限数据。
3:再调用 AccessDecisionManager 来校验当前用户是否拥有该权限。
4:如果有就放行接口,没有则抛出异常,该异常会被 AccessDeniedHandler 处理。
说明:
Authentication
:存储了认证信息,代表当前登录用户。
SecurityContext
:上下文对象,用来获取Authentication。
SecurityContextHolder
:上下文管理对象(使用ThreadLocal策略来存储用户信息),用来在程序任
何地方获取SecurityContext。
GrantedAuthority:
该接口表示了当前用户所拥有的权限信息。这些信息由授权负责对象
AccessDecisionManager来使用,并决定最终用户是否可以访问某资源。
UserDetails:该
接口规范了用户详细信息所拥有的字段,譬如用户名、密码、账号是否过期、是
否锁定等。在Spring Security中,获取当前登录的用户的信息,一般情况是需要在这
个接口上面进行扩展,用来对接自己系统的用户。
Authentication && SecurityContext && SecurityContextHolder关系:
Authentication解释:
Principal
:用户信息,没有认证时一般是用户名,认证后一般是用户对象
Credentials
:用户凭证,一般是密码
Authorities
:用户权限
若依系统查看SpringSecurity的设计,可重项目中SpringSecurity的核心配置文件SecurityConfig出发,该文件配置了整个认证授权流程需要的一些自定义过滤器,具体逻辑配置文件及各个过滤器中都有解释,系统中认证的逻辑放置在/login登录接口中,登录接口中如下代码会去调用我们实现SpringSecurity的接口UserDetailsService中的loadUserByUsername方法完成我们重数据库中查询用户数据进行认证
SpringSecurity核心配置文件
配置SpringSecurity的核心配置文件SecurityConfig继承自WebSecurityConfigurerAdapter
2.1:指定认证失败处理类实现AuthenticationEntryPoint接口。
2.2:指定不需要认证的资源,其他资源都需要认证。
2.3:指定登出过滤器实现LogoutSuccessHandler/JWT过滤器继承OncePerRequestFi
lter/CORS过滤器实现WebMvcConfigurer
2.4:指定身份认证接口实现UserDetailsService接口并指定密码器
/**
* spring security配置
*/
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) // 开启注解授权
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
/**
* 自定义用户认证逻辑
* 即重数据库根据用户名查询用户进行认证
*/
@Autowired
private UserDetailsService userDetailsService;
/**
* 认证失败处理类
* 访问需要权限的接口,而用户没有此权限处理类
*/
@Autowired
private AuthenticationEntryPointImpl unauthorizedHandler;
/**
* 退出处理类
*/
@Autowired
private LogoutSuccessHandlerImpl logoutSuccessHandler;
/**
* token认证过滤器
* 验证token + token刷新
*/
@Autowired
private JwtAuthenticationTokenFilter authenticationTokenFilter;
/**
* 跨域过滤器
*/
@Autowired
private CorsFilter corsFilter;
/**
* 允许匿名访问的地址
*/
// @Autowired
// private PermitAllUrlProperties permitAllUrl;
/**
* 解决 无法直接注入 AuthenticationManager
*
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception
{
return super.authenticationManagerBean();
}
/**
* anyRequest | 匹配所有请求路径
* access | SpringEl表达式结果为true时可以访问
* anonymous | 匿名可以访问
* denyAll | 用户不能访问
* fullyAuthenticated | 用户完全认证可以访问(非remember-me下自动登录)
* hasAnyAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问
* hasAnyRole | 如果有参数,参数表示角色,则其中任何一个角色可以访问
* hasAuthority | 如果有参数,参数表示权限,则其权限可以访问
* hasIpAddress | 如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
* hasRole | 如果有参数,参数表示角色,则其角色可以访问
* permitAll | 用户可以任意访问
* rememberMe | 允许通过remember-me登录的用户访问
* authenticated | 用户登录后可访问
*/
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception
{
// 注解标记允许匿名访问的url
// ExpressionUrlAuthorizationConfigurer.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();
// permitAllUrl.getUrls().forEach(url -> registry.antMatchers(url).permitAll());
httpSecurity
// CSRF禁用,因为不使用session
.csrf().disable()
// 禁用HTTP响应标头
.headers().cacheControl().disable().and()
// 认证失败处理类
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
// 基于token,所以不需要session(此处禁用session是指SpringSecurity不采用session机制,不代表整个系统禁用session功能)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 过滤请求
.authorizeRequests()
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
.antMatchers("/login", "/register", "/captchaImage").permitAll()
// 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
.antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable();
// 添加Logout filter
httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
// 添加JWT filter
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); // 验证刷新Token
// 添加CORS filter
httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
}
/**
* 强散列哈希加密实现
* 密码器,此密码器对相同密码进行加密会得到不同的密文,不同的密文又可以还原成明文
*/
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder()
{
return new BCryptPasswordEncoder();
}
/**
* 身份认证接口
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception
{
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}
}
登录业务方法:
/** 登录业务方法 */
@Override
public String login(String username, String password) {
// 用户验证
Authentication authentication = null;
try
{
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
AuthenticationContextHolder.setContext(authenticationToken);
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager.authenticate(authenticationToken);
}
catch (Exception e)
{
if (e instanceof BadCredentialsException) // 有异常抛出,此异常会被SpringSecurity异常过滤器处理
{
// @TODO:异步记录认证失败信息
throw new UserPasswordNotMatchException();
}
else
{
// @TODO:异步记录业务异常
throw new ServiceException(e.getMessage());
}
}
finally
{
AuthenticationContextHolder.clearContext();
}
// Security认证成功后重Authentication获取用户信息
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
/**
* @TODO:
1:异步记录日志信息
2:异步记录用户登录成功信息(更新库用户登录时间,IP等信息)
*/
// 生成token
return tokenService.createToken(loginUser);
}
SpringSecurity的UserDetailsService接口中的loadUserByUsername完成认证
/***
* 重写loadUserByUsername方法,实现重数据库中加载用户信息进行认证
* 该方法返回一个UserDetails对象交由SpringSecurity管理
* 其他补充:为什么认证时只传账号,不传密码进行认证,因为数据库支持的加密方式很少MD5
* 等,所以一般传入账号进行认证,再进行密码校验
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
/**
* 根据账号查询用户信息
* 根据用户账号查询用户信息 + 用户部门信息 + 用户角色信息
* */
SysUser user = userService.selectUserByUserName(username);
if (user == null) // 用户不存在
{
log.info("登录用户:{} 不存在.", username); // {}为占位符,记录时会将username数据填充到{}中
throw new ServiceException("登录用户:" + username + " 不存在");
}
else if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) // 用户被标记删除
{
log.info("登录用户:{} 已被删除.", username);
throw new ServiceException("您的账号:" + username + " 已被删除");
}
else if (UserStatus.DISABLE.getCode().equals(user.getStatus())) // 用户被标记禁用
{
log.info("登录用户:{} 已被停用.", username);
throw new ServiceException("您的账号:" + username + " 已停用");
}
/** 进入此处表示账号信息是正常的,开始校验密码,如果校验密码不匹配会抛出异常就不会往下执行生成UserDetails对象 */
passwordService.validate(user);
// 密码校验通过生成UserDetails对象交给SpringSecurity管理
return createLoginUser(user);
}
public UserDetails createLoginUser(SysUser user)
{
// LoginUser实现了UserDetails接口
return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
}
登录接口认证通过生成JWT
/**
* 创建令牌
*
* @param loginUser 用户信息
* @return 令牌
*/
public String createToken(LoginUser loginUser)
{
String token = UUID.randomUUID().toString();
loginUser.setToken(token); // loginUser中的token为UUID,Redis中token为:login_tokens:uuid
// @TODO:此处可获取UserAgent并解析用户信息,可拿取IP/IP归属地/游览器类型/操作系统等信息存入到loginUser对象中(即设置用户代理信息)
refreshToken(loginUser); // 为loginUser对象设置登录时间,token过期时间属性值,并将token存入到Redis中Key为login_tokens:uuid Value为loginUser对象并设置在Redis中过期时间
/**
* 构建JWT数据声明只存放uuid Key为login_user_key VALUE:uuid
* 后期前端请求携带JWT,后端对JWT进行解析,根据数据声明的Key login_user_key去数据声明中取出该uuid
* 拿到uuid再拼接存入Redis中token的前缀获取到用户信息
* */
Map claims = new HashMap<>();
claims.put(Constants.LOGIN_USER_KEY, token);
return createToken(claims);
}
创建JWT
/**
* 从数据声明生成令牌
*
* @param claims 数据声明
* @return 令牌
*/
private String createToken(Map claims)
{
String token = Jwts.builder()
.setClaims(claims) // 设置数据声明(实质是一个HashMap Key为约定的login_user_key Value为唯一字符串uuid)
.signWith(SignatureAlgorithm.HS512, secret).compact(); // 设置签名和密匙进行加密
return token;
}
JWT过滤器完成对请求token的校验与刷新
/**
* token过滤器 验证token有效性
* 如果Redis中token过期则会进入认证失败处理类AuthenticationEntryPointImpl,提示:访问Xxx,认证失败,无法访问系统资源
*/
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
@Autowired
private TokenService tokenService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException
{
// 重请求头中取出token,组装token前缀,再去Redis中根据token取出用户信息
LoginUser loginUser = tokenService.getLoginUser(request);
/**
* JWT过滤器链新开了一个线程来进行认证所以此处SecurityUtils.getAuthentication()才会为null或:JWT过滤器执行在前当前还无法拿到认证信息
*/
if (loginUser != null && SecurityUtils.getAuthentication() == null)
{
// 校验刷新令牌(刷新token:更新loginUser对象中token的有效期,并将loginUser对象更新到Redis中)
tokenService.verifyToken(loginUser);
// 再把更新过后的loginUser对象重新设置到authenticationToken中,即完成UsernamePasswordAuthenticationToken对象的token有效期刷新,从此获取到的UsernamePasswordAuthenticationToken对象中的token有效期为最新刷新的
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken); // 登录过的对象设置到上下文中,从此这个JWT过滤器链线程就知道哪些用户登录过了
}
chain.doFilter(request, response);
}
}
授权处理
开启注解授权:
在SpringSecurity的核心配置文件SecurityConfig文件上添加@EnableGlobalMethodSecurity
注解,表示开启注解授权。
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
使用@PreAuthorize(‘@ss.hasPermi('xxx:xx:xx')’)在接口方法上表示访问此方法前做授权,即在访问方法前校验当前用户有没有权限访问此接口,若某些接口不需要授权访问则不在接口方法上添加此注解代表此接口是公开的无需授权即可以访问,若依自创了一套自己的授权方式即使用@ss引入bean的方法通过传入此接口的权限字符与重Redis中取出的权限字符列表想比较,如果有匹配则表示用户有权限访问此接口,没有则拒绝访问。
@ss:bean引入,即调用名为ss这个bean下面的hasPermi方法传入当前接口权限字符串进行授权
/**
* RuoYi首创 自定义权限实现,ss取自SpringSecurity首字母
*
* @author ruoyi
*/
@Service("ss")
public class PermissionService
{
/** 所有权限标识 */
private static final String ALL_PERMISSION = "*:*:*";
/**
* 验证用户是否具备某权限
*
* @param permission 权限字符串
* @return 用户是否具备某权限
*/
public boolean hasPermi(String permission)
{
if (permission == null || "".equals(permission))
{
return false;
}
LoginUser loginUser = SecurityUtils.getLoginUser();
// 如果用户为null,或用户的权限为空就没有权限访问
if (loginUser == null || CollectionUtils.isEmpty(loginUser.getPermissions()))
{
return false;
}
PermissionContextHolder.setContext(permission);
/**
* p1:用户的所有权限
* p2:用户访问当前接口所需要的权限
* */
return hasPermissions(loginUser.getPermissions(), permission);
}
/**
* 判断是否包含权限
*
* @param permissions 权限列表
* @param permission 权限字符串
* @return 用户是否具备某权限
*/
private boolean hasPermissions(Set permissions, String permission)
{
// 判断是否为最大权限*:*:* 或 用户的权限列表中是否包含当前请求这个接口的权限
return permissions.contains(ALL_PERMISSION) || permissions.contains(permission.trim());
}
}
准备测试资源接口
/**
* admin用户权限最大,对于admin用户的权限控制是在代码中进行特殊处理的
* 数据库中ID为1的用户,即认为其是超管,对超管的权限使用*:*;*即最大权限
*
* ry用户权限是根据数据库中查询的权限字符进行权限匹配
*/
@RestController
@RequestMapping("/user")
public class SysUserController {
/** 访问用户管理菜单需要 system:user:list 权限 */
@PreAuthorize("@ss.hasPermi('system:user:list')")
@GetMapping("/manager")
public String userManager(){
return "用户管理菜单";
}
/** 访问新增用户按钮接口需要 system:user:add 权限 */
@PreAuthorize("@ss.hasPermi('system:user:add')")
@GetMapping("/add")
public String userAdd(){
return "用户新增按钮接口";
}
/** 访问删除用户按钮接口需要 system:user:remove 权限 */
@PreAuthorize("@ss.hasPermi('system:user:remove')")
@GetMapping("/remove")
public String userDel(){
return "用户删除按钮接口";
}
/**
* 编辑用户按钮接口需要 system:user:update 权限
* 数据库中的编辑权限字符为:system:user:edit,此处故意将编辑权限改为system:user:update
* 因为超管最大权限是在代码中做处理,而普通用户没此权限故应该普通用户访问不了此接口
* */
@PreAuthorize("@ss.hasPermi('system:user:update')")
@GetMapping("/update")
public String userUpdate(){
return "用户编辑按钮接口";
}
}
若依为角色授权界面:
为角色授权时不仅要指定此角色可以访问哪些菜单,还要指定开放菜单下哪些按钮(功能)
数据库层面体现:
菜单表
菜单角色表
gitee地址:https://gitee.com/pengyubin/springsecurity-demo.git