当前位置: 首页 > wzjs >正文

招聘信息网站建设网站建设的实训技术总结

招聘信息网站建设,网站建设的实训技术总结,wordpress图片暗箱,注册有限公司需要什么条件多少钱之所以想写这一系列,是因为之前工作过程中使用Spring Security OAuth2搭建了网关和授权服务器,但当时基于spring-boot 2.3.x,其默认的Spring Security是5.3.x。之后新项目升级到了spring-boot 3.3.0,结果一看Spring Security也升级…

之所以想写这一系列,是因为之前工作过程中使用Spring Security OAuth2搭建了网关和授权服务器,但当时基于spring-boot 2.3.x,其默认的Spring Security是5.3.x。之后新项目升级到了spring-boot 3.3.0,结果一看Spring Security也升级为6.3.0。无论是Spring Security的风格和以及OAuth2都做了较大改动,里面甚至将授权服务器模块都移除了,导致在配置同样功能时,花费了些时间研究新版本的底层原理,这里将一些学习经验分享给大家。

注意由于框架不同版本改造会有些使用的不同,因此本次系列中使用基本框架是 spring-boo-3.3.0(默认引入的Spring Security是6.3.0),JDK版本使用的是19,本系列OAuth2的代码采用Spring Security6.3.0框架,所有代码都在oauth2-study项目上:https://github.com/forever1986/oauth2-study.git

目录

  • 1 底层原理
  • 2 几个OIDC的过滤器
    • 2.1 OidcProviderConfigurationEndpointFilter
    • 2.2 OidcUserInfoEndpointFilter
    • 2.3 OidcLogoutEndpointFilter
  • 3 自定义用户信息
    • 3.1 OidcUserInfoAuthenticationProvider返回用户信息
    • 3.2 自定义用户信息
      • 3.2.1 代码逻辑
      • 3.2.2 演示效果

上一章,我们讲解了OIDC1.0协议以及给出一个演示demo,那么这一章我们来剖析一下Spring Authrization Server如何实现OIDC的。

1 底层原理

1)我们通过授权码模式下的代码原理,先看一下我们配置了odic之后,Filter过滤器链有什么变化

在这里插入图片描述

从上图可以看出增加了OidcLogoutEndpointFilterOidcUserInfoEndpointFilterOidcProviderConfigurationEndpointFilter可以看出对于授权码模式下,其认证还是基于原先OAuth2那一套,因此这里有兴趣的朋友可以去看看《系列之八-Spring Authrization Server的基本原理》其中OAuth2AuthorizationEndpointFilter过滤器的处理逻辑就能明白授权码的过程

2)那么怎么返回id_token的呢?我们在《系列之九-token的获取》中讲到token的获取是通过OAuth2TokenEndpointFilter过滤器,其中使用OAuth2AuthorizationCodeAuthenticationProvider进行生成,最终使用OAuth2TokenGenerator的三种不同的实现类进行生成。

3)我们先来看看OAuth2AuthorizationCodeAuthenticationProvider代码,先判断有没有请求openid的scope,有的话调用tokenGenerator进行生成

在这里插入图片描述

4)我们再来看看tokenGenerator的实现类之一JwtGenerator,其可以处理access_token 和id_token(这个我们在前面《系列之九-token的获取》已经讲过)。只不过在处理id_token与access_token加入的信息不太一样而已

在这里插入图片描述

5)至此,我们就明白了,Spring Authrization Server实现OIDC还是基于OAuth2的基础上做了一些修改。那么我们前面提到的关于OIDC的几个过滤器是做什么用的?下面一小节我们来看看。

注意:由于Spring Authrization Server1.3版本已经没有简化模式(Implicit Grant),因此没有实现OIDC的Implicit Flow和Hybrid Flow
在这里插入图片描述

2 几个OIDC的过滤器

2.1 OidcProviderConfigurationEndpointFilter

OidcProviderConfigurationEndpointFilter过滤器有2段代码非常重要,一个是ENDPOINT_URI,可以看到是拦截/.well-known/openid-configuration请求的
在这里插入图片描述

一个是doFilterInternal方法,就是把授权服务器的配置信息发布出来。
在这里插入图片描述

因此该Filter中为了实现OIDC协议的4.1小结,为了发布一些授权服务器的信息。我们可以回忆一下,在《系列之十三 - 资源服务器–底层原理》那一章,就讲过资源服务器是通过/.well-known/openid-configuration请求获得jwt-set-uri的。

2.2 OidcUserInfoEndpointFilter

1)该过滤器是拦截/userinfo请求,也就是获得用户信息

2)其最终的通过OidcUserInfoAuthenticationProvider来组装用户信息返回的

在这里插入图片描述

3)因此该过滤器只是为了实现OIDC协议的5.3.1小结

2.3 OidcLogoutEndpointFilter

该过滤器是为了实现OIDC协议的2小结向授权服务器发起登出的功能。这里就不解析了。

3 自定义用户信息

我们发现在访问/userinfo接口时,只返回一个用户名,如果我们想返回更多的信息,应该如何操作呢?

在这里插入图片描述

3.1 OidcUserInfoAuthenticationProvider返回用户信息

1)我们先来看看OidcUserInfoAuthenticationProvider是如何返回用户信息

在这里插入图片描述

2)我们重点来看看userInfoMapper这个是如何组装

在这里插入图片描述

3)这里有一个小细节,就是它是根据请求授权的scopes来返回的,其映射如下表

scopeValue
openidid_token, sub
profilename, family_name, given_name, middle_name, nickname, prefered_userame, profle, picture, website, gender, bithdate, zoneinfo, locale, updated_at
emailemail、 email_verified
phonephone_number、phone_number_verified
addressaddress

3.2 自定义用户信息

现在我们知道其通过id_token里面的信息取获取的。那么我们有2种方式可以自定义用户信息:

  • 第一种方法:自定义id_token,将信息放入id_token中,我们需要自定义OAuth2TokenGenerator。或许你存一些无关紧要的信息还是可以接受的,但是如果信息中包括个人隐私以及系统权限等信息,就会使得id_token暴露太多信息。如果想实现这个,参考官方的案例
  • 第二种方法:自定义OidcUserInfoAuthenticationProvider,我们可以从数据库获取用户信息,然后通过授权的scopes控制返回信息。这样既能自定义,也能做权限控制。

3.2.1 代码逻辑

代码参考lesson13子模块,下面我们就采用第二种方法来实现自定义返回用户信息

前提条件:使用数据库保存用户信息,因此使用原先oauth_study数据库,并创建t_user表

1)在oauth_study数据库,创建t_user表(词表在lesson06子模块创建过,如果已经创建,就直接使用即可)

-- oauth_study.t_user definitionCREATE TABLE oauth_study.`t_user` (`id` bigint NOT NULL AUTO_INCREMENT,`username` varchar(100) NOT NULL,`password` varchar(100) NOT NULL,`email` varchar(100) DEFAULT NULL,`phone` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;INSERT INTO oauth_study.t_user (username, password, email, phone) VALUES('test', '{noop}1234', 'test@demo.com', '13788888888');

2)创建lesson13子模块,其pom引入如下

<dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-oauth2-authorization-server</artifactId></dependency><!-- lombok依赖,用于get/set的简便--><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></dependency><!-- mysql依赖,用于连接mysql数据库--><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId></dependency><!-- mybatis-plus依赖,用于使用mybatis-plus--><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency><!-- pool2和druid依赖,用于mysql连接池--><dependency><groupId>org.apache.commons</groupId><artifactId>commons-pool2</artifactId></dependency><dependency><groupId>com.alibaba</groupId><artifactId>druid-spring-boot-starter</artifactId></dependency>
</dependencies>

3)在resources目录下,创建yaml文件

server:port: 9000logging:level:org.springframework.security: tracespring:# 配置数据源datasource:type: com.alibaba.druid.pool.DruidDataSourcedriver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://127.0.0.1:3306/oauth_study?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=trueusername: rootpassword: rootdruid:initial-size: 5min-idle: 5maxActive: 20maxWait: 3000timeBetweenEvictionRunsMillis: 60000minEvictableIdleTimeMillis: 300000validationQuery: select 'x'testWhileIdle: truetestOnBorrow: falsetestOnReturn: falsepoolPreparedStatements: falsefilters: stat,wall,slf4jconnectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000;socketTimeout=10000;connectTimeout=1200# mybatis-plus的配置
mybatis-plus:global-config:banner: falsemapper-locations: classpath:mappers/*.xmltype-aliases-package: com.demo.lesson13.entity# 将handler包下的TypeHandler注册进去type-handlers-package: com.demo.lesson13.handlerconfiguration:cache-enabled: falselocal-cache-scope: statement

4)在entity包下,创建LoginUserDetails和TUser,是用于Spring Security的用户存入数据库

/*** 扩展Spring Security的UserDetails*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class LoginUserDetails implements UserDetails {private TUser tUser;@Override@JsonIgnorepublic Collection<? extends GrantedAuthority> getAuthorities() {return List.of();}@Overridepublic String getPassword() {return tUser.getPassword();}@Overridepublic String getUsername() {return tUser.getUsername();}@Overridepublic boolean isAccountNonExpired() {return true;}@Overridepublic boolean isAccountNonLocked() {return true;}@Overridepublic boolean isCredentialsNonExpired() {return true;}@Overridepublic boolean isEnabled() {return true;}
}
/*** 表user结构*/
@Data
public class TUser implements Serializable {@TableId(type = IdType.ASSIGN_ID)private Long id;private String username;private String password;private String email;private String phone;}

5)在mapper包下,创建TUserMapper

@Mapper
public interface TUserMapper {// 根据用户名,查询用户信息@Select("select * from t_user where username = #{username}")TUser selectByUsername(String username);}

6)在service包下,创建UserDetailsServiceImpl ,用于覆盖默认的用户查询

@Service
public class UserDetailsServiceImpl implements UserDetailsService {@Autowiredprivate TUserMapper tUserMapper;@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {// 查询自己数据库的用户信息TUser user = tUserMapper.selectByUsername(username);if(user == null){throw new UsernameNotFoundException(username);}return new LoginUserDetails(user);}}

7)在provider包下,创建MyOidcUserInfoAuthenticationProvider类,几乎复制OidcUserInfoAuthenticationProvider,并做小部分修改

public class MyOidcUserInfoAuthenticationProvider implements AuthenticationProvider {private final Log logger = LogFactory.getLog(getClass());private final OAuth2AuthorizationService authorizationService;private final TUserMapper tUserMapper;private DefaultOidcUserInfoMapper userInfoMapper = new MyOidcUserInfoAuthenticationProvider.DefaultOidcUserInfoMapper();/*** Constructs an {@code OidcUserInfoAuthenticationProvider} using the provided* parameters.* @param authorizationService the authorization service*/public MyOidcUserInfoAuthenticationProvider(OAuth2AuthorizationService authorizationService, TUserMapper tUserMapper) {Assert.notNull(authorizationService, "authorizationService cannot be null");Assert.notNull(tUserMapper, "tUserMapper cannot be null");this.authorizationService = authorizationService;this.tUserMapper = tUserMapper;}@Overridepublic Authentication authenticate(Authentication authentication) throws AuthenticationException {OidcUserInfoAuthenticationToken userInfoAuthentication = (OidcUserInfoAuthenticationToken) authentication;AbstractOAuth2TokenAuthenticationToken<?> accessTokenAuthentication = null;if (AbstractOAuth2TokenAuthenticationToken.class.isAssignableFrom(userInfoAuthentication.getPrincipal().getClass())) {accessTokenAuthentication = (AbstractOAuth2TokenAuthenticationToken<?>) userInfoAuthentication.getPrincipal();}if (accessTokenAuthentication == null || !accessTokenAuthentication.isAuthenticated()) {throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_TOKEN);}String accessTokenValue = accessTokenAuthentication.getToken().getTokenValue();OAuth2Authorization authorization = this.authorizationService.findByToken(accessTokenValue,OAuth2TokenType.ACCESS_TOKEN);if (authorization == null) {throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_TOKEN);}if (this.logger.isTraceEnabled()) {this.logger.trace("Retrieved authorization with access token");}OAuth2Authorization.Token<OAuth2AccessToken> authorizedAccessToken = authorization.getAccessToken();if (!authorizedAccessToken.isActive()) {throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_TOKEN);}if (!authorizedAccessToken.getToken().getScopes().contains(OidcScopes.OPENID)) {throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INSUFFICIENT_SCOPE);}OAuth2Authorization.Token<OidcIdToken> idToken = authorization.getToken(OidcIdToken.class);if (idToken == null) {throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_TOKEN);}// 修改1:此处从数据库获得数据,并塞入idToken即可TUser tUser = null;if(idToken.getClaims()!=null&&idToken.getClaims().get(StandardClaimNames.SUB) instanceof String){tUser = tUserMapper.selectByUsername((String) idToken.getClaims().get(StandardClaimNames.SUB));}if (this.logger.isTraceEnabled()) {this.logger.trace("Validated user info request");}OidcUserInfoAuthenticationContext authenticationContext = OidcUserInfoAuthenticationContext.with(userInfoAuthentication).accessToken(authorizedAccessToken.getToken()).authorization(authorization).build();// 修改2:传入tUserOidcUserInfo userInfo = this.userInfoMapper.apply(authenticationContext, tUser);if (this.logger.isTraceEnabled()) {this.logger.trace("Authenticated user info request");}return new OidcUserInfoAuthenticationToken(accessTokenAuthentication, userInfo);}@Overridepublic boolean supports(Class<?> authentication) {return OidcUserInfoAuthenticationToken.class.isAssignableFrom(authentication);}/*** Sets the {@link Function} used to extract claims from* {@link OidcUserInfoAuthenticationContext} to an instance of {@link OidcUserInfo}* for the UserInfo response.** <p>* The {@link OidcUserInfoAuthenticationContext} gives the mapper access to the* {@link OidcUserInfoAuthenticationToken}, as well as, the following context* attributes:* <ul>* <li>{@link OidcUserInfoAuthenticationContext#getAccessToken()} containing the* bearer token used to make the request.</li>* <li>{@link OidcUserInfoAuthenticationContext#getAuthorization()} containing the* {@link OidcIdToken} and {@link OAuth2AccessToken} associated with the bearer token* used to make the request.</li>* </ul>* @param userInfoMapper the {@link Function} used to extract claims from* {@link OidcUserInfoAuthenticationContext} to an instance of {@link OidcUserInfo}*/public void setUserInfoMapper(DefaultOidcUserInfoMapper userInfoMapper) {Assert.notNull(userInfoMapper, "userInfoMapper cannot be null");this.userInfoMapper = userInfoMapper;}private static final class DefaultOidcUserInfoMapper {// @formatter:offprivate static final List<String> EMAIL_CLAIMS = Arrays.asList(StandardClaimNames.EMAIL,StandardClaimNames.EMAIL_VERIFIED);private static final List<String> PHONE_CLAIMS = Arrays.asList(StandardClaimNames.PHONE_NUMBER,StandardClaimNames.PHONE_NUMBER_VERIFIED);private static final List<String> PROFILE_CLAIMS = Arrays.asList(StandardClaimNames.NAME,StandardClaimNames.FAMILY_NAME,StandardClaimNames.GIVEN_NAME,StandardClaimNames.MIDDLE_NAME,StandardClaimNames.NICKNAME,StandardClaimNames.PREFERRED_USERNAME,StandardClaimNames.PROFILE,StandardClaimNames.PICTURE,StandardClaimNames.WEBSITE,StandardClaimNames.GENDER,StandardClaimNames.BIRTHDATE,StandardClaimNames.ZONEINFO,StandardClaimNames.LOCALE,StandardClaimNames.UPDATED_AT);// @formatter:onpublic OidcUserInfo apply(OidcUserInfoAuthenticationContext authenticationContext, TUser tUser) {OAuth2Authorization authorization = authenticationContext.getAuthorization();OidcIdToken idToken = authorization.getToken(OidcIdToken.class).getToken();// 修改3:组装新的map,为了演示,我们只需要传入电话和email做演示Map<String, Object> map = new ConcurrentHashMap<>(idToken.getClaims());map.put(StandardClaimNames.EMAIL, tUser.getEmail());map.put(StandardClaimNames.PHONE_NUMBER, tUser.getPhone());OAuth2AccessToken accessToken = authenticationContext.getAccessToken();Map<String, Object> scopeRequestedClaims = getClaimsRequestedByScope(map,accessToken.getScopes());return new OidcUserInfo(scopeRequestedClaims);}private static Map<String, Object> getClaimsRequestedByScope(Map<String, Object> claims,Set<String> requestedScopes) {Set<String> scopeRequestedClaimNames = new HashSet<>(32);scopeRequestedClaimNames.add(StandardClaimNames.SUB);if (requestedScopes.contains(OidcScopes.ADDRESS)) {scopeRequestedClaimNames.add(StandardClaimNames.ADDRESS);}if (requestedScopes.contains(OidcScopes.EMAIL)) {scopeRequestedClaimNames.addAll(EMAIL_CLAIMS);}if (requestedScopes.contains(OidcScopes.PHONE)) {scopeRequestedClaimNames.addAll(PHONE_CLAIMS);}if (requestedScopes.contains(OidcScopes.PROFILE)) {scopeRequestedClaimNames.addAll(PROFILE_CLAIMS);}Map<String, Object> requestedClaims = new HashMap<>(claims);requestedClaims.keySet().removeIf(claimName -> !scopeRequestedClaimNames.contains(claimName));return requestedClaims;}}
}

8)在config包下,新建JdbcSecurityConfig配置授权服务器的客户端

@Configuration
public class JdbcSecurityConfig {@Beanpublic RegisteredClientRepository registeredClientRepository() {RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())// 客户端id.clientId("oidc-client")// 客户端密码.clientSecret("{noop}secret")// 客户端认证方式.clientAuthenticationMethods(methods ->{methods.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);})// 配置授权码模式.authorizationGrantTypes(grantTypes -> {grantTypes.add(AuthorizationGrantType.AUTHORIZATION_CODE);})// 需要授权确认.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())// 回调地址.redirectUri("http://localhost:8080/login/oauth2/code/oidc-client").postLogoutRedirectUri("http://localhost:8080/")// 授权范围.scopes(scopes->{scopes.add(OidcScopes.OPENID);scopes.add(OidcScopes.PROFILE);scopes.add(OidcScopes.EMAIL);}).build();return new InMemoryRegisteredClientRepository(registeredClient);}@Beanpublic OAuth2AuthorizationService oAuth2AuthorizationService(){return new InMemoryOAuth2AuthorizationService();}
}

9)在config包下,新建SecurityConfig进行授权服务器的配置

@Configuration
public class SecurityConfig {@Autowiredprivate OAuth2AuthorizationService oAuth2AuthorizationService;@Autowiredprivate TUserMapper tUserMapper;// 自定义授权服务器的Filter链@Bean@Order(Ordered.HIGHEST_PRECEDENCE)SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)// oidc配置.oidc(oidc-> oidc.userInfoEndpoint( userinfo -> userinfo.authenticationProvider(new MyOidcUserInfoAuthenticationProvider(oAuth2AuthorizationService, tUserMapper))));// 同时作为资源服务器,使用/userinfo接口http.oauth2ResourceServer((resourceServer) -> resourceServer.jwt(withDefaults()));// 异常处理http.exceptionHandling((exceptions) -> exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")));return http.build();}// 自定义Spring Security的链路。如果自定义授权服务器的Filter链,则原先自动化配置将会失效,因此也要配置Spring Security@Bean@Order(SecurityProperties.BASIC_AUTH_ORDER)SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {http.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()).formLogin(withDefaults());return http.build();}}

3.2.2 演示效果

1)请求授权code

在这里插入图片描述

2)登录

在这里插入图片描述

3)再次请求授权码code,state参数的值来自上一步。

在这里插入图片描述

4)请求token

在这里插入图片描述

5)请求用户信息

在这里插入图片描述

6)你可以尝试scopes中加入phone,则会返回用户电话号码。当然DefaultOidcUserInfoMapper还是比较简单,实际业务可能会有很多用户数据和权限控制,这个就根据自己的实际需求做吧。

结语:目前为止,我们将Spring Security中如何实现OIDC的内容都讲完了。下面我们还继续了解其它高级特性。


文章转载自:

http://buwqMyJZ.rqxch.cn
http://CXvc2ugS.rqxch.cn
http://UaVkdr5U.rqxch.cn
http://1zBI08Tr.rqxch.cn
http://Bnu71IKU.rqxch.cn
http://5a3JtWzW.rqxch.cn
http://bGk2dMHs.rqxch.cn
http://svL6ePco.rqxch.cn
http://dEkVV31s.rqxch.cn
http://ZEQznAeL.rqxch.cn
http://6E7zr36y.rqxch.cn
http://6Cxs449c.rqxch.cn
http://P9FlbmHg.rqxch.cn
http://7oITGNkC.rqxch.cn
http://GODvTX8T.rqxch.cn
http://8bcKnDSC.rqxch.cn
http://dyEIHo5j.rqxch.cn
http://Z1UuEydD.rqxch.cn
http://898Ku7QT.rqxch.cn
http://9Qhfejgp.rqxch.cn
http://3T0nJ3fb.rqxch.cn
http://mEQkdalZ.rqxch.cn
http://Jz4UnvIG.rqxch.cn
http://mvdOXHLd.rqxch.cn
http://AWBeuEx0.rqxch.cn
http://8JG8rweC.rqxch.cn
http://Xed9T04i.rqxch.cn
http://XC14qttN.rqxch.cn
http://I2AAoEyH.rqxch.cn
http://dEzibHRo.rqxch.cn
http://www.dtcms.com/wzjs/617892.html

相关文章:

  • 网站建设费可分摊几年台州建设局网站
  • 做网站给菠菜引流玉林市住房和城乡建设局网站
  • 制作完整网站需要掌握哪些知识wordpress 字
  • 成都网站开发排名网站运营实训报告总结
  • 绑定ip地址的网站建盏大师排名表及落款
  • 怎么做网站在谷歌自己做电商网站
  • wordpress 网站静态wordpress 微信客户端
  • asp.net 网站开发 pdf网络营销典型企业
  • 合肥红酒网站建设个人网站和企业网站区别
  • 石家庄市市政建设总公司网站wordpress地址无法更改
  • 单页面 网站做柜子喜欢上哪些网站看
  • 怎么建站网站仙侠手游代理平台
  • 开源的公司网站专业网站建设平台代理商
  • 怎么用ppt做网站短租网站那家做的好处
  • 目前做那些网站致富管理系统中的计算机应用
  • 西安做网站建设的wordpress分类目录管理404
  • 网站营销与推广方案wordpress中用户权限
  • 潍坊建设局职称公布网站中牟建设工程信息网站
  • 全球最大设计网站好看的模板网站建设
  • 常州兼职网站建设wordpress新手技巧
  • 专业网站建设费用包括哪些企业网站长度
  • 做一手楼房的网站网站存在风险怎么解决
  • 企业网站建设的上市公司wordpress数据库迁移
  • 广州建设网站 公司上海广告公司赵菲
  • 黄冈网站建设费用用地方别名做网站名
  • 网站源码使用方法wordpress注册中文名
  • 网站建设哈尔滨app开发2什么叫做网络营销
  • seo推广网站有哪wordpress 内存优化
  • 快速做网站详情页中国建工网官网
  • 宁波网站网站建设24小时网站建设