Go使用JWT构建用户认证系统

【译文】原文地址
大家好,我将尝试把使用Go结合JWT构建用户认证系统的整个过程记录下来,作为一个参考希望对你有所帮助。

什么是JWT

对于不是很熟悉JWT的人来说,JWT是JSON Web Token的缩写。JWT是在服务器端对用户身份进行认证的一种方法。

在传统方法中,我们有用于验证用户身份的会话,在成功登录时,我们将使用该用户的详细信息创建一个新会话,并将其存储在服务器存储中。然后将创建的session ID发送回客户机。随后客户端发送后续请求都会携带这个session ID,因此服务端可以通过查询存储的session ID来对客户端请求进行认证。当不同的服务对应独立的存储时(例如微服务架构)这种认证方法就复杂了。

JWT是构建微服务架构应用的救星。在JWT使用中,信息只存储在客户端。JWT只是一些包含用户声明信息的JSON数据。JWT的关键属性是令牌(Token)本身包含验证所需的所有细节,因为它们携带消息身份验证代码(MAC)。JWT由三部分组成,头部、有效负载和签名,分别用点号分开的。

JWT例子

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
在上面的例子中,第一个点号签名的部分是头部,第二个点号和第一个点号之间的是有效负载,后面的是签名部分。上面的token是使用Base64Url编码版本。我们去jwt.io将上面的token解码为JSON数据来查看。

让我们来看看JWT的每个部分都包含哪些内容

头部:包含一些关于token的元数据,如所使用的的签名类型等。下面是上述token解码后的JSON数据:

{
“alg”: “HS256”,
“typ”: “JWT”
}

有效负载:包含用户的识别信息,以上token解码后如下所示:

{
“sub”: “1234567890”,
“name”: “John Doe”,
“iat”: 1516239022
}

签名:是我们上面讨论过的消息认证码(MAC message authentication code),它有助于token的验证。签名是通过结合头部和有效负载的base64Url编码格式并在服务器中使用密钥对其签名来创建的。例如,在HS256签名类型的JWT中,签名的创建如下:

signature = hmacsha256(encoded_header + “.” + encoded_payload, “server_secret”)

hmacsha256是一种哈希算法。签名只能由头部、有效负载和密匙的所有者才能生成。因此,在身份验证期间,在接收到JWT后,服务器将使用已编码的头和所获得的JWT中的有效负载创建一个新签名,并使用服务器中提供的密钥对其进行签名。然后将这个新创建的签名与获得的JWT中存在的签名进行比较。如果匹配,服务器就认为该令牌有效,并通过获取token中的用户标识来处理请求。

JWT认证系统的基本流程如下:
1、客户端发送带有用户名和密码的登录请求
2、服务器通过查询数据库来验证用户名和密码的组合,并验证请求。
3、如果有效,将创建一个新的JWT token,其中包含用户的技术标识和一个过期时间(稍后将讨论这个过期时间)。
4、然后服务器基于base64Url对头部和有效负载进行编码,并使用密匙对他们进行签名。这样就创建了完整的JWT。
5、服务器将JWT作为响应发送回客户机。客户机将在随后的请求中携带这个JWT发送给服务器以获得身份验证。
6、服务器在接收到带有JWT的请求后,尝试通过检查token中的过期时间并创建一个新的签名,然后token中的签名进行对比。

到目前为止,,在讨论JWT时,我们用一个通用的token来表示。但是在实现中,您将发现JWT是使用两个术语AccessToken和RefreshToken来实现。这两个都是JSON web Token,为什么需要两个?我们知道JWT本身包含验证所需的所有信息,假设一个场景,黑客得到了请求中发送的token。黑客可以冒充用户向服务器发送请求。现在黑客可以连续有效地访问服务器了。为了解决这个问题,我们使用两个不同的token。AccessToken是在发送请求用于用户身份验证,但是有过期时间,过期后将权限失效。因此黑客获得AccessToken只能获得短暂的权限。那么现在当AccessToken过期时,可信用户不会也失去访问权限吗?是的,这就是使用RefreshToken的原因。RefreshToken没有任何过期时间,可以使用它从服务器获取新的AccessToken。

总之,在基于用户名和密码成功验证用户身份之后,服务器现在创建两个名为AccessToken和RefreshToken的令牌,并将它们返回给客户机。在后续的请求中客户端将AccessToken发送给服务器进行身份验证。这个AccessToken将在一段时间后过期,在这段时间内客户端可以使用RefreshToken获得一个新的AccessToken。

希望以上理论分析给出了对JWT的理解。但是我建议阅读文章后面的文献来对JWT进行深入的了解。现在我们来看看Go的JWT实现。

Go实现

现在让我们看看在golang中实现JWT身份验证代码。我们将讨论JWT身份验证的主要代码片段,完整代码实现。我们将为用户对象和相应的sql schema使用如下数据模型:

// User is the data type for user object
type User struct {
    ID        string    `json:"id" sql:"id"`
    Email     string    `json:"email" validate:"required" sql:"email"`
    Password  string    `json:"password" validate:"required" sql:"password"`
    Username  string    `json:"username" sql:"username"`
    TokenHash string    `json:"tokenhash" sql:"tokenhash"`
    CreatedAt time.Time `json:"createdat" sql:"createdat"`
    UpdatedAt time.Time `json:"updatedat" sql:"updatedat"`
}

// schema for user table
const schema = `
        create table if not exists users (
            id varchar(36) not null,
            email varchar(225) not null unique,
            username varchar(225),
            password varchar(225) not null,
            tokenhash varchar(15) not null,
            createdat timestamp not null,
            updatedat timestamp not null,
            primary key (id)
        );
`

我们在用户结构上添加了json、validate和sql标签,这有助于我们对用户对象进行编码/解码和验证。让我们从实现认证系统的路由逻辑开始:

// create a serve mux
    sm := mux.NewRouter()

    // register handlers
    postR := sm.Methods(http.MethodPost).Subrouter()
    postR.HandleFunc("/signup", uh.Signup)
    postR.HandleFunc("/login", uh.Login)
    postR.Use(uh.MiddlewareValidateUser)

    // used the PathPrefix as workaround for scenarios where all the
    // get requests must use the ValidateAccessToken middleware except
    // the /refresh-token request which has to use ValidateRefreshToken middleware
    refToken := sm.PathPrefix("/refresh-token").Subrouter()
    refToken.HandleFunc("", uh.RefreshToken)
    refToken.Use(uh.MiddlewareValidateRefreshToken)

    getR := sm.Methods(http.MethodGet).Subrouter()
    getR.HandleFunc("/greet", uh.Greet)
    getR.Use(uh.MiddlewareValidateAccessToken)

这里我们使用gorilla mux包进行路由。我们创建一个新的服务mux,然后为我们将要处理的每个http方法创建子路由器。然后我们在子路由器上注册路由和相应的处理函数。还要注意,用Use()在子路由器上注册了中间件函数。对于子路由器的所有路由上的请求,将首先执行一个中间件函数。

接下来,我们将深入登录和注册处理数的细节。但在此之前,我们先来看看这两个handle函数所使用的中间件函数。

// MiddlewareValidateUser validates the user in the request
func (uh *UserHandler) MiddlewareValidateUser(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        uh.logger.Debug("user json", r.Body)
        user := &data.User{}

        err := data.FromJSON(user, r.Body)
        if err != nil {
            uh.logger.Error("deserialization of user json failed", "error", err)
            w.WriteHeader(http.StatusBadRequest)
            data.ToJSON(&GenericError{Error: err.Error()}, w)
            return
        }

        // validate the user
        errs := uh.validator.Validate(user)
        if len(errs) != 0 {
            uh.logger.Error("validation of user json failed", "error", errs)
            w.WriteHeader(http.StatusBadRequest)
            data.ToJSON(&ValidationError{Errors: errs.Errors()}, w)
            return
        }

        // add the user to the context
        ctx := context.WithValue(r.Context(), UserKey{}, *user)
        r = r.WithContext(ctx)

        // call the next handler
        next.ServeHTTP(w, r)
    })
}

MiddlewareValidateUser函数解析请求体中给出的用户对象,并将数据编码为用户结构。然后我们验证用户结构,这是使用我们提供的验证标记完成的。如果User结构具有所有必需的字段,则将对象添加到请求上下文并调用下一个处理函数(在本例中可能是signup或login)。
现在来看看处理函数:

// Signup handles signup request
func (uh *UserHandler) Signup(w http.ResponseWriter, r *http.Request) {
    user := r.Context().Value(UserKey{}).(data.User)
    hashedPass, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)

    w.Header().Set("Content-Type", "application/json")

    if err != nil {
        uh.logger.Error("unable to hash password", "error", err)
        w.WriteHeader(http.StatusInternalServerError)
        data.ToJSON(&GenericError{Error: err.Error()}, w)
        return
    }

    user.Password = string(hashedPass)
    user.TokenHash = utils.GenerateRandomString(15)

    err = uh.repo.Create(context.Background(), &user)
    if err != nil {
        uh.logger.Error("unable to insert user to database", "error", err)
        errMsg := err.Error()
        if strings.Contains(errMsg, PgDuplicateKeyMsg) {
            w.WriteHeader(http.StatusBadRequest)
            data.ToJSON(&GenericError{Error: ErrUserAlreadyExists}, w)
        } else {
            w.WriteHeader(http.StatusInternalServerError)
            data.ToJSON(&GenericError{Error: errMsg}, w)
        }
        return
    }

    uh.logger.Debug("User created successfully")
    w.WriteHeader(http.StatusCreated)
    data.ToJSON(&GenericMessage{Message: "user created successfully"}, w)
}

上面的是注册处理函数。在请求上下文当中有一个经过验证的user对象。我们对密码进行hash然后存储到数据库中。如果你已经注意到它了想知道TokenHash属性的用途,就是一段长度固定随机字符串,稍后会讨论。因此,这里我们存储给定的用户并返回一个成功响应。

// Login handles login request
func (uh *UserHandler) Login(w http.ResponseWriter, r *http.Request) {

    reqUser := r.Context().Value(UserKey{}).(data.User)

    user, err := uh.repo.GetUserByEmail(context.Background(), reqUser.Email)
    if err != nil {
        uh.logger.Error("error fetching the user", "error", err)
        errMsg := err.Error()
        if strings.Contains(errMsg, PgNoRowsMsg) {
            w.WriteHeader(http.StatusBadRequest)
            data.ToJSON(&GenericError{Error: ErrUserNotFound}, w)
        } else {
            w.WriteHeader(http.StatusInternalServerError)
            data.ToJSON(&GenericError{Error: err.Error()}, w)
        }
        return
    }

    if valid := uh.authService.Authenticate(&reqUser, user); !valid {
        uh.logger.Debug("Authetication of user failed")
        w.WriteHeader(http.StatusBadRequest)
        w.Header().Set("Content-Type", "application/json")
        data.ToJSON(&GenericError{Error: "incorrect password"}, w)
        return
    }

    accessToken, err := uh.authService.GenerateAccessToken(user)
    if err != nil {
        uh.logger.Error("unable to generate access token", "error", err)
        w.WriteHeader(http.StatusInternalServerError)
        w.Header().Set("Content-Type", "application/json")
        data.ToJSON(&GenericError{Error: err.Error()}, w)
        return
    }
    refreshToken, err := uh.authService.GenerateRefreshToken(user)
    if err != nil {
        uh.logger.Error("unable to generate refresh token", "error", err)
        w.WriteHeader(http.StatusInternalServerError)
        w.Header().Set("Content-Type", "application/json")
        data.ToJSON(&GenericError{Error: err.Error()}, w)
        return
    }

    uh.logger.Debug("successfully generated token", "accesstoken", accessToken, "refreshtoken", refreshToken)
    w.WriteHeader(http.StatusOK)
    w.Header().Set("Content-Type", "application/json")
    data.ToJSON(&AuthResponse{AccessToken: accessToken, RefreshToken: refreshToken, Username: user.Username}, w)
}

这是登录处理函数。我们知道,在请求上下文中应该有一个有效的user对象,因为用户验证中间件函数应该在这个函数之前执行。我们首先检查数据库中是否存在带有请求中给定电子邮件的用户,并检索它。然后,请求中的用户(requestUser)和从DB检索的用户(user)被传递到Authenticate函数。让我们看看它在做什么:

func (auth *AuthService) Authenticate(reqUser *data.User, user *data.User) bool {

    if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(reqUser.Password)); err != nil {
        auth.logger.Debug("password hashes are not same")
        return false
    }
    return true
}

这里我们只比较请求中提供的密码和DB中的密码。我们使用一个特殊的哈希比较函数,因为数据库中的密码是以哈希格式存储的。如果密码散列匹配,我们就认为给定的请求凭据是可信的。
一旦登录请求凭证经过验证,我们就可以开始生成AccessToken和RefreshToken了。希望我们记得为什么有两个令牌,下面的代码片段解释了它们是如何生成的:

// GenerateRefreshToken generate a new refresh token for the given user
func (auth *AuthService) GenerateRefreshToken(user *data.User) (string, error) {

    cusKey := auth.GenerateCustomKey(user.ID, user.TokenHash)
    tokenType := "refresh"

    claims := RefreshTokenCustomClaims{
        user.ID,
        cusKey,
        tokenType,
        jwt.StandardClaims{
            Issuer: "bookite.auth.service",
        },
    }

    signBytes, err := ioutil.ReadFile(auth.configs.RefreshTokenPrivateKeyPath)
    if err != nil {
        auth.logger.Error("unable to read private key", "error", err)
        return "", errors.New("could not generate refresh token. please try again later")
    }

    signKey, err := jwt.ParseRSAPrivateKeyFromPEM(signBytes)
    if err != nil {
        auth.logger.Error("unable to parse private key", "error", err)
        return "", errors.New("could not generate refresh token. please try again later")
    }

    token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)

    return token.SignedString(signKey)
}

我们暂且放慢速度,逐行解释下上面的代码。首先,我们创建一个“cusKey”值,它是用用户ID和用户HashToken(存储每个用户时生成的随机字符串)的散列组合。我们将把这个customkey添加到JWT有效负载中,但是为什么呢?因此,正如我们上面所讨论的,为了处理AccessToken可能被黑客攻击的情况,我们添加了一个过期时间戳,使它在特定时间后失效。但是,如果刷新token被黑了怎么办?黑客现在可以使用它获得新的AccessToken 's,并在服务器上执行错误操作。处理这个问题的一种方法是周期性的key旋转,我们改变密钥,这将使旧的标记失效。尽管它是JWT必须实现的东西,但它是周期性的活动,用户不能执行它。我们作为用户,我们知道我们的数据传输被破坏了,我们该如何采取行动呢?不幸的是,JWT让用户无法做到这一点,作为用户,我们只能更改密码,但这对JWT令牌没有影响,刷新令牌将保持有效,直到在服务器中执行key旋转。

为了处理这个特殊的场景,我们将这个customkey值添加到RefreshToken的有效负载中。在验证RefreshToken期间,我们将再次使用用户ID和HashToken创建这个customKey,并将其与RefreshToken有效负载中的customKey进行比较。所以现在如果这个HashToken改变了,那么RefreshToken就会失效。每次用户更改密码时,我们都会用一个新的随机字符串来更改这个HashToken。因此,我们为用户提供了一种通过修改密码来使RequestToken无效的方法。希望我对引入HashToken的原因进行冗长的解释是有意义的,我们将继续讨论下一部分,即创建JWT有效负载。

RefreshTokenCustomClaims构成了我们的有效负载。它由诸如用户id、customkey、tokenttype、颁发者(属于JWT标准声明)等信息组成。注意,我们没有向有效负载添加任何过期时间戳信息。现在我们有了有效负载,头文件将由JWT库生成,接下来我们需要的是签名。我们这里使用的签名方法是RS256。

简而言之,RS256方法遵循公钥密码学,其中我们有两个密钥,公钥和私钥。私钥将用于对令牌进行签名,公钥将用于验证令牌,这与HSA方法不同,在HSA方法中,一个密钥将用于签名和验证。用私钥加密的数据只能用相应的公钥解密,反之亦然。

(注意:在解释JWT时,我们使用HSA方法进行签名。不同的是HSA是基于哈希的方法,RSA是基于加密的方法。为了进一步了解每种方法的优缺点,请阅读下面的参考资料。
linux系统中生成rsa密钥的命令:

  • 生成rsa私钥
openssl genrsa -out auth-private.pem 2048
  • 导出rsa公钥
openssl rsa -in auth-private.pem -outform PEM -pubout -out auth-public.pem

因此,我们从服务器文件系统读取privatekey。现在我们有了创建token所需的所有组件——有效负载、头(指定的签名方法)和签名秘密。我们只需要创建我们的令牌并使用秘密对其进行签名。是的! !我们有了刷新token。

接下来,我们使用类似的方法来创建AccessToken,只需很少的修改,比如使用不同的私钥(不包括customKey的需要)。这里需要注意的重要一点是,我们正在为Accesstoken添加一个过期时间戳。

// GenerateAccessToken generates a new access token for the given user
func (auth *AuthService) GenerateAccessToken(user *data.User) (string, error) {

    userID := user.ID
    tokenType := "access"

    claims := AccessTokenCustomClaims{
        userID,
        tokenType,
        jwt.StandardClaims{
            ExpiresAt: time.Now().Add(time.Minute * time.Duration(auth.configs.JwtExpiration)).Unix(),
            Issuer:    "bookite.auth.service",
        },
    }

    signBytes, err := ioutil.ReadFile(auth.configs.AccessTokenPrivateKeyPath)
    if err != nil {
        auth.logger.Error("unable to read private key", "error", err)
        return "", errors.New("could not generate access token. please try again later")
    }

    signKey, err := jwt.ParseRSAPrivateKeyFromPEM(signBytes)
    if err != nil {
        auth.logger.Error("unable to parse private key", "error", err)
        return "", errors.New("could not generate access token. please try again later")
    }

    token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)

    return token.SignedString(signKey)
}

至此,我们已经介绍了与注册和登录处理函数有关的所有内容。

现在让我们看看如何处理刷新令牌请求。如前所述,我们使用refresh-token在AccessToken过期时获得一个新的AccessToken。因此,我们需要在这里验证给定的RefreshToken并提供一个新的AccessToken。验证部分在在refresh-token子外部注册的MiddlewareValidateRefreshToken中间件函数中执行。

// MiddlewareValidateRefreshToken validates whether the request contains a bearer token
// it also decodes and authenticates the given token
func (uh *UserHandler) MiddlewareValidateRefreshToken(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        uh.logger.Debug("validating refresh token")
        uh.logger.Debug("auth header", r.Header.Get("Authorization"))
        token, err := extractToken(r)
        if err != nil {
            uh.logger.Error("token not provided or malformed")
            w.WriteHeader(http.StatusBadRequest)
            data.ToJSON(&GenericError{Error: err.Error()}, w)
            return
        }
        uh.logger.Debug("token present in header", token)

        userID, customKey, err := uh.authService.ValidateRefreshToken(token)
        if err != nil {
            uh.logger.Error("token validation failed", "error", err)
            w.WriteHeader(http.StatusBadRequest)
            data.ToJSON(&GenericError{Error: err.Error()}, w)
            return
        }
        uh.logger.Debug("refresh token validated")

        user, err := uh.repo.GetUserByID(context.Background(), userID)
        if err != nil {
            uh.logger.Error("invalid token: wrong userID while parsing", err)
            w.WriteHeader(http.StatusBadRequest)
            data.ToJSON(&GenericError{Error: "invalid token: authentication failed"}, w)
            return
        }

        actualCustomKey := uh.authService.GenerateCustomKey(user.ID, user.TokenHash)
        if customKey != actualCustomKey {
            uh.logger.Debug("wrong token: authetincation failed")
            w.WriteHeader(http.StatusBadRequest)
            data.ToJSON(&GenericError{Error: "invalid token: authentication failed"}, w)
            return
        }

        ctx := context.WithValue(r.Context(), UserKey{}, *user)
        r = r.WithContext(ctx)

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

推荐阅读更多精彩内容