Spring Security 结合jwt 自定义权限认证 基于huike CRM系统

项目源码 前后端分离 前端在web包下

登录认证

验证码流程

请求 URL:

http://localhost:81/dev-api/captchaImage

找captchaImage

先生成uuid 声明要返回的属性

String code验证码 
String capStr传给验证码生成器的验证码的公式
BufferedImage image 验证码生成器生成的图片

判断是math还是char类型的验证码

在成员属性定义 通过读取配置文件的方式

 成员属性定义了两个Producer 分别通过@Resource注解装配

 即为Producer接口多态的注入了两个不同的实现类captchaProducer和captchaProducerMath

 

 captchaProducer 字符串型验证码:

captchaProducerMath 数学运算型字符串:

这里就是生成验证码

 

用redis缓存即将返回给前端的验证码信息

verifyKey作为redis缓存的key 
code即value  
Constants.CAPTCHA_EXPIRATION 是缓存的有效时间
TimeUnit.MINUTES  有效时间的时间类型

最后将验证码信息写入流中 将流用AjaxResult封装 返回给前端AjaxResult对象

jwt认证过滤器

这时候看SecurityConfig 继承 WebSecurityConfigurerAdapter

先要去SecurityConfig配置一个configure类 配置需要鉴权验证拦截和不需要验证放行的资源 需要对登录模块放行

 查看SecurityConfig 可以继承重新的方法 重写authenticationManagerBean()

用推荐的格式就行 如果只有一个authenticationManagerBean()不用起名字

使用@Bean注入到ioc容器中

这样就能在登录校验类SysLoginService中注入成员属性

这样就可以调用它的认证方法来对账号密码校验

 这个方法需要传一个authentication接口的实现类对象

可以找到这个接口所有的实现类对象

用UsernamePasswordAuthenticationToken这个实现类 将用户名 密码封装进去 然后让authenticationManager进行认证操作

这一步就是判断密码是否正确

校验是在DaoAuthenticationProvider类中进行

crm项目所使用的密码编码器加密

 如果密码不匹配就扔出异常 接收处理后然后记录日志

authenticationManager最终会调用UserDetailsService的实现类UserDetailsServiceImpl中的重写的方法loadUserByUsername

 这里会去数据库找用户的数据 通过createLoginUser去找用户对应的permissions 权限列表 这里具体在后面授权实现分析

loadUserByUsername方法会根据username去数据库找用户数据 然后就可以根据返回结果判断登录用户状态

而异常会被捕获后进行判断 然后记录登录失败原因的日志 这里主要是用户状态的异常

如果没有异常 则说明登录成功 成功先记录日志 然后用authentication获取登录用户信息

将封装过的LoginUser对象传给TokenService层的createToken方法 来创建token

先生成uuid字符串 封装至loginUser对象中

setUserAgent(loginUser);  设置用户loginUser的一些ip等信息

refreshToken(loginUser);用redis根据前面生成的uuid缓存用户数据loginUser的信息

 

设置等下需要放入jwt中的数据 用一个map集合封装  

调用createToken方法 生成jwt处理完成后的token

该jwt基于SignatureAlgorithm.HS512 加密 密钥为

 属于主模块admin中的yml配置中

 最后回到SysLoginController中的login方法中 封装AjaxResult返回给前端

异步线程类AsyncFactory 用于记录日志

recordLogininfor方法的返回值TimerTask

 先是创建了一个TimerTask对象 然后重写了其中的匿名内部类run()方法

该方法会创建一个子线程 内部获取用户的ip等信息 封装到SysLogininfor对象中

AsyncManager这个类会调用.me方法 这个方法 会返回这个类的静态对象(饿汉模式加载 随着类加载而生成)

用这个me对象调用其execute(TimerTask task)方法 将上面创建的TimerTask对象传入 执行异步线程

这个方法中用成员变量ScheduledExecutorService 任务调度线程池执行schedule方法

使用了带延迟的时间调度,只执行一次

因为ScheduledExecutorService继承于ExecutorService,所以本身支持线程池的所有功能。额外还提供了4种方法,我们来看看其作用。
/**
 * 带延迟时间的调度,只执行一次
 * 调度之后可通过Future.get()阻塞直至任务执行完毕
 */
1. public ScheduledFuture<?> schedule(Runnable command,
                                      long delay, TimeUnit unit);

/**
 * 带延迟时间的调度,只执行一次
 * 调度之后可通过Future.get()阻塞直至任务执行完毕,并且可以获取执行结果
 */
2. public <V> ScheduledFuture<V> schedule(Callable<V> callable,
                                          long delay, TimeUnit unit);

/**
 * 带延迟时间的调度,循环执行,固定频率
 */
3. public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
                                                 long initialDelay,
                                                 long period,
                                                 TimeUnit unit);

/**
 * 带延迟时间的调度,循环执行,固定延迟
 */
4. public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
                                                    long initialDelay,
                                                    long delay,
                                                    TimeUnit unit);

jwt认证过滤器

获取token  解析获取其中的uuid  从redis缓存中取对应的数据 如果首次登录则是没有token的
如果不是首次登录 用户携带token过来 则刷新缓存

因为SecurityContextHolder存入的是要一个authenticationToken对象 不能将loginUser直接存入 所以需要先创建一个authenticationToken对象 通过UsernamePasswordAuthenticationToken创建
需要传入三个参数第一个是loginUser 第二个先不填 第三个 loginUser.getAuthorities 是获取用户权限

 super.setAuthenticated(true); 代表是判断该用户是否是已认证的状态

大体流程 过滤器功能实现后放入spring容器后需要去配置 SpringSecurity的过滤器交给spring容器后并不会自动生效 整个流程由SpringSecurity自己管理 所以需要去SecurityConfig中的configure方法中配置

而JwtAuthenticationTokenFilter拦截器应该配置在UsernamePasswordAuthenticationFilter之前 形成过滤器链 下图红框这里

 

使用httpSecurity.addFilterBefore设置 第一个参数 要添加的拦截器 第二个参数 在哪个拦截器之前 

permitAll策略 无论未登录还是已登录都可以访问
anonymous策略 匿名访问类型 未登录可以访问 已登录不可以访问

@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
            // CSRF禁用,因为不使用session
            .csrf().disable()
            // 认证失败处理类
            .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
            // 基于token,所以不需要session
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
            // 过滤请求
            .authorizeRequests()
            // 对于登录login 验证码captchaImage 允许匿名访问
            .antMatchers("/login", "/captchaImage").anonymous()
            .antMatchers(
                    HttpMethod.GET,
                    "/*.ttf",
                    "/*.woff",
                    "/*.gif",
                    "/*.eot",
                    "/*.json",
                    "/*.woff2",
                    "/*.png",
                    "/*.ico",
                    "/*.svg",
                    "/*.jpg",
                    "/*.html",
                    "/**/*.ttf",
                    "/**/*.woff",
                    "/**/*.gif",
                    "/**/*.eot",
                    "/**/*.json",
                    "/**/*.woff2",
                    "/**/*.png",
                    "/**/*.ico",
                    "/**/*.svg",
                    "/**/*.jpg",
                    "/**/*.html",
                    "/**/*.css",
                    "/**/*.js"
            ).permitAll().
                    antMatchers(
                    //mybatis复习相关的接口全部放行,同学们可以通过postMan进行测试而不需要进行权限认证
                    "/review/**",
                            "/review"
            ).permitAll()
            .antMatchers("/common/downloadByMinio**").permitAll()
            .antMatchers("/profile/**").anonymous()
            .antMatchers("/common/download**").anonymous()
            .antMatchers("/common/download/resource**").anonymous()
            .antMatchers("/webjars/**").anonymous()
            .antMatchers("/*/api-docs").anonymous()
            .antMatchers("/druid/**").anonymous()
            // 除上面外的所有请求全部需要鉴权认证
            .anyRequest().authenticated()
            .and()
            .headers().frameOptions().disable();
    httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
    // 添加JWT filter  成员属性注入JwtAuthenticationTokenFilter authenticationTokenFilter 设置配置JWT拦截器在UsernamePasswordAuthenticationFilter之后
    httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    // 添加CORS filter
    httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
    httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
}

登出功能

本质是spring security底层帮我们处理的 是LogoutSuccessHandlerImpl这个实现类

LogoutSuccessHandlerImpl implements LogoutSuccessHandler

先从redis缓存中从redis缓存中拿用户信息 这里并没有从SecurityContextHolder中获取用户信息

//应该是可以从SecurityContextHolder中获取当前登录用户的信息的
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();

redis缓存用户登录信息的时间为30分钟     会存在用户信息过期 过期了应该让用户重新登录 而这时候去SecurityContextHolder中拿用户信息来做登出处理是否不合理? 这里时间不够并没有去具体理解

不是空把redis缓存中的用户信息删除

 public void delLoginUser(String token)
{
    if (StringUtils.isNotEmpty(token))
    {
        String userKey = getTokenKey(token);
        redisCache.deleteObject(userKey);
    }
}

记录操作日志

如果从redis缓存中没拿到信息 说明用户已经连接超时了缓存已经过期 直接返回结果

授权

授权基本流程

在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。

所以我们在项目中只需要把当前登录用户的权限信息也存入Authentication。

然后设置我们的资源所需要的权限即可。

在JwtAuthenticationTokenFilter中的doFilterInternal方法中

先通过tokenService.getLoginUser()方法拿带有权限信息的用户数据

将其封装到Authentication中给后面的权限校验器FilterSecurityInterceptor校验

 

这其中的用户信息数据是在而在UserDetailsServiceImpl类中的
loadUserByUsername方法中通过userService.selectUserByUserName(username)去
SysUserServiceImpl类中的selectUserByUserName方法中查询

 该sql查出当前user用户所有的信息以及dept部门信息和role角色信息 封装为一个SysUser对象

 

 

 

这样就获取了用户的基本信息 但是还需要用户的权限信息 通过createLoginUser方法去获取权限信息 调用了SysPermissionService类中的getMenuPermission方法去获取用户权限

 SysPermissionService类是用来处理用户权限的

查询出用户的权限信息的sql根据sys_menu的menu_id关联sys_role_menu

通过sys_role_menu的role_id关联sys_user_role

通过sys_user_role的role_id关联sys_role

最后创建一个LoginUser对象 为该对象设置两个成员属性 user用户属性 permissions 权限列表

 

  

由于SpringSecurity是通过识别getAuthorities()这个方法中的权限 这个方法返回值是个泛型为Collection<? extends GrantedAuthority>集合

需要找到一个这个GrantedAuthority接口的实现类设置权限permissions 这里按理应该这么做的 但是找不到 猜测并不是根据用户权限来设置 而是根据用户角色来设置权限的 我将new UsernamePasswordAuthenticationToken(loginUser, null, null)第三个选项设置为null后权限依然存在 所以猜测这里使用了角色鉴权

因为loginUser中已经封装了权限列表permissions 项目中的接口都通过自定义权限校验@PreAuthorize("@ss.hasPermi('xxx')")直接读取permissions 来判断权限

角色与权限

在spring security中,当用户登录成功后,当前登录用户信息将保存在Authentication对象中,该对象中有一个getAuthorities方法,用来返回当前对象所具备的权限信息,也就是已经授予当前登录用户的权限,getAuthorities方法返回值是Collection<? extends GrantedAuthority>,即集合中存放的是GrantedAuthority的子类,当需要进行权限判断的时候,就会调用该方法获取用户的权限,进而做出判断。
无论用户的认证方式是用户名/密码形式、remember-me形式,还是其他如CAS、OAuth2等认证方式,最终用户的权限信息都可以通过getAuthorities方法获取。
那么对于Authentication#getAuthorities方法的返回值,应该如何理解:

从设计层面来讲,角色和权限是两个完全不同的东西:权限就是一些具体的操作,例如针对员工数据的读权限(READ_EMPLOYEE)和针对员工数据的写权限(WRITE_EMPLOYEE);角色则是某些权限的集合,例如管理员角色ROLE_ADMIN、普通用户角色ROLE_USER。
从代码层面来讲,角色和权限并没有太大的不同,特别是在spring security中,角色和权限的处理的方式基本上是一样的,唯一的区别在于spring security在多个地方会自动给角色添加一个ROLE_前缀,而权限则不会自动添加任何前缀。
至于Authentication#getAuthorities方法的返回值,则要分情况来对待:

如果权限系统设计比较简单,就是用户<=>权限<=>资源三者之间的关系,那么getAuthorities方法的含义就很明确,就是返回用户的权限。
如果权限系统设计比较复杂,同时存在角色和权限的概念,如用户<=>角色<=>权限<=>资源(用户关联角色、角色关联权限、权限关联资源),此时可以将getAuthorities方法的返回值当做权限来理解。由于spring security并未提供相关的角色类,因此这个时候需要自定义角色类。
如果系统同时存在角色和权限,可以使用GrantedAuthority的实现类来封装权限列表

但是找了整个CRM项目 暂时没找到GrantedAuthority的实现类 并且权限接口设置null依然可以获取权限,所以项目应该是没有根据GrantedAuthority来鉴权的

 

 接口

实现类

授权实现

限制访问资源所需权限

SpringSecurity为我们提供了基于注解的权限控制方案,这也是我们项目中主要采用的方式。我们可以使用注解去指定访问对应的资源所需的权限。

但是要使用它我们需要先开启相关配置。

@EnableGlobalMethodSecurity

@EnableGlobalMethodSecurity 三方法详解

要开启Spring方法级安全,在添加了@Configuration注解的类上再添加@EnableGlobalMethodSecurity注解即可

其中注解@EnableGlobalMethodSecurity有几个方法:

  • prePostEnabled 确定 前置注解[@PreAuthorize,@PostAuthorize,..] 是否启用

  • securedEnabled 确定安全注解 [@Secured] 是否启用

  • jsr250Enabled 确定 JSR-250注解 [@RolesAllowed..]是否启用

在同一个应用程序中,可以启用多个类型的注解,但是只应该设置一个注解对于行为类的接口或者类。如:

  • 一个程序启用多个类型注解:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true))
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    ...
}

但是只应该设置一个注解对于行为类的接口或者类

public interface UserService {
  List<User> findAllUsers();
 
  @PreAuthorize("hasAnyRole('user')")
  void updateUser(User user);
 
    // 下面不能设置两个注解,如果设置两个,只有其中一个生效
    // @PreAuthorize("hasAnyRole('user')")
  @Secured({ "ROLE_user", "ROLE_admin" })
  void deleteUser();
}

启用securedEnabled

public interface UserService {
    List<User> findAllUsers();
    
    @Secured({"ROLE_user"})
    void updateUser(User user);
 
    @Secured({"ROLE_admin", "ROLE_user1"})
    void deleteUser();
}

 @Secured注解是用来定义业务方法的安全配置。在需要安全[角色/权限等]的方法上指定 @Secured,并且只有那些角色/权限的用户才可以调用该方法。

@Secured缺点(限制)就是不支持Spring EL表达式。不够灵活。并且指定的角色必须以ROLE_开头,不可省略。

在上面的例子中,updateUser 方法只能被拥有user权限的用户调用。deleteUser 方法只能够被拥有admin 或者user1 权限的用户调用。而如果想要指定"AND"条件,即调用deleteUser方法需同时拥有ADMINDBA角色的用户,@Secured便不能实现。

这时就需要使用prePostEnabled提供的注解@PreAuthorize/@PostAuthorize

启用prePostEnabled

public interface UserService {
    List<User> findAllUsers();
 
    @PostAuthorize ("returnObject.type == authentication.name")
    User findById(int id);
 
    @PreAuthorize("hasRole('ADMIN')")
    void updateUser(User user);
    
    @PreAuthorize("hasRole('ADMIN') AND hasRole('DBA')")
    void deleteUser(int id);
}

该注解更适合方法级的安全,也支持Spring 表达式语言,提供了基于表达式的访问控制。参见常见内置表达式了解支持表达式的完整列表

上面只使用到了一个注解@PreAuthorize,启用prePostEnabled后,提供有四个注解:

  • @PreAuthorize 进入方法之前验证授权。可以将登录用户的roles参数传到方法中验证。

    一些用法:

 // 只能user角色可以访问
@PreAuthorize ("hasAnyRole('user')")
// user 角色或者 admin 角色都可访问
@PreAuthorize ("hasAnyRole('user') or hasAnyRole('admin')")
// 同时拥有 user 和 admin 角色才能访问
@PreAuthorize ("hasAnyRole('user') and hasAnyRole('admin')")
// 限制只能查询 id 小于 10 的用户
@PreAuthorize("#id < 10")
User findById(int id);
 
// 只能查询自己的信息
 @PreAuthorize("principal.username.equals(#username)")
User find(String username);
 
// 限制只能新增用户名称为abc的用户
@PreAuthorize("#user.name.equals('abc')")
void add(User user)

@PostAuthorize 该注解使用不多,在方法执行后再进行权限验证。 适合验证带有返回值的权限。Spring EL 提供 返回对象能够在表达式语言中获取返回的对象returnObject。如:

// 查询到用户信息后,再验证用户名是否和登录用户名一致
@PostAuthorize("returnObject.name == authentication.name")
@GetMapping("/get-user")
public User getUser(String name){
    return userService.getUser(name);
}
// 验证返回的数是否是偶数
@PostAuthorize("returnObject % 2 == 0")
public Integer test(){
    // ...
    return id;
}

@PreFilter 对集合类型的参数执行过滤,移除结果为false的元素

// 指定过滤的参数,过滤偶数
@PreFilter(filterTarget="ids", value="filterObject%2==0")
public void delete(List<Integer> ids, List<String> username)

@PostFilter 对集合类型的返回值进行过滤,移除结果为false的元素

@PostFilter("filterObject.id%2==0")
public List<User> findAll(){
    ...
    return userList;
}

对于前面使用@Secured注解的缺点,现在使用@PreAuthorize/@PostAuthorize

public interface UserService {
    List<User> findAllUsers();
 
    @PostAuthorize ("returnObject.type == authentication.name")
    User findById(int id);
 
    @PreAuthorize("hasRole('ADMIN')")
    void updateUser(User user);
    
    @PreAuthorize("hasRole('ADMIN') AND hasRole('DBA')")
    void deleteUser(int id);
}

@preAuthorize 可使用 AND 和 or

表达式描述
hasRole([role])当前用户是否拥有指定角色。
hasAnyRole([role1,role2])多个角色是一个以逗号进行分隔的字符串。如果当前用户拥有指定角色中的任意一个则返回true。
hasAuthority([auth])等同于hasRole
hasAnyAuthority([auth1,auth2])等同于hasAnyRole
Principle代表当前用户的principle对象
authentication直接从SecurityContext获取的当前Authentication对象
permitAll总是返回true,表示允许所有的
denyAll总是返回false,表示拒绝所有的
isAnonymous()当前用户是否是一个匿名用户
isRememberMe()表示当前用户是否是通过Remember-Me自动登录的
isAuthenticated()表示当前用户是否已经登录认证成功了。
isFullyAuthenticated()如果当前用户既不是一个匿名用户,同时又不是通过Remember-Me自动登录的,则返回true。

表达式判断 

自定义权限校验PermissionService

@Service("ss")加上注解以便让ioc容器接管

自定义的权限校验

从缓存中拿loginUser 判断是否有权限

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值