Skip to content

Latest commit

 

History

History
810 lines (593 loc) · 32.6 KB

File metadata and controls

810 lines (593 loc) · 32.6 KB

五、Go 中的模板和选项

随着我们社交网络 web 服务的基础知识的充实,是时候让我们的项目从一个演示玩具变成一些可以实际使用的东西了,也许最终也可以投入生产。

要做到这一点,我们需要关注一些事情,其中一些我们将在本章中讨论。在最后一章中,我们研究了社交网络应用程序的主要功能。现在,我们需要确保从 REST 的角度来看,这些事情都是可能的。

为了实现这一点,在本章中,我们将介绍:

  • 使用OPTIONS提供内置文档和对资源端点用途的 REST 友好解释
  • 考虑替代输出格式并介绍如何实现它们
  • 为我们的 API 实现和强制执行安全性
  • 允许用户注册使用安全密码
  • 允许用户从基于 web 的界面进行身份验证
  • 近似类 OAuth 认证系统
  • 允许外部应用程序代表其他用户发出请求

在实现这些事情之后,我们将有一个服务的基础,允许用户直接通过 API 或通过第三方服务与它进行交互。

分享我们的选择

我们已经暗示了OPTIONSHTTP 动词的价值和用途,因为它与 HTTP 规范和 REST 的最佳实践有关。

根据 RFC 2616,HTTP/1.1 规范,对OPTIONS请求的响应应返回有关客户端可以对资源和/或请求的端点执行哪些操作的信息。

您可以在找到HTTP/1.1****征求意见RFC)https://www.ietf.org/rfc/rfc2616.txt

换句话说,在我们的早期示例中,使用OPTIONS调用/api/users应该返回一个指示,GETPOSTPUTDELETE是该 REST 资源请求中当前可用的选项。

目前,对于正文内容应该类似于什么或包含什么,没有预定义的格式,尽管规范指出这可能会在将来的版本中概述。这给我们提供了一些余地,我们如何提出可用的行动;在大多数情况下,我们都希望尽可能做到稳健和信息丰富。

下面的代码是对我们当前 API 的简单修改,其中包含了一些关于我们前面概述的OPTIONS请求的基本信息。首先,我们将在api.go文件的导出Init()函数中为请求添加特定于方法的处理程序:

func Init() {
  Routes = mux.NewRouter()
  Routes.HandleFunc("/api/users", UserCreate).Methods("POST")
  Routes.HandleFunc("/api/users", UsersRetrieve).Methods("GET")	
  Routes.HandleFunc("/api/users/{id:[0-9]+}",UsersUpdate).Methods("PUT")
  Routes.HandleFunc("/api/users", UsersInfo).Methods("OPTIONS")
}

然后,我们将添加处理程序:

func UsersInfo(w http.ResponseWriter, r *http.Request) {
  w.Header().Set("Allow","DELETE,GET,HEAD,OPTIONS,POST,PUT")
}

用 cURL 直接调用这个函数可以得到我们想要的结果。在下面的屏幕截图中,您会注意到响应顶部的Allow标题:

Sharing our OPTIONS

仅此一点就可以满足基于 REST 的世界中对OPTIONS动词最普遍接受的要求,但请记住,身体没有格式,我们希望尽可能地表达。

我们可以这样做的一种方法是提供一个特定于文档的包;在本例中,它被称为规范。请记住,这是完全可选的,但对于碰巧遇到它的任何开发人员来说,这都是一个不错的选择。让我们来看看我们如何为自订的 API 设置这一点:

package specification
type MethodPOST struct {
  POST EndPoint
}
type MethodGET struct {
  GET EndPoint
}
type MethodPUT struct {
  PUT EndPoint
}
type MethodOPTIONS struct {
  OPTIONS EndPoint
}
type EndPoint struct {
  Description string `json:"description"`
  Parameters []Param `json:"parameters"`
}
type Param struct {
  Name string "json:name"
  ParameterDetails Detail `json:"details"`
}
type Detail struct {
  Type string "json:type"
  Description string `json:"description"`
  Required bool "json:required"
}

var UserOPTIONS = MethodOPTIONS{ OPTIONS: EndPoint{ Description: "This page" } }
var UserPostParameters = []Param{ {Name: "Email", ParameterDetails: Detail{Type:"string", Description: "A new user's email address", Required: false} } }

var UserPOST = MethodPOST{ POST: EndPoint{ Description: "Create a user", Parameters: UserPostParameters } }
var UserGET = MethodGET{ GET: EndPoint{ Description: "Access a user" }}

然后您可以直接在我们的api.go文件中引用。首先,我们将创建一个包含所有可用方法的通用接口切片:

type DocMethod interface {
}

然后我们可以在UsersInfo方法中编译我们的各种方法:

func UsersInfo(w http.ResponseWriter, r *http.Request) {
  w.Header().Set("Allow","DELETE,GET,HEAD,OPTIONS,POST,PUT")

  UserDocumentation := []DocMethod{}
  UserDocumentation = append(UserDocumentation, Documentation.UserPOST)
  UserDocumentation = append(UserDocumentation, Documentation.UserOPTIONS)
  output := SetFormat(UserDocumentation)
  fmt.Fprintln(w,string(output))
}

您的屏幕应与此类似:

Sharing our OPTIONS

实现替代格式

看看 API 格式的世界,您现在知道有两大玩家:XMLJSON。作为人类可读的格式,这两种格式已经拥有格式世界十多年了。

就像通常的情况一样,开发人员和技术专家很少会为某件事情高兴地长久。在编码和解码的计算复杂性以及模式的冗长性将许多开发人员推向 JSON 之前,XML 一直是第一位的。

JSON 也并非没有缺点。如果没有明确的间距,人们就无法阅读,这会过度增加文档的大小。默认情况下,它也不处理注释。

有多种替代格式处于观望状态。YAML代表YAML 不是标记语言,是一种以空格分隔的格式,它使用缩进使人类非常可读。示例文档如下所示:

---
api:
  name: Social Network
  methods:
    - GET
    - POST
    - PUT
    - OPTIONS
    - DELETE

缩进系统作为一种模拟代码块的方法,对任何有 Python 经验的人来说都很熟悉。

提示

有许多用于 Go 的 YAML 实现。最值得注意的是go-yaml,可在上找到 https://github.com/go-yaml/yaml

TOMLTom 显而易见的简约语言所采用的方法对于任何使用过.ini风格配置文件的人来说都非常熟悉。

滚动我们自己的数据表示格式

在构建我们自己的数据格式方面,TOML 是一种很好的格式,主要是因为它的简单性使它能够以多种方式在这种格式内完成输出。

在设计像 TOML 这样简单的东西时,您可能会立即想到 Go 的文本模板格式,因为呈现它的控制机制基本上是固有的。以这种结构和循环为例:

type GenericData struct {
  Name string
  Options GenericDataBlock
}

type GenericDataBlock struct {
  Server string
  Address string
}

func main() {
  Data := GenericData{ Name: "Section", Options: GenericDataBlock{Server: "server01", Address: "127.0.0.1"}}

}

而且,当根据文本模板解析结构时,它将精确地生成我们想要的内容,如下所示:{{.Name}}

{{range $index, $value := Options}}
  $index = $value
{{end}}

这种方法的一个大问题是,您没有用于解组数据的固有系统。换句话说,您可以以这种格式生成数据,但不能以另一种方式将其分解回 Go 结构。

另一个问题是,随着格式复杂性的增加,使用 Go 模板库中有限的控制结构来实现这种格式的所有复杂性和怪癖变得不太合理。

如果您选择滚动您自己的格式,您应该避免使用文本模板,并查看允许您生成和使用结构化数据格式的编码包。

我们将在下一章详细介绍编码包。

引入安全和认证

任何 web 服务或 API 的一个关键方面是能够确保信息安全,并且只允许特定用户访问特定的内容。

历史上,有许多方法可以实现这一点,最早的方法之一是 HTTP 摘要认证。

另一个常见的是包含开发人员凭据,即 API 密钥。这不再被推荐了,主要是因为 API 的安全性完全依赖于这些凭证的安全性。但是,它在很大程度上是一种不言而喻的方法,用于允许身份验证,并且作为服务提供商,它允许您跟踪谁在发出特定请求,并且它还允许限制请求。

今天的大玩家是 OAuth,我们将很快看到这一点。然而,首先,我们需要确保我们的 API 只能通过 HTTPS 访问。

强制 HTTPS

在这一点上,我们的 API 开始让客户端和用户做一些事情,即创建用户、更新他们的数据,并包括这些用户的图像数据。我们开始涉足一些我们不想在现实环境中公开的东西。

我们可以看到的第一个安全步骤是在 API 上强制使用 HTTPS 而不是 HTTP。Go 通过 TLS 而不是 SSL 实现 HTTPS,因为 TLS 被认为是服务器端更安全的协议。驱动因素之一是 SSL 3.0 中的漏洞,特别是 2014 年暴露的 Poodlebleed 漏洞。

提示

您可以在上阅读更多关于 Poodlebleed 的 https://poodlebleed.com/

让我们看看如何在以下代码中将任何非安全请求重新路由到其安全对位:

package main

import
(
  "fmt"
  "net/http"
  "log"
  "sync"
)

const (
  serverName = "localhost"
  SSLport = ":443"
  HTTPport = ":8080"
  SSLprotocol = "https://"
  HTTPprotocol = "http://"
)

func secureRequest(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintln(w,"You have arrived at port 443, but you are not yet secure.")
}

这是我们(暂时)正确的端点。它还不是 TSL(或 SSL),所以我们实际上并没有监听 HTTPS 连接,因此消息是这样的。

func redirectNonSecure(w http.ResponseWriter, r *http.Request) {
  log.Println("Non-secure request initiated, redirecting.")
  redirectURL := SSLprotocol + serverName + r.RequestURI
  http.Redirect(w, r, redirectURL, http.StatusOK)
}

这是我们的重定向处理程序。您可能会注意到http.StatusOK状态代码,显然我们希望发送一个 301 永久移动错误(或http.StatusMovedPermanently常量)。但是,如果您正在测试,您的浏览器可能会缓存状态并自动尝试重定向您。

func main() {
  wg := sync.WaitGroup{}
  log.Println("Starting redirection server, try to access @ http:")

  wg.Add(1)
  go func() {
    http.ListenAndServe(HTTPport,http.HandlerFunc(redirectNonSecure))
    wg.Done()
  }()
  wg.Add(1)
  go func() {
    http.ListenAndServe(SSLport,http.HandlerFunc(secureRequest))
    wg.Done()
  }()
  wg.Wait()
}

那么,为什么我们要将这些方法包装在匿名 goroutine 中呢?好吧,把它们拿出来,你会看到,因为ListenAndServe函数是阻塞的,我们永远不会通过调用以下语句来同时运行这两个函数:

http.ListenAndServe(HTTPport,http.HandlerFunc(redirectNonSecure))
http.ListenAndServe(SSLport,http.HandlerFunc(secureRequest))

当然,在这方面你有选择。您可以简单地将第一个设置为 goroutine,这将允许程序移动到第二个服务器。此方法提供了一些更细粒度的控件,用于演示。

增加 TLS 支持

在前面的示例中,我们显然没有监听 HTTPS 连接。Go 让这变得很容易;但是,与大多数 SSL/TLS 一样,在处理证书时会出现复杂性。

对于这些示例,我们将使用自签名证书,Go 也使其变得简单。在crypto/tls包中,有一个名为generate_cert.go的文件,可用于生成证书密钥。

通过导航到您的 Go 二进制目录,然后是src/pkg/crypto/tls,您可以生成一个密钥对,通过运行以下命令,您可以使用该密钥对进行测试:

go run generate_cert.go --host localhost --ca true

然后,您可以将这些文件移动到任何您想要的地方,理想情况下是在运行 API 的目录中。

接下来,我们移除http.ListenAndServe函数并将其更改为http.ListenAndServeTLS。这需要包含关键点位置的两个附加参数:

http.ListenAndServeTLS(SSLport, "cert.pem", "key.pem", http.HandlerFunc(secureRequest))

为了更加明确,我们也稍微修改一下secureRequest处理程序:

fmt.Fprintln(w,"You have arrived at port 443, and now you are marginally more secure.")

如果我们现在运行此程序并转到浏览器,我们希望看到一个警告,假设我们的浏览器可以保证我们的安全:

Adding TLS support

假设我们信任自己,这并不总是可取的,点击这里,我们将看到来自安全处理程序的消息:

Adding TLS support

当然,如果我们再次访问http://localhost:8080,我们现在应该会被自动重定向到 301 状态码。

当您可以访问支持 OpenSSL 的操作系统时,创建自签名证书就相当容易了。

如果您想尝试使用真实的证书而不是自签名的证书,您可以通过一系列服务免费获得一份为期一年的签名(但未经验证)证书。其中比较流行的是 StartSSL(https://www.startssl.com/ ),这使得获得免费和付费证书成为一个无痛的过程。

让用户注册和认证

您可能还记得,作为 API 应用程序的一部分,我们有一个自包含的接口,允许我们为 API 本身提供 HTML 接口。如果我们不锁定我们的用户,任何关于安全的讨论都会消失。

当然,实现用户身份验证安全性的最简单方法是通过存储和使用带有散列机制的密码。服务器以明文形式存储密码是很常见的,所以我们不会这样做;但是,我们希望在密码中至少实现一个附加的安全参数。

我们不仅要存储用户的密码,还要至少存储一个密码。这不是一个万无一失的安全措施,尽管它严格限制了字典和彩虹攻击的威胁。

为此,我们将创建一个名为password的新包作为套件的一部分,它允许我们生成随机盐,然后将该值与密码一起加密。

我们可以使用GenerateHash()创建和验证密码。

快速命中-产生盐

获取密码很简单,创建安全哈希也相当容易。为了使身份验证过程更加安全,我们缺少的是一个 salt。让我们看看如何做到这一点。首先,让我们向数据库中添加密码和盐域:

ALTER TABLE `users`
  ADD COLUMN `user_password` VARCHAR(1024) NOT NULL AFTER `user_nickname`,
  ADD COLUMN `user_salt` VARCHAR(128) NOT NULL AFTER `user_password`,
  ADD INDEX `user_password_user_salt` (`user_password`, `user_salt`);

有了这个,让我们看看我们的密码包,其中包含了盐和散列生成函数:

package password

import
(
  "encoding/base64"
  "math/rand"
  "crypto/sha256"
  "time"
)

const randomLength = 16

func GenerateSalt(length int) string {
  var salt []byte
  var asciiPad int64

  if length == 0 {
    length = randomLength
  }

  asciiPad = 32

  for i:= 0; i < length; i++ {
    salt = append(salt, byte(rand.Int63n(94) + asciiPad) )
  }

  return string(salt)
}

我们的GenerateSalt()函数在一组字符中生成一个随机字符串。在这种情况下,我们希望从 ASCII 表中的 32 开始,然后上升到 126。

func GenerateHash(salt string, password string) string {
  var hash string
  fullString := salt + password
  sha := sha256.New()
  sha.Write([]byte(fullString))
  hash = base64.URLEncoding.EncodeToString(sha.Sum(nil))

  return hash
}

这里,我们根据密码和 salt 生成一个哈希。这不仅对创建密码很有用,而且对验证密码也很有用。以下ReturnPassword()函数主要用作其他函数的包装器,允许您创建密码并返回其哈希值:

func ReturnPassword(password string) (string, string) {
  rand.Seed(time.Now().UTC().UnixNano())

  salt := GenerateSalt(0)

  hash := GenerateHash(salt,password)

  return salt, hash
}

在我们的客户端,您可能还记得我们在 jQuery 中通过 AJAX 发送了所有数据。我们在一个引导选项卡上有一个方法,允许我们创建用户。首先,让我们提醒自己选项卡设置。

现在是userCreate()函数,我们在其中添加了一些东西。首先,有一个密码字段,允许我们在创建用户时发送该密码。在没有安全连接的情况下,我们以前可能不太愿意这样做:

  function userCreate() {
    action = "https://localhost/api/users";
    postData = {};
    postData.email = $('#createEmail').val();
    postData.user = $('#createUsername').val();
    postData.first = $('#createFirst').val();
    postData.last= $('#createLast').val();
    postData.password = $('#createPassword').val();

接下来,我们可以修改.ajax响应以响应不同的 HTTP 状态代码。请记住,如果用户名或电子邮件 ID 已经存在,我们已经设置了冲突。那么,让我们也来处理这个问题:

var formData = new FormData($('form')[0]);
$.ajax({

    url: action,  //Server script to process data
    dataType: 'json',
    type: 'POST',
    statusCode: {
      409: function() {
        $('#api-messages').html('Email address or nickname already exists!');
        $('#api-messages').removeClass('alert-success').addClass('alert-warning');
        $('#api-messages').show();
        },
      200: function() {
        $('#api-messages').html('User created successfully!');
        $('#api-messages').removeClass('alert-warning').addClass('alert-success');
        $('#api-messages').show();
        }
      },

现在,如果我们得到 200 的响应,我们就知道我们的 API 端已经创建了用户。如果我们得到 409,我们将向用户报告电子邮件地址或用户名在警报区域中。

在 Go 中检查 OAuth

正如我们在第 4 章中简要所述,在 Go中设计 API,OAuth 是允许应用程序使用另一个应用程序的用户身份验证与第三方应用程序交互的更常见的方法之一。

它在社交媒体服务中非常流行;Facebook、Twitter 和 GitHub 都使用 oauth2.0 来允许应用程序代表用户与它们的 api 接口。

这里值得注意的是,虽然有许多 API 调用我们可以轻松地不受限制,主要是GET请求,但还有一些特定于用户的请求,我们需要确保我们的用户授权这些请求。

让我们快速回顾一下我们可以实现的方法,以便在服务器上启用类似于 OAuth 的东西:

Endpoint
/api/oauth/authorize
/api/oauth/token
/api/oauth/revoke

考虑到我们有一个小型的、主要基于演示的服务,我们长期保持访问令牌活动的风险是最小的。长寿命访问令牌显然为不必要的访问打开了更多的机会,使所述访问向可能不遵守最佳安全协议的客户端开放。

在正常情况下,我们希望在令牌上设置一个到期时间,这可以通过使用 memcache 系统或具有到期时间的密钥库来完成。这允许值自然消亡,而不必显式地破坏它们。

我们需要做的第一件事是添加一个用于客户端凭据的表,即consumer_keyconsumer_token

CREATE TABLE `api_credentials` (
  `user_id` INT(10) UNSIGNED NOT NULL,
  `consumer_key` VARCHAR(128) NOT NULL,
  `consumer_secret` VARCHAR(128) NOT NULL,
  `callback_url` VARCHAR(256) NOT NULL
  CONSTRAINT `FK__users` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON UPDATE NO ACTION ON DELETE NO ACTION
)

我们将根据新创建的数据库检查详细信息以验证凭据,如果凭据正确,我们将返回一个访问令牌。

接入令牌可以是任何格式;鉴于演示的安全性限制较低,我们将返回随机生成的字符串的 MD5 哈希。在现实世界中,即使对于一个短暂的代币来说,这可能也不够,但在这里它会起到作用。

提示

记住,我们在password包中实现了一个随机字符串生成器。您可以通过调用以下语句在api.go中创建快速密钥和秘密值:

  fmt.Println(Password.GenerateSalt(22))
  fmt.Println(Password.GenerateSalt(41))

如果您将此密钥和机密值馈送到先前创建的表中,并将其与现有用户关联,那么您将拥有一个活动的 API 客户端。请注意,这可能会生成无效的 URL 字符,因此我们将把对/oauth/token端点的访问限制为POST

我们的伪 OAuth 机制将进入它自己的包中,它将严格地生成令牌,我们将把这些令牌保存在 API 包中的令牌片段中。

在我们的核心 API 包中,我们将添加两个新函数来验证凭据和pseudoauth包:

  import(
  Pseudoauth "github.com/nkozyra/gowebservice/pseudoauth" 
  )

我们将添加的函数是CheckCredentials()CheckToken()。第一个将接受一个密钥、一个 nonce、一个时间戳和一个加密方法,然后我们将其与[T2]值一起散列,以查看签名是否匹配。本质上,所有这些请求参数都与互知但未加载的秘密相结合,以创建以互知方式散列的签名。如果这些签名对应,应用程序可以发出请求令牌或访问令牌(后者通常是为了交换请求令牌而发出的,我们稍后将对此进行详细讨论)。

在我们的例子中,我们将接受一个consumer_key值、一个 nonce、一个时间戳和一个签名,并且暂时假设 HMAC-SHA1 被用作签名方法。SHA1 由于碰撞的可行性增加而失去了一些优势,但对于开发应用程序而言,它将是可行的,并且可以在以后简单地替换。Go 还提供了现成的 SHA224、SHA256、SH384 和 SH512。

nonce 和 timestamp 的目的是专门增加安全性。nonce 几乎可以肯定地作为请求的唯一标识哈希,时间戳允许我们定期使数据过期,以保留内存和/或存储。我们不打算在这里这样做,尽管我们将检查以确保以前没有使用过 nonce。

为了开始验证客户端,我们在数据库中查找共享机密:

func CheckCredentials(w http.ResponseWriter, r *http.Request)  {
  var Credentials string
  Response := CreateResponse{}
  consumerKey := r.FormValue("consumer_key")
  fmt.Println(consumerKey)
  timestamp := r.FormValue("timestamp")
  signature := r.FormValue("signature")
  nonce := r.FormValue("nonce")
  err := Database.QueryRow("SELECT consumer_secret from api_credentials where consumer_key=?", consumerKey).Scan(&Credentials)
    if err != nil {
    error, httpCode, msg := ErrorMessages(404)
    log.Println(error)	
    log.Println(w, msg, httpCode)
    Response.Error = msg
    Response.ErrorCode = httpCode
    http.Error(w, msg, httpCode)
    return
  }

在这里,我们获取consumer_key值并查找我们的共享consumer_secret令牌,将传递给我们的ValidateSignature函数,如下所示:

  token,err := Pseudoauth.ValidateSignature(consumerKey,Credentials,timestamp,nonce,signature,0)
  if err != nil {
    error, httpCode, msg := ErrorMessages(401)
    log.Println(error)	
    log.Println(w, msg, httpCode)
    Response.Error = msg
    Response.ErrorCode = httpCode
    http.Error(w, msg, httpCode)
    return
  }

如果我们发现我们的请求无效(由于不正确的凭据或现有的 nonce),我们将返回一个未经授权的错误和 401 状态代码:

  AccessRequest := OauthAccessResponse{}
  AccessRequest.AccessToken = token.AccessToken
  output := SetFormat(AccessRequest)
  fmt.Fprintln(w,string(output))
}

否则,我们将在 JSON 主体响应中返回访问代码。以下是pseudoauth包本身的代码:

package pseudoauth
import
(
  "crypto/hmac"
  "crypto/sha1"
  "errors"
  "fmt"
  "math/rand"
  "strings"
  "time"
)

这没什么好奇怪的!我们需要一些加密软件包和math/rand来允许我们播种:

type Token struct {
  Valid bool
  Created int64
  Expires int64
  ForUser int
  AccessToken string
}

这里的比我们现在使用的要多一些,但是您可以看到我们可以创建具有特定访问权限的令牌:

var nonces map[string] Token
func init() {
  nonces = make(map[string] Token)
}

func ValidateSignature(consumer_key string, consumer_secret string, timestamp string,  nonce string, signature string, for_user int) (Token, error) {
  var hashKey []byte
  t := Token{}
  t.Created = time.Now().UTC().Unix()
  t.Expires = t.Created + 600
  t.ForUser = for_user

  qualifiedMessage := []string{consumer_key, consumer_secret, timestamp, nonce}
  fullyQualified := strings.Join(qualifiedMessage," ")

  fmt.Println(fullyQualified)
  mac := hmac.New(sha1.New, hashKey)
  mac.Write([]byte(fullyQualified))
  generatedSignature := mac.Sum(nil)

  //nonceExists := nonces[nonce]

  if hmac.Equal([]byte(signature),generatedSignature) == true {

    t.Valid = true
    t.AccessToken = GenerateToken()
    nonces[nonce] = t
    return t, nil
  } else {
    err := errors.New("Unauthorized")
    t.Valid = false
    t.AccessToken = ""
    nonces[nonce] = t
    return t, err
  }

}

这是 OAuth 等服务如何尝试验证已签名请求的粗略近似;nonce、公钥、时间戳和共享私钥使用相同的加密进行计算。如果它们匹配,则请求有效。如果它们不匹配,则应返回一个错误。

我们可以稍后使用时间戳为任何给定的请求提供一个短窗口,以便在意外签名泄漏的情况下,可以将损坏降至最低:

func GenerateToken() string {
  var token []byte
  rand.Seed(time.Now().UTC().UnixNano())
  for i:= 0; i < 32; i++ {
    token = append(token, byte(rand.Int63n(74) + 48) )
  }
  return string(token)
}

代表用户提出请求

当代表用户发出请求时,OAuth2 过程中涉及到一个关键的中间步骤,即用户的身份验证。显然,这不会发生在消费者应用程序中,因为这会带来安全风险,用户凭据可能会受到恶意或非恶意的破坏。

因此,这个过程需要一些重定向。

首先,需要将用户重定向到登录位置的初始请求。如果他们已经登录,他们将能够授予对应用程序的访问权限。接下来,我们的服务将获取一个回调 URL,并将用户与其请求令牌一起发送回去。这将允许第三方应用程序代表用户发出请求,除非用户限制对第三方应用程序的访问。

为了存储有效令牌(本质上是用户和第三方开发人员之间的许可连接),我们将为此创建一个数据库:

CREATE TABLE `api_tokens` (
  `api_token_id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  `application_user_id` INT(10) UNSIGNED NOT NULL,
  `user_id` INT(10) UNSIGNED NOT NULL,
  `api_token_key` VARCHAR(50) NOT NULL,
  PRIMARY KEY (`api_token_id`)
)

我们需要一些东西来完成这项工作,首先,通过依赖sessions表,为当前未登录的用户提供一个登录表单。现在让我们在 MySQL 中创建一个非常简单的实现:

CREATE TABLE `sessions` (
  `session_id` VARCHAR(128) NOT NULL,
  `user_id` INT(10) NOT NULL,
  UNIQUE INDEX `session_id` (`session_id`)
)

接下来,我们需要一个登录用户的授权表单,它允许我们为用户和服务创建一个有效的 API 访问令牌,并将用户重定向到回调。

该模板可以是一个非常简单的 HTML 模板,可以放在/authorize上。因此,我们需要将该路线添加到api.go

  Routes.HandleFunc("/authorize", ApplicationAuthorize).Methods("POST")
  Routes.HandleFunc("/authorize", ApplicationAuthenticate).Methods("GET")

POST的请求将检查确认,如果一切正常,则通过此:

<!DOCTYPE html>
<html>
  <head>
    <title>{{.Title}}</title>
  </head>
  <body>
  {{if .Authenticate}}
      <h1>{{.Title}}</h1>
      <form action="{{.Action}}" method="POST">
      <input type="hidden" name="consumer_key" value="{.ConsumerKey}" />
      Log in here
      <div><input name="username" type="text" /></div>
      <div><input name="password" type="password" /></div>
      Allow {{.Application}} to access your data?
      <div><input name="authorize" value="1" type="radio"> Yes</div>
      <div><input name="authorize" value="0" type="radio"> No</div>
      <input type="submit" value="Login" />
  {{end}}
  </form>
  </body>
</html>

Go 的模板语言在很大程度上(但不是完全)没有逻辑。我们可以使用 if控制结构将两个页面的 HTML 代码保存在一个模板中。为了简洁起见,我们还将创建一个非常简单的[T1]结构,它允许我们构建非常基本的响应页面:

type Page struct {
  Title string
  Authorize bool
  Authenticate bool
  Application string
  Action string
  ConsumerKey string
}

我们现在不打算维护登录状态,这意味着每个用户都需要在任何时候登录,只要他们希望给予第三方访问权,以代表他们发出 API 请求。我们将在此过程中进行微调,特别是在使用 Gorilla 工具包中提供的安全会话数据和 cookie 时。

因此,第一个请求将包括一个带有consumer_key值的登录尝试,以识别应用程序。您还可以在此处包含完整凭据(nonce 等),但由于这只允许您的应用程序访问单个用户,因此可能没有必要这样做。

func ApplicationAuthenticate(w http.ResponseWriter, r *http.Request) {
  Authorize := Page{}
  Authorize.Authenticate = true
  Authorize.Title = "Login"
  Authorize.Application = ""
  Authorize.Action = "/authorize"

  tpl := template.Must(template.New("main").ParseFiles("authorize.html"))
  tpl.ExecuteTemplate(w, "authorize.html", Authorize)
}

所有请求都将被发送到同一地址,这将允许我们验证登录凭据(记住我们的password包中的 GenerateHash(),如果它们有效,我们将在api_connections中创建连接,然后将用户返回到与 API 凭据关联的回调 URL。

以下是确定登录凭据是否正确的函数,如果正确,则使用我们创建的request_token值重定向到回调 URL:

func ApplicationAuthorize(w http.ResponseWriter, r *http.Request) {

  username := r.FormValue("username")
  password := r.FormValue("password")
  allow := r.FormValue("authorize")

  var dbPassword string
  var dbSalt string
  var dbUID string

  uerr := Database.QueryRow("SELECT user_password, user_salt, user_id from users where user_nickname=?", username).Scan(&dbPassword, &dbSalt, &dbUID)
  if uerr != nil {

  }

使用user_password值、user_salt值和提交的密码值,我们可以使用GenerateHash()函数并进行直接比较来验证密码的有效性,因为它们是 Base64 编码的。

  consumerKey := r.FormValue("consumer_key")
  fmt.Println(consumerKey)

  var CallbackURL string
  var appUID string
  err := Database.QueryRow("SELECT user_id,callback_url from api_credentials where consumer_key=?", consumerKey).Scan(&appUID, &CallbackURL)
  if err != nil {

    fmt.Println(err.Error())
    return
  }

  expectedPassword := Password.GenerateHash(dbSalt, password)
  if dbPassword == expectedPassword && allow == "1" {

    requestToken := Pseudoauth.GenerateToken()

    authorizeSQL := "INSERT INTO api_tokens set application_user_id=" + appUID + ", user_id=" + dbUID + ", api_token_key='" + requestToken + "' ON DUPLICATE KEY UPDATE user_id=user_id"

    q, connectErr := Database.Exec(authorizeSQL)
    if connectErr != nil {

    } else {
      fmt.Println(q)
    }
    redirectURL := CallbackURL + "?request_token=" + requestToken
    fmt.Println(redirectURL)
    http.Redirect(w, r, redirectURL, http.StatusAccepted)

expectedPassword与数据库中的密码进行核对后,可以判断用户的身份验证是否正确。如果他们这样做了,我们将创建令牌并将用户重定向回回调 URL。然后,另一个应用程序负责存储令牌以备将来使用。

  } else {

    fmt.Println(dbPassword, expectedPassword)
    http.Redirect(w, r, "/authorize", http.StatusUnauthorized)
  }

}

现在我们在第三方拥有了令牌,我们可以使用该令牌和client_token值发出 API 请求,以代表单个用户发出请求,例如创建连接(朋友和追随者)、发送自动消息或设置状态更新。

总结

本章开始时,我们研究了引入更多 REST 风格选项和功能、更好的安全性和基于模板的演示的方法。为了实现这一目标,我们研究了 OAuth 安全模型的一个基本抽象,该模型允许我们使外部客户机能够在用户的域中工作。

现在,我们的应用程序可以通过 OAuth 风格的身份验证进行访问,并通过 HTTPS 进行保护,我们现在可以扩展社交网络应用程序的第三方集成,允许其他开发人员利用和扩展我们的服务。

在下一章中,我们将详细介绍应用程序的客户端和消费者端,扩展 OAuth 选项,并通过 API 支持更多操作,包括创建和删除用户之间的连接以及创建状态更新。