Jwt

一、搭建项目结构

  1、新建一个Maven项目,项目结构如图所示

2、SpringMvcConfig文件

package com.yj.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import com.yj.interceptor.JwtInterceptor;

@Configuration
@ComponentScan(basePackages = { "com.yj.jwt.interceptor" })
public class SpringMvcConfig extends WebMvcConfigurerAdapter {

	@Bean
	public JwtInterceptor jwtInterceptor() {
		return new JwtInterceptor();
	}

	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		registry.addInterceptor(jwtInterceptor()).addPathPatterns("/*").excludePathPatterns("/login");
	}
}

2、IndexController文件

package com.yj.controller;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

import com.yj.entity.BusinessException;
import com.yj.entity.User;
import com.yj.utils.CommonUtils;
import com.yj.utils.JwtUtil;

@RequestMapping
public class IndexController {
	private final static Logger logger = LoggerFactory.getLogger(IndexController.class);

	@Value(value = "${TTL}")
	private String TTL;

	@Value(value = "${refreshTTL}")
	private String refreshTTL;

	@RequestMapping("/index")
	@ResponseBody
	public String index(HttpServletRequest request, HttpServletResponse response) {
		User user=JwtUtil.parseUser(request);
		return "欢迎:"+user.getName();
	}

	@RequestMapping(value = "/login", method = RequestMethod.POST)
	public User login(HttpServletRequest request, HttpServletResponse response, @RequestBody User user) {
		if ("123456".equals(user.getPwd())) {//数据库查询
			String token = null;
			String refreshToken;
			try {
				token = JwtUtil.createJWT(user, Long.parseLong(TTL) * 60000);
				refreshToken = JwtUtil.createJWT(user, Long.parseLong(refreshTTL) * 60000);
				CommonUtils.addCookie(response, "token", token);
				CommonUtils.addCookie(response, "refreshToken", refreshToken);
			} catch (BusinessException e) {
				logger.info("发生异常:" + e.getMessage());
			} catch (Exception e) {
				e.printStackTrace();
			}
			return JwtUtil.parseUser(request);
		}
		return null;
	}
}

3、BusinessException文件

package com.yj.jwt.entity;

public class BusinessException extends RuntimeException {
	private static final long serialVersionUID = -8138602623241348983L;
	private String errorMessage = null;

	public BusinessException() {
		super();
	}

	public BusinessException(String errorMessage) {
		super(errorMessage);
		this.errorMessage = errorMessage;
	}

	public String getMessage() {
		if (errorMessage != null) {
			return errorMessage;
		}
		if (super.getMessage() != null)
			return super.getMessage();
		return errorMessage;
	}
}

4、User文件

package com.yj.jwt.entity;

public class User {
	private String id;
	private String name;
	private String pwd;
	public String getId() {
		return id;
	}
	public void setId(String id) {
		this.id = id;
	}
	public String getName() {
		return name;
	}
	public void setName(String name) {
		this.name = name;
	}
	public String getPwd() {
		return pwd;
	}
	public void setPwd(String pwd) {
		this.pwd = pwd;
	}
}

5、JwtInterceptor文件

package com.yj.interceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import com.yj.entity.User;
import com.yj.utils.CommonUtils;
import com.yj.utils.JwtUtil;

@Component
public class JwtInterceptor implements HandlerInterceptor {

	private final static Logger logger = LoggerFactory.getLogger(JwtInterceptor.class);

	@Value(value = "${TTL}")
	private String TTL;

	@Value(value = "${refreshTTL}")
	private String refreshTTL;

	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object o) throws Exception {
		String token = CommonUtils.getCookie(request, "token");
		String refreshToken = CommonUtils.getCookie(request, "refreshToken");
		logger.info("token:" + token);
		logger.info("refreshToken:" + refreshToken);
		if (StringUtils.isEmpty(token) || StringUtils.isEmpty(refreshToken)) {
			response.sendRedirect("/loginPage");
			return false;
		}
		if (!JwtUtil.tokenIsExpired(token)) {
			logger.info("token未过期,无需刷新token");
			return true;
		}
		logger.info("token过期了,需判断refreshToken是否过期");
		if (!JwtUtil.tokenIsExpired(refreshToken)) {
			logger.info("refreshToken未过期,重新构造token");
			User user = JwtUtil.parseUser(request);
			token = JwtUtil.createJWT(user, Long.parseLong(TTL) * 60000);
			CommonUtils.addCookie(response, "token", token);
			CommonUtils.getThreadLocal().set(token);
			return true;
		}
		logger.info("refreshToken也过期,需要重新登录");
		response.sendRedirect("/loginPage");
		return false;
	}

	@Override
	public void afterCompletion(HttpServletRequest arg0, HttpServletResponse arg1, Object arg2, Exception arg3)
			throws Exception {
	}

	@Override
	public void postHandle(HttpServletRequest arg0, HttpServletResponse arg1, Object arg2, ModelAndView arg3)
			throws Exception {
	}
}

6、CommonUtils文件

package com.yj.utils;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class CommonUtils {
	
	private static ThreadLocal<String> threadJwt = new ThreadLocal<String>();

	public static String getCookie(HttpServletRequest request, String key) {
		Cookie cookies[] = request.getCookies();
		Cookie sCookie = null;
		String sid = null;
		if (cookies != null && cookies.length > 0) {
			for (int i = 0; i < cookies.length; i++) {
				sCookie = cookies[i];
				if (key.equals(sCookie.getName())) {
					sid = sCookie.getValue();
					break;
				}
			}
		}
		return sid;
	}

	public static void addCookie(HttpServletResponse response, String key, String value) {
		Cookie cookie = new Cookie(key, value);
		cookie.setMaxAge(-1);
		cookie.setPath("/");// 可在同一应用服务器内共享cookie
		cookie.setHttpOnly(true);// 防御XSS攻击
		// cookie.setDomain(".taobao.com");cookie跨域访问
		response.addCookie(cookie);
	}
	
	public static ThreadLocal<String> getThreadLocal(){
		return threadJwt;
	}
}

7、JwtUtil文件

package com.yj.utils;

import java.text.SimpleDateFormat;
import java.util.Date;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.HttpServletRequest;
import org.apache.commons.codec.binary.Base64;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.yj.entity.BusinessException;
import com.yj.entity.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

@Component
public class JwtUtil {

	private final static Logger logger = LoggerFactory.getLogger(JwtUtil.class);

	private static String jwtKey;

	@Value("${jwtKey}")
	public void setJwtKey(String jwtKey) {
		JwtUtil.jwtKey = jwtKey;
	}

	/**
	 * 由字符串生成加密key
	 * 
	 * @return
	 */
	public static SecretKey generalKey() {
		byte[] encodedKey = Base64.decodeBase64(jwtKey);
		SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
		return key;
	}

	/**
	 * 创建jwt
	 * 
	 * @param id
	 * @param subject
	 * @param ttlMillis
	 * @return
	 * @throws Exception
	 */
	public static String createJWT(User user, long ttlMillis) throws BusinessException {
		String id = user.getId();
		String subject = JSON.toJSONString(user);
		SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
		JwtBuilder builder;
		try {
			SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
			long nowMillis = System.currentTimeMillis();
			Date now = new Date(nowMillis);
			logger.info("当前时间:" + sdf.format(now));
			SecretKey key = generalKey();
			builder = Jwts.builder().setId(id).setIssuedAt(now).setSubject(subject).signWith(signatureAlgorithm, key);
			if (ttlMillis >= 0) {
				long expMillis = nowMillis + ttlMillis;
				Date exp = new Date(expMillis);
				logger.info("失效时间:" + sdf.format(exp));
				builder.setExpiration(exp);
			}
		} catch (Exception e) {
			throw new BusinessException("生成jwt时发生异常");
		}
		String result = builder.compact();
		logger.info("签名串:" + result);
		return result;
	}

	/**
	 * 获取claims
	 * 
	 * @param token
	 * @return Claims
	 * @throws Exception
	 */
	public static Claims getClaims(String token) {
		Claims claims = null;
		SecretKey key = generalKey();
		try {
			claims = Jwts.parser().setSigningKey(key).parseClaimsJws(token).getBody();
		} catch (ExpiredJwtException e) {
			logger.info("token过期了");
			return e.getClaims();
		} catch (Exception e) {
			e.printStackTrace();
		}
		return claims;
	}

	/**
	 * 判断Token是否过期
	 * 
	 * @param token
	 * @return true过期,false未过期
	 */
	public static Boolean tokenIsExpired(String token) {
		Claims claims = getClaims(token);
		if (claims.getExpiration().before(new Date())) {
			return true;
		}
		return false;
	}

	/**
	 * 生成subject信息
	 * 
	 * @param user
	 * @return String
	 */
	public static String generalSubject(User user) {
		JSONObject jo = new JSONObject();
		jo.put("name", user.getName());
		jo.put("id", user.getId());
		return jo.toJSONString();
	}

	/**
	 * 解析user
	 * 
	 * @param request
	 * @return User
	 */
	public static User parseUser(HttpServletRequest request) {
		String token = CommonUtils.getThreadLocal().get();
		if (StringUtils.isEmpty(token)) {
			token = CommonUtils.getCookie(request, "token");
		}
		String userStr = null;
		User user = null;
		try {
			userStr = getClaims(token).getSubject();
			user = JSON.parseObject(userStr, User.class);
		} catch (Exception e) {
			e.printStackTrace();
			throw new BusinessException("获取jwt内的user对象时发生异常");
		}
		return user;
	}
}

8、JwtApplication文件

package com.yj.jwt;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class JwtApplication {
	public static void main(String[] args) {
		SpringApplication.run(JwtApplication.class, args);
	}
}

9、application.properties文件

#单位为分钟(一般来说token设置为几小时,refreshToken设置为几天,具体情况视业务而定)
TTL=1
refreshTTL=3
#加密key
jwtKey=${random.value}

10、pom.xml文件

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>

	<groupId>com.yj</groupId>
	<artifactId>Jwt</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<packaging>jar</packaging>

	<name>Jwt</name>
	<url>http://maven.apache.org</url>

	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>1.5.16.RELEASE</version>
		<relativePath />
	</parent>

	<properties>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt</artifactId>
			<version>0.7.0</version>
		</dependency>
		<dependency>
			<groupId>com.auth0</groupId>
			<artifactId>java-jwt</artifactId>
			<version>3.4.0</version>
		</dependency>
		<dependency>
			<groupId>com.alibaba</groupId>
			<artifactId>fastjson</artifactId>
			<version>1.2.16</version>
		</dependency>
		<dependency>
			<groupId>commons-codec</groupId>
			<artifactId>commons-codec</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
		</dependency>
	</dependencies>
</project>

二、验证

1、访问

http://127.0.0.1:8080/login

验证通过,登陆成功

2、token有效期为1分钟,refresh有效期为2分钟,访问index接口:

     ①1分钟内,token未过期,拦截器放行

     ②1分钟-2分钟内,token过期,refreshToken未过期,刷新token,返回新生成的token给客户端

     ③2分钟后,token过期,refreshToken也过期,需重新登录

三、Jwt总结

1、token结构

 . 分为三段,通过解码可以得到:

①头部

头部用于描述关于该JWT的最基本的信息,例如其类型以及签名所用的算法等。这也可以被表示成一个JSON对象:

{
  "alg": "HS256",
  "typ": "JWT"
}

jwt的头部包含两部分信息:

  • 声明类型,这里是jwt

  • 声明加密的算法 通常直接使用 HMAC SHA256

然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

②载荷

我们先将用户认证的操作描述成一个JSON对象。其中添加了一些其他的信息,帮助今后收到这个JWT的服务器理解这个JWT。

{
    "sub": "1",
    "iss": "http://localhost:8000/auth/login",
    "iat": 1451888119,
    "exp": 1454516119,
    "nbf": 1451888119,
    "jti": "37c107e4609ddbcc9c096ea5ee76c667"
}

将上面的JSON对象进行base64编码可以得到下面的字符串:

eyJzdWIiOiIxIiwiaXNzIjoiaHR0cDpcL1wvbG9jYWx
ob3N0OjgwMDFcL2F1dGhcL2xvZ2luIiwiaWF0IjoxNDUxODg4MTE5LCJleHAiOjE0NTQ1MTYxMTksIm5iZiI6MTQ1MTg4OD
ExOSwianRpIjoiMzdjMTA3ZTQ2MDlkZGJjYzljMDk2ZWE1ZWU3NmM2NjcifQ

③签名

将上面的两个编码后的字符串都用句号.连接在一起(头部在前),就形成了

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwiaXNzIjoiaHR0cDpcL1wvbG9jYWxob3N0OjgwMDFcL2F1dGhcL2xvZ2luIiwiaWF0IjoxNDUxODg4MTE5LCJleHAiOjE0NTQ1MTYxMTksIm5iZiI6MTQ1MTg4ODExOSwianRpIjoiMzdjMTA3ZTQ2MDlkZGJjYzljMDk2ZWE1ZWU3NmM2NjcifQ

最后,我们将上面拼接完的字符串用HS256算法进行加密。在加密的时候,我们还需要提供一个密钥(secret):

HMACSHA256(
    base64UrlEncode(header) + "." +
    base64UrlEncode(payload),
    secret
)

这样就可以得到我们加密后的内容:

wyoQ95RjAyQ2FF3aj8EvCSaUmeP0KUqcCJDENNfnaT4

这一部分又叫做签名。

最后将这一部分签名也拼接在被签名的字符串后面,我们就得到了完整的JWT

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwiaXNzIjoiaHR0cDpcL1wvbG9jYWx
ob3N0OjgwMDFcL2F1dGhcL2xvZ2luIiwiaWF0IjoxNDUxODg4MTE5LCJleHAiOjE0NTQ1MTYxMTksIm5iZiI6MTQ1MTg4OD
ExOSwianRpIjoiMzdjMTA3ZTQ2MDlkZGJjYzljMDk2ZWE1ZWU3NmM2NjcifQ.wyoQ95RjAyQ2FF3aj8EvCSaUmeP0KUqcCJDENNfnaT4

2、Jwt问题总结

① 作废

传统的 session+cookie 方案用户点击注销,服务端清空 session 即可,因为状态保存在服务端。但 jwt 的方案就比较难办了,因为 jwt 是无状态的,服务端通过计算来校验有效性。没有存储起来,所以即使客户端删除了 jwt,但是该 jwt 还是在有效期内,只不过处于一个游离状态。(所有的认证信息都在JWT中,由于在服务端没有状态,即使你知道了某个JWT被盗取了,你也没有办法将其作废。在JWT过期之前(你绝对应该设置过期时间),你无能为力。

  • 仅仅清空客户端的 cookie,这样用户访问时就不会携带 jwt,服务端就认为用户需要重新登录。这是一个典型的假注销,对于用户表现出退出的行为,实际上这个时候携带对应的 jwt 依旧可以访问系统。
  • 清空或修改服务端的用户对应的 secret,这样在用户注销后,jwt 本身不变,但是由于 secret 不存在或改变,则无法完成校验。这也是为什么将 secret 设计成和用户相关的原因。
  • 借助第三方存储自己管理 jwt 的状态,可以以 jwt 为 key,实现去 redis 一类的缓存中间件中去校验存在性。方案设计并不难,但是引入 redis 之后,就把无状态的 jwt 硬生生变成了有状态了,违背了 jwt 的初衷,实际上这个方案和 session 都差不多了。

修改密码则略微有些不同,假设号被到了,修改密码(是用户密码,不是 jwt 的 secret)之后,盗号者在原 jwt 有效期之内依旧可以继续访问系统,所以仅仅清空 cookie 自然是不够的,这时,需要强制性的修改 secret。

②续签

续签问题可以说是我抵制使用 jwt 来代替传统 session 的最大原因,因为 jwt 的设计中我就没有发现它将续签认为是自身的一个特性。传统的 cookie 续签方案一般都是框架自带的,session 有效期 30 分钟,30 分钟内如果有访问,session 有效期被刷新至 30 分钟。而 jwt 本身的 payload 之中也有一个 exp 过期时间参数,来代表一个 jwt 的时效性,而 jwt 想延期这个 exp 就有点身不由己了,因为 payload 是参与签名的,一旦过期时间被修改,整个 jwt 串就变了,jwt 的特性天然不支持续签,只能自己另外加一个refreshToken来实现,一般而言,jwt 的过期时间可以设置为数小时,而 refreshToken 的过期时间设置为数天。

实际上我的项目中由于历史遗留问题,就是使用 jwt 来做登录和会话管理的,为了解决续签问题,我们在 redis 中单独会每个 jwt 设置了过期时间,每次访问时刷新 jwt 的过期时间,若 jwt 不存在与 redis 中则认为过期。

tips:精确控制 redis 的过期时间不是件容易的事,可以参考我最近的一篇借助于 spring session 讲解 redis 过期时间的排坑记录。

同样改变了 jwt 的流程,不过嘛,世间安得两全法。我只能奉劝各位还未使用 jwt 做会话管理的朋友,尽量还是选用传统的 session+cookie 方案,有很多成熟的分布式 session 框架和安全框架供你开箱即用。

③cookies的跨域访问限制

有些人认为前端代码将JWT通过HTTP header发送给服务端(而不是通过cookie自动发送)可以有效防护CSRF。在这种方案中,服务端代码在完成认证后,会在HTTP,response的header中返回JWT,前端代码将该JWT存放到Local Storage,后续发送请求时,再将该jwt放入request header中传到服务端来,则跨域资源共享(CORS)将不会成为问题,因为它不使用cookie

跨域共享cookie的方法:设置cookie.setDomain(".jszx.com"); 

    A机所在的域:home.langchao.com,A有应用cas 
    B机所在的域:jszx.com,B有应用webapp_b 
    1)在cas下面设置cookie的时候,增加cookie.setDomain(".jszx.com");,这样在webapp_b下面就可以取到cookie。 
    2)这个参数必须以“.”开始。 
    3)输入url访问webapp_b的时候,必须输入域名才能解析。比如说在A机器输入:http://lc-bsp.jszx.com:8080/webapp_b,可以获取cas在客户端设置的cookie,而B机器访问本机的应用,输入:http://localhost:8080/webapp_b则不可以获得cookie。 

    4)设置了cookie.setDomain(".jszx.com"),还可以在默认的home.langchao.com下面共享。

④各浏览器对cookie有一定的长度限制

究竟JWT可以用来做什么

我的同事做过一个形象的解释:

JWT(其实还有SAML)最适合的应用场景就是“开票”,或者“签字”。

在有纸化办公时代,多部门、多组织之间的协同工作往往会需要拿着A部门领导的“签字”或者“盖章”去B部门“使用”或者“访问”对应的资源,其实这种“领导签字/盖章”就是JWT,都是一种由具有一定权力的实体“签发”并“授权”的“票据”。一般的,这种票据具有可验证性(领导签名/盖章可以被验证,且难于模仿),不可篡改性(涂改过的文件不被接受,除非在涂改处再次签字确认);并且这种票据一般都是“一次性”使用的,在访问到对应的资源后,该票据一般会被资源持有方收回留底,用于后续的审计、追溯等用途。

举两个例子:

  1. 员工李雷需要请假一天,于是填写请假申请单,李雷在获得其主管部门领导签字后,将请假单交给HR部门韩梅梅,韩梅梅确认领导签字无误后,将请假单收回,并在公司考勤表中做相应记录。
  2. 员工李雷和韩梅梅因工外出需要使用公司汽车一天,于是填写用车申请单,签字后李雷将申请单交给车队司机老王,乘坐老王驾驶的车辆外出办事,同时老王将用车申请单收回并存档。

在以上的两个例子中,“请假申请单”和“用车申请单”就是JWT中的payload,领导签字就是base64后的数字签名,领导是issuer,“HR部门的韩梅梅”和“司机老王”即为JWT的audience,audience需要验证领导签名是否合法,验证合法后根据payload中请求的资源给予相应的权限,同时将JWT收回。

放到系统集成的场景中,JWT更适合一次性操作的认证:

服务B你好, 服务A告诉我,我可以操作<JWT内容>, 这是我的凭证(即JWT)

在这里,服务A负责认证用户身份(相当于上例中领导批准请假),并颁布一个很短过期时间的JWT给浏览器(相当于上例中的请假单),浏览器(相当于上例中的请假员工)在向服务B的请求中带上该JWT,则服务B(相当于上例中的HR员工)可以通过验证该JWT来判断用户是否有权执行该操作。这样,服务B就成为一个安全的无状态的服务了。

总结

  1. 在Web应用中,别再把JWT当做session使用,绝大多数情况下,传统的cookie-session机制工作得更好
  2. JWT适合一次性的命令认证,颁发一个有效期极短的JWT,即使暴露了危险也很小,由于每次操作都会生成新的JWT,因此也没必要保存JWT,真正实现无状态。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

猎户星座。

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值