Security+jwt 实现无状态认证,前后端分离(附带网页案例及完整源码)

Security系列教程

  • ①Security入门体验
  • ②Security自定义账号密码验证+thymeleaf登录案例(附带网页案例
  • ③Security+验证码登录案例(附带网页案例)
  • ④Security+jwt 实现无状态认证,前后端分离(附带网页案例))

文章目录

  • Security系列教程
  • 简介
    • 1. 环境
    • 2. 封装JWT工具类
    • 3. Security中添加JWT过滤器
    • 4. 重写Security的处理器
    • 5. Security的核心配置
    • 6. 登录和授权实现
    • 7. 效果演示
    • 8. 源码分享


简介

本章主要实现Spring Security自定义用户认证功能,jwt 实现token无状态认证。

  • 使用JWT来传输数据,实际上传输的是一个字符串,这个字符串就是所谓的json web token字符串。
  • jwt认证和传统的session和cookie模式相比,由于token认证实现了无状态,不再依赖session和cookie,所以无需要考虑伪造cookie等跨域攻击。

1. 环境

  • Springboot版本2.5.3
  • 新增hutool工具包,用于生成jwt
       <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-securityartifactId>
        dependency>
        
        <dependency>
            <groupId>com.alibabagroupId>
            <artifactId>fastjsonartifactId>
            <version>1.2.80version>
        dependency>
        
        <dependency>
            <groupId>cn.hutoolgroupId>
            <artifactId>hutool-allartifactId>
            <version>5.7.22version>
        dependency>
  • yml中配置jwt自定义秘钥和有效期
server:
  port: 9999
  
jwt:
  # 密钥
  secret: xxxxx.xxxx.xxxx
  # 有效期(秒)
  expire: 86400

2. 封装JWT工具类

  • 封装JWT工具类,用于生成和校验token
  • jwt认证和传统的session模式不同,由于是无状态认证,所以每次请求都需要进行一次认证,这里最好把用户角色和权限直接写进token中,或放入redis缓存中管理,以免每次都去数据库查询角色和权限,影响服务器性能。
  • 本案例选择把角色和权限直接写入token
@Slf4j
@Component
public class JwtProvider {

    @Value("${jwt.expire}")
    private Integer expire;

    @Value("${jwt.secret}")
    private String secret;

    public static final String TOKEN_HEADER = "token";

    public static final String AUTHORITY = "authority";

    /**
     * 生成token
     *
     * @param userId 用户id
     */
    public String createToken(Object userId) {
        return createToken(userId, null, null);
    }


    /**
     * 生成token,我们把用户id和授权信息都放入token中,
     * 无状态下每次请求都是会校验token的,这样做就不用每次校验都去查数据库了
     *
     * @param userId 用户id
     * @param roles  角色集合
     * @param auths  权限集合
     */
    public String createToken(Object userId, List<String> roles, List<String> auths) {
        ArrayList<String> authorityList = new ArrayList<>();
        // 合并角色和权限
        Optional.ofNullable(roles).ifPresent(authorityList::addAll);
        Optional.ofNullable(auths).ifPresent(authorityList::addAll);
        // 数组转String
        String authorityStr = String.join(",", authorityList);
        // 设置token有效期
        Date validity = new Date((new Date()).getTime() + expire * 1000);
        return JWT.create()
                // 秘钥
                .setKey(this.getSecretKey())
                // 代表这个JWT的主体,即它的所有人
                .setSubject(String.valueOf(userId))
                // 是一个时间戳,代表这个JWT的签发时间;
                .setIssuedAt(new Date())
                // 放入用户id
                .setPayload("userId", userId)
                // 放入角色和权限
                .setPayload(AUTHORITY, authorityStr)
                // 有效期
                .setExpiresAt(validity)
                .sign();

    }

    /**
     * 校验token
     */
    public boolean validateToken(String authToken) {
        try {
            return JWT.of(authToken).setKey(this.getSecretKey()).validate(0);
        } catch (Exception e) {
            log.error("无效的token:" + authToken);
        }
        return false;
    }

    /**
     * 解码token
     */
    public JWT decodeToken(String token) {
        if (validateToken(token)) {
            JWT jwt = JWT.of(token).setKey(this.getSecretKey());
            // 客户端id
            Object clientId = jwt.getPayload("clientId");
            // 用户id
            Object userId = jwt.getPayload("userId");
            log.info("token有效,userId:{}", userId);
            return jwt;
        }
        log.error("***token无效***");
        return null;
    }

    private byte[] getSecretKey() {
        return secret.getBytes(StandardCharsets.UTF_8);
    }
}

3. Security中添加JWT过滤器

  • 重写BasicAuthenticationFilter
  • 从请求头中取出token,进行认证,通过后放行
  • 由于我们的token已经存储了用户的角色和权限,所以这里我们直接从token中解析出用户角色和权限放入认证管理器
@Slf4j
public class JwtFilter extends BasicAuthenticationFilter {

    private final JwtProvider jwtProvider;

    public JwtFilter(AuthenticationManager authenticationManager, JwtProvider jwtProvider) {
        super(authenticationManager);
        this.jwtProvider = jwtProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = request.getHeader(JwtProvider.TOKEN_HEADER);
        // 这里如果没有jwt,继续往后走,因为后面还有鉴权管理器等去判断是否拥有身份凭证,所以是可以放行的
        // 注意登录等放行接口请求头不可带token,否则会进来认证
        if (StrUtil.isBlankOrUndefined(token)) {
            chain.doFilter(request, response);
            return;
        }
        JWT jwt = jwtProvider.decodeToken(token);
        if (jwt == null) {
            throw new JWTException("token 异常");
        }
        // 获取角色和权限
        List<GrantedAuthority> authority = this.getAuthority(jwt);
        // 构建UsernamePasswordAuthenticationToken,这里密码为null,是因为提供了正确的JWT,实现自动登录
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(jwt, null, authority);
        SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
        chain.doFilter(request, response);
    }

    /**
     * 从token中获取用户权限
     */
    private List<GrantedAuthority> getAuthority(JWT jwt) {
        String authsStr = (String) jwt.getPayload(JwtProvider.AUTHORITY);
        if (!StrUtil.isBlank(authsStr)) {
            // 角色和权限都在这里添加,角色以ROLE_前缀,不是ROLE_前缀的视为权限
            return AuthorityUtils.commaSeparatedStringToAuthorityList(authsStr);
        }
        return null;
    }

}

  • 注意:放行接口请求头不可带token,否则会进来认证,导致放行失败。

4. 重写Security的处理器

  • 由于前后端分离,这里我们先封装一下通用响应
@EqualsAndHashCode(callSuper = false)
@Data
@Accessors(chain = true)
public class ResponseResult<T> implements Serializable {

    private static final long serialVersionUID = -1L;

    private Integer code;

    private String message;

    private T data;

    public ResponseResult(Integer code, String message, T data) {
        super();
        this.code = code;
        this.message = message;
        this.data = data;
    }

    private static <T> ResponseResult<T> build(Integer code, String message, T data) {
        return new ResponseResult<>(code, message, data);
    }

    public static <T> ResponseResult<T> ok() {
        return new ResponseResult<>(RespCode.OK.code, RespCode.OK.message, null);
    }

    public static <T> ResponseResult<T> ok(T data) {
        return build(RespCode.OK.code, RespCode.OK.message, data);
    }

    public static <T> ResponseResult<T> fail() {
        return fail(RespCode.ERROR.message);
    }

    public static <T> ResponseResult<T> fail(String message) {
        return fail(RespCode.ERROR, message);
    }

    public static <T> ResponseResult<T> fail(RespCode respCode) {
        return fail(respCode, respCode.message);
    }

    public static <T> ResponseResult<T> fail(RespCode respCode, String message) {
        return build(respCode.getCode(), message, null);
    }

    public enum RespCode {
        /**
         * 业务码
         */
        OK(20000, "请求成功"),
        MY_ERROR(20433, "自定义异常"),
        UNAUTHORIZED(20401, "未授权"),
        LOGIN_FAIL(20402, "账号或密码错误"),
        ERROR(20400, "未知异常");

        RespCode(int code, String message) {
            this.code = code;
            this.message = message;
        }

        private final int code;
        private final String message;

        public int getCode() {
            return code;
        }

        public String getMessage() {
            return message;
        }
    }
}

  • 授权处理器,用于处理无权访问
@Component
@RequiredArgsConstructor
@Slf4j
public class AccessFailure implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException {
        log.info("拒绝访问,无权限");
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(JSONObject.toJSONString(ResponseResult.fail(ResponseResult.RespCode.UNAUTHORIZED)));
    }
}
  • JWT认证处理器,处理认证失败
@Component
@Slf4j
public class JwtAuthFailure implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        log.info("认证失败,token不存在或已失效");
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(JSONObject.toJSONString(ResponseResult.fail(ResponseResult.RespCode.UNAUTHORIZED)));
    }
}
  • 退出登录处理器,处理退出登录成功
@Slf4j
@Component
public class LogoutSuccess extends SimpleUrlLogoutSuccessHandler {

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(JSONObject.toJSONString(ResponseResult.ok().setMessage("退出登录成功!")));
    }
}

5. Security的核心配置

  • @EnableWebSecurity启动Security的默认功能
  • @EnableGlobalMethodSecurity(prePostEnabled = true) 开启权限注解控制
  • 重写WebSecurityConfigurerAdapter ,注册我们前面定义的过滤器处理器,添加拦截规则,关闭跨域。
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final SimpleUrlLogoutSuccessHandler logoutSuccess;

    private final JwtAuthFailure authFailure;

    private final AccessFailure accessFailure;

    private final JwtProvider jwtProvider;

    /**
     * 白名单
     */
    public final static String[] AUTH_WHITELIST = {"/login"};
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .logout()
                .logoutSuccessHandler(logoutSuccess)
                // 禁用session
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                // 配置拦截规则
                .and()
                .authorizeRequests()
                .antMatchers(AUTH_WHITELIST).permitAll()
                .anyRequest().authenticated()
                // 异常处理器
                .and()
                .exceptionHandling()
                // jwt认证失败
                .authenticationEntryPoint(authFailure)
                // 拒绝访问
                .accessDeniedHandler(accessFailure)
                // 配置自定义的过滤器
                .and()
                .addFilter(new JwtFilter(authenticationManager(), jwtProvider))
                // 验证码过滤器放在UsernamePassword过滤器之前
                // .addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class)
                // 关闭跨域
                .cors().and().csrf().disable();
    }

    @Override
    public void configure(WebSecurity web) {
        // 配置静态文件不需要认证
        web.ignoring().antMatchers("/static/**");
    }

    /**
     * 指定加密方式
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

6. 登录和授权实现

  • 由于我们是无状态登录,所以不需要再用Security的自带的登录了,也不用重写UserDetailsService 这么麻烦。
  • 封装一下工具类,用于获取登录者信息,密码MD5加密等
public class SecurityUtils {

    private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder();

    /**
     * 获取登录者的信息
     */
    public static JWT getInfo() {
        return (JWT) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    }

    /**
     * 获取登录者的id
     */
    public static Object getUserId() {
        JWT info = getInfo();
        return info.getPayload("userId");
    }

    /**
     * 获取登录者的权限
     */
    public static String getAuths() {
        return (String) getInfo().getPayload(JwtProvider.AUTHORITY);
    }

    /**
     * 密码加密
     *
     * @param password 明文密码
     * @return 加密后的密码
     */
    public static String passwordEncoder(String password) {
        return PASSWORD_ENCODER.encode(password);
    }

    /**
     * 密码比对
     *
     * @param rawPassword     明文密码
     * @param encodedPassword 加密后的密码
     * @return 是否通过
     */
    public static boolean passwordMatches(CharSequence rawPassword, String encodedPassword) {
        return PASSWORD_ENCODER.matches(rawPassword, encodedPassword);
    }
}
  • 模拟一个用户
@Data
@Accessors(chain = true)
public class MyUser {

    /**
     * id
     */
    private Long userId;

    /**
     * 账号
     */
    private String username;

    /**
     * 密码
     */
    private String password;


    /**
     * 角色
     */
    private List<String> roles;

    /**
     * 权限
     */
    private List<String> auths;

}
  • 接口
public interface UserService {

    /**
     * 获取用户
     *
     * @param username 账号
     * @return user
     */
    MyUser getUser(String username);

}
  • 实现类,这里为了方便测试,就不用数据库了
  • HashMap代替数据库,我们这里模拟了一个admin用户, 拥有ROLE_ADMIN角色和readwrite权限
@Service
public class UserServiceImpl implements UserService {

    /**
     * 模拟一个数据库用户
     * 账号 admin
     * 密码 123456
     */
    private final static HashMap<String, MyUser> USER_MAP = new LinkedHashMap<>() {
        {
            put("admin", new MyUser()
                    .setUserId(1L)
                    .setUsername("admin")
                    .setPassword(SecurityUtils.passwordEncoder("123456"))
                    // 角色以ROLE_前缀
                    .setRoles(Arrays.asList("ROLE_ADMIN"))
                    // 权限
                    .setAuths(Arrays.asList("read", "write"))
            );
        }
    };
    
    @Override
    public MyUser getUser(String username) {
        return USER_MAP.get(username);
    }
}
  • 登录接口
@RestController
@RequiredArgsConstructor
@Slf4j
@CrossOrigin(origins = "*")
public class IndexController {

    private final JwtProvider jwtProvider;

    private final UserService userService;

    /**
     * 登录
     */
    @PostMapping(value = "/login")
    public ResponseResult<String> login(@RequestParam("username") String username,
                                        @RequestParam("password") String password) {
        MyUser user = userService.getUser(username);
        if (Objects.nonNull(user) && SecurityUtils.passwordMatches(password, user.getPassword())) {
            String token = jwtProvider.createToken(user.getUserId(), user.getRoles(), user.getAuths());
            return ResponseResult.ok(token);
        }
        return ResponseResult.fail("账号或密码错误");
    }

    /**
     * 获取用户信息
     */
    @GetMapping("/info")
    @PreAuthorize("hasRole('ROLE_ADMIN') or hasAuthority('read')")
    public ResponseResult<HashMap<String, Object>> info() {
        return ResponseResult.ok(new HashMap<>(1) {
            {
                put("userId", SecurityUtils.getUserId());
                put("auths", SecurityUtils.getAuths());
            }
        });
    }

    @GetMapping("/test")
    @PreAuthorize("hasRole('xxx') or hasAuthority('aaa')")
    public ResponseResult<String> test() {
        return ResponseResult.ok();
    }

    @GetMapping("/test1")
    @PreAuthorize("hasAuthority('write')")
    public ResponseResult<String> test1() {
        return ResponseResult.ok();
    }

    @GetMapping("/test2")
    @PreAuthorize("hasAuthority('read')")
    public ResponseResult<String> test2() {
        return ResponseResult.ok();
    }
    
}

7. 效果演示

  • 网页源码在结尾有提供,网页在项目根目录下html文件夹中,浏览器直接点击即可运行。
    Security+jwt 实现无状态认证,前后端分离(附带网页案例及完整源码)_第1张图片

8. 源码分享

本系列项目已收录
Springboot、SpringCloud全家桶教程+源码,各种常用框架使用案例都有哦,具备完善的文档,致力于让开发者快速搭建基础环境并让应用跑起来,并提供丰富的使用示例供使用者参考,快来看看吧。

  • 项目源码github地址
  • 项目源码国内gitee地址

你可能感兴趣的:(SpringBoot-cli,开发脚手架,spring,boot,spring,security,jwt,token)