Spring Security

原理

Spring Security 原理上来说是一个过滤器链。

以下列出过滤器链中关键的部分。

  • UsernamePasswordAuthenticationFilter

    用户名密码认证过滤器

  • ExceptionTranslationFilter

    异常转换过滤器

  • FilterSecurityInterceptor

    过滤器安全拦截器,负责授权的

最后再进入原定的API

image-20230112194528074

默认的过滤器链

image-20230112195133228

其中DefaultLoginPage…、DefaultLogoutPage…风别是默认登录与默认登出页面。

查看过滤器办法

image-20230112195437274

在项目启动后通过debug能查看

1
run.getBean(DefaultSecurityFilterChain.class)

认证流程

image-20230112200539215

一直调用到UserDetailsService的实现类 InMemoryUserDetailsManager(这是个默认用内存中读取的对象以后换掉)

然后把查询到的用户信息封装成UserDetails

如果认证成功那么人过滤器会把这个对象给存储到application也就是context中。

后面其他的过滤器会从context中读取

通常一个前后端分离项目要改的地方

  1. 重新实现UserDetailsService 因为之前从内存中读取,我们要从数据库中读取。

  2. 删除默认登录、登出页。留有接口既可以。

  3. 我们不需要UsernamePasswordAuthenticationFilter,而是使用自己的controller

  4. 改造ProviderManager,如果成功调用Redis存储用户权限信息。

  5. 校验 - 加入一个JWT过滤器,这个过滤器需要读取Redis来验证用户是否登录。

搭建

准备环境

  • pom

    1
    2
    3
    4
    5
    6
    7
    8
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
  • application.yml

    1
    2
    server:
    port: 29201

搭建环境

登陆校验的流程,

image-20220618133256165

  • 引入security包

    1
    2
    3
    4
    5
      
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    引入这个包之后就需要登陆了 默认的用户名是user

搞定JWT与redis

  • redis要能够写入读取json
  • JWT要可加解密

编写代码

编写代码以适应前后端分离的流程

  • 登录

    • 自定义登录接口

    • 自定义UserDetailsService

      • 在这个视线中查询数据库
  • 校验

    • 定义JWT认证过滤器
      • 获取token
      • 解析token获取其中的Userid
      • 从redis中获取用户信息
      • 存入SecurityContextHolder中
  • 创建用户

1.替换UserDetailsService 实现类

image-20230115054722536

1
2
3
//查询用户与权限
//存储用户与权限到redis中
//转换并返回UserDetails 当然也可以用User直接继承

继承UserDetails需要做的事

建立一个基础UserDetails的类并将User类存进去

  • getAuthorities

    获取权限信息

  • getPWD

  • getUNAME

  • isAccoutNonExpired

    判断是否没过期

    其他boolean类型的暂时都全改成true

加密密码

默认系统回去找PasswordEncoder

所以我们只需要使用@Bean注解将我们需要的加密方式注入进系统就可以了

1
2
3
4
5
6
7
8
9
@Configuration
public class SecurityConfig {

@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

}

登录接口

  • 准备AuthenticationManager

    通过实现WebSecurityConfigurerAdapter.authenticationManagerBean()

    方法并注入到bean来创造AuthenticationManager

  • 调用AuthenticationManager.authentication方法

    使用UsernamePasswordAuthenticationToken实现类来调用。

  • 在登录接口login中使用am进行验证,如果验证通过则:

    1. 存储到redis
    2. 生成jwt
    3. 返回

    代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
      
    //调用验证
    Authentication authentication=null;
    try {
    authentication = authenticationManager.authenticate(
    new UsernamePasswordAuthenticationToken(
    coreUser.getEmail(),
    coreUser.getPassword()
    )
    );
    }catch (Exception e){
    e.printStackTrace();
    }
    if (Objects.isNull(authentication) || !authentication.isAuthenticated()) {
    throw new ServiceException(StatusEnum.ERROR_LOGIN);
    }
    CoreUserDetails userDetails = (CoreUserDetails) authentication.getPrincipal();
    userService.saveUserToStore(userDetails.getCoreUser());
    String jwt = jwtUtil.getJwt(userDetails.getCoreUser().getId(), userDetails.getUsername(), userDetails.getCoreUser().getRoles());
    return jwt;
    }

准备好JWT

认证token

通过过滤器解析、查找redis用户来证明授权。

  1. 获取token
  2. 解析token
  3. 从redis中取得用户信息
  4. 存入SecurityContextHolder
  5. 放行

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package org.akachi.credit.alpha.security.filter;

import org.akachi.credit.alpha.domain.StatusEnum;
import org.akachi.credit.alpha.entity.CoreUser;
import org.akachi.credit.alpha.exception.ServiceException;
import org.akachi.credit.alpha.service.CoreUserService;
import org.akachi.credit.alpha.util.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;

/**
* @Author akachi
* @Email zsts@hotmail.com
* @Date 2023/1/16 3:57
*/
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
JwtUtil jwtUtil;
@Autowired
CoreUserService coreUserService;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//1.获取token
String token = request.getHeader("token");
if (!StringUtils.hasText(token)) {
//如果没有token 放行,交给后面的过滤器解决
filterChain.doFilter(request, response);
return;
}
//2.解析token
String userId = null;
try {
userId = jwtUtil.getUserId(token);
if (!StringUtils.hasText(userId) ||
jwtUtil.getPayLoad(token).getExpiration().getTime() < System.currentTimeMillis()
) {
filterChain.doFilter(request, response);
return;
}
} catch (Exception e) {
throw new ServiceException(StatusEnum.ERROR_TOKEN);
}

//3.从redis中取得用户信息
CoreUser coreUser = coreUserService.getUserFromStore(userId);
if (Objects.isNull(coreUser) && !StringUtils.hasText(coreUser.getEmail())) {
//系统中没查询到用户
throw new ServiceException(StatusEnum.ERROR_NOT_LOGIN);
}
//4.存入SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(
coreUser,null,
//@TODO 这里要权限信息
null
)
);
//5.放行
filterChain.doFilter(request, response);
}
}

定义过滤器位置

过滤器所在位置必须在FilterSecurityInterceptor

之前否则被拒绝后没机会触发验证。

在SecurityConfig.configure

1
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

大量测试

登出接口

不做

长连接时根据tokenId来交互

根据UserID来发信

只要有一个端在线消息就消费

权限代码实现

权限信息需要存储在SecurityContextHolder.Authentication中

  • 在启动类中添加注解@EnableGlobalMethodSecurity(prePostEnabled = true)

  • 在JwtAuthenticationTokenFilter中未完成的部分加入代码

    1
    2
    3
    4
    5
    6
    SecurityContextHolder.getContext().setAuthentication(
    new UsernamePasswordAuthenticationToken(
    loginUser, null,
    loginUser.getAuthorities()
    )
    );
  • 在LoginUser中实现方法getAuthorities

    1
    2
    3
    4
    5
    6
    7
    8
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
    List<GrantedAuthority> grantedAuthorities = coreUser
    .getRoles().stream().
    map(e -> new SimpleGrantedAuthority(e.getCode()))
    .collect(Collectors.toList());
    return grantedAuthorities;
    }

异常处理

  • AuthenticationEntryPointImpl 认证异常处理类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Component
    public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
    @Autowired
    Gson gson;
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
    ResponseResult result = new ResponseResult(StatusEnum.ERROR_LOGIN);
    WebUtils.renderJson(response, gson.toJson(result));
    }
    }
  • AccessDeniedHandlerImpl 登录异常处理类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Component
    public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
    @Autowired
    Gson gson;
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
    ResponseResult result = new ResponseResult(StatusEnum.ERROR_AUTHENTICATION);
    WebUtils.renderJson(response, gson.toJson(result));
    }
    }

解决跨域问题

除了使用普通方法允许跨域以外还需要配置spring security

  • 正常的配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    package org.akachi.credit.alpha.config;

    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.stereotype.Component;
    import org.springframework.web.servlet.config.annotation.CorsRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

    /**
    * @Author akachi
    * @Email zsts@hotmail.com
    * @Date 2023/1/19 3:52
    */
    @Component
    public class CorsConfig implements WebMvcConfigurer {

    @Value("${server.config.debug}")
    private Boolean debug;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
    if (!debug) {
    return;
    }
    //允许跨域的路径。
    registry.addMapping("/**")
    //允许跨域的域名
    .allowedOriginPatterns("*")
    //是否允许cookie
    .allowCredentials(true)
    //允许的请求方式
    .allowedMethods("GET", "POST", "DELETE", "PUT")
    //设置header属性
    .allowedHeaders("*")
    //跨域允许时间
    .maxAge(3600);
    }
    }
  • 开启spring security的跨域配置

    1
    //@TODO

自定义权限校验

  • 创建权限校验类

    创建任意一个bean并且从context中获取用户

    1
    2
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    LoginUser loginUser = (LoginUser) authentication.getPrincipal();
    • 编写类

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      package org.akachi.credit.security.expression;

      import org.akachi.credit.alpha.entity.CoreRole;
      import org.akachi.credit.alpha.entity.CoreRule;
      import org.akachi.credit.alpha.util.UserUtil;
      import org.akachi.credit.security.entity.LoginUser;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.beans.factory.annotation.Value;
      import org.springframework.stereotype.Component;

      import java.util.List;
      import java.util.regex.Pattern;

      /**
      * @Author akachi
      * @Email zsts@hotmail.com
      * @Date 2023/1/19 4:31
      */
      @Component("ae")
      public class AkachiExpressionRoot {

      @Autowired
      UserUtil userUtil;

      @Value("${server.name}")
      public String appName;

      /**
      * @param path 权限字符串路径
      * @return
      */
      public boolean hasAuthority(String path) {
      LoginUser loginUser = userUtil.getUtil();
      //获取用户权限
      List<CoreRole> roles = loginUser.getCoreUser().getRoles();
      //判断用户是否拥有权限
      for (CoreRole role : roles) {
      if (role.getIsLimit() == true) {
      //先判断限制
      for (CoreRule rule : role.getRules()) {
      String pattern = rule.getApiRegular();
      boolean isMatch = Pattern.matches(pattern, path);
      if (isMatch) {
      return false;
      }
      }
      } else {
      //再判断允许
      for (CoreRule rule : role.getRules()) {
      String pattern = rule.getApiRegular();
      boolean isMatch = Pattern.matches(pattern, path);
      if (isMatch) {
      return true;
      }
      }
      }
      }
      //默认无权限
      return false;
      }
      }
  • 在注解PreAuthorize中使用@ae来调用我的类

    1
    @PreAuthorize("@ae.hasAuthority('/test/test')")

问题汇总

  • spring-test与JPS无法兼容

    在TEST的Class头上加入@Transactional注解已解决问题。