Spring 源码分析(四)Sercurity

Spring 源码分析(四)Sercurity

sschrodinger

2019/03/04


登陆验证流程


假设有如下的 spring security 配置。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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.password.NoOpPasswordEncoder;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
        .inMemoryAuthentication()
        .passwordEncoder(NoOpPasswordEncoder.getInstance())
        .withUser("user_1").password("user_1").roles("USER")
        .and()
        .withUser("user_2").password("user_2").roles("USER");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // TODO Auto-generated method stub
        http
        .authorizeRequests()
            .antMatchers("/resources/**").permitAll()
            .antMatchers("/register").permitAll()
            .anyRequest().authenticated()
            .and()
        .formLogin()
            .loginPage("/login")
            .defaultSuccessUrl("/home", true)
            .permitAll();
    }
    
     @Bean
     @Override
     public AuthenticationManager authenticationManagerBean() throws Exception {
         return super.authenticationManagerBean();
     }

}

输入任意 url (非/login),流程如下:

step 1

在 Spring 中,登陆验证的逻辑始于 DelegatingFilterProxy,这是一个继承了 Filter 的类,用于实现 Java Servlet 规范的过滤器。

DelegatingFilterProxydoFilter() 方法如下:

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {

    // Lazily initialize the delegate if necessary.
    Filter delegateToUse = this.delegate;
    if (delegateToUse == null) {
        synchronized (this.delegateMonitor) {
            delegateToUse = this.delegate;
            if (delegateToUse == null) {
                WebApplicationContext wac = findWebApplicationContext();
                if (wac == null) {
                    throw new IllegalStateException("No WebApplicationContext found: " +
                            "no ContextLoaderListener or DispatcherServlet registered?");
                }
                delegateToUse = initDelegate(wac);
            }
            this.delegate = delegateToUse;
        }
    }

    // Let the delegate perform the actual doFilter operation.
    invokeDelegate(delegateToUse, request, response, filterChain);
}

最重要的是 invokeDelegate() 方法,Spring 所有的 Filter 逻辑都是在 invokeDelegate() 方法中完成的,所以没有 filterChain.doFilter(request, response, filterChain) 将处理过程抛出到下一 Filter。

参数 delefateToUse 包括了所有的过滤器,调试信息如下:

delegate    FilterChainProxy  (id=124)
    |-...//其他
    |-beanName  "springSecurityFilterChain" (id=126)
    |-filterChains  ArrayList<E>  (id=133)  
        |-elementData   Object[1]  (id=158) 
            |-[0]   DefaultSecurityFilterChain  (id=3344)   
                |-org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@630e5010,
                |-org.springframework.security.web.context.SecurityContextPersistenceFilter@4eab9aec,
                |-org.springframework.security.web.header.HeaderWriterFilter@533e8807,
                |-org.springframework.security.web.csrf.CsrfFilter@156eeff1,
                |-org.springframework.security.web.authentication.logout.LogoutFilter@656c0eae,
                |-org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@267b678f,
                |-org.springframework.security.web.savedrequest.RequestCacheAwareFilter@2c6efee3,
                |-org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@56adbb07,
                |-org.springframework.security.web.authentication.AnonymousAuthenticationFilter@10b5ff4d,
                |-org.springframework.security.web.session.SessionManagementFilter@2b4ba2d9,
                |-org.springframework.security.web.access.ExceptionTranslationFilter@2bc0603f,
                |-org.springframework.security.web.access.intercept.FilterSecurityInterceptor@4e826fd4

Spring security 的验证工作都是在 DelegatingFilterProxy 中的一个名为 springSecurityFilterChain 中完成的。

我们看 invokeDelegate() 的源码,如下:

protected void invokeDelegate(
        Filter delegate, ServletRequest request, ServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {

    delegate.doFilter(request, response, filterChain);
}

step 2

第二步进入 delegatedoFilter() 方法执行。

delegate 的本质是继承了 Filter 的类。维护了一个 SecurityFilterChain 列表。SecurityFilterChain 定义如下:

public interface SecurityFilterChain {

    boolean matches(HttpServletRequest request);

    List<Filter> getFilters();
}

在当前环境中,List<SecurityFilterChain> 只维护了一个元素。即

@Override
public void doFilter(ServletRequest request, ServletResponse response,
        FilterChain chain) throws IOException, ServletException {
    boolean clearContext = request.getAttribute(FILTER_APPLIED) == null;
    if (clearContext) {
        try {
            request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
            doFilterInternal(request, response, chain);
        }
        finally {
            SecurityContextHolder.clearContext();
            request.removeAttribute(FILTER_APPLIED);
        }
    }
    else {
        doFilterInternal(request, response, chain);
    }
}

private void doFilterInternal(ServletRequest request, ServletResponse response,
        FilterChain chain) throws IOException, ServletException {

    FirewalledRequest fwRequest = firewall
            .getFirewalledRequest((HttpServletRequest) request);
    HttpServletResponse fwResponse = firewall
            .getFirewalledResponse((HttpServletResponse) response);

    List<Filter> filters = getFilters(fwRequest);

    if (filters == null || filters.size() == 0) {
        if (logger.isDebugEnabled()) {
            logger.debug(UrlUtils.buildRequestUrl(fwRequest)
                    + (filters == null ? " has no matching filters"
                            : " has an empty filter list"));
        }

        fwRequest.reset();

        chain.doFilter(fwRequest, fwResponse);

        return;
    }

    VirtualFilterChain vfc = new VirtualFilterChain(fwRequest, chain, filters);
    vfc.doFilter(fwRequest, fwResponse);
}

我们看 doFilter() 方法,首先,判断 request 是否有 FILTER_APPLIED 属性,如果没有,则需要添加,然后执行 doFilterInternal() 方法。

getFilters(fwRequest) 通过 url 得到符合条件的所有 filterChain 的第一个 Filter。即会得到如下 12 个 Filter:

org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter,
org.springframework.security.web.context.SecurityContextPersistenceFilter,
org.springframework.security.web.header.HeaderWriterFilter,
org.springframework.security.web.csrf.CsrfFilter,
org.springframework.security.web.authentication.logout.LogoutFilter,
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter,
org.springframework.security.web.savedrequest.RequestCacheAwareFilter,
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter,
org.springframework.security.web.authentication.AnonymousAuthenticationFilter,
org.springframework.security.web.session.SessionManagementFilter,
org.springframework.security.web.access.ExceptionTranslationFilter,
org.springframework.security.web.access.intercept.FilterSecurityInterceptor

变量chain代表原始 FilterChain,当 getFilters(fwRequest) 返回不为 null 时,返回值和 chain 会组合成 VirtualFilterChain,构造函数如下:

private VirtualFilterChain(FirewalledRequest firewalledRequest,
        FilterChain chain, List<Filter> additionalFilters) {
    this.originalChain = chain;
    this.additionalFilters = additionalFilters;
    this.size = additionalFilters.size();
    this.firewalledRequest = firewalledRequest;
}

VirtualFilterChaindoFilter 函数如下:

@Override
public void doFilter(ServletRequest request, ServletResponse response)
        throws IOException, ServletException {
    if (currentPosition == size) {
        if (logger.isDebugEnabled()) {
            logger.debug(UrlUtils.buildRequestUrl(firewalledRequest)
                    + " reached end of additional filter chain; proceeding with original chain");
        }

        // Deactivate path stripping as we exit the security filter chain
        this.firewalledRequest.reset();

        originalChain.doFilter(request, response);
    }
    else {
        //currrent addition filter pos, init is zero
        currentPosition++;

        Filter nextFilter = additionalFilters.get(currentPosition - 1);

        if (logger.isDebugEnabled()) {
            logger.debug(UrlUtils.buildRequestUrl(firewalledRequest)
                    + " at position " + currentPosition + " of " + size
                    + " in additional filter chain; firing Filter: '"
                    + nextFilter.getClass().getSimpleName() + "'");
        }

        nextFilter.doFilter(request, response, this);
    }
}

注意,当执行 VirtualFilterChaindoFilter 函数时,会先执行 else 语句,currentPosition 先自加,然后将自身作为参数执行 nextFilterdofilter() 方法,这样就会重复执行 else 语句,将所有的 additionalFilter 全部执行完。

step 3

执行所有 VirtualFilterChain 中的 Filter

nextFilter() 依次执行如下 12 个 Filter

1.WebAsyncManagerIntegrationFilter
2.SecurityContextPersistenceFilter
3.HeaderWriterFilter
4.CsrfFilter
5.LogoutFilter
6.UsernamePasswordAuthenticationFilter
7.RequestCacheAwareFilter
8.SecurityContextHolderAwareRequestFilter
9.AnonymousAuthenticationFilter
10.SessionManagementFilter
11.ExceptionTranslationFilter
12.FilterSecurityInterceptor

其中比较重要的是2,5,6,9,12三个 filter

SecurityContextPersistenceFilter 主要是对保存的密码信息等做持久化处理。

LogoutFilter 主要是匹配退出 url 并作退出处理逻辑。

UsernamePasswordAuthenticationFilter 主要作用是当匹配登录界面时,做一些登陆的操作,否则直接到下一过滤器。

AnonymousAuthenticationFilter 主要作用是当没有进行登陆时,自动创建匿名用户。

FilterSecurityInterceptor 继承了 AbstractSecurityInterceptor,主要用于登陆的核心逻辑。

note(AbstractSecurityInterceptor api note)

  • Abstract class that implements security interception for secure objects.
  • The AbstractSecurityInterceptor will ensure the proper startupconfiguration of the security interceptor. It will also implement the proper handlingof secure object invocations, namely:
    • Obtain the Authentication object from the SecurityContextHolder.
    • Determine if the request relates to a secured or public invocation by looking upthe secure object request against the SecurityMetadataSource.
    • For an invocation that is secured (there is a list of ConfigAttributesfor the secure object invocation):
      • If either the org.springframework.security.core.Authentication.isAuthenticated() returns false, or the alwaysReauthenticate is true,authenticate the request against the configured AuthenticationManager. Whenauthenticated, replace the Authentication object on the SecurityContextHolder with the returned value.
      • Authorize the request against the configured AccessDecisionManager.
      • Perform any run-as replacement via the configured RunAsManager.
      • Pass control back to the concrete subclass, which will actually proceed withexecuting the object. A InterceptorStatusToken is returned so that after thesubclass has finished proceeding with execution of the object, its finally clause canensure the AbstractSecurityInterceptor is re-called and tidies upcorrectly using finallyInvocation(InterceptorStatusToken).
      • The concrete subclass will re-call the AbstractSecurityInterceptor viathe afterInvocation(InterceptorStatusToken, Object) method.
      • If the RunAsManager replaced the Authentication object,return the SecurityContextHolder to the object that existed after the callto AuthenticationManager.
      • If an AfterInvocationManager is defined, invoke the invocation managerand allow it to replace the object due to be returned to the caller.
    • For an invocation that is public (there are no ConfigAttributes forthe secure object invocation):
      • As described above, the concrete subclass will be returned an InterceptorStatusToken which is subsequently re-presented to the AbstractSecurityInterceptor after the secure object has been executed. The AbstractSecurityInterceptor will take no further action when its afterInvocation(InterceptorStatusToken, Object) is called.
    • Control again returns to the concrete subclass, along with the Objectthat should be returned to the caller. The subclass will then return that result orexception to the original caller.

FilterSecurityInterceptorinvoke() 方法如下:

public void invoke(FilterInvocation fi) throws IOException, ServletException {
    if ((fi.getRequest() != null)
            && (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
            && observeOncePerRequest) {
        // filter already applied to this request and user wants us to observe
        // once-per-request handling, so don't re-do security checking
        fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
    }
    else {
        // first time this request being called, so perform security checking
        if (fi.getRequest() != null && observeOncePerRequest) {
            fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
        }

        InterceptorStatusToken token = super.beforeInvocation(fi);

        try {
            fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
        }
        finally {
            super.finallyInvocation(token);
        }

        super.afterInvocation(token, null);
    }
}

beforeInvocation() 函数的处理逻辑是先验证,如果不满足条件,则抛出 accessDeniedException 错误,否则生成 InterceptorStatusToken 给上一级。

protected InterceptorStatusToken beforeInvocation(Object object) {
    Assert.notNull(object, "Object was null");
    final boolean debug = logger.isDebugEnabled();

    if (!getSecureObjectClass().isAssignableFrom(object.getClass())) {
        throw new IllegalArgumentException(
                "Security invocation attempted for object "
                        + object.getClass().getName()
                        + " but AbstractSecurityInterceptor only configured to support secure objects of type: "
                        + getSecureObjectClass());
    }

    Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource()
            .getAttributes(object);

    if (attributes == null || attributes.isEmpty()) {
        if (rejectPublicInvocations) {
            throw new IllegalArgumentException(
                    "Secure object invocation "
                            + object
                            + " was denied as public invocations are not allowed via this interceptor. "
                            + "This indicates a configuration error because the "
                            + "rejectPublicInvocations property is set to 'true'");
        }

        if (debug) {
            logger.debug("Public object - authentication not attempted");
        }

        publishEvent(new PublicInvocationEvent(object));

        return null; // no further work post-invocation
    }

    if (SecurityContextHolder.getContext().getAuthentication() == null) {
        credentialsNotFound(messages.getMessage(
                "AbstractSecurityInterceptor.authenticationNotFound",
                "An Authentication object was not found in the SecurityContext"),
                object, attributes);
    }

    Authentication authenticated = authenticateIfRequired();

    // Attempt authorization
    try {
        this.accessDecisionManager.decide(authenticated, object, attributes);
    }
    catch (AccessDeniedException accessDeniedException) {
        publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated,
                accessDeniedException));

        throw accessDeniedException;
    }

    if (debug) {
        logger.debug("Authorization successful");
    }

    if (publishAuthorizationSuccess) {
        publishEvent(new AuthorizedEvent(object, attributes, authenticated));
    }

    // Attempt to run as a different user
    Authentication runAs = this.runAsManager.buildRunAs(authenticated, object,
                attributes);

    if (runAs == null) {
        if (debug) {
            logger.debug("RunAsManager did not change Authentication object");
        }

        // no further work post-invocation
        return new InterceptorStatusToken(SecurityContextHolder.getContext(), false,
                attributes, object);
    }
    else {
        if (debug) {
            logger.debug("Switching to RunAs Authentication: " + runAs);
        }

        SecurityContext origCtx = SecurityContextHolder.getContext();
        SecurityContextHolder.setContext(SecurityContextHolder.createEmptyContext());
        SecurityContextHolder.getContext().setAuthentication(runAs);

        // need to revert to token.Authenticated post-invocation
        return new InterceptorStatusToken(origCtx, true, attributes, object);
    }
}

ExceptionTranslationFilter 会捕捉 FilterSecurityInterceptor 抛出的错误,并进行错误处理。错误处理语句如下:

catch (Exception ex) {
    // Try to extract a SpringSecurityException from the stacktrace
    Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex);
    RuntimeException ase = (AuthenticationException) throwableAnalyzer
            .getFirstThrowableOfType(AuthenticationException.class, causeChain);

    if (ase == null) {
        ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType(
                AccessDeniedException.class, causeChain);
    }

    if (ase != null) {
        if (response.isCommitted()) {
            throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", ex);
        }
        handleSpringSecurityException(request, response, chain, ase);
    }
    else {
        // Rethrow ServletExceptions and RuntimeExceptions as-is
        if (ex instanceof ServletException) {
            throw (ServletException) ex;
        }
        else if (ex instanceof RuntimeException) {
            throw (RuntimeException) ex;
    }

    // Wrap other Exceptions. This shouldn't actually happen
        // as we've already covered all the possibilities for doFilter
        throw new RuntimeException(ex);
    }
}

错误的主要处理过程在 handleSpringSecurityException(request, response, chain, ase) 中,如下:

private void handleSpringSecurityException(HttpServletRequest request,
    HttpServletResponse response, FilterChain chain, RuntimeException exception)
    throws IOException, ServletException {
    if (exception instanceof AuthenticationException) {
        logger.debug(
                "Authentication exception occurred; redirecting to authentication entry point",
                exception);

        sendStartAuthentication(request, response, chain,
                    (AuthenticationException) exception);
    } else if (exception instanceof AccessDeniedException) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authenticationTrustResolver.isAnonymous(authentication) || authenticationTrustResolver.isRememberMe(authentication)) {
            logger.debug(
                    "Access is denied (user is " + (authenticationTrustResolver.isAnonymous(authentication) ? "anonymous" : "not fully authenticated") + "); "redirecting to authentication entry point",
                    exception);

            sendStartAuthentication(
                    request,
                    response,
                    chain,
                    new InsufficientAuthenticationException(
                        messages.getMessage(
                            "ExceptionTranslationFilter.insufficientAuthentication",
                            "Full authentication is required to access this resource")));
        } else {
            logger.debug(
                    "Access is denied (user is not anonymous); delegating to AccessDeniedHandler",
                    exception);

            accessDeniedHandler.handle(request, response,
                    (AccessDeniedException) exception);
        }
    }
}

sendStartAuthentication 函数中,会对连接进行重定向到规定的登陆界面。

如在 /login 界面利用 POST 提交表单信息登陆。流程的 step 1 和 step 2 和上相同,step 3 略有区别。在执行到 UsernamePasswordAuthenticationFilter 时,就会进行登陆逻辑,代码如下:

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

    HttpServletRequest request = (HttpServletRequest) req;
    HttpServletResponse response = (HttpServletResponse) res;

    if (!requiresAuthentication(request, response)) {
        chain.doFilter(request, response);

        return;
    }

    if (logger.isDebugEnabled()) {
        logger.debug("Request is to process authentication");
    }

    Authentication authResult;

    try {
        authResult = attemptAuthentication(request, response);
        if (authResult == null) {
            // return immediately as subclass has indicated that it hasn't completed
            // authentication
            return;
        }
        sessionStrategy.onAuthentication(authResult, request, response);
    }
    catch (InternalAuthenticationServiceException failed) {
        logger.error(
                "An internal error occurred while trying to authenticate the user.",
                failed);
        unsuccessfulAuthentication(request, response, failed);

        return;
    }
    catch (AuthenticationException failed) {
        // Authentication failed
        unsuccessfulAuthentication(request, response, failed);

        return;
    }

    // Authentication success
    if (continueChainBeforeSuccessfulAuthentication) {
        chain.doFilter(request, response);
    }

    successfulAuthentication(request, response, chain, authResult);
}

具体逻辑是 attemptAuthentication() 函数进行验证,当验证成功时,执行 successfulAuthentication(request, response, chain, authResult) 进行跳转,失败时,执行 unsuccessfulAuthentication(request, response, failed) 进行跳转,假如设置了跳转地址,则回继续调用 doFilter() 方法,在 FilterSecurityInterceptor 中利用UserNamePasswordToken 进行授权。

综上,时序图(关键类)如下:


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

推荐阅读更多精彩内容