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

基于Spring Security 6的OAuth2 系列之十九 - 高级特性--OIDC1.0协议之二

之所以想写这一系列,是因为之前工作过程中使用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 definition

CREATE 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: 9000

logging:
  level:
    org.springframework.security: trace


spring:
  # 配置数据源
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/oauth_study?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
    username: root
    password: root
    druid:
      initial-size: 5
      min-idle: 5
      maxActive: 20
      maxWait: 3000
      timeBetweenEvictionRunsMillis: 60000
      minEvictableIdleTimeMillis: 300000
      validationQuery: select 'x'
      testWhileIdle: true
      testOnBorrow: false
      testOnReturn: false
      poolPreparedStatements: false
      filters: stat,wall,slf4j
      connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000;socketTimeout=10000;connectTimeout=1200

# mybatis-plus的配置
mybatis-plus:
  global-config:
    banner: false
  mapper-locations: classpath:mappers/*.xml
  type-aliases-package: com.demo.lesson13.entity
  # 将handler包下的TypeHandler注册进去
  type-handlers-package: com.demo.lesson13.handler
  configuration:
    cache-enabled: false
    local-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
    @JsonIgnore
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of();
    }

    @Override
    public String getPassword() {
        return tUser.getPassword();
    }

    @Override
    public String getUsername() {
        return tUser.getUsername();
    }

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

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

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

    @Override
    public 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 {

    @Autowired
    private TUserMapper tUserMapper;

    @Override
    public 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;
    }

    @Override
    public 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:传入tUser
        OidcUserInfo userInfo = this.userInfoMapper.apply(authenticationContext, tUser);

        if (this.logger.isTraceEnabled()) {
            this.logger.trace("Authenticated user info request");
        }

        return new OidcUserInfoAuthenticationToken(accessTokenAuthentication, userInfo);
    }

    @Override
    public 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:off
        private 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:on

        public 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 {

    @Bean
    public 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);
    }

    @Bean
    public OAuth2AuthorizationService oAuth2AuthorizationService(){
        return new InMemoryOAuth2AuthorizationService();
    }
}

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

@Configuration
public class SecurityConfig {

    @Autowired
    private OAuth2AuthorizationService oAuth2AuthorizationService;

    @Autowired
    private 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的内容都讲完了。下面我们还继续了解其它高级特性。

相关文章:

  • 视点开场动画实现(九)
  • KubeSphere 产品生命周期管理政策公告正式发布!
  • ALV某个字段没有显示
  • kubeadm拉起的k8s集群证书过期的做法集群已奔溃也可以解决
  • 【核心算法篇七】《DeepSeek异常检测:孤立森林与AutoEncoder对比》
  • 用Chrome Recorder轻松完成自动化测试脚本录制
  • 计算机网络原理试题六
  • Flink SQL与Doris实时数仓Join实战教程(理论+实例保姆级教程)
  • wangEditor 编辑器 Vue 2.0 + Nodejs 配置
  • 性能测试(三)之环境搭建
  • RocketMQ - 常见问题
  • 12-滑动窗口
  • JavaScript数组-获取数组中的元素
  • Java 富文本编辑器
  • MATLAB更改图论的布局:设置layout
  • C++ 与 Java 的对比分析:除法运算中的错误处理
  • 深入理解 JavaScript 中的视图类型:为什么Int8Array、Uint16Array和Float32Array等是视图?
  • 【C++】string
  • JAVA Kotlin Androd 使用String.format()格式化日期
  • 二叉排序树(BST)
  • 方正证券总裁何亚刚到龄退休,54岁副总裁姜志军接棒
  • 梵蒂冈选出新教皇,外交部:望新教皇推动中梵关系不断改善
  • 甘肃省政府原副省长赵金云被决定逮捕
  • 习近平离京赴莫斯科对俄罗斯进行国事访问并出席纪念苏联伟大卫国战争胜利80周年庆典
  • 4月外汇储备增加410亿美元,黄金储备连续6个月增加
  • 俄乌互相空袭、莫斯科机场关闭,外交部:当务之急是避免局势紧张升级