若依项目学习笔记04——Security权限
学习前先说明一下,这里默认大家都是了解了相关技术的,如果还没学习过的话,大家先去简单看一下大致的相关教程,这里就不占用篇幅来讲解相关技术概念和原理啦;
本项目需要讲解的Security部分还是挺多的,如配置介绍、密码加密、退出配置、登陆配置、权限讲解和权限注解等,我都汇总给到这一个章节来讲
1. 配置介绍
1.1 com.ruoyi.framework.config.SecurityConfig
/**
* EnableGlobalMethodSecurity:开启security功能;prePostEnabled:方法执行前验证;securedEnabled:是否有权限访问
*/
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter //要配置security就要继承这个
{
@Autowired
private UserDetailsService userDetailsService;//自定义用户认证逻辑
@Autowired
private AuthenticationEntryPointImpl unauthorizedHandler;//认证失败处理类
@Autowired
private LogoutSuccessHandlerImpl logoutSuccessHandler;//退出处理类
@Autowired
private JwtAuthenticationTokenFilter authenticationTokenFilter;//token认证过滤器
@Autowired
private CorsFilter corsFilter;//跨域过滤器
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception//解决 无法直接注入 AuthenticationManager
{
//重新获取一下AuthenticationManager
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
{
httpSecurity
// CSRF禁用,因为不使用session
.csrf().disable()
// 认证失败处理类:exceptionHandling()允许配置异常处理,
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
// 屏蔽session:基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 过滤请求
.authorizeRequests()
// 对于登录login 验证码captchaImage 允许匿名访问
.antMatchers("/login", "/captchaImage").anonymous()
// 对指定的资源放行
.antMatchers(
HttpMethod.GET,
"/*.html",
"/**/*.html",
"/**/*.css",
"/**/*.js"
).permitAll()
// 放行文件上传(如用户头像)
.antMatchers("/profile/**").anonymous()
// 放行文件下载
.antMatchers("/common/download**").anonymous()
.antMatchers("/common/download/resource**").anonymous()
//放行swagger,关于swagger下面有介绍博文
.antMatchers("/swagger-ui.html").anonymous()
.antMatchers("/swagger-resources/**").anonymous()
.antMatchers("/webjars/**").anonymous()
.antMatchers("/*/api-docs").anonymous()
//放行阿里druid监控的控制台
.antMatchers("/druid/**").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable();
//重写退出后的处理类,从request中拿到token进行判断
httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
// 添加JWT filter,在authenticationTokenFilter进行token认证;下面有关于jwt的教程博文链接
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
// 添加CORS filter
httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder()//强散列哈希加密实现
{
return new BCryptPasswordEncoder();//进到源码PasswordEncoder中,encode加密,matches匹配
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception//身份认证接口
{
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}
}
Swagger:用JSON或YAML元数据来描述API属性,并且提供了Web UI,它可以将元数据转换为一个很好的HTML文档,在该UI中,我们可以浏览有关API端点的信息,还可以将UI用作REST客户端 ,调用任何端点,指定要发送的数据并检查响应;关于swagger的教程大家可以先看看这篇博文
JWT:JSON Web Token(缩写 JWT)是目前最流行的跨域认证解决方案,服务器不保存 session 数据,所有数据都保存在客户端,每次请求都发回服务器;详细见这篇介绍博文
2. 密码加密
接着上面的security配置文件讲,即 configure()
方法
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception//身份认证接口
{
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}
我们进入BCryptPasswordEncoder的实现接口PasswordEncoder
可以看到其中有两个方法,一个是对密码进行加密的
encode()
方法,传入明文密码,然后对其进行MD5加密,这个过程是不可逆的,也就是说每次加密生成的密码都是不一样的;另一个是matches()
通过对明文密码和加密后的密码进行匹配;例如在重置密码中就有调用该方法
在用户信息SysUserController的新增用户方法也有调用到
下面是被调用的安全服务工具类SecurityUtils的加密方法encryptPassword()
和 匹配方法matchesPassword()
public static String encryptPassword(String password)
{
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
return passwordEncoder.encode(password);
}
public static boolean matchesPassword(String rawPassword, String encodedPassword)
{
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
return passwordEncoder.matches(rawPassword, encodedPassword);
}
我们可以通过SysLoginService(登录校验方法)进行查看其进行的流程;下面是SysLoginService中涉及的登录验证方法
// 用户验证
Authentication authentication = null;
try
{
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(username, password));
}
catch (Exception e)
{
if (e instanceof BadCredentialsException)
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
throw new UserPasswordNotMatchException();
}
else
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
throw new CustomException(e.getMessage());
}
}
该用户验证方法调用 UserDetailsServiceImpl.loadUserByUsername
(大家自行查看),对进行判断,不存在/被删除/停用?如果是的话,则匹配是否是 BadCredentialsException
,然后抛出 UserPasswordNotMatchException()
用户密码不匹配异常
3. 退出配置
我们已经在SecurityConfig中定义了退出的配置了
//指定了退出的配置.退出的Url.退出成功的处理方法
httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
LogoutSuccessHandlerImpl
实现了 LogoutSuccessHandler
,重写了其中的 onLogoutSuccess()
方法,删除缓存并记录日志
我们用vscode打开前端代码,来到 src.layout.omponents.Navbar.vue (Navbar.vue是项目顶栏的导航区域)中,可以看到退出登录部分有一个点击事件 logout
<el-dropdown-item divided @click.native="logout">
<span>退出登录</span>
</el-dropdown-item>
我们往下看,来到 <script>
区,有定义了退出点击事件的方法
methods: {
toggleSideBar() {
this.$store.dispatch('app/toggleSideBar')
},
async logout() {//对弹出框进行配置
this.$confirm('确定注销并退出系统吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.$store.dispatch('LogOut').then(() => {//如果点击确定,则调用logout方法,并跳转到首页登录页
location.href = '/index';
})
})
}
}
被调用的logout方法为 src.api.login.js
// 退出方法
export function logout() {
return request({//定义了请求路径和方式
url: '/logout',
method: 'post'
})
}
4. 登录配置 *
我们继续回到 SecurityConfig 配置中,其中有登录(身份认证接口)的实现
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception
{
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}
UserDetailsService
是一个接口,其中有登录的抽象方法 loadUserByUsername
;下面我们通过登录流程来讲解登录是如何配置和进行的,我们来到登录验证 SysLoginController
中
@PostMapping("/login")
public AjaxResult login(@RequestBody LoginBody loginBody)
{
AjaxResult ajax = AjaxResult.success();
// 生成令牌,传入用户名、密码、验证码和uuid进行判断
String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
loginBody.getUuid());
ajax.put(Constants.TOKEN, token);将判断信息加入AjaxResult
return ajax;
}
登录方法调用了 loginService
,其中包括对验证码的判断,对用户状态的判断和生成其token
5. 权限讲解
这部分我们配合
SysLoginService
来讲解,依旧是用户验证部分,调用 UserDetailsServiceImpl.loadUserByUsername ,在loadUserByUsername()
中验证完用户状态后,会创建登录用户,这是会调用 SysPermissionService.getMenuPermission ,即菜单权限的集合,管理员则使用perms.add("*:*:*")
来赋予其所有权限,这各会在后面判断;普通用户则使用一个普通的数据库查询方法selectMenuPermsByUserId(user.getUserId())
,其返回一个集合,我们可以点进去看看,她所对应的mapper文件是 SysMenuMapper ,我们找到selectMenuPermsByUserId
<select id="selectMenuPermsByUserId" parameterType="Long" resultType="String">
select distinct m.perms
from sys_menu m
left join sys_role_menu rm on m.menu_id = rm.menu_id
left join sys_user_role ur on rm.role_id = ur.role_id
left join sys_role r on r.role_id = ur.role_id
where m.status = '0' and r.status = '0' and ur.user_id = #{userId}
</select>
该功能就是返回perms的字段,去重;我们可以打开数据库查看对应的表格
perms字段都是各种查询、增删查改和导入导出等标识,即对应的业务模块;查询完毕后返回一个 LoginUser
(实现了 UserDetails
) ,其结果就是所创建新登录用户的权限
public LoginUser(SysUser user, Set<String> permissions)
{
this.user = user;
this.permissions = permissions;
}
然后我们再回到
SysLoginService
,我们拿到一个LoginUser
后,通过createToken(loginUser)
将她存到token当中;然后使用refreshToken(loginUser)
刷新,然后在当中去把对应权限保存保存在缓存中和设置他的loginUser,这样下次就可以直接在缓存中校验,其对应实现在 framework.web.service.PermissionService中;
注意区分 菜单权限 和 角色权限
6. 权限注解
这部分讲权限注解部分,我们打开 PermissionService
,这个类是处理权限注解相关部分;@Service("ss")
是自定义的service标识符
一开始定义的分割符是 SysMenuServiceImpl.selectMenuPermsByUserId 所需,因为该方法索要遍历的集合是用 “,” 进行分割的;下面对 PermissionService
中的方法进行讲解
hasPermi()
: 验证用户是否具备某权限
public boolean hasPermi(String permission)//传进来一个权限标识字符串
{
//判断是否为空
if (StringUtils.isEmpty(permission))
{
return false;
}
//从 request中获取用户信息
LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))//如果不为空则有这个用户
{
return false;
}
//对权限和权限集合进行匹配,匹配成功则说明有对应权限,返回boolean
return hasPermissions(loginUser.getPermissions(), permission);
}
这个方法我们结合创建用户来理解,打开 ruoyi.web.controller.system.add 方法;我们看预处理标签 @PreAuthorize("@ss.hasPermi('system:user:add')")
,这里会匹配service标识符为 ss 的类,然后调用hasPermi方法传入 “system:user:add” 权限字符串,去比较当前用户是否拥有新增用户的权限
-
lacksPermi()
: 验证用户是否不具备某权限,与 hasPermi逻辑相反;比如我们让某个用户没有查询的权限,其他权限都有,就可以调用这个方法 -
hasAnyPermi()
: 验证用户是否具有以下任意一个权限;比如上面的新增用户方法中,我们不止要验证是否有add权限,还要验证是否有编辑edit权限,我们可以改成@PreAuthorize("@ss.hasAnyPermi('system:user:add','system:user:edit')")
-
hasRole()
: 判断用户是否拥有某个角色; -
lacksRole()
: 验证用户是否不具备某角色,与 hasRole逻辑相反 -
hasAnyRoles()
: 验证用户是否具有以下任意一个角色
综上,权限注解的使用就是根据自己所需的功能要求,在对应的控制类方法中添加
@PreAuthorize
预处理注解,通过@ss
来找到当前所需被标识为@Service("ss")
的 的service类,然后通过方法名和传入的权限参数来调用相应的权限判断方法;有些方法可以传入多个权限参数,特别要注意,要根据所需的方法来改对应的调用方法名