当前位置:   article > 正文

SpringBoot整合Spring Security + JWT_springboot整合springsecurity jwt

springboot整合springsecurity jwt
版本
  • SpringBoot:2.2.5.RELEASE
  • jjwt:0.9.0
  • Jdk:1.8
  • Maven:3.5.2
  • Idea:2019.3
依赖

项目pom.xml文件中引入Spring Security和Jwt的依赖坐标

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

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.0</version>
</dependency>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
准备

整合Redis,将登录用户信息缓存进Redis,整合参考链接部分

食用

0:配置Spring Security
这是Spring Security的参考配置项,主要包括以下内容:URL拦截、匿名用户访问无权限资源处理器(AuthenticationEntryPointHandler)、登出处理器(LogoutSuccessHandler)、登出URL、过滤器(TokenFilter)、UserDetailsService实现类等

  • 指定不同URL访问权限
  • 指定密码加密方式
  • 指定访问无权资源处理器
  • 指定登出URL及处理器
  • 指定UserDetailsService实现
  • 指定自定义过滤器
import com.liu.gardenia.security.security.filter.TokenFilter;
import com.liu.gardenia.security.security.handler.AuthenticationEntryPointHandler;
import com.liu.gardenia.security.security.handler.MyLogoutSuccessHandler;
import com.liu.gardenia.security.security.service.UserDetailsServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
 * Spring Security配置
 *
 * @author liujiazhong
 */
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final AuthenticationEntryPointHandler unauthorizedHandler;
    private final MyLogoutSuccessHandler logoutSuccessHandler;
    private final TokenFilter tokenFilter;

    public SecurityConfig(AuthenticationEntryPointHandler unauthorizedHandler, MyLogoutSuccessHandler logoutSuccessHandler,
                          TokenFilter tokenFilter) {
        this.unauthorizedHandler = unauthorizedHandler;
        this.logoutSuccessHandler = logoutSuccessHandler;
        this.tokenFilter = tokenFilter;
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    @Override
    public UserDetailsService userDetailsService() {
        return new UserDetailsServiceImpl();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * hasRole                  如果有参数,参数表示角色,则其角色可以访问
     * hasAnyRole               如果有参数,参数表示角色,则其中任何一个角色可以访问
     * hasAuthority             如果有参数,参数表示权限,则其权限可以访问
     * hasAnyAuthority          如果有参数,参数表示权限,则其中任何一个权限可以访问
     * hasIpAddress             如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
     * permitAll                用户可以任意访问
     * anonymous                匿名可以访问
     * rememberMe               允许通过remember-me登录的用户访问
     * denyAll                  用户不能访问
     * authenticated            用户登录后可访问
     * fullyAuthenticated       用户完全认证可以访问(非remember-me下自动登录)
     * access                   SpringEl表达式结果为true时可以访问
     * anyRequest               匹配所有请求路径
     */
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .csrf().disable()
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                .authorizeRequests()
                .antMatchers("/api/user/login").anonymous()
                .antMatchers(
                        HttpMethod.GET,
                        "/*.html",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js",
                        "/**/*.ico"
                ).permitAll()
                .antMatchers("/swagger-ui.html").anonymous()
                .antMatchers("/swagger-resources/**").anonymous()
                .anyRequest().authenticated()
                .and()
                .headers().frameOptions().disable();
        httpSecurity.logout().logoutUrl("/api/user/logout").logoutSuccessHandler(logoutSuccessHandler);
        httpSecurity.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService()).passwordEncoder(bCryptPasswordEncoder());
    }
}
  • 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
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97

1:实现匿名用户访问无权限资源异常处理器
无权访问时可以根据自己业务需求做相应操作,例如抛出异常、返回提示信息给前端、打印日志等

/**
 * @author liujiazhong
 */
@Slf4j
@Component
public class AuthenticationEntryPointHandler implements AuthenticationEntryPoint, Serializable {
    private static final long serialVersionUID = 1L;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) {
        log.warn("认证失败,无法访问系统资源:{}", request.getRequestURI());
        ServletUtils.renderString(response, "无权访问");
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

2:实现登出处理器
实现登出时的相关操作,例如从redis中移除缓存的登陆用户信息、把登出成功的提示信息返回给前端等

/**
 * @author liujiazhong
 */
@Slf4j
@Component
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {

    private final TokenService tokenService;

    public MyLogoutSuccessHandler(TokenService tokenService) {
        this.tokenService = tokenService;
    }

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        UserInfo userInfo = tokenService.getUserInfo(request);
        if (Objects.nonNull(userInfo)) {
            tokenService.removeUserInfo(userInfo.getUuid());
        }
        ServletUtils.renderString(response, "logout success.");
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

3:自定义过滤器进行授权操作
从Redis缓存中取出用户信息,生成Spring Security身份认证令牌放入Security上下文中,这里可以选择不同的授权方式

/**
 * @author liujiazhong
 */
@Slf4j
@Component
public class TokenFilter extends OncePerRequestFilter {

    private final TokenService tokenService;

    public TokenFilter(TokenService tokenService) {
        this.tokenService = tokenService;
    }

    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain chain)
            throws IOException, ServletException {
        log.info("into token filter...");
        UserInfo userInfo = tokenService.getUserInfo(request);
        if (Objects.nonNull(userInfo) && Objects.isNull(SecurityUtils.getAuthentication())) {
            tokenService.verifyToken(userInfo);
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userInfo, null, userInfo.getAuthorities());
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        chain.doFilter(request, response);
    }

}
  • 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

4:准备登录用户信息实体
这里需要实现Spring Security中的UserDetails接口,重写接口中的一些方法,比如指定用户名和密码等,可以根据业务情况告诉Security当前账户是否过期、是否锁定、是否禁用等信息,还可以直接通过getAuthorities()方法把该账号对应的角色和权限返回给Security,也可以自己手动实现权限验证,我这里选择手动实现

/**
 * @author liujiazhong
 */
@Getter
@Setter
public class UserInfo implements UserDetails {

    private Long userId;

    private String username;

    private String password;

    private Set<String> permissions;

    private String uuid;

    private Long loginTime;

    private Long expireTime;

    @JsonIgnore
    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @JsonIgnore
    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }
}
  • 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

5:实现UserDetailsService
这里重写接口中的loadUserByUsername()方法,从数据库查询到用户信息和该用户对应的权限列表,返回UserInfo

/**
 * @author liujiazhong
 */
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserInfo userInfo = userInfo(username);
        if (Objects.isNull(userInfo)) {
            throw new RuntimeException("user not found.");
        }
        // todo check user: status...

        return userInfo;
    }

    private UserInfo userInfo(String username) {
        // todo find userInfo from mysql
        UserInfo userInfo = null;
        if (Objects.equals("liu", username)) {
            userInfo = new UserInfo();
            userInfo.setUserId(1001L);
            userInfo.setUsername("liu");
            userInfo.setPassword(SecurityUtils.encryptPassword("1111"));
            userInfo.setPermissions(userPermissionByUserId(userInfo.getUserId()));
        }
        return userInfo;
    }

    private Set<String> userPermissionByUserId(Long userId) {
        // todo find permissions from mysql
        Set<String> permissions = new HashSet<>(1);
        permissions.add("*:*:*");
        return permissions;
    }

}
  • 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

6:Jwt相关
这里涉及到了Token的生成与解析,用户信息的缓存等操作,RedisCache的实现参考连接部分整合Redis

/**
 * token验证处理
 *
 * @author liujiazhong
 */
@Component
public class TokenService {

    protected static final long MILLIS_SECOND = 1000;
    protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND;
    private static final Long MILLIS_MINUTE_TEN = 20 * 60 * 1000L;

    private final RedisCache redisCache;
    private final TokenConfig tokenConfig;

    public TokenService(RedisCache redisCache, TokenConfig tokenConfig) {
        this.redisCache = redisCache;
        this.tokenConfig = tokenConfig;
    }

    public UserInfo getUserInfo(HttpServletRequest request) {
        String token = getToken(request);
        if (StringUtils.isEmpty(token)) {
            return null;
        }
        Claims claims = parseToken(token);
        String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
        String userKey = getTokenKey(uuid);
        Object value = redisCache.getCacheObject(userKey);
        if (Objects.isNull(value)) {
            return null;
        }
        if (value instanceof UserInfo) {
            return (UserInfo) value;
        }
        throw new RuntimeException("UserInfo Cache Type Error.");
    }

    public void setUserInfo(UserInfo userInfo) {
        if (Objects.nonNull(userInfo) && StringUtils.isNotEmpty(userInfo.getUuid())) {
            refreshToken(userInfo);
        }
    }

    public void removeUserInfo(String uuid) {
        if (StringUtils.isNotEmpty(uuid)) {
            String userKey = getTokenKey(uuid);
            redisCache.deleteObject(userKey);
        }
    }

    public String createToken(UserInfo userInfo) {
        String uuid = IdUtils.uuid();
        userInfo.setUuid(uuid);
        refreshToken(userInfo);

        Map<String, Object> claims = new HashMap<>(1);
        claims.put(Constants.LOGIN_USER_KEY, uuid);
        return Jwts.builder().setClaims(claims).signWith(SignatureAlgorithm.HS512, tokenConfig.getSecret()).compact();
    }

    public void verifyToken(UserInfo userInfo) {
        long expireTime = userInfo.getExpireTime();
        long currentTime = System.currentTimeMillis();
        if (expireTime - currentTime <= MILLIS_MINUTE_TEN) {
            refreshToken(userInfo);
        }
    }

    public void refreshToken(UserInfo userInfo) {
        userInfo.setLoginTime(System.currentTimeMillis());
        userInfo.setExpireTime(userInfo.getLoginTime() + tokenConfig.getExpireTime() * MILLIS_MINUTE);
        String userKey = getTokenKey(userInfo.getUuid());
        redisCache.setCacheObject(userKey, userInfo, tokenConfig.getExpireTime(), TimeUnit.MINUTES);
    }

    public String getUsernameFromToken(String token) {
        Claims claims = parseToken(token);
        return claims.getSubject();
    }

    private Claims parseToken(String token) {
        return Jwts.parser().setSigningKey(tokenConfig.getSecret()).parseClaimsJws(token).getBody();
    }

    private String getToken(HttpServletRequest request) {
        String token = request.getHeader(tokenConfig.getHeader());
        if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX)) {
            token = token.replace(Constants.TOKEN_PREFIX, "");
        }
        return token;
    }

    private String getTokenKey(String uuid) {
        return Constants.LOGIN_TOKEN_KEY + uuid;
    }
}
  • 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
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97

7:鉴权
鉴权操作的实现,从缓存中获取到登录用户信息,判定当前用户拥有的权限中是否包含该资源的权限

/**
 * @author liujiazhong
 */
@Slf4j
@Service("ps")
public class PermissionServiceImpl {

    private static final String ALL_PERMISSION = "*:*:*";

    private final TokenService tokenService;

    public PermissionServiceImpl(TokenService tokenService) {
        this.tokenService = tokenService;
    }

    public boolean hasPermission(String permission) {
        if (StringUtils.isBlank(permission)) {
            return false;
        }
        UserInfo info = tokenService.getUserInfo(ServletUtils.getRequest());
        if (Objects.isNull(info) || CollectionUtils.isEmpty(info.getPermissions())) {
            return false;
        }
        return check(info.getPermissions(), permission);
    }

    private boolean check(Set<String> permissions, String permission) {
        return permissions.contains(ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
    }

}
  • 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

8:给资源加权限
使用@PreAuthorize注解标记资源,“ps”为步骤7中的鉴权实现类,“hasPermission”为鉴权方法,“gardenia:demo:info”为自定义的资源权限

/**
 * @author liujiazhong
 */
@RestController
@RequestMapping("api/demo")
public class DemoController {

    @GetMapping("hello")
    public String hello() {
        return "hello";
    }

    @PreAuthorize("@ps.hasPermission('gardenia:demo:info')")
    @GetMapping("info")
    public String info() {
        return "liu";
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

9:登录
认证通过后直接返回Token

/**
 * @author liujiazhong
 */
@Slf4j
@Service
public class UserLoginServiceImpl implements UserLoginService {

    private final TokenService tokenService;
    private final AuthenticationManager authenticationManager;

    public UserLoginServiceImpl(TokenService tokenService, AuthenticationManager authenticationManager) {
        this.tokenService = tokenService;
        this.authenticationManager = authenticationManager;
    }

    @Override
    public String login(String username, String password) {
        Authentication authentication;
        try {
            authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
        } catch (Exception e) {
            if (e instanceof BadCredentialsException) {
                throw new PasswordException();
            } else {
                throw new RuntimeException(e.getMessage());
            }
        }
        UserInfo userInfo = (UserInfo) authentication.getPrincipal();
        return tokenService.createToken(userInfo);
    }

}
  • 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
补充

补充上文中使用到的几个自定义工具类

IdUtils

public class IdUtils {

    public static String uuid() {
        return UUID.randomUUID().toString();
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

SecurityUtils

public class SecurityUtils {

    public static Authentication getAuthentication() {
        return SecurityContextHolder.getContext().getAuthentication();
    }

    public static String encryptPassword(String password) {
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        return passwordEncoder.encode(password);
    }

    public static boolean validPassword(String password, String encodedPassword) {
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        return passwordEncoder.matches(password, encodedPassword);
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

ServletUtils

@Slf4j
public class ServletUtils {

    public static HttpServletRequest getRequest() {
        return getRequestAttributes().getRequest();
    }

    public static ServletRequestAttributes getRequestAttributes() {
        RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
        return (ServletRequestAttributes) attributes;
    }

    public static void renderString(HttpServletResponse response, String string) {
        try {
            response.setStatus(200);
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().print(string);
        } catch (Exception e) {
            log.error("ServletUtils.renderString exception...", e);
        }
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

TokenConfig

/**
 * @author liujiazhong
 */
@Data
@Component
@ConfigurationProperties("gardenia.security.token")
public class TokenConfig {

    private String header;

    private String secret;

    private Integer expireTime;

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
gardenia:
  security:
    token:
      header: Authorization
      secret: secret
      expire-time: 30
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
链接

SpringBoot整合Spring Data Redis:https://blog.csdn.net/momo57l/article/details/105427898
Spring Security:https://spring.io/projects/spring-security#overview
CSRF:https://docs.spring.io/spring-security/site/docs/5.3.2.BUILD-SNAPSHOT/reference/html5/#csrf
JWT:https://jwt.io/

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/菜鸟追梦旅行/article/detail/546383
推荐阅读
相关标签
  

闽ICP备14008679号