IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> Java知识库 -> springboot+shiro+jwt实现基于token的无状态授权认证 -> 正文阅读

[Java知识库]springboot+shiro+jwt实现基于token的无状态授权认证

这里不再讲shiro的原理和鉴权认证流程,网上一大堆,可以自行查阅。我们知道,原本的shiro是基于session来进行认证登录的,但现如今大多数都是前后端分离的,session不利于跨域,以及页面的跳转应该是由前端来控制,后端最好不参与页面的跳转,只返回所需的数据。所以,这里将对shiro禁用session,整合jwt实现无状态的token登录认证。话不多说,我们直接上代码。
最后会附上我的项目地址。

maven的配置

        <!-- redis 相关 dependency start-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!-- shiro依赖,使用最新的springboot starter的形式 -->
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring-boot-web-starter</artifactId>
            <version>1.9.1</version>
        </dependency>
        <!-- token依赖 -->
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.19.2</version>
        </dependency>

JWT工具类

@Slf4j
public class JWTUtil {

    private static final long EXPIRE = 60 * 1000L;// token的有效时长
    private static final String SECRET = "jwt+shiro+heZhan";// token的私钥
    private static final String USER_KEY = "userName";

    public static String createBearerToken(String userName){
        return "Bearer " + createToken(userName);
    }

    /**
     * 创建token
     * @param userName 用户名
     * @return 创建的token
     */
    public static String createToken(String userName){
        // token的过期时间
        long current = System.currentTimeMillis();
        Date date = new Date(current + EXPIRE);
        // jwt的header部分
        Map<String, Object> map = new HashMap<>();
        map.put("alg", "HS256");
        map.put("typ", "JWT");

        // 创建token
        String token;
        try {
            token = JWT.create()
                    .withHeader(map) //header部分
                    .withClaim(USER_KEY, userName) //存储用户信息
                    .withClaim("current", current) //当前的时间戳
                    .withExpiresAt(date) //过期时间
                    .withIssuedAt(new Date(current)) //签发时间
                    .sign(Algorithm.HMAC256(SECRET)); //私钥
        } catch (Exception e){
            log.error("为用户{}创建token失败", userName);
            throw new RuntimeException("为用户创建token失败", e);
        }
        return token;
    }

    /**
     * 校验token
     * @param token 传入的token
     * @return 是否校验通过
     */
    public static boolean verifyToken(String token) throws AuthenticationException {
        try {
            JWTVerifier verifier = JWT.require(Algorithm.HMAC256(SECRET)).build();
            verifier.verify(token);
            return true;
        } catch (Exception e){
            log.error("校验token={}失败", token, e);
            throw e;
        }
    }

    /**
     * 从token中获取用户信息
     * @param token 传入的token
     * @return 用户信息
     */
    public static String getUserInfo(String token){
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim(USER_KEY).asString();
        } catch (Exception e){
            log.error("从token={}中获取用户信息失败", token, e);
            return null;
        }
    }

    /**
     * 判断是否过期
     */
    public static boolean isExpire(String token) {
        DecodedJWT jwt = JWT.decode(token);
        return jwt.getExpiresAt().getTime() < System.currentTimeMillis();
    }

}

密码加密工具类

因为用户输入账号密码后,我们在首页第一次登录需要匹配账号密码,但这里的密码是明文的,很不安全,所以这里我们对密码进行加密,shiro提供了加密的方法,我们这里直接调用。

public class EncryptionUtil {

    /**
     * 对用户输入的密码进行加密,并返回16进制的字符串
     * @param password 输入的密码
     * @param salt 加密盐
     * @return
     */
    public static String encryption(String password, String salt){
        // 加盐加密,目的是为了让相同的密码通过不同的盐hash散列后的值不同
        ByteSource byteSource = ByteSource.Util.bytes(salt);
        SimpleHash result = new SimpleHash("SHA-1", password, byteSource, 2);
        return result.toHex();
    }
}

上面的SimpleHash类在new的时候传入了4个参数:

  1. 表明加密的算法,这里使用了"SHA-1"的加密算法;
  2. 要加密的数据,这里传入了用户的密码;
  3. 加密盐,这里使用账号作为加密盐,确保万一两个人使用了同样的密码,但是账号不同,加密后的数据也会不同;
  4. 加密次数。

最后返回16进制的加密后的字符串,因为在shiro中的密码匹配器中,默认返回的也是16进制的字符串。

首页使用账号密码登录

这里我们先模拟首页登录,一般首页都是输入账号密码来登录,然后后台这里,我们先去匹配用户的账号密码,匹配成功后,下发token,之后的请求,都是携带下发的token去请求。

自定义Realm类

@Component
@DependsOn("myHashedCredentialsMatcher")
public class MyRealm extends AuthorizingRealm {

    @Resource
    private UserService userService;

    /**
     * 构造器中配置登录校验器
     */
    public MyRealm(MyHashedCredentialsMatcher myHashedCredentialsMatcher) {
        super();
        myHashedCredentialsMatcher.setHashAlgorithmName("SHA-1");// 加密算法的名称
        myHashedCredentialsMatcher.setHashIterations(2);// 加密的次数
        myHashedCredentialsMatcher.setStoredCredentialsHexEncoded(true);// 是否储存为16进制
        this.setCredentialsMatcher(myHashedCredentialsMatcher);
    }

    /**
     * 授权,权限校验
     * @param principalCollection
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        User user = (User) SecurityUtils.getSubject().getPrincipal();
        // 这里获取到了登录信息后,可以根据用户从数据库里获取该用户所拥有的权限
        // 这里只作为演示,所以就写死了几个权限存放
        Set<String> permissions = new HashSet<>();
        permissions.add("user:show");
        permissions.add("user:admin");
        permissions.add("user:add");
        info.setStringPermissions(permissions);
        return info;
    }

    /**
     * 认证,登录认证
     * @param authenticationToken
     * @return
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) authenticationToken;
        String userName = usernamePasswordToken.getUsername();
        String password = new String(usernamePasswordToken.getPassword());

        User user = userService.getUserByName(userName);
        if (user == null || StringUtils.isBlank(userName) || StringUtils.isBlank(password)){
            throw new AuthenticationException();
        }
//        if (user.getState() == 1){
//            throw new ExcessiveAttemptsException();
//        }
        // 这里将user作为主体存放起来,后面要用的话,可以 (User) SecurityUtils.getSubject().getPrincipal(); 这样获取
        return new SimpleAuthenticationInfo(user, user.getPassword(), ByteSource.Util.bytes(user.getName()), getName());
    }

    /**
     * <p>设置此Realm处理哪种类型的登录,这里标明处理UsernamePasswordToken类型的登录,也就是账号密码形式的登录。</p>
     * <p>因为shiro的机制是根据subject.login(token)这个登录方法中的token类型来分配Realm</p>
     * @return
     */
    @Override
    public Class getAuthenticationTokenClass() {
        return UsernamePasswordToken.class;
    }
}

配置自定义的密码匹配器

@Slf4j
@Component
public class MyHashedCredentialsMatcher extends HashedCredentialsMatcher {

    @Resource
    private RedisUtil redisUtil;// 一个redis工具类

    @Resource
    private UserService userService;//从数据库里获取用户信息的service

    public static final String KEY_PREFIX = "shiro:cache:retryLimit:";

    public static final Integer MAX = 5;// 最大登录次数

    @Override
    public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
        // 获取用户名
        UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) token;
        String userName = usernamePasswordToken.getUsername();
        String key = KEY_PREFIX + userName;
        // 获取用户登录失败次数
        AtomicInteger atomicInteger = (AtomicInteger) redisUtil.get(key);
        if (atomicInteger == null){
            atomicInteger = new AtomicInteger(0);
        }
        User user = userService.getUserByName(userName);
        if (atomicInteger.incrementAndGet() > MAX){
            // 如果用户登录失败次数大于5次,抛出锁定用户异常,并修改数据库用户状态字段
            if (user != null && user.getState() != 1){
                user.setState(1);// 设置为锁定状态
                userService.updateById(user);
            }
            log.info("锁定用户"+ userName);
            throw new ExcessiveAttemptsException();
        }
        // 判断用户的账号和密码是否正确
        boolean matches = super.doCredentialsMatch(token, info);
        if (matches){
            // 如果匹配上了
            redisUtil.delete(key);
            // 将用户的状态改为0
            if (user.getState() != 0){
                userService.updateUserState(user.getId(), 0);
            }
        } else {
            redisUtil.set(key, atomicInteger, 300);
        }
        return matches;
    }
}

到这里,我们关于首页使用账号密码登录认证的主体代码已经写完,接下来还需要写一个shiroConfig的配置类去装配,这个先等我们写完接下来的token认证的代码再来写config类。

首页登录后的其他请求,基于token认证

自定义Realm类

@Component
@DependsOn("myJWTCredentialsMatcher")
public class JWTRealm extends AuthorizingRealm {

    public JWTRealm(MyJWTCredentialsMatcher myJWTCredentialsMatcher) {
        super(myJWTCredentialsMatcher);
    }

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        User user = (User) SecurityUtils.getSubject().getPrincipal();
        // 这里获取到了登录信息后,可以根据用户从数据库里获取该用户所拥有的权限
        // 这里只作为演示,所以就写死了几个权限存放
        Set<String> permissions = new HashSet<>();
        permissions.add("user:show");
        permissions.add("user:admin");
        permissions.add("user:add");
        info.setStringPermissions(permissions);
        return info;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        BearerToken bearerToken = (BearerToken) token;
        String tokenString = bearerToken.getToken();
        String userName = JWTUtil.getUserInfo(tokenString);
        User user = new User();
        user.setName(userName);
        return new SimpleAuthenticationInfo(user, tokenString, getName());
    }

    @Override
    public Class getAuthenticationTokenClass() {
        return BearerToken.class;
    }
}

自定义JWT过滤器

@Slf4j
public class JWTFilter extends BearerHttpAuthenticationFilter {

    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        boolean res = false;
        HttpServletRequest req = WebUtils.toHttp(request);
        // 先判断是否传入了token
        if (!isLoginAttempt(request, response)){
            req.setAttribute("exception", new NotLoginException("未登录!"));
            req.getRequestDispatcher("/api/loginError").forward(request, response);
            return false;
        }
        // 再看是否过期
        BearerToken token = (BearerToken) createToken(request, response);
        if (JWTUtil.isExpire(token.getToken())){
            // 刷新token
            if (!refreshToken(request, response)){
                req.getRequestDispatcher("/api/loginExpire").forward(request, response);
                return false;
            }
            return true;
        }
        try {
            /*
            这里最终会调用subject.login(token)去处理认证,
            这里因为继承了BearerHttpAuthenticationFilter,
            所以这里会自动包装成一个BearerToken作为参数代入subject.login(token)中,
            怎么包装的呢?原来它会从请求头里获取一个"Authorization"字段的值,拿到这个值去进行包装
             */
            res = super.onAccessDenied(request, response);
        } catch (Exception e){
            Throwable cause = e.getCause();
            if (cause instanceof TokenExpiredException){
                refreshToken(request, response);
            } else {
                throw e;
            }
        }
        return res;
    }

    /**
     * 刷新token
     * @param request
     * @param response
     * @return
     */
    private boolean refreshToken(ServletRequest request, ServletResponse response){
        log.info("刷新token...");
        HttpServletRequest req= (HttpServletRequest) request;
        String tokenHeader = req.getHeader(AUTHORIZATION_HEADER);
        String[] tokens = tokenHeader.split(" ");
        String token = tokens[1];
        String userName = JWTUtil.getUserInfo(token);
        String newToken = JWTUtil.createToken(userName);
        BearerToken bearerToken = new BearerToken(newToken);
        try {
            getSubject(request, response).login(bearerToken);
            HttpServletResponse res = (HttpServletResponse) response;
            res.setHeader("Access-Control-Expose-Headers", "Authorization");
            res.setHeader("Authorization", "Bearer " + newToken);
            return true;
        } catch (Exception e){
            log.error("token刷新失败", e);
            return false;
        }
    }
}

这里,我们本可以直接使用shiro自带的BearerHttpAuthenticationFilter来校验token,但这里有个问题,就是如果token过期后,它不会抛出异常,而是返回一个false,这里和token校验的其他错误一样,不会抛异常,只会返回false,所以我们需要写一个自定义的filter来重写它的认证方法,先判断token是否过期,再刷新下token。这里只是模拟,所以token过期了就直接刷新token,在企业里应用的话,还需要一个refreshToken,这个是可以在生成token的同时生成,并写在缓存里,刷新token的时候去校验下refreshToken。

自定义token匹配器

@Slf4j
@Component
public class MyJWTCredentialsMatcher implements CredentialsMatcher {

    @Override
    public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws RuntimeException{
        BearerToken bearerToken = (BearerToken) token;
        String tokenString = bearerToken.getToken();
        return JWTUtil.verifyToken(tokenString);
    }
}

多Realm下的配置

因为这里是多个Realm类,原本在这里可以由Realm来自动注册装配的,然后由ModularRealmAuthenticator来自动管理的,但是这样的话,realm类去做认证鉴权时抛出的异常,在这里就会被捕获,而且不会抛出,如下图:
在这里插入图片描述
所以,我们需要自定义一个类,来继承ModularRealmAuthenticator并重写此方法:

@Slf4j
public class MyModularRealmAuthenticator extends ModularRealmAuthenticator {

    /**
     * 重写,以便用realm进行登录认证时可以成功抛出异常
     * @param realms
     * @param token
     * @return
     */
    @Override
    protected AuthenticationInfo doMultiRealmAuthentication(Collection<Realm> realms, AuthenticationToken token) throws AuthenticationException {
        AuthenticationStrategy strategy = getAuthenticationStrategy();

        AuthenticationInfo aggregate = strategy.beforeAllAttempts(realms, token);

        if (log.isTraceEnabled()) {
            log.trace("Iterating through {} realms for PAM authentication", realms.size());
        }

        for (Realm realm : realms) {

            try {
                aggregate = strategy.beforeAttempt(realm, token, aggregate);
            } catch (ShortCircuitIterationException shortCircuitSignal) {
                // Break from continuing with subsequnet realms on receiving
                // short circuit signal from strategy
                break;
            }

            if (realm.supports(token)) {

                log.trace("Attempting to authenticate token [{}] using realm [{}]", token, realm);

                AuthenticationInfo info = null;
                Throwable t = null;
                // 下面这行取消try catch,直接拿出来,有异常则直接抛出,其它的用原代码
                info = realm.getAuthenticationInfo(token);
                aggregate = strategy.afterAttempt(realm, token, info, aggregate, t);

            } else {
                log.debug("Realm [{}] does not support token {}.  Skipping realm.", realm, token);
            }
        }

        aggregate = strategy.afterAllAttempts(token, aggregate);

        return aggregate;
    }
}

这个类,会在下面要写的config中装配一下。

配置shiroConfig

配置shiro禁用session

public class MySubjectFactory extends DefaultWebSubjectFactory {

    /**
     * 重写此方法,禁止Subject创建session
     * @param context
     * @return
     */
    @Override
    public Subject createSubject(SubjectContext context) {
        context.setSessionCreationEnabled(false);
        return super.createSubject(context);
    }
}
public class MySubjectDAO extends DefaultSubjectDAO {

    @Override
    protected boolean isSessionStorageEnabled(Subject subject) {
        return false;
    }
}

总共是两个地方都要继承并重写,才能禁用session。

配置跨域的filter

@Slf4j
public class CorsFilter extends PathMatchingFilter {
    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        httpResponse.setHeader("Access-control-Allow-Origin",httpRequest.getHeader("Origin"));
        httpResponse.setHeader("Access-control-Allow-Methods","GET,POST,OPTIONS,PUT,DELETE");
        httpResponse.setHeader("Access-control-Allow-Headers",httpRequest.getHeader("Access-Control-Request-Headers"));
        //防止乱码,适用于传输JSON数据
        httpResponse.setHeader("Content-Type","application/json;charset=UTF-8");
        // 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
        if (httpRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
            log.debug("收到一个OPTIONS请求--"+httpRequest.getRequestURI());
            httpResponse.setStatus(HttpStatus.OK.value());
            return false;
        }
        return super.preHandle(request, response);
    }
}

shiroConfig

@Configuration
public class ShiroConfig {

    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager){
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        shiroFilterFactoryBean.setLoginUrl("/api/login");
        Map<String, Filter> filterMap = new HashMap<>();
        filterMap.put("cors", new CorsFilter());
        filterMap.put("jwt", new JWTFilter());
        shiroFilterFactoryBean.setFilters(filterMap);
        /*
        因为这里配置的路径和拦截规则,是需要按照顺序的,所以使用LinkedHashMap而不是HashMap
         */
        Map<String, String> map = new LinkedHashMap<>();
        // authc:所有url都必须认证通过才可以访问,anon:所有url都可以匿名访问
        map.put("/api/*", "anon");
//        map.put("/**", "authc");
        /*
        使用BearerHttpAuthenticationFilter过滤器来拦截,并获取请求头里的Authorization字段,
        并将其所携带的jwt token内容包装成一个BearerToken对象,并调用login方法进入realm进行身份验证。
         */
        map.put("/**", "jwt");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
        // 下面注释的那个,全局配置的禁用session的不管用,需要再覆盖两个Bean注入,注入的方法往下看
//        shiroFilterFactoryBean.setGlobalFilters(Collections.singletonList("noSessionCreation"));//关键:全局配置NoSessionCreationFilter,把整个项目切换成无状态服务。
        return shiroFilterFactoryBean;
    }

    /*
    下面两个Bean(subjectDAO和subjectFactory),作用是关闭Subject的session
     */

    @Bean
    public SubjectDAO subjectDAO(){
        return new MySubjectDAO();
    }

    @Bean
    public SubjectFactory subjectFactory(){
        return new MySubjectFactory();
    }

    @Bean
    public Authorizer authorizer(){
        return new ModularRealmAuthorizer();
    }

    /**
     * 设置多个realm处理登录时可以抛出异常
     * @return
     */
    @Bean
    public Authenticator authenticator(){
        return new MyModularRealmAuthenticator();
    }

}

测试

首页登录controller

@RestController
@Slf4j
@RequestMapping("/api")
public class LoginController {

    @GetMapping("/login")
    public String frontPage(){
        return "未登录,跳首页去登录";
    }

    /**
     * 使用账号密码来登录,通过登录校验后,返回token
     * @param loginInfo 登录信息
     * @return
     */
    @PostMapping("/login")
    public String login(@RequestBody LoginInfo loginInfo, HttpServletResponse response) {
        // 创建一个subject,是shiro的登录用户主体
        Subject subject = SecurityUtils.getSubject();
        // 认证提交前准备token
        UsernamePasswordToken token = new UsernamePasswordToken();
        token.setUsername(loginInfo.getUserName());
        token.setPassword(loginInfo.getPassword().toCharArray());
        // 执行登录
        try {
            /*
            这里就会调用Realm去处理登录校验之类的事情,至于用哪个Realm,就看这里传入的token是哪个类的token,
            然后由接管不同类型token的Realm去处理
             */
            subject.login(token);
            User user = (User) subject.getPrincipal();
            String tokenString = JWTUtil.createBearerToken(user.getName());
            response.setHeader(ConstantEnum.AUTHORIZATION.getValue(), tokenString);
        } catch (LockedAccountException e){
            subject.logout();
            return "账号已被锁定,请联系管理员!";
        } catch (UnknownAccountException e){
            subject.logout();
            return "未知账号!";
        } catch (ExcessiveAttemptsException e){
            subject.logout();
            return "账号或密码错误次数过多!请5分钟后再登录!";
        } catch (IncorrectCredentialsException e){
            subject.logout();
            return "密码不正确!";
        } catch (AuthenticationException e){
            subject.logout();
            return "账号或密码不正确!";
        }
        if (subject.isAuthenticated()){
            return "登录成功";
        } else {
            subject.logout();
            return "登录失败";
        }
    }

    @PostMapping("/logout")
    public String logout(){
        Subject subject = SecurityUtils.getSubject();
        subject.logout();
        return "退出登录!";
    }

    @GetMapping("/loginError")
    public String loginError(HttpServletResponse response) {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        return "未登录!";
    }

    @GetMapping("/loginExpire")
    public String loginExpire(){
        return "token已过期,请重新登录!";
    }
}

权限鉴权controller

@RestController
@Slf4j
@RequestMapping("/permission")
public class PermissionController {

    /**
     * 这个接口模拟有权限后成功调用
     * @return
     */
    @GetMapping("/show")
    @RequiresPermissions("user:add")
    public String showPermission(){
        return "有权限看到此信息...";
    }

    /**
     * 这个接口模拟没有权限
     * @return
     */
    @GetMapping("/showUnable")
    @RequiresPermissions("user:update")
    public String showPermissionUnable(){
        return "有权限看到此信息...";
    }
}

项目地址

项目地址:代码链接

  Java知识库 最新文章
计算距离春节还有多长时间
系统开发系列 之WebService(spring框架+ma
springBoot+Cache(自定义有效时间配置)
SpringBoot整合mybatis实现增删改查、分页查
spring教程
SpringBoot+Vue实现美食交流网站的设计与实
虚拟机内存结构以及虚拟机中销毁和新建对象
SpringMVC---原理
小李同学: Java如何按多个字段分组
打印票据--java
上一篇文章      下一篇文章      查看所有文章
加:2022-07-17 16:07:52  更:2022-07-17 16:12:39 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/23 15:19:03-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码