zl程序教程

您现在的位置是:首页 >  后端

当前栏目

springboot-安全认证security+jwt总结

SpringBoot认证安全 总结 Security JWT
2023-09-11 14:19:58 时间

目录

一、背景

二、基本jar依赖引入

三、security模块

1、编写配置类

2、UnauthorizedHandler代码

3、security验证用户名和密码的部分

四、jwt模块

1、jwt原理部分

2、jwt一共需要四个类

五、总结


一、背景

要做一个后台管理系统,会引入多个系统,这就需要做用户认证和权限管理。用户认证通过token来实现,市面上的技术有很多,我这里仅仅来说明一下security+jwt的一种实现过程,没有做页面,需要做页面的同学自行实现。

有些容易入坑的点,我看别的资料没有说太清楚,这里记录下,希望能帮助到跳坑的同学。

二、基本jar依赖引入

<!-- security -->
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- jwt依赖 -->
<dependency>
   <groupId>io.jsonwebtoken</groupId>
   <artifactId>jjwt</artifactId>
   <version>0.9.1</version>
</dependency>

总结:

我看有的帖子也引入了jjwt的API、impl包,我这里没有用到,实现权限控制和token校验两个完全够用

三、security模块

1、编写配置类

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    //操作用户
    @Autowired
    FfUserService ffUserService;

    //token校验
    @Autowired
    JwtAuthenPreFilter jwtAuthenPreFilter;

    /**
     *token异常
     */
    @Autowired
    UnauthorizedHandler unauthorizedHandler;

    //配置放行策略
    @Value("${jwt.security.antMatchers}")
    private String antMatchers;


    /**
     * 密码加密算法
     *
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(10);
    }

    /**
     * 验证用户来源,主要是验证账号和密码
     *
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(ffUserService).passwordEncoder(passwordEncoder());
    }

    /**
     * 忽略策略
     * @param web
     * @throws Exception
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers(antMatchers.split(","));
    }

    /**
     * 用户授权
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .anyRequest().authenticated()       // 所有的验证都需要验证
                .and()
                .csrf().disable()                      // 禁用 Spring Security 自带的跨域处理              
// 定制我们自己的 session 策略:调整为让 Spring Security 不创建和使用 session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler);
        // 将自定义的过滤器添加在指定过滤器之前
        http.addFilterBefore(jwtAuthenPreFilter, FilterSecurityInterceptor.class);
        // 禁用缓存
        http.headers().cacheControl();
    }

}

总结:

  • 需要放行的可以在两个地方配置,第一种如上图;第二种可以在第二个configure中配置。比如:.antMatchers(antMatchers.split(",")).permitAll()
  • 在第二个configure中这里特别注意一下配置的顺序,exceptionHandling().authenticationEntryPoint(myAuthenticationEntryPoint())如果放在前面会导致放行策略不生效。
  • security是通过用户名和密码来实现认证的,不一定能满足实际业务需要,所以要扩展,前后端分离目前常用的做法就是基于usertoken的,即上面的自定义的jwtAuthenPreFilter过滤器
  • addFilterAfter: 将自定义的过滤器添加在指定过滤器之后
  • addFilterBefore:将自定义的过滤器添加在指定过滤器之前
  • addFilter:添加一个过滤器,但必须是Spring Security自身提供的过滤器实例或其子过滤器
  • addFilterAt: 添加一个过滤器在指定过滤器位置

2、UnauthorizedHandler代码

@Component
@Slf4j
public class UnauthorizedHandler implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        //用户登录时身份认证未通过
        if(e instanceof BadCredentialsException){
            //用户登录时身份认证失败
            ResultUtil.writeJavaScript(httpServletResponse, ErrorCodeEnum.TOKEN_INVALID.getCode(), e.getMessage());
        }else if (e instanceof InsufficientAuthenticationException){
            //缺少请求头参数,Authorization传递是token值,所以是参数是必须的
            ResultUtil.writeJavaScript(httpServletResponse, ErrorCodeEnum.NO_TOKEN.getCode(), ErrorCodeEnum.NO_TOKEN.getMessage());
        }else{
            //用户token无效
            ResultUtil.writeJavaScript(httpServletResponse, ErrorCodeEnum.TOKEN_INVALID.getCode(), ErrorCodeEnum.TOKEN_INVALID.getMessage());
        }
    }
}

注意:

  • 接口中的逻辑异常要捕获,不然会被拦截报token异常就不美观了,也可以完善这个类。
  • 也可以扩展单独的异常处理模块做统一处理,但是业务异常我还是推荐根据业务场景来单独处理,一味的追求统一处理不见得都是好事。

3、security验证用户名和密码的部分

  • 网上资料很多,大家自己补充

四、jwt模块

1、jwt原理部分

  • 网上资料很多,大家自己补充

2、jwt一共需要四个类

  • JwtAuthenPreFilter:token校验和有关业务,这部分可以根据自己项目来实现
@Component
@Slf4j
public class JwtAuthenPreFilter extends OncePerRequestFilter {

    @Autowired
    private JwtTokenUtil jwtTokenUtil;
    //@Autowired
    //private RedisUtil redisUtil;
    /**
     * 防止filter被执行两次
     */
    private static final String FILTER_APPLIED = "__spring_security_JwtAuthenPreFilter_filterApplied";

    @Value("${jwt.header:Authorization}")
    private String tokenHeader;

    @Value("${jwt.tokenHead:Bearer}")
    private String tokenHead;
    /**
     * 距离快过期多久刷新令牌
     */
    @Value("${jwt.token.subRefresh:#{10*60}}")
    private Long subRefresh;
    // 不需要认证的接口
    @Value("${jwt.security.antMatchers}")
    private String antMatchers;
    @Autowired
    private FfUserService ffUserService ;

    public JwtAuthenPreFilter() {
    }

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {

        if (httpServletRequest.getAttribute(FILTER_APPLIED) != null) {
            filterChain.doFilter(httpServletRequest, httpServletResponse);
            return;
        }
        httpServletRequest.setAttribute(FILTER_APPLIED, true);

        //过滤掉不需要token验证的url
        SkipPathAntMatcher skipPathRequestMatcher = new SkipPathAntMatcher(Arrays.asList(antMatchers.split(",")));
        if (skipPathRequestMatcher.matches(httpServletRequest)) {
            filterChain.doFilter(httpServletRequest, httpServletResponse);
        } else {
            try {
                //1.判断是否有效 2.判断是否过期 3.如果未过期的,且过期时间小于10分钟的延长过期时间,并在当前response返回新的header,客户端需替换此令牌
                String authHeader = httpServletRequest.getHeader(this.tokenHeader);
                if (authHeader != null && authHeader.startsWith(tokenHead)) {
                    final String authToken = authHeader.substring(tokenHead.length());
                    JWTUserDetail userDetail = jwtTokenUtil.getUserFromToken(authToken);
                    if (ObjectUtils.isEmpty(userDetail)) {
                        log.info("令牌非法,解析失败{}!", authToken);
                        throw new BadCredentialsException(ErrorCodeEnum.TOKEN_INVALID.getMessage());
                    }
                    if (jwtTokenUtil.isTokenExpired(authToken)) {
                        log.info("令牌已失效!{}", authToken);
                        throw new BadCredentialsException(ErrorCodeEnum.TOKEN_INVALID.getMessage());
                    }

                    //令牌快过期生成新的令牌并设置到返回头中,客户端在每次的restful请求如果发现有就替换原值
                    if (new Date(System.currentTimeMillis() - subRefresh).after(jwtTokenUtil.getExpirationDateFromToken(authToken))) {
                        String resAuthToken = jwtTokenUtil.generateToken(userDetail);
                        httpServletResponse.setHeader(tokenHeader, tokenHead + resAuthToken);
                    }
                    JwtTokenUtil.LOCAL_USER.set(userDetail);
                    UserDetails userDetails = ffUserService.loadUserByUsername(userDetail.getLoginName());
                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                } else {
                    //需要校验却无用户token
                    log.info("无header请求-->" + httpServletRequest.getRequestURI());
                    throw new InsufficientAuthenticationException(ErrorCodeEnum.NO_TOKEN.getMessage());
                }
            } catch (Exception e) {
                //log.info("令牌解析失败!", e);
                throw new BadCredentialsException(ErrorCodeEnum.TOKEN_INVALID.getMessage());
            }
            filterChain.doFilter(httpServletRequest, httpServletResponse);
            //调用完成后清除
            JwtTokenUtil.LOCAL_USER.remove();
        }
    }
}
  • JWTUserDetail:token转换成user类
@Data
public class JWTUserDetail implements Serializable {
    /**
     * 登陆用户编号
     */
    private long userId;
    /**
     * 登陆用户账户名称(可能为手机号邮箱或者名称用户维度唯一)
     */
    private String loginName;
    /**
     * 登陆用户类型
     */
    private UserType userType;
    /**
     * 登陆用户凭证
     */
    private String jwtToken;
    /**
     * 登陆时间
     */
    private Date loginTime;

    private static ObjectMapper mapper = new ObjectMapper();
    public enum UserType {
        User("USER", 1),
        Operator("OPT", 2),
        Erp("ERP", 3);

        private String name;
        private int index;

        private UserType(String name, int index) {
            this.name = name;
            this.index = index;
        }
        @Override
        public String toString() {
            return this.name;
        }

        public static String getName(int index) {
            for (UserType c : UserType.values()) {
                if (c.getIndex() == index) {
                    return c.getName();
                }
            }
            return null;
        }

        public String getName() {
            return name;
        }

        public int getIndex() {
            return index;
        }
    }
    public static JWTUserDetail fromJson(String json) throws JsonProcessingException {
        return mapper.readValue(json,JWTUserDetail.class);//JSONObject.parseObject(json, JWTUserDetail.class);
    }
    public String toJson() throws JsonProcessingException {
        return mapper.writeValueAsString(this);//.toJSONString(this);
    }
}
  • JwtTokenUtil:token工具
@Component
public class JwtTokenUtil implements Serializable {
    private static final long serialVersionUID = -5883980282405596071L;

    public static final ThreadLocal<JWTUserDetail> LOCAL_USER = new ThreadLocal<>();

    public final static String JWT_TOKEN_PREFIX = "jwt:%s:%d";


    private final String JWT_LOGIN_NAME = "JWT_LOGIN_NAME";
    private final String JWT_LOGIN_TIME = "JWT_LOGIN_TIME";
    private final String JWT_LOGIN_USERID = "JWT_LOGIN_USERID";
    private final String JWT_LOGIN_USERTYPE = "JWT_LOGIN_USERTYPE";
    //签名方式
    private final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.HS256;
    //密匙
    @Value("${jwt.security.secret}")
    private String secret;

    @Value("${jwt.access_token:#{30*24*60*60}}")
    private Long access_token_expiration;

    public String getLoginNameFromToken(String token) {
        return getClaimsFromToken(token).getSubject();
    }

    /**
     * 根据token 获取用户信息
     */
    public JWTUserDetail getUserFromToken(String token) {
        JWTUserDetail jwtUserDetails = new JWTUserDetail();
        Claims claims = getClaimsFromToken(token);
        jwtUserDetails.setUserId(claims.get(JWT_LOGIN_USERID, Long.class));
        jwtUserDetails.setLoginName(claims.get(JWT_LOGIN_NAME, String.class));
        jwtUserDetails.setUserType(Enum.valueOf(JWTUserDetail.UserType.class, (String) claims.get(JWT_LOGIN_USERTYPE)));
        jwtUserDetails.setLoginTime(new Date(claims.get(JWT_LOGIN_TIME, Long.class)));
        jwtUserDetails.setJwtToken(token);
        return jwtUserDetails;
    }

    /**
     * 根据用户信息生成token
     *
     * @param user
     * @return
     */
    public String generateToken(JWTUserDetail user) {
        Map<String, Object> claims = new HashMap<>();
        claims.put(JWT_LOGIN_NAME, user.getLoginName());
        claims.put(JWT_LOGIN_TIME, user.getLoginTime());
        claims.put(JWT_LOGIN_USERID, user.getUserId());
        claims.put(JWT_LOGIN_USERTYPE, user.getUserType());
        return Jwts.builder()
                //一个map 可以资源存放东西进去
                .setClaims(claims)
                //  用户名写入标题
                .setSubject(user.getLoginName())
                .setId(UUID.randomUUID().toString())
                .setIssuedAt(new Date())
                //过期时间
                .setExpiration(new Date(System.currentTimeMillis() + access_token_expiration * 1000))
                //数字签名
                .signWith(SIGNATURE_ALGORITHM, secret)
                .compact();
    }
    /**
     * 根据token 获取生成时间
     */
    public Date getCreatedDateFromToken(String token) {
        return getClaimsFromToken(token).getIssuedAt();
    }

    /**
     * 根据token 获取过期时间
     */
    public Date getExpirationDateFromToken(String token) {
        return getClaimsFromToken(token).getExpiration();
    }

    /**
     * token 是否过期
     */
    public Boolean isTokenExpired(String token) {
        return getExpirationDateFromToken(token).before(new Date());
    }

    /***
     * 解析token 信息
     * @param token
     * @return
     */
    private Claims getClaimsFromToken(String token) {
        return Jwts.parser()
                //签名的key
                .setSigningKey(secret)
                // 签名token
                .parseClaimsJws(token)
                .getBody();
    }

}
  • SkipPathAntMatcher:token校验的放行策略
@Slf4j
public class SkipPathAntMatcher implements RequestMatcher {
    private List<String> pathsToSkip;

    public SkipPathAntMatcher(List<String> pathsToSkip) {
        this.pathsToSkip = pathsToSkip;
    }

    @Override
    public boolean matches(HttpServletRequest request) {
        if (!ObjectUtils.isEmpty(pathsToSkip)) {
            for (String s : pathsToSkip) {
                AntPathRequestMatcher antPathRequestMatcher = new AntPathRequestMatcher(s);
                if (antPathRequestMatcher.matches(request)) {
                    return true;
                }
            }
        }
        return false;
    }
}

五、总结

  • 放行策略有两个地方一定要都配置,1.security要配置,2.token的过滤器也要配置。
  • security是校验URL是否有权限访问或者直接放行,我这里没有写用户角色和权限,是因为后面我计划给不同的角色返回不同的菜单,通过菜单来区分。如果你的业务需要可以单独设置
  • token过滤器是配置这次请求是否token有效。security放行的,token也要放行。比如登录和注册页面,这时用户没有任何权限的,所以都放行。

最后,欢迎大家关注我的个人公众号,我会把经历分享出来,助你了解圈内圈外事。

同时也欢迎大家添加个人微信【shishuai860505】,我拉大家进我的读者交流群。