如何在Go中正确使用基本认证(详细指南)

36 阅读8分钟

在搜索HTTP基本认证的例子时,我找到的每一个结果都很不幸地包含了过时的代码(即没有使用Go 1.C版本中引入的 r.BasicAuth()的功能),或者不能防止定时攻击。

所以在这篇文章中,我想快速讨论如何在你的Go应用程序中正确实现它。

我们将从一些背景信息开始,但如果你对这些不感兴趣,你可以直接跳到代码中

什么是基本认证?我应该在什么时候使用它?

作为一个开发者,你可能已经熟悉了当你访问一个受保护的URL时,网络浏览器所显示的提示:

当你在这个提示中输入用户名和密码时,网络浏览器会向服务器发送一个包含Authorization 头的HTTP请求--与此相似:

Authorization: Basic YWxpY2U6cGE1NXdvcmQ=

Authorization 头的值是由字符串Basic ,然后是格式为username:password ,并以base-64编码的用户名和密码组成的。在这个具体的例子中,YWxpY2U6cGE1NXdvcmQ= 是数值alice:pa55word 的base-64 编码。

当服务器收到这个请求时,它可以从Authorization 头解码用户名和密码并检查它们是否有效。如果凭证无效,服务器可以返回一个401 Unauthorized 响应,浏览器可以重新显示提示。

基本认证可以用在很多不同的场景中,但当你有一个低价值的资源,并希望用一种快速和简单的方法来保护它不被窥视时,它往往是一个很好的选择。

为了帮助保持安全,你应该:

  • 只在HTTPS连接上使用它:如果你不使用HTTPS,Authorization 标头有可能被攻击者截获并解码,然后他可以使用用户名和密码来访问你的受保护资源。

  • 使用一个攻击者难以猜测或暴力破解的强密码

  • 考虑在你的应用程序中添加速率限制,以使攻击者更难粗暴地使用证书。

还值得指出的是,大多数编程语言和命令行工具(如curlwget )以及网络浏览器都支持基本认证,即开即用。

保护一个网络应用程序

保护你的应用程序的最简单方法是创建一些 中间件.在这个中间件中,我们要做三件事:

  • 从请求Authorization 头中提取用户名和密码,如果它存在的话。最好的方法是使用 r.BasicAuth()方法,该方法在Go 1.4中引入。

  • 将提供的用户名和密码与你期望的值进行比较。为了避免定时攻击的风险,你应该使用Go的 subtle.ConstantTimeCompare()函数来做这个比较。

    注意:在Go中(和大多数语言一样),正常的== 比较运算符一旦发现两个字符串之间有差异就会返回。因此,如果第一个字符是不同的,它将在只看一个字符后返回。从理论上讲,这为定时攻击提供了机会,攻击者可以向你的应用程序发出大量请求,并查看平均响应时间的差异。他们收到401 响应所需的时间可以有效地告诉他们有多少字符是正确的,如果有足够的请求,他们可以建立一个完整的用户名和密码的图片。像网络抖动这样的事情使得这种特定的攻击很难实现,但远程计时攻击已经成为现实,而且在未来可能变得更加可行。鉴于我们可以很容易地通过使用subtle.ConstantTimeCompare() 来防止这种风险,这样做是有意义的。

    同样重要的是要注意,使用subtle.ConstantTimeCompare() ,会泄露用户名和密码的长度信息。为了防止这种情况,我们应该在比较之前使用快速的加密哈希函数(如SHA-256)对提供的和预期的用户名和密码值进行哈希。这可以确保我们所提供的和预期的值在长度上是相等的,并防止subtle.ConstantTimeCompare() 本身提前返回。

  • 如果用户名和密码不正确,或者请求不包含有效的Authorization 头,那么中间件应该发送一个401 Unauthorized 响应并设置一个 WWW-Authenticate头,以告知客户端应使用基本认证来获得访问。否则,中间件应该允许请求继续进行并调用链中的下一个处理程序。

把这些放在一起,实现一些中间件的模式看起来像这样:

func basicAuth(next http.HandlerFunc) http.HandlerFunc {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Extract the username and password from the request 
        // Authorization header. If no Authentication header is present 
        // or the header value is invalid, then the 'ok' return value 
        // will be false.
		username, password, ok := r.BasicAuth()
		if ok {
            // Calculate SHA-256 hashes for the provided and expected
            // usernames and passwords.
			usernameHash := sha256.Sum256([]byte(username))
			passwordHash := sha256.Sum256([]byte(password))
			expectedUsernameHash := sha256.Sum256([]byte("your expected username"))
			expectedPasswordHash := sha256.Sum256([]byte("your expected password"))

            // Use the subtle.ConstantTimeCompare() function to check if 
            // the provided username and password hashes equal the  
            // expected username and password hashes. ConstantTimeCompare
            // will return 1 if the values are equal, or 0 otherwise. 
            // Importantly, we should to do the work to evaluate both the 
            // username and password before checking the return values to 
            // avoid leaking information.
			usernameMatch := (subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1)
			passwordMatch := (subtle.ConstantTimeCompare(passwordHash[:], expectedPasswordHash[:]) == 1)

            // If the username and password are correct, then call
            // the next handler in the chain. Make sure to return 
            // afterwards, so that none of the code below is run.
			if usernameMatch && passwordMatch {
				next.ServeHTTP(w, r)
				return
			}
		}

        // If the Authentication header is not present, is invalid, or the
        // username or password is wrong, then set a WWW-Authenticate 
        // header to inform the client that we expect them to use basic
        // authentication and send a 401 Unauthorized response.
		w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
	})
}

重要提示:你有可能在看了上面的代码后想:"我以为你不应该用SHA-256来散列密码......"。如果你是这样想的,必须强调的是,用户名和密码被散列并不是为了存储,它们被散列只是为了得到两个等长的字节片,可以在恒定时间内进行比较。低碰撞风险是最重要的,而像SHA-256这样的快速散列就足以达到这个目的。

在这里你可能也想知道realm 值是什么,为什么我们要在WWW-Authenticate 响应头中把它设置为"restricted"

基本上,境界值是一个字符串,允许你在你的应用程序中创建受保护空间的分区。因此,例如,一个应用程序可以有一个"documents" 境界和一个"admin area" 境界,这需要不同的凭证。网络浏览器(或其他类型的客户端)可以缓存并自动为同一领域内的任何请求重复使用相同的用户名和密码,这样就不需要为每个请求显示提示。

如果你的应用程序不需要多个分区,你可以将境界设置为一个单一的硬编码值,如"restricted" ,就像我们在上面的代码中那样。

为了安全和/或灵活起见,你可能也喜欢把预期的用户名和密码值存储在环境变量中,或者在启动应用程序时作为命令行标志值传递,而不是把它们硬编码到你的应用程序中。

一个工作实例

让我们在一个小的--但功能齐全的--网络应用程序的背景下快速看一下这个问题。

如果你想跟着做,在你的电脑上创建一个新的basic-auth-example 目录,添加一个main.go 文件,初始化一个模块,并使用mkcert工具创建一对本地信任的TLS证书。像这样:

$ mkdir basic-auth-example
$ cd basic-auth-example
$ touch main.go
$ go mod init example.com/basic-auth-example
go: creating new go.mod: module example.com/basic-auth-example
$ mkcert localhost
Created a new certificate valid for the following names 📜
     - "localhost"
    
    The certificate is at "./localhost.pem" and the key at "./localhost-key.pem" ✅
    
    It will expire on 21 September 2023 🗓
$ ls
go.mod  localhost-key.pem  localhost.pem  main.go

然后在main.go 文件中添加以下代码,这样应用程序就可以从环境变量中读取预期的用户名和密码,并使用我们上面描述的中间件模式。

package main

import (
    "crypto/sha256"
    "crypto/subtle"
    "fmt"
    "log"
    "net/http"
    "os"
    "time"
)

type application struct {
    auth struct {
        username string
        password string
    }
}

func main() {
    app := new(application)

    app.auth.username = os.Getenv("AUTH_USERNAME")
    app.auth.password = os.Getenv("AUTH_PASSWORD")

    if app.auth.username == "" {
        log.Fatal("basic auth username must be provided")
    }

    if app.auth.password == "" {
        log.Fatal("basic auth password must be provided")
    }

    mux := http.NewServeMux()
    mux.HandleFunc("/unprotected", app.unprotectedHandler)
    mux.HandleFunc("/protected", app.basicAuth(app.protectedHandler))

    srv := &http.Server{
        Addr:         ":4000",
        Handler:      mux,
        IdleTimeout:  time.Minute,
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 30 * time.Second,
    }

    log.Printf("starting server on %s", srv.Addr)
    err := srv.ListenAndServeTLS("./localhost.pem", "./localhost-key.pem")
    log.Fatal(err)
}

func (app *application) protectedHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "This is the protected handler")
}

func (app *application) unprotectedHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "This is the unprotected handler")
}

func (app *application) basicAuth(next http.HandlerFunc) http.HandlerFunc {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		username, password, ok := r.BasicAuth()
		if ok {
			usernameHash := sha256.Sum256([]byte(username))
			passwordHash := sha256.Sum256([]byte(password))
			expectedUsernameHash := sha256.Sum256([]byte(app.auth.username))
			expectedPasswordHash := sha256.Sum256([]byte(app.auth.password))

			usernameMatch := (subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1)
			passwordMatch := (subtle.ConstantTimeCompare(passwordHash[:], expectedPasswordHash[:]) == 1)

			if usernameMatch && passwordMatch {
				next.ServeHTTP(w, r)
				return
			}
		}

		w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
	})
}   

然后你应该能够启动应用程序,使用一对临时的AUTH_USERNAMEAUTH_PASSWORD 环境变量像这样:

$ AUTH_USERNAME=alice AUTH_PASSWORD=p8fnxeqj5a7zbrqp go run .
2021/06/20 16:09:21 starting server on :4000

在这一点上,如果你打开你的网络浏览器并访问https://localhost:4000/protected ,你应该会看到基本的认证提示。

另外,你可以使用curl 来做一些请求,以验证验证检查是否正常工作:

$ curl -i https://localhost:4000/unprotected
HTTP/2 200 
content-type: text/plain; charset=utf-8
content-length: 32
date: Sun, 20 Jun 2021 14:09:56 GMT

This is the unprotected handler

$ curl -i https://localhost:4000/protected
HTTP/2 401 
content-type: text/plain; charset=utf-8
www-authenticate: Basic realm="restricted", charset="UTF-8"
x-content-type-options: nosniff
content-length: 13
date: Sun, 20 Jun 2021 14:09:59 GMT

Unauthorized

$ curl -i -u alice:p8fnxeqj5a7zbrqp https://localhost:4000/protected
HTTP/2 200 
content-type: text/plain; charset=utf-8
content-length: 30
date: Sun, 20 Jun 2021 14:10:14 GMT

This is the protected handler

$ curl -i -u alice:wrongPa55word https://localhost:4000/protected
HTTP/2 401 
content-type: text/plain; charset=utf-8
www-authenticate: Basic realm="restricted", charset="UTF-8"
x-content-type-options: nosniff
content-length: 13
date: Sun, 20 Jun 2021 14:15:30 GMT

Unauthorized

向受保护的资源发出请求

最后,当你需要访问一个受保护的资源时,Go让它变得非常简单明了。你所需要做的就是在执行请求之前调用 r.SetBasicAuth()方法,然后再执行你的请求。就像这样:

package main

import (
    "fmt"
    "io"
    "log"
    "net/http"
    "time"
)

func main() {
    client := http.Client{Timeout: 5 * time.Second}

    req, err := http.NewRequest(http.MethodGet, "https://localhost:4000/protected", http.NoBody)
    if err != nil {
        log.Fatal(err)
    }

    req.SetBasicAuth("alice", "p8fnxeqj5a7zbrqp")

    res, err := client.Do(req)
    if err != nil {
        log.Fatal(err)
    }

    defer res.Body.Close()

    resBody, err := io.ReadAll(res.Body)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("Status: %d\n", res.StatusCode)
    fmt.Printf("Body: %s\n", string(resBody))
}