解决Spring security sso中App和浏览器访问兼容问题

原本以为在网上找了一个spring security实现sso的demo就能顺利地实现单点登录应用,但是too young too simple,按照demo给的代码确实能通过浏览器实现sso,但是app端携带token去访问需要权限的资源时却被拒绝。
究其原因,我们首先从架构层面来分析一下:

基础架构

sso基本架构图

认证流程

1.每一次浏览器的请求访问资源服务器时,资源服务器都会检查session是否存在当前用户的认证信息。
2.如果不存在,就会跳转到认证服务器进行认证。
3.认证成功后分别会在认证服务器和当前资源服务器各存储一份用户相关的认证信息。
4.用户每次访问资源服务器时就不需要再去认证服务器进行认证了。
这个详细的认证过程在Spring security oauth2认证流程分析中有详细分析过

问题1:同一个浏览器访问不同的资源服务器,只要进行过一次认证,访问其他的资源服务器就不需要重新认证了呢?
其实不需要认证是假的。
1.假设访问资源服务器1后用户登录认证通过,认证服务器和资源服务器1上分别都存有用户的认证信息。
2.当前用户访问资源服务器2(前提是同一个浏览器),此时资源服务器2上面并没有用户的认证信息,就会自动跳转到认证服务器,跳转到认证服务器后,认证服务器发现用户已经认证(虽然用户之前认证后会跳转,但是认证服务器的cookie信息还在浏览器上),便会回调资源服务器2。
3.资源服务器根据回调信息拿到当前用户的认证信息,将其保存起来,方便用户下次访问。
问题2:App或第三方应用怎么访问?
因为有一些App或第三方应用无法基于cookie发送请求,按照上述浏览器的访问流程根本行不通。
1.App或第三方应用根据是否应用内存在token判断何时需要登录认证,不需要像浏览器那样各种重定向。
2.App或第三方应用就需要直接跳过资源服务器直接访问认证服务器进行认证并获取token
3.App或第三方应用通过认证服务器认证后获取token,再通过token向资源服务器请求服务。
此时会出现一个问题:请求被拒绝。因为资源服务器并不存在token对应的认证信息。

App认证和请求资源

这样的架构显然是无法请求到资源的,因为用户的认证信息都在认证服务器中,所有需要权限的资源都是无法访问的。
为了解决我们的困境,可以把本来存储在认证服务器的认证信息共享出来:
版本1

App认证和请求资源

把认证信息存储抽离出来是很有必要的,因为正常情况下,为了避免服务遭遇单点故障,都会是集群化部署,如图:
App认证和请求资源

按照上图所示,
1.App在认证服务器认证后,用户认证信息是存储在类似redis或其他存储空间的。
2.App携带token访问资源服务,资源服务通过token获取用户认证信息,验证通过后提供服务,否则拒绝访问。
版本2
App认证和请求资源

1.认证步骤不变,App在认证服务器认证后,用户认证信息是存储在类似redis或其他存储空间的。
2.当携带token请求资源时,资源服务器间接地通过认证服务器去验证token的有效性,验证成功后就提供服务,否则拒绝访问。
JWT
针对于JWT的实现,架构又回到了
JWT认证和请求资源

或者
JWT认证和请求资源

JWT的方式区别于不需要额外的存储,只需要signingKey就能对token进行解析,这就意味着只需要signingKey就可以在任意服务器对token进行验证。

认证服务器

认证服务器的核心功能就是颁发token。但是也没法避免有时候需要向认证服务器请求某些服务。
以spring boot 2.1.6.RELEASE版本为例
依赖

    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.springframework.security.oauth:spring-security-oauth2:2.3.6.RELEASE'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    compile 'org.apache.commons:commons-lang3:3.9'
    compile 'commons-collections:commons-collections:3.2.2'
    compile 'commons-beanutils:commons-beanutils:1.9.3'
    compile 'javax.xml.bind:jaxb-api:2.3.0'
    compile 'com.sun.xml.bind:jaxb-impl:2.3.0'
    compile 'com.sun.xml.bind:jaxb-core:2.3.0'
    compile 'javax.activation:activation:1.1.1'

自定义Filter,用于认证服务器解析App请求携带的Authorization信息,也就是token信息

App请求示例

import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Enumeration;
import java.util.Objects;

/** @author zouwei */
public class AppAuthenticationProcessFilter extends OncePerRequestFilter {

    private static final String AUTHORIZATION_KEY = "Authorization";

    private TokenStore tokenStore;

    public AppAuthenticationProcessFilter(TokenStore tokenStore) {
        this.tokenStore = tokenStore;
    }

    @Override
    public void doFilterInternal(
            HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        Authentication authentication = attemptAuthentication(request);
        if (Objects.nonNull(authentication)) {
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

    /**
     * 验证权限
     *
     * @param request
     * @return
     * @throws AuthenticationException
     */
    private Authentication attemptAuthentication(HttpServletRequest request)
            throws AuthenticationException {
        String token = extractToken(request);
        if (!StringUtils.isBlank(token)) {
            return tokenStore.readAuthentication(token);
        }
        return null;
    }

    /**
     * 先从请求头中获取token,没获取到就从请求体中获取
     *
     * @param request
     * @return
     */
    protected String extractToken(HttpServletRequest request) {
        Enumeration<String> headers = request.getHeaders(AUTHORIZATION_KEY);
        while (headers.hasMoreElements()) {
            String value = parseAuthorizationValue(headers.nextElement());
            if (!StringUtils.isBlank(value)) {
                return value;
            }
        }
        // 如果没有从请求头中拿到token,就从请求体中获取
        return obtainRequestToken(request);
    }

    /**
     * 从请求体中获取token
     *
     * @param request
     * @return
     */
    private String obtainRequestToken(HttpServletRequest request) {
        String authorizationValue = request.getParameter(AUTHORIZATION_KEY);
        return parseAuthorizationValue(authorizationValue);
    }

    /**
     * 解析AuthorizationValue
     *
     * @param authorizationValue
     * @return
     */
    private String parseAuthorizationValue(String authorizationValue) {
        if (Objects.isNull(authorizationValue)) {
            return null;
        }
        if ((authorizationValue
                .toLowerCase()
                .startsWith(OAuth2AccessToken.BEARER_TYPE.toLowerCase()))) {
            String authHeaderValue =
                    authorizationValue.substring(OAuth2AccessToken.BEARER_TYPE.length()).trim();
            int commaIndex = authHeaderValue.indexOf(',');
            if (commaIndex > 0) {
                authHeaderValue = authHeaderValue.substring(0, commaIndex);
            }
            return authHeaderValue;
        }
        return null;
    }
}
import com.example.oauth2.filter.AppAuthenticationProcessFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/** @author zouwei */
@Configuration
@EnableWebSecurity
public class ApplicationSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired private RedisConnectionFactory redisConnectionFactory;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        /** 创建自定义Filter */
        AppAuthenticationProcessFilter appAuthenticationProcessFilter =
                new AppAuthenticationProcessFilter(tokenStore());

        http
                /** 将自定义Filter加入FilterChain */
                .addFilterAfter(
                        appAuthenticationProcessFilter, UsernamePasswordAuthenticationFilter.class)
                .formLogin()
                .and()
                .authorizeRequests()
                .anyRequest()
                .authenticated()
                .and()
                .csrf()
                .disable();
    }

    /**
     * 密码模式要配置AuthenticationManager
     *
     * @return
     * @throws Exception
     */
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * 加密方式
     *
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 指定tokenStore
     *
     * @return
     */
    @Bean
    public TokenStore tokenStore() {
        return new RedisTokenStore(redisConnectionFactory);
    }
}
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.TokenStore;

/** @author zouwei */
@Configuration
@EnableAuthorizationServer
public class ApplicationServerConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired private PasswordEncoder passwordEncoder;
    /** 用于设置token的存储方式 */
    @Autowired private TokenStore tokenStore;

    /** 密码模式需要 */
    @Autowired private AuthenticationManager authenticationManager;

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        /** 资源服务器验证token的时候需要权限 */
        security.checkTokenAccess("isAuthenticated()");
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("client1")
                .secret(passwordEncoder.encode("secret1"))
                .redirectUris("http://127.0.0.1:8081/client1/login")
                .authorizedGrantTypes("authorization_code", "password", "refresh_token")
                .scopes("all")
                .and()
                .withClient("client2")
                .secret(passwordEncoder.encode("secret2"))
                .redirectUris("http://127.0.0.1:8082/client2/login")
                .authorizedGrantTypes("authorization_code", "password", "refresh_token")
                .scopes("all");
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        /** 指定tokenStore */
        endpoints
                .tokenStore(tokenStore)
                /** password模式需要 */
                .authenticationManager(authenticationManager);
    }
}
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

/** @author zouwei */
@Component
public class ApplicationUserDetailsService implements UserDetailsService {

    @Autowired private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        /**
         * 为了测试方便,暂且直接返回User
         */
        return new User(
                username,
                passwordEncoder.encode("123456"),
                AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER"));
    }
}

以上代码即可创建一个简单的认证服务器,并且可支持浏览器和App认证和访问资源。也就是说,它既是认证服务器,也是资源服务器。
至于为什么不直接使用框架自己提供的资源服务器配置和注解,是因为框架提供的不能同时支持浏览器和App。通过源码分析发现,框架自己也就是一个Filter做了相关的实现,按照这个原理,我就自定义了Filter达到双端兼容的效果。

资源服务器

下面的这个是单纯的资源服务器
依赖

    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure:2.1.6.RELEASE'
    implementation 'org.springframework.security.oauth:spring-security-oauth2:2.3.6.RELEASE'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    compile 'org.apache.commons:commons-lang3:3.9'
    compile 'commons-collections:commons-collections:3.2.2'
    compile 'commons-beanutils:commons-beanutils:1.9.3'
    compile 'javax.xml.bind:jaxb-api:2.3.0'
    compile 'com.sun.xml.bind:jaxb-impl:2.3.0'
    compile 'com.sun.xml.bind:jaxb-core:2.3.0'
    compile 'javax.activation:activation:1.1.1'

资源服务器用于验证App请求的Authorization头信息,也就是token有效性

import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.token.ResourceServerTokenServices;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Enumeration;
import java.util.Objects;

/** @author zouwei */
public class AppAuthenticationProcessFilter extends OncePerRequestFilter {

    private static final String AUTHORIZATION_KEY = "Authorization";

    private TokenStore tokenStore;

    private ResourceServerTokenServices tokenServices;

    public AppAuthenticationProcessFilter(TokenStore tokenStore) {
        this.tokenStore = tokenStore;
    }

    public AppAuthenticationProcessFilter(ResourceServerTokenServices tokenServices) {
        this.tokenServices = tokenServices;
    }

    @Override
    public void doFilterInternal(
            HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        Authentication authentication = attemptAuthentication(request);
        if (Objects.nonNull(authentication)) {
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

    /**
     * 验证权限
     *
     * @param request
     * @return
     * @throws AuthenticationException
     */
    private Authentication attemptAuthentication(HttpServletRequest request)
            throws AuthenticationException {
        String token = extractToken(request);
        if (!StringUtils.isBlank(token)) {
            if (Objects.nonNull(tokenStore)) {
                /** 直接访问tokenStore获取认证信息 */
                return tokenStore.readAuthentication(token);
            } else {
                try {
                    /** 访问认证服务器获取认证信息 */
                    return tokenServices.loadAuthentication(token);
                } catch (Exception e) {
                    return null;
                }
            }
        }
        return null;
    }

    /**
     * 先从请求头中获取token,没获取到就从请求体中获取
     *
     * @param request
     * @return
     */
    protected String extractToken(HttpServletRequest request) {
        Enumeration<String> headers = request.getHeaders(AUTHORIZATION_KEY);
        while (headers.hasMoreElements()) {
            String value = parseAuthorizationValue(headers.nextElement());
            if (!StringUtils.isBlank(value)) {
                return value;
            }
        }
        // 如果没有从请求头中拿到token,就从请求体中获取
        return obtainRequestToken(request);
    }

    /**
     * 从请求体中获取token
     *
     * @param request
     * @return
     */
    private String obtainRequestToken(HttpServletRequest request) {
        String authorizationValue = request.getParameter(AUTHORIZATION_KEY);
        return parseAuthorizationValue(authorizationValue);
    }

    /**
     * 解析AuthorizationValue
     *
     * @param authorizationValue
     * @return
     */
    private String parseAuthorizationValue(String authorizationValue) {
        if (Objects.isNull(authorizationValue)) {
            return null;
        }
        if ((authorizationValue
                .toLowerCase()
                .startsWith(OAuth2AccessToken.BEARER_TYPE.toLowerCase()))) {
            String authHeaderValue =
                    authorizationValue.substring(OAuth2AccessToken.BEARER_TYPE.length()).trim();
            int commaIndex = authHeaderValue.indexOf(',');
            if (commaIndex > 0) {
                authHeaderValue = authHeaderValue.substring(0, commaIndex);
            }
            return authHeaderValue;
        }
        return null;
    }
}
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.oauth2.provider.token.ResourceServerTokenServices;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import sso.oauth2.client2.filter.AppAuthenticationProcessFilter;

/** @author zouwei */
@Configuration
@EnableOAuth2Sso
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired private RedisConnectionFactory redisConnectionFactory;

    /**
     * 用于访问认证服务器
     */
    @Autowired private ResourceServerTokenServices tokenServices;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        /**
         * 创建自定义Filter,有两种构造方式,分别对应不同的架构模式
         */
        AppAuthenticationProcessFilter appAuthenticationProcessFilter =
                new AppAuthenticationProcessFilter(tokenServices);

        http
                /**
                 * 将自定义Filter加入FilterChain
                 */
                .addFilterAfter(
                        appAuthenticationProcessFilter, UsernamePasswordAuthenticationFilter.class)
                .authorizeRequests()
                .antMatchers("/", "/login**")
                .permitAll()
                .anyRequest()
                .authenticated();
    }

    /**
     * 定义TokenStore
     * @return
     */
    @Bean
    public TokenStore tokenStore() {
        return new RedisTokenStore(redisConnectionFactory);
    }
}

配置文件:

#client-id
security.oauth2.client.client-id=client1
security.oauth2.client.client-secret=secret1
#跳转到认证服务器的地址
security.oauth2.client.user-authorization-uri=http://127.0.0.1:8080/oauth/authorize
#请求令牌地址
security.oauth2.client.access-token-uri=http://127.0.0.1:8080/oauth/token
#JWT获取秘钥地址
#security.oauth2.resource.jwt.key-uri=http://127.0.0.1:8080/oauth/token_key
#JWT秘钥
#security.oauth2.resource.jwt.key-value=signingKey

#请求认证服务器验证token
security.oauth2.resource.token-info-uri=http://127.0.0.1:8080/oauth/check_token

server.port=8081

server.servlet.context-path=/client1

可以按照上述资源服务器代码和配置,再创建一个client2资源服务,这样就可以联合演示单点登录这个功能。
上述代码和配置中的redis配置是默认的,如有不同,需要自己进行自定义配置

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,163评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,301评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,089评论 0 352
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,093评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,110评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,079评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,005评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,840评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,278评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,497评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,667评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,394评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,980评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,628评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,649评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,548评论 2 352

推荐阅读更多精彩内容