Spring Security 自定义拦截器Filter实现登录认证
创始人
2024-01-25 11:29:19
0

前言

需求来源: 微信小程序获取授权码code, 通过授权码code, 获取微信用户信息(比如openid,unionId), 并记录登录状态(比如token信息的获取);
原本打算使用Spring Security中OAuth2.0的机制 实现用小程序登录,发现小程序再已经获取授权码code登录流程和Spring Security中OAuth 2.0登录的流程有点不一样,所以直接使用spring security的Filter进行处理;

小程序登录流程在这里插入图片描述

Spring Security中的OAuth 2.0 ​​ 授权码模式:
在这里插入图片描述
获取授权码code部分已经由小程序做过了, 现在我们无需再自己的服务oauth2去获取code,而是要直接去认证获取我们所需要的access_token 信息;

小程序已经持有了​​code​​​,它依然需要将​​code​​传递给后端服务器来执行后面的流程。那么我们能不能利用图2中第3个调用​​redirectUri​​​的步骤呢?换个角度来看问题第三方就是小程序反正它也是将一个​​code​​传递给了后端服务器,只要返回登录状态就行了,反正剩下的登录流程都跟小程序无关。我觉得它是可以的。在Spring Security中我们可以使用​​code​​​通过​​tokenUri​​​来换取​​token​​​。那么在微信小程序登录流程中,​​code​​​最终换取的只是登录态,没有特定的要求。但是后端肯定需要去获取用户的一些信息,比如​​openId​​​,用户微信信息之类的。总之要根据微信平台提供的API来实现。通过改造​​tokenUri​​​和​​userInfoUri​​可以做到这一点。


思路

在这里插入图片描述
小程序实现Filter 过滤器链来完成, 基于ProxyFilter代理
默认的过滤器顺序列表

order过滤器名称
100ChannelProcessingFilter
200ConcurrentSessionFilter
300SecurityContextPersistenceFilter
400LogoutFilter
500X509AuthenticationFilter
600RequestHeaderAuthenticationFilter
700CasAuthenticationFilter
800UsernamePasswordAuthenticationFilter
900OpenIDAuthenticationFilter
1000DefaultLoginPageGeneratingFilter
1100DigestAuthenticationFilter
1200BasicAuthenticationFilter
1300RequestCacheAwareFilter
1400SecurityContextHolderAwareRequestFilter
1500RememberMeAuthenticationFilter
1600AnonymousAuthenticationFilter
1700SessionManagementFilter
1800ExceptionTranslationFilter
1900FilterSecurityInterceptor
2000SwitchUserFilter

实现

登录拦截器
@Slf4j
public class LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {@Resourceprivate SecurityConfigProperties securityConfigProperties;@Resourceprivate JwtConfigProperties jwtConfigProperties;private Class requestDataType;public LoginAuthenticationFilter(AuthenticationManager authenticationManager,String loginPath) {super(new AntPathRequestMatcher(loginPath));super.setAuthenticationManager(authenticationManager);}@Overridepublic void afterPropertiesSet() {Assert.notNull(securityConfigProperties.getRequestDataTypeName(), "登录请求数据类型必须被设定");try {this.requestDataType = Class.forName(securityConfigProperties.getRequestDataTypeName());} catch (ClassNotFoundException var2) {Assert.notNull(this.requestDataType, "登录请求数据类型必须是有效的类型");}super.afterPropertiesSet();}@Overridepublic Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {ServletInputStream inputStream = request.getInputStream();// String requestBody = StreamUtils.copyToString(inputStream, Charset.forName("UTF-8"));Object requestData = new ObjectMapper().readValue(inputStream, this.requestDataType);if (requestData != null) {if (requestData instanceof Map) {// 可以扩展不同的参数类型走不同的认证处理器; 比如走jwt的时候,会走JwtAuthenticationToken相关的认证处理器JwtAuthenticationProvider}// 此处不同引入的模块走自己的登录认证ProviderAuthentication auth = this.getAuthenticationManager().authenticate((Authentication)requestData);if (auth != null) {UserDetails userDetail = (UserDetails)auth.getDetails();request.setAttribute(jwtConfigProperties.getUserKey(), userDetail.getUsername());return auth;} else {return null;}} else {throw new RuntimeException("无授权用户信息");}}@Overridepublic void destroy() {if (log.isInfoEnabled()) {log.info("正在注销......");}}}
认证token拦截器
@Slf4j
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {@Autowiredprivate UserDetailsService userService;@Resourceprivate JwtConfigProperties jwtConfigProperties;@Resourceprivate TokenParser tokenParser;private List permissiveRequestMatchers;@Autowiredprivate JwtTokenService jwtTokenService;@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {String requestURI = request.getRequestURI();if (log.isDebugEnabled()) {log.debug("开始对请求:{}进行认证", requestURI);}if (!this.requireAuthentication(request)) {filterChain.doFilter(request, response);return;}// 获取 认证头String authorizationHeader = request.getHeader(jwtConfigProperties.getAuthorizationHeaderName());if (!checkIsTokenAuthorizationHeader(authorizationHeader)) {log.debug("获取到认证头Authorization的值:[{}]但不是我们系统中登录后签发的。", authorizationHeader);filterChain.doFilter(request, response);return;}String token = JwtTokenOperator.getToken(request, jwtConfigProperties.getAuthorizationHeaderName(), jwtConfigProperties.getTokenHeaderPrefix());JwtAuthInfo authInfo = tokenParser.parse(token, jwtConfigProperties.getTokenType());if (authInfo == null) {writeJson(response, "认证token不合法");return;}if (authInfo.getRefreshToken()) {writeJson(response, "认证token不合法,请勿直接用刷新token认证");return;}String userKey = authInfo.getUserKey();UserDetails user = this.userService.loadUserByUsername(userKey);if (ObjectUtil.isEmpty(user)) {writeJson(response, "用户不存在,无效令牌");return;}if (authInfo.getExpirationTime() < System.currentTimeMillis()) {// 令牌失效自动刷新令牌handleTokenExpired(response, request);}// 构建认证对象JwtAuthenticationToken jwtAuthToken = new JwtAuthenticationToken(user, authInfo, user.getAuthorities());request.setAttribute(jwtConfigProperties.getSignatureKey(), userKey);jwtAuthToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));SecurityContextHolder.getContext().setAuthentication(jwtAuthToken);filterChain.doFilter(request, response);}/*** 請求認證* @param request* @return*/private boolean requireAuthentication(HttpServletRequest request) {String authHeader = request.getHeader(jwtConfigProperties.getAuthorizationHeaderName());if  (authHeader == null) {if (log.isDebugEnabled()) {log.debug("请求头中不包含令牌信息,所以将跳过认证");}return false;}if (CollectionUtil.isNotEmpty(this.permissiveRequestMatchers)) {Iterator var2 = this.permissiveRequestMatchers.iterator();while(var2.hasNext()) {RequestMatcher matcher = (RequestMatcher)var2.next();boolean isPermissiveUrl = matcher.matches(request);if (isPermissiveUrl) {if (log.isDebugEnabled()) {log.debug("请求:{}为特权请求,将跳过认证", request.getRequestURI());}return false;}}}return true;}@Overrideprotected void initFilterBean() throws ServletException {super.initFilterBean();if (CollectionUtil.isNotEmpty(jwtConfigProperties.getPermissiveRequestUrls())) {this.permissiveRequestMatchers = new ArrayList(jwtConfigProperties.getPermissiveRequestUrls().size());Iterator var1 = jwtConfigProperties.getPermissiveRequestUrls().iterator();while(var1.hasNext()) {String url = (String)var1.next();AntPathRequestMatcher matcher = new AntPathRequestMatcher(url);this.permissiveRequestMatchers.add(matcher);}}}@Overrideprotected String getAlreadyFilteredAttributeName() {if (log.isDebugEnabled()) {log.debug("正在检查时否已经拦截过滤过");}String name = this.getClass().getName();return name + ".FILTERED";}/*** 判断是否是系统中登录后签发的token** @param authorizationHeader* @return*/private boolean checkIsTokenAuthorizationHeader(String authorizationHeader) {if (StringUtils.isBlank(authorizationHeader)) {return false;}if (!StringUtils.startsWith(authorizationHeader, jwtConfigProperties.getTokenHeaderPrefix())) {return false;}return true;}/*** 处理token过期情况** @param response* @param request* @return* @throws IOException*/private void handleTokenExpired(HttpServletResponse response, HttpServletRequest request) throws IOException, ServletException {// 获取刷新 tokenString refreshTokenHeader = request.getHeader(jwtConfigProperties.getRefreshHeaderName());// 检测 refresh-token 是否是我们系统中签发的if (!checkIsTokenAuthorizationHeader(refreshTokenHeader)) {log.debug("获取到刷新认证头:[{}]的值:[{}]但不是我们系统中登录后签发的。", jwtConfigProperties.getRefreshHeaderName(), refreshTokenHeader);writeJson(response, "token过期了,refresh token 不是我们系统签发的");return;}String referToken = JwtTokenOperator.getToken(request,jwtConfigProperties.getRefreshHeaderName(),jwtConfigProperties.getTokenHeaderPrefix());JwtAuthInfo authInfo = this.tokenParser.parse(referToken, jwtConfigProperties.getTokenType());if (authInfo == null) {writeJson(response, "refresh token不合法");return;}// 判断 refresh-token 是否过期if (authInfo.getExpirationTime() < System.currentTimeMillis()) {writeJson(response, "refresh token 过期了");return;}if (authInfo.getRefreshToken()) {writeJson(response, "refresh token不合法,请勿直接用认证token刷新令牌");return;}// 重新签发 tokenauthInfo.setEffectiveTime(0L);String userToken = jwtTokenService.generateUserToken(authInfo);// 刷新 refresh token, 刷新token,提供刷新token接口获取// authInfo.setEffectiveTime(0L);// String refreshToken = jwtTokenService.generateRefreshUserToken(authInfo);response.addHeader(jwtConfigProperties.getAuthorizationHeaderName(), jwtConfigProperties.getTokenHeaderPrefix() + userToken);// response.addHeader(jwtConfigProperties.getRefreshHeaderName(), jwtConfigProperties.getTokenHeaderPrefix() + refreshToken);}private void writeJson(HttpServletResponse response, String msg) throws IOException {response.setCharacterEncoding(StandardCharsets.UTF_8.name());response.setContentType(MediaType.APPLICATION_JSON_VALUE);response.setStatus(HttpStatus.UNAUTHORIZED.value());response.getWriter().println(JSONUtil.parse(CommonResult.buildFailure(StatusEnum.UNAUTHORIZED, msg)));}
}
token 处理
@Service
public class JwtTokenService {@Resourceprivate JwtConfigProperties jwtConfigProperties;private static String SECURITY_NAMESPACE_PREFIX = "security-auth";public String generateUserToken(JwtAuthInfo auth) {String token = this._generateUserToken(auth);return token;}public JwtAuthInfo parseToken(String token) {if (StringUtils.isNotBlank(jwtConfigProperties.getTokenHeaderPrefix()) && token.startsWith(jwtConfigProperties.getTokenHeaderPrefix())) {token = token.substring(jwtConfigProperties.getTokenHeaderPrefix().length());}return (JwtAuthInfo) TokenParserRegistrar.getInstance().getParser(jwtConfigProperties.getTokenType()).parse(token, jwtConfigProperties.getSignatureKey(), jwtConfigProperties.getTokenType());}public String generateRefreshUserToken(JwtAuthInfo auth) {long startTime = auth.getEffectiveTime();if (startTime <= 0L) {startTime = System.currentTimeMillis();auth.setEffectiveTime(startTime);auth.setIssueTime(startTime);long expireTime = startTime + jwtConfigProperties.getRefreshTokenExpiredSecond();auth.setExpirationTime(expireTime);}auth.setRefreshToken(true);return generateCommonToken(auth, jwtConfigProperties.getAlgorithmName(), jwtConfigProperties.getSignatureKey());}private String _generateUserToken(JwtAuthInfo auth) {long startTime = auth.getEffectiveTime();if (startTime <= 0L) {startTime = System.currentTimeMillis();auth.setEffectiveTime(startTime);auth.setIssueTime(startTime);long expireTime = startTime + jwtConfigProperties.getTokenExpireSecond();auth.setExpirationTime(expireTime);}auth.setRefreshToken(false);String token = generateCommonToken(auth, jwtConfigProperties.getAlgorithmName(), jwtConfigProperties.getSignatureKey());this.cacheToken(auth, token);return token;}private void cacheToken(JwtAuthInfo jwtAuthInfo,String token) {//String cacheKey = this.buildTokenCacheKey(userId, tokenId);//this.redisTemplate.opsForValue().set(cacheKey, token);//this.redisTemplate.expire(cacheKey, jwtConfigProperties.getRefreshInterval() + 5000L, TimeUnit.MILLISECONDS);RedisUtils.setObject(buildTokenCacheKey(jwtAuthInfo), token, Integer.parseInt(String.valueOf(jwtConfigProperties.getTokenExpireSecond())));}private String buildTokenCacheKey(JwtAuthInfo jwtAuthInfo) {return String.join(":",SECURITY_NAMESPACE_PREFIX,jwtAuthInfo.getApplicationKey(),jwtAuthInfo.getUserKey());}public String fetchToken(JwtAuthInfo jwtAuthInfo) {String cacheKey = this.buildTokenCacheKey(jwtAuthInfo);String cachedToken = RedisUtils.getObject(cacheKey, String.class);return cachedToken;}public String generateCommonToken(AuthInfo authInfo, String algorithm, String signatureKey) {JwtAuthInfo jwtAuthInfo = (JwtAuthInfo)authInfo;Map header = new HashMap();header.put("alg", jwtConfigProperties.getAlgorithmName());header.put("typ", "JWT");JwtBuilder builder = Jwts.builder().setHeader(header).setHeaderParam("refresh_token", authInfo.getRefreshToken()).setSubject(jwtAuthInfo.getApplicationKey()).setId(jwtAuthInfo.getUserKey()).setIssuer(jwtAuthInfo.getIssuer()).setIssuedAt(new Date(jwtAuthInfo.getIssueTime())).setExpiration(new Date(jwtAuthInfo.getExpirationTime())).setNotBefore(new Date(jwtAuthInfo.getEffectiveTime())).setAudience(jwtAuthInfo.getTokenUser());SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.forName(algorithm);builder.signWith(signatureAlgorithm, signatureKey);return builder.compact();}}
配置信息
security
@ConditionalOnProperty(name="security.enable",havingValue="true")
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {@Resourceprivate SecurityConfigProperties securityConfigProperties;@Resourceprivate JwtConfigProperties jwtConfigProperties;@Resourceprivate UserDetailsService userDetailsService;// 处理业务的认证管理器@Resourceprivate List authenticationProviderList;@Resourceprivate CustomerAuthenticationSuccessHandler customerAuthenticationSuccessHandler;@Resourceprivate JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;@Overrideprotected void configure(HttpSecurity httpSecurity) throws Exception {ExpressionUrlAuthorizationConfigurer.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();for (String url : securityConfigProperties.getUrls()) {registry.antMatchers(url).permitAll();}//允许跨域请求的OPTIONS请求registry.antMatchers(HttpMethod.OPTIONS).permitAll();//任何请求需要身份认证registry.and().authorizeRequests().antMatchers("/login").permitAll().antMatchers("/**/*.js").permitAll().antMatchers("/**/*.css").permitAll().antMatchers("/images/**").permitAll().antMatchers("/**/*.html").permitAll().antMatchers("/**/*.ftl").permitAll().antMatchers(jwtConfigProperties.getRefreshTokenUrl()).permitAll().anyRequest()// 允许认证过的用户访问.authenticated()// 关闭跨站请求防护及不使用session.and().csrf().disable()// //因为使用JWT,所以不需要HttpSession.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)// 自定义权限拒绝处理类.and()//允许配置错误处理.exceptionHandling().accessDeniedHandler(restfulAccessDeniedHandler()).authenticationEntryPoint(restAuthenticationEntryPoint())// 自定义权限拦截器JWT过滤器.and().addFilterAt(loginAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)//登录Filter// 在指定的Filter类之前添加过滤器, 使用自定义的 Token过滤器 验证请求的Token是否合法.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);}@Bean@Overridepublic AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();}/*** 省份认证接口* @param auth 用来配置认证管理器AuthenticationManager*             装配自定义的Provider* @throws Exception*/@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {// jwt; 多个就配置多个for (CustomerAuthenticationProvider customerAuthenticationProvider : authenticationProviderList) {auth.authenticationProvider(customerAuthenticationProvider);}// 此处自定义userDetailsService;auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());}/*** 强散列哈希加密实现* @return*/@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}@Beanpublic LoginAuthenticationFilter loginAuthenticationFilter() throws Exception{LoginAuthenticationFilter loginAuthenticationFilter = new LoginAuthenticationFilter(authenticationManagerBean(), securityConfigProperties.getLoginPath());// 自定义实现login successloginAuthenticationFilter.setAuthenticationSuccessHandler(customerAuthenticationSuccessHandler);return loginAuthenticationFilter;}@Beanpublic RestfulAccessDeniedHandler restfulAccessDeniedHandler() {return new RestfulAccessDeniedHandler();}@Beanpublic RestAuthenticationEntryPoint restAuthenticationEntryPoint() {return new RestAuthenticationEntryPoint();}
认证成功或失败村里
@Slf4j
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {@Overridepublic void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {response.setCharacterEncoding("UTF-8");response.setContentType("application/json");log.info(request.getRequestURI() + authException.getMessage());response.getWriter().println(JSONUtil.parse(CommonResult.buildFailure(StatusEnum.UNAUTHORIZED)));response.getWriter().flush();}
}
@Slf4j
public class RestfulAccessDeniedHandler implements AccessDeniedHandler{@Overridepublic void handle(HttpServletRequest request,HttpServletResponse response,AccessDeniedException e) throws IOException, ServletException {response.setCharacterEncoding("UTF-8");response.setContentType("application/json");log.info(request.getRequestURI() + e.getMessage());response.getWriter().println(JSONUtil.parse(CommonResult.buildFailure(StatusEnum.NO_OPERATION_PERMISSION)));response.getWriter().flush();}
}

jwt配置

@Component
@Data
public class JwtConfigProperties {@Value("${jwt.token-type:jwt}")private String tokenType;@Value("${jwt.authorization-header-name:Authorization}")private String authorizationHeaderName;// jwt 放开的请求@Value("#{'${jwt.permissive-request-urls:login}'.split(',')}")private List permissiveRequestUrls;@Value("${jwt.signature-algorithm:HS256}")private String algorithmName;@Value("${jwt.token-header-prefix:Bearer }")private String tokenHeaderPrefix = "Bearer ";@Value("${jwt.signature-key:xxxxx}")private String signatureKey;@Value("${jwt.token-expire-second:300000}")private Long tokenExpireSecond = 300000L;@Value("${jwt.refresh-token-url:/**/token/refresh}")private String refreshTokenUrl;@Value("${jwt.refresh-token-expired-second:400000}")private Long refreshTokenExpiredSecond= 86400000L;@Value("${jwt.refresh-header-name:Refresh-Token}")private String refreshHeaderName;@Value("${jwt.user-key:userKey}")private String userKey;
}
@Getter
@Setter
@Component
public class SecurityConfigProperties {@Value("#{'${security.url.ignored:login}'.split(',')}")private List urls = new ArrayList<>();@Value("${security.url.login.path:wx/login}")private String loginPath;@Value("${security.url.login.request-data-type}")private String requestDataTypeName;}

#####设置认证类

@Getter
@Setter
public class DefaultMiniprogramAuthenticationData extends AbstractAuthenticationToken {private static final long serialVersionUID = 1L;/**小程序访问Code*/private String accessCode;;/**小程序会话key*/private String sessionKey;/**小程序openId*/private String openId;/**小程序unionId*/private String unionId;public DefaultMiniprogramAuthenticationData() {super(Collections.emptyList());}public DefaultMiniprogramAuthenticationData(MiniprogramUserDetail user, String sessionKey) {super(Collections.emptyList());super.setDetails(user);super.setAuthenticated(true);this.sessionKey = sessionKey;}@Overridepublic Object getPrincipal() {return this.getDetails();}@Overridepublic Object getCredentials() {return this.accessCode;}}
@Component
@Slf4j
public class MiniprogramAuthenticationProvider implements CustomerAuthenticationProvider {@Autowiredprivate ProgramWechatUserManager programWechatUserManager;@Autowiredprivate MiniprogramAccountService miniprogramAccountService;@Autowiredprivate ApplicationProperties applicationProperties;@Autowiredprivate ProgramConfigProperties programConfigProperties;@Overridepublic Authentication authenticate(Authentication authentication) throws AuthenticationException {log.info("MiniprogramAuthenticationProvider");if (!(authentication instanceof DefaultMiniprogramAuthenticationData)) {return null;}DefaultMiniprogramAuthenticationData authenticationToken = (DefaultMiniprogramAuthenticationData)authentication;String accessCode = authenticationToken.getAccessCode();try {UserinfoResponse userInfo = programWechatUserManager.getUserInfo(accessCode);if (userInfo == null) {if (log.isErrorEnabled()) {log.error("使用访问令牌{}无法获取到用户信息", accessCode);}throw new BadCredentialsException("无法识别用户");} else {String sessionKey = userInfo.getSessionKey();String openId = userInfo.getOpenid();String unionId = userInfo.getUnionid();DefaultMiniprogramAuthenticationData authenticationProgramToken = new DefaultMiniprogramAuthenticationData(new MiniprogramUserDetail(miniprogramAccount), sessionKey);authenticationToken.setUnionId(unionId);authenticationToken.setOpenId(openId);return authenticationProgramToken;}} catch (Exception var10) {throw new RuntimeException(var10);}}@Overridepublic boolean supports(Class authentication) {return authentication.isAssignableFrom(DefaultMiniprogramAuthenticationData.class);}
}

相关内容

热门资讯

喜欢穿一身黑的男生性格(喜欢穿... 今天百科达人给各位分享喜欢穿一身黑的男生性格的知识,其中也会对喜欢穿一身黑衣服的男人人好相处吗进行解...
网络用语zl是什么意思(zl是... 今天给各位分享网络用语zl是什么意思的知识,其中也会对zl是啥意思是什么网络用语进行解释,如果能碰巧...
发春是什么意思(思春和发春是什... 本篇文章极速百科给大家谈谈发春是什么意思,以及思春和发春是什么意思对应的知识点,希望对各位有所帮助,...
苏州离哪个飞机场近(苏州离哪个... 本篇文章极速百科小编给大家谈谈苏州离哪个飞机场近,以及苏州离哪个飞机场近点对应的知识点,希望对各位有...
为什么酷狗音乐自己唱的歌不能下... 本篇文章极速百科小编给大家谈谈为什么酷狗音乐自己唱的歌不能下载到本地?,以及为什么酷狗下载的歌曲不是...
家里可以做假山养金鱼吗(假山能... 今天百科达人给各位分享家里可以做假山养金鱼吗的知识,其中也会对假山能放鱼缸里吗进行解释,如果能碰巧解...
华为下载未安装的文件去哪找(华... 今天百科达人给各位分享华为下载未安装的文件去哪找的知识,其中也会对华为下载未安装的文件去哪找到进行解...
四分五裂是什么生肖什么动物(四... 本篇文章极速百科小编给大家谈谈四分五裂是什么生肖什么动物,以及四分五裂打一生肖是什么对应的知识点,希...
怎么往应用助手里添加应用(应用... 今天百科达人给各位分享怎么往应用助手里添加应用的知识,其中也会对应用助手怎么添加微信进行解释,如果能...
客厅放八骏马摆件可以吗(家里摆... 今天给各位分享客厅放八骏马摆件可以吗的知识,其中也会对家里摆八骏马摆件好吗进行解释,如果能碰巧解决你...