Spring Security 源码解析

《 Spring Security 源码解析 (一)》



1 简介

  最近一个项目,使用了试下流行的Spring Cloud 微服务架构,使用Spring Cloud oauth2.0 协议搭建访问授权器。由于项目体量较小,不对外提供授权服务,故采用oauth协议的密码模式来做令牌授权。项目中深深体会到了spring Security的强大,写下此篇博客来记录所学到的知识,希望对大家建立spring Security的体系能产生帮助。鉴于本人水平所限,有错误不足之处,望各位指正。

 spring security 核心组件

2.1 SecurityContextPersistenceFilter

spring 使用过滤链对url进行拦截,在过滤链的顶端是SecurityContextPersistenceFilter,这个过滤器的作用:用户在登录过后,后续的访问通过sessionid来识别,当如今微服务流行时,前后端传送是不传输seesion的,但过滤器还是可以帮我们清除上下文的一些信息。具体的代码:


//执行过滤链

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {

//获取请求和响应

        HttpServletRequest request = (HttpServletRequest)req;

        HttpServletResponse response = (HttpServletResponse)res;

        if (request.getAttribute("__spring_security_scpf_applied") != null) {

            chain.doFilter(request, response);

        } else {

            boolean debug = this.logger.isDebugEnabled();

            request.setAttribute("__spring_security_scpf_applied", Boolean.TRUE);

            if (this.forceEagerSessionCreation) {

                HttpSession session = request.getSession();

                if (debug && session.isNew()) {

                    this.logger.debug("Eagerly created session: " + session.getId());

                }

            }

//包装request 和response

            HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);

            //从安全上下文长裤中获取SecurityContext容器

            SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);

            boolean var13 = false;

            try {

                var13 = true;

                // 获取安全响应上下文

                SecurityContextHolder.setContext(contextBeforeChainExecution);

                chain.doFilter(holder.getRequest(), holder.getResponse());

                var13 = false;

            } finally {

                if (var13) {

                //最终清除安全上下文信息

                    SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();

                    SecurityContextHolder.clearContext();

                    this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());

                    request.removeAttribute("__spring_security_scpf_applied");

                    if (debug) {

                        this.logger.debug("SecurityContextHolder now cleared, as request processing completed");

                    }

                }

            }

            SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();

            SecurityContextHolder.clearContext();

            this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());

            request.removeAttribute("__spring_security_scpf_applied");

            if (debug) {

                this.logger.debug("SecurityContextHolder now cleared, as request processing completed");

            }

        }

    }


从源码中可以看出安全上下文的信息是存在SecurityContextHolder这个容器当中的,当服务请求结束的时候,SecurityContextHolder清除了session信息。过滤器一般负责核心处理,具体业务,交给其他类实现,在spring的设计中我们可以看到很多诸如此类的设计除了SecurityContextPersistenceFilter,在spring Security的一堆过滤器链中,在学习中我们应该举一反三,这样有利于我们对源码的学习,也能提高自己的代码能力。还有一些很重要的filter,比如AbstractAuthenticationProcessingFilter,以及它的子类SecurityContextPersistenceFilter等等都是很总要的过滤器,我们将重点对AbstractAuthenticationProcessingFilter进行讲解

2.2 SecurityContextHolder

SecurityContextHolder用于存储安全上下文(SecurityContext)信息。当前用户信息都被保存到SecurityContextHolder中,其实是保存到TContextHolderStrategy的实现类中,顾名思义,SecurityContextHolder绑定了本地线程,在用户退出后, SecurityContextPersistenceFilter会qing清除Context信息。我们来看源码:


public class SecurityContextHolder {

//存储策略

    public static final String MODE_THREADLOCAL = "MODE_THREADLOCAL";

    public static final String MODE_INHERITABLETHREADLOCAL = "MODE_INHERITABLETHREADLOCAL";

    public static final String MODE_GLOBAL = "MODE_GLOBAL";

    public static final String SYSTEM_PROPERTY = "spring.security.strategy";

    private static String strategyName = System.getProperty("spring.security.strategy");

    private static SecurityContextHolderStrategy strategy;

    private static int initializeCount = 0;

    public SecurityContextHolder() {

    }

//清除上下文

    public static void clearContext() {

        strategy.clearContext();

    }

    private static void initialize() {

        if (!StringUtils.hasText(strategyName)) {

            strategyName = "MODE_THREADLOCAL";

        }

        if (strategyName.equals("MODE_THREADLOCAL")) {

        //创建真正的安全上下文容器

            strategy = new ThreadLocalSecurityContextHolderStrategy();

        } else if (strategyName.equals("MODE_INHERITABLETHREADLOCAL")) {

            strategy = new InheritableThreadLocalSecurityContextHolderStrategy();

        } else if (strategyName.equals("MODE_GLOBAL")) {

            strategy = new GlobalSecurityContextHolderStrategy();

        } else {

            try {

                Class<?> clazz = Class.forName(strategyName);

                Constructor<?> customStrategy = clazz.getConstructor();

                strategy = (SecurityContextHolderStrategy)customStrategy.newInstance();

            } catch (Exception var2) {

                ReflectionUtils.handleReflectionException(var2);

            }

        }

        ++initializeCount;

    }

//....代码省略

//  初始化

    static {

        initialize();

    }

}


从代码中我们也可以看出,SecurityContextHolder就是这么和线程关联的,并提供了clear的方法,方便进程结束后对安全上线文的清除。写在这里我们也可以仿照SecurityContextHolder的策略来实现我们的业务逻辑:


@Slf4j

@Service

@Lazy(false)

public class TenantContextHolder implements ApplicationContextAware, DisposableBean {

private static ThreadLocal<String> tenantThreadLocal= new ThreadLocal<>();

private static ApplicationContext applicationContext =null;

@Override

public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {

TenantContextHolder.applicationContext =applicationContext;

}

public static final void setTenant(String schema){

tenantThreadLocal.set(schema);

}

public static final String getTenant(){

String schema = tenantThreadLocal.get();

if(schema == null){

schema = "";

}

return schema;

}

@Override

public void destroy() throws Exception {

TenantContextHolder.clearHolder();

}

public static void clearHolder() {

if (log.isDebugEnabled()) {

log.debug("清除TenantContextHolder中的ApplicationContext:" + applicationContext);

}

applicationContext = null;

}

}

2.3 AbstractAuthenticationProcessingFilter

从名字也可以看出这是一个抽象类,这是一个很重要的类,用户权限校验都与此类有关,请求进入filter会执行doFilter()方法,首先判断是否能够处理当前请求,如果是则调用子类的attemptAuthentication()方法进行验证,在这个抽象类中,引入了统一认证管理AuthenticationManager ,登录成功AuthenticationSuccessHandler等等,登录成功后,又定义了登录成功后的方法,将封装的Authentication信息封装到SecurityContextHolder中,这个类中业务逻辑很多,我这里代码没有贴全,希望朋友们自己能够阅读源码,了解这个类的核心。


public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {

//事件发布器

    protected ApplicationEventPublisher eventPublisher;

//核心几口 所有的权限校验都由它进行统一管理

    private AuthenticationManager authenticationManager;

//记住我

    private RememberMeServices rememberMeServices = new NullRememberMeServices();

    private RequestMatcher requiresAuthenticationRequestMatcher;

    private boolean continueChainBeforeSuccessfulAuthentication = false;

    private SessionAuthenticationStrategy sessionStrategy = new NullAuthenticatedSessionStrategy();

    //登录成功处理

    private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();

    //失败处理

    private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();

  //  代码省略.........

    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {

        HttpServletRequest request = (HttpServletRequest)req;

        HttpServletResponse response = (HttpServletResponse)res;


        //判断当前的filter是否可以处理当前请求,不可以的话则交给下一个filter处理

        if (!this.requiresAuthentication(request, response)) {

            chain.doFilter(request, response);

        } else {

            if (this.logger.isDebugEnabled()) {

                this.logger.debug("Request is to process authentication");

            }

            Authentication authResult;

            try {

            //对权限进行校验

                authResult = this.attemptAuthentication(request, response);

                if (authResult == null) {

                    return;

                }

//认证成功

            this.sessionStrategy.onAuthentication(authResult, request, response);


            if (this.continueChainBeforeSuccessfulAuthentication) {

                chain.doFilter(request, response);

            }

            this.successfulAuthentication(request, response, chain, authResult);

        }

    }

//权限校验的方法

    public abstract Authentication attemptAuthentication(HttpServletRequest var1, HttpServletResponse var2) throws AuthenticationException, IOException, ServletException;

    public void setAuthenticationManager(AuthenticationManager authenticationManager) {

        this.authenticationManager = authenticationManager;

    }

}


AbstractAuthenticaitonProcessingFilter有很4个子类,比如:UsernamepasswordProcessingFiter,OAuth2ClientAuthenticationFilter,他们都实现了**attemptAuthentication**方法,我们来看:


public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";

    public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";

    private String usernameParameter = "username";

    private String passwordParameter = "password";

    private boolean postOnly = true;

//在webconfig里,配置http.login 并且路径为/login、方法为post 就会被这个拦截器拦截

    public UsernamePasswordAuthenticationFilter() {

        super(new AntPathRequestMatcher("/login", "POST"));

    }

    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        if (this.postOnly && !request.getMethod().equals("POST")) {

            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());

        } else {

            String username = this.obtainUsername(request);

            String password = this.obtainPassword(request);

            if (username == null) {

                username = "";

            }

            if (password == null) {

                password = "";

            }

            // 注意 划重点

//获取用户名和密码 并将用户名和密码封装成一个UsernamePasswordAuthenticationToken这么个token

            username = username.trim();

            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);

            this.setDetails(request, authRequest);

            //将校验交由AuthenticationManger的authenticate方法去执行

            return this.getAuthenticationManager().authenticate(authRequest);

        }

    }


2.4 AuthenticationManager

上文我们提到,所有的权限校验都是由AuthenticationManager去完成的 那么我们去看看它的源码

public interface AuthenticationManager {

    Authentication authenticate(Authentication var1) throws AuthenticationException;

}


我们看到AuthenticationManager是一个接口,所有的校验都是由他的实现类去完成的,AuthenticationManager制作统一的管理,这里我们就要开动脑筋想想,这么设计的用意是什么,这里我留着空间让大家思考,废话不多说我们来看看他的两个核心实现类OAuth2AuthenticationManager、ProviderManager ,每个都为我们提供了校验接入点,接下来我们就说说ProviderManager,OAuth2AuthenticationManager则放到Oauth相关章节去讲

2.5 ProviderManager

我们知道,用户校验的话有很多种,比如说用户名+密码、手机号+验证码、邮箱+密码、social登录等等,那么ProviderManager是怎么知道我们是哪一种请求呢?我们继续来看看源码:


public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {

// 代码省略 ....

    public ProviderManager(List<AuthenticationProvider> providers) {

        this(providers, (AuthenticationManager)null);

    }

    public ProviderManager(List<AuthenticationProvider> providers, AuthenticationManager parent) {

        this.eventPublisher = new ProviderManager.NullEventPublisher();

        this.providers = Collections.emptyList();

        this.messages = SpringSecurityMessageSource.getAccessor();

        this.eraseCredentialsAfterAuthentication = true;

        Assert.notNull(providers, "providers list cannot be null");

        this.providers = providers;

        this.parent = parent;

        this.checkState();

    }

//权限校验

    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

    //获取当前认证的类型

        Class<? extends Authentication> toTest = authentication.getClass();

        AuthenticationException lastException = null;

        Authentication result = null;

        boolean debug = logger.isDebugEnabled();

        //获取迭代器

        Iterator var6 = this.getProviders().iterator();

//循环获取 这里和4.x版本有所不同个 有兴趣的可以看看4.x的版本

        while(var6.hasNext()) {

            AuthenticationProvider provider = (AuthenticationProvider)var6.next();

            if (provider.supports(toTest)) {

                if (debug) {

                    logger.debug("Authentication attempt using " + provider.getClass().getName());

                }

                try {

                    result = provider.authenticate(authentication);

                    //通过当前类型获取到认证信息且不为null 则停止循环

                    if (result != null) {

                    将result装换成Authentication信息

                        this.copyDetails(authentication, result);

                        break;

                    }

                } catch (AccountStatusException var11) {

                    this.prepareException(var11, authentication);

                    throw var11;

                } catch (InternalAuthenticationServiceException var12) {

                    this.prepareException(var12, authentication);

                    throw var12;

                } catch (AuthenticationException var13) {

                    lastException = var13;

                }

            }

        }

//如果结果为空 则调用父类的authenticate方法 这里可以理解成递归

        if (result == null && this.parent != null) {

            try {


                result = this.parent.authenticate(authentication);

            } catch (ProviderNotFoundException var9) {

                ;

            } catch (AuthenticationException var10) {

                lastException = var10;

            }

        }

//获取到结果

        if (result != null) {

            if (this.eraseCredentialsAfterAuthentication && result instanceof CredentialsContainer) {

              //移除密码

              ((CredentialsContainer)result).eraseCredentials();

            }

//发布验证成功事件 并返回结果

            this.eventPublisher.publishAuthenticationSuccess(result);

            return result;

        } else {

        //执行到此,说明没有认证成功,包装异常信息

            if (lastException == null) {

                lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound", new Object[]{toTest.getName()}, "No AuthenticationProvider found for {0}"));

            }

            this.prepareException((AuthenticationException)lastException, authentication);

            throw lastException;

        }

    }

    public List<AuthenticationProvider> getProviders() {

        return this.providers;

    }

// .................

}


看到这里的话,我想刚才留的思考就可以解答了,当获取的认证信息和Providers不相符的时候,就调用父类AuthenticationManger的authenticate方法 ,与下个provider进行匹配,通俗来说,当前台是已邮箱加密码登录的,首先 Iterator 获取的可能是usernamepassword模式的,那么查出结果是空,就调用父类的方法,又走了一遍子类实现,又继续认证,直到认证成功,将返回的 result 既 Authentication 对象进一步封装为 Authentication Token,比如usernamepasswordtoken 、remembermetoken等等。

2.6 Authentication

上文我们一直有提到过Authentication ,那么我们来看一下Authentication具体是什么

public interface Authentication extends Principal, Serializable {

//权限信息集合

    Collection<? extends GrantedAuthority> getAuthorities();

//获取凭证

    Object getCredentials();

//获取详情

    Object getDetails();

//获取当前用户

    Object getPrincipal();

//是否认证

    boolean isAuthenticated();

    void setAuthenticated(boolean var1) throws IllegalArgumentException;

}


认证授权信息都会封装到这里,比如我们用户登录后 将用户名和密码封装成UsernamePasswordToken 就是实现了这个接口,它封装了用户权限信息,以及对象信息,是整个框架的核心类

2.7 UserDetailsService和 UserDetails

这也是Spring Security 最重要的接口,通过该接口的loadUserByUsername()方法返回一个UserDetails对象,那么这个对象是什么呢?


public interface UserDetailsService {

    UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;

}


我们看到UserDetailsService就定义了一个接口,那么想必它也有很多适用不同业务功能的实现类,我们稍后再看,先瞧瞧UserDetails这个类


public interface UserDetails extends Serializable {

//权限集合

    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

//是否过期

    boolean isAccountNonExpired();

//是被锁

    boolean isAccountNonLocked();

//凭证(密码)是否失效

    boolean isCredentialsNonExpired();

//用户是否可用(是否删除)

    boolean isEnabled();

}


是不是和Authentication很像呢!那为什么要定义两个呢?这里依旧留一个疑问。我们再来看UserDetails,还是个接口,那么我们来看他的子类实现


public class User implements UserDetails, CredentialsContainer {

    private static final long serialVersionUID = 500L;

    private static final Log logger = LogFactory.getLog(User.class);

    private String password;

    private final String username;

    private final Set<GrantedAuthority> authorities;

    private final boolean accountNonExpired;

    private final boolean accountNonLocked;

    private final boolean credentialsNonExpired;

    private final boolean enabled;

    public User(String username, String password, Collection<? extends GrantedAuthority> authorities) {

        this(username, password, true, true, true, true, authorities);

    }

    public User(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {

        if (username != null && !"".equals(username) && password != null) {

            this.username = username;

            this.password = password;

            this.enabled = enabled;

            this.accountNonExpired = accountNonExpired;

            this.credentialsNonExpired = credentialsNonExpired;

            this.accountNonLocked = accountNonLocked;

            this.authorities = Collections.unmodifiableSet(sortAuthorities(authorities));

        } else {

            throw new IllegalArgumentException("Cannot pass null or empty values to constructor");

        }

    }

// 代码省略.............

}


当我们使用用户名密码登录的时候,从数据库中查到的用户名和密码都会封装到这里

2.8DaoAuthenticationProvider

用户登录后将用户登录提交信息封装成了Authentication对象,那么我们如何从数据库中获取用户名和密码来进行校验,如何进行密码加解密。这里我们就要说说DaoAuthenticationProvider了:

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

    private static final String USER_NOT_FOUND_PASSWORD = "userNotFoundPassword";

    //问价加解密处理

    private PasswordEncoder passwordEncoder;

    private volatile String userNotFoundEncodedPassword;

    //注入UserDetailsService 调用子类的loadUserByUsername方法 获得UserDetails对象

    private UserDetailsService userDetailsService;

    public DaoAuthenticationProvider() {

    //密码处理

        this.setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());

    }

//仔细看好这里  从数据库中获取到的UserDetails对象和前台表单提交封装的UsernamePasswordAuthenticationToken对象 进行比较

    protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {

        if (authentication.getCredentials() == null) {

            this.logger.debug("Authentication failed: no credentials provided");

            throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));

        } else {

            String presentedPassword = authentication.getCredentials().toString();

            if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {

                this.logger.debug("Authentication failed: password does not match stored value");

                throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));

            }

        }

    }

    protected void doAfterPropertiesSet() throws Exception {

        Assert.notNull(this.userDetailsService, "A UserDetailsService must be set");

    }

    protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {

        this.prepareTimingAttackProtection();

        try {

        //在这里通过用户名从数据库中拿到UserDetails 然后交给additionalAuthenticationChecks去验证

            UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);

            if (loadedUser == null) {

                throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");

            } else {

                return loadedUser;

            }

    // ..........................

}

我们以表单登录的来看一下用户登录的执行流程

图片来源于网络,侵权请留言!!

让我们再梳理一下Spring security的核心类


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