构建一个即时消息应用(二):OAuth | Linux 中国

2019 年 10 月 28 日 Linux中国
在这篇帖子中,我们将会通过为应用添加社交登录功能进入后端开发。
-- Nicolás Parada

上一篇:模式

在这篇帖子中,我们将会通过为应用添加社交登录功能进入后端开发。

社交登录的工作方式十分简单:用户点击链接,然后重定向到 GitHub 授权页面。当用户授予我们对他的个人信息的访问权限之后,就会重定向回登录页面。下一次尝试登录时,系统将不会再次请求授权,也就是说,我们的应用已经记住了这个用户。这使得整个登录流程看起来就和你用鼠标单击一样快。

如果进一步考虑其内部实现的话,过程就会变得复杂起来。首先,我们需要注册一个新的 GitHub OAuth 应用

这一步中,比较重要的是回调 URL。我们将它设置为 http://localhost:3000/api/oauth/github/callback。这是因为,在开发过程中,我们总是在本地主机上工作。一旦你要将应用交付生产,请使用正确的回调 URL 注册一个新的应用。

注册以后,你将会收到“客户端 id”和“安全密钥”。安全起见,请不要与任何人分享他们 👀

顺便让我们开始写一些代码吧。现在,创建一个 main.go 文件:

   
   
     
  1. package main
  2. import (
  3. "database/sql"
  4. "fmt"
  5. "log"
  6. "net/http"
  7. "net/url"
  8. "os"
  9. "strconv"
  10. "github.com/gorilla/securecookie"
  11. "github.com/joho/godotenv"
  12. "github.com/knq/jwt"
  13. _ "github.com/lib/pq"
  14. "github.com/matryer/way"
  15. "golang.org/x/oauth2"
  16. "golang.org/x/oauth2/github"
  17. )
  18. var origin *url.URL
  19. var db *sql.DB
  20. var githubOAuthConfig *oauth2.Config
  21. var cookieSigner *securecookie.SecureCookie
  22. var jwtSigner jwt.Signer
  23. func main() {
  24. godotenv.Load()
  25. port := intEnv("PORT", 3000)
  26. originString := env("ORIGIN", fmt.Sprintf("http://localhost:%d/", port))
  27. databaseURL := env("DATABASE_URL", "postgresql://root@127.0.0.1:26257/messenger?sslmode=disable")
  28. githubClientID := os.Getenv("GITHUB_CLIENT_ID")
  29. githubClientSecret := os.Getenv("GITHUB_CLIENT_SECRET")
  30. hashKey := env("HASH_KEY", "secret")
  31. jwtKey := env("JWT_KEY", "secret")
  32. var err error
  33. if origin, err = url.Parse(originString); err != nil || !origin.IsAbs() {
  34. log.Fatal("invalid origin")
  35. return
  36. }
  37. if i, err := strconv.Atoi(origin.Port()); err == nil {
  38. port = i
  39. }
  40. if githubClientID == "" || githubClientSecret == "" {
  41. log.Fatalf("remember to set both $GITHUB_CLIENT_ID and $GITHUB_CLIENT_SECRET")
  42. return
  43. }
  44. if db, err = sql.Open("postgres", databaseURL); err != nil {
  45. log.Fatalf("could not open database connection: %v\n", err)
  46. return
  47. }
  48. defer db.Close()
  49. if err = db.Ping(); err != nil {
  50. log.Fatalf("could not ping to db: %v\n", err)
  51. return
  52. }
  53. githubRedirectURL := *origin
  54. githubRedirectURL.Path = "/api/oauth/github/callback"
  55. githubOAuthConfig = &oauth2.Config{
  56. ClientID: githubClientID,
  57. ClientSecret: githubClientSecret,
  58. Endpoint: github.Endpoint,
  59. RedirectURL: githubRedirectURL.String(),
  60. Scopes: []string{"read:user"},
  61. }
  62. cookieSigner = securecookie.New([]byte(hashKey), nil).MaxAge(0)
  63. jwtSigner, err = jwt.HS256.New([]byte(jwtKey))
  64. if err != nil {
  65. log.Fatalf("could not create JWT signer: %v\n", err)
  66. return
  67. }
  68. router := way.NewRouter()
  69. router.HandleFunc("GET", "/api/oauth/github", githubOAuthStart)
  70. router.HandleFunc("GET", "/api/oauth/github/callback", githubOAuthCallback)
  71. router.HandleFunc("GET", "/api/auth_user", guard(getAuthUser))
  72. log.Printf("accepting connections on port %d\n", port)
  73. log.Printf("starting server at %s\n", origin.String())
  74. addr := fmt.Sprintf(":%d", port)
  75. if err = http.ListenAndServe(addr, router); err != nil {
  76. log.Fatalf("could not start server: %v\n", err)
  77. }
  78. }
  79. func env(key, fallbackValue string) string {
  80. v, ok := os.LookupEnv(key)
  81. if !ok {
  82. return fallbackValue
  83. }
  84. return v
  85. }
  86. func intEnv(key string, fallbackValue int) int {
  87. v, ok := os.LookupEnv(key)
  88. if !ok {
  89. return fallbackValue
  90. }
  91. i, err := strconv.Atoi(v)
  92. if err != nil {
  93. return fallbackValue
  94. }
  95. return i
  96. }

安装依赖项:

   
   
     
  1. go get -u github.com/gorilla/securecookie
  2. go get -u github.com/joho/godotenv
  3. go get -u github.com/knq/jwt
  4. go get -u github.com/lib/pq
  5. ge get -u github.com/matoous/go-nanoid
  6. go get -u github.com/matryer/way
  7. go get -u golang.org/x/oauth2

我们将会使用 .env 文件来保存密钥和其他配置。请创建这个文件,并保证里面至少包含以下内容:

   
   
     
  1. GITHUB_CLIENT_ID=your_github_client_id
  2. GITHUB_CLIENT_SECRET=your_github_client_secret

我们还要用到的其他环境变量有:

◈  PORT:服务器运行的端口,默认值是  3000
◈  ORIGIN:你的域名,默认值是  http://localhost:3000/。我们也可以在这里指定端口。
◈  DATABASE_URL:Cockroach 数据库的地址。默认值是  postgresql://root@127.0.0.1:26257/messenger?sslmode=disable
◈  HASH_KEY:用于为 cookie 签名的密钥。没错,我们会使用已签名的 cookie 来确保安全。
◈  JWT_KEY:用于签署 JSON 网络令牌Web Token的密钥。

因为代码中已经设定了默认值,所以你也不用把它们写到 .env 文件中。

在读取配置并连接到数据库之后,我们会创建一个 OAuth 配置。我们会使用 ORIGIN 信息来构建回调 URL(就和我们在 GitHub 页面上注册的一样)。我们的数据范围设置为 “read:user”。这会允许我们读取公开的用户信息,这里我们只需要他的用户名和头像就够了。然后我们会初始化 cookie 和 JWT 签名器。定义一些端点并启动服务器。

在实现 HTTP 处理程序之前,让我们编写一些函数来发送 HTTP 响应。

   
   
     
  1. func respond(w http.ResponseWriter, v interface{}, statusCode int) {
  2. b, err := json.Marshal(v)
  3. if err != nil {
  4. respondError(w, fmt.Errorf("could not marshal response: %v", err))
  5. return
  6. }
  7. w.Header().Set("Content-Type", "application/json; charset=utf-8")
  8. w.WriteHeader(statusCode)
  9. w.Write(b)
  10. }
  11. func respondError(w http.ResponseWriter, err error) {
  12. log.Println(err)
  13. http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
  14. }

第一个函数用来发送 JSON,而第二个将错误记录到控制台并返回一个 500 Internal Server Error 错误信息。

OAuth 开始

所以,用户点击写着 “Access with GitHub” 的链接。该链接指向 /api/oauth/github,这将会把用户重定向到 github。

   
   
     
  1. func githubOAuthStart(w http.ResponseWriter, r *http.Request) {
  2. state, err := gonanoid.Nanoid()
  3. if err != nil {
  4. respondError(w, fmt.Errorf("could not generte state: %v", err))
  5. return
  6. }
  7. stateCookieValue, err := cookieSigner.Encode("state", state)
  8. if err != nil {
  9. respondError(w, fmt.Errorf("could not encode state cookie: %v", err))
  10. return
  11. }
  12. http.SetCookie(w, &http.Cookie{
  13. Name: "state",
  14. Value: stateCookieValue,
  15. Path: "/api/oauth/github",
  16. HttpOnly: true,
  17. })
  18. http.Redirect(w, r, githubOAuthConfig.AuthCodeURL(state), http.StatusTemporaryRedirect)
  19. }

OAuth2 使用一种机制来防止 CSRF 攻击,因此它需要一个“状态”(state)。我们使用 Nanoid() 来创建一个随机字符串,并用这个字符串作为状态。我们也把它保存为一个 cookie。

OAuth 回调

一旦用户授权我们访问他的个人信息,他将会被重定向到这个端点。这个 URL 的查询字符串上将会包含状态(state)和授权码(code): /api/oauth/github/callback?state=&code=

   
   
     
  1. const jwtLifetime = time.Hour * 24 * 14
  2. type GithubUser struct {
  3. ID int `json:"id"`
  4. Login string `json:"login"`
  5. AvatarURL *string `json:"avatar_url,omitempty"`
  6. }
  7. type User struct {
  8. ID string `json:"id"`
  9. Username string `json:"username"`
  10. AvatarURL *string `json:"avatarUrl"`
  11. }
  12. func githubOAuthCallback(w http.ResponseWriter, r *http.Request) {
  13. stateCookie, err := r.Cookie("state")
  14. if err != nil {
  15. http.Error(w, http.StatusText(http.StatusTeapot), http.StatusTeapot)
  16. return
  17. }
  18. http.SetCookie(w, &http.Cookie{
  19. Name: "state",
  20. Value: "",
  21. MaxAge: -1,
  22. HttpOnly: true,
  23. })
  24. var state string
  25. if err = cookieSigner.Decode("state", stateCookie.Value, &state); err != nil {
  26. http.Error(w, http.StatusText(http.StatusTeapot), http.StatusTeapot)
  27. return
  28. }
  29. q := r.URL.Query()
  30. if state != q.Get("state") {
  31. http.Error(w, http.StatusText(http.StatusTeapot), http.StatusTeapot)
  32. return
  33. }
  34. ctx := r.Context()
  35. t, err := githubOAuthConfig.Exchange(ctx, q.Get("code"))
  36. if err != nil {
  37. respondError(w, fmt.Errorf("could not fetch github token: %v", err))
  38. return
  39. }
  40. client := githubOAuthConfig.Client(ctx, t)
  41. resp, err := client.Get("https://api.github.com/user")
  42. if err != nil {
  43. respondError(w, fmt.Errorf("could not fetch github user: %v", err))
  44. return
  45. }
  46. var githubUser GithubUser
  47. if err = json.NewDecoder(resp.Body).Decode(&githubUser); err != nil {
  48. respondError(w, fmt.Errorf("could not decode github user: %v", err))
  49. return
  50. }
  51. defer resp.Body.Close()
  52. tx, err := db.BeginTx(ctx, nil)
  53. if err != nil {
  54. respondError(w, fmt.Errorf("could not begin tx: %v", err))
  55. return
  56. }
  57. var user User
  58. if err = tx.QueryRowContext(ctx, `
  59. SELECT id, username, avatar_url FROM users WHERE github_id = $1
  60. `, githubUser.ID).Scan(&user.ID, &user.Username, &user.AvatarURL); err == sql.ErrNoRows {
  61. if err = tx.QueryRowContext(ctx, `
  62. INSERT INTO users (username, avatar_url, github_id) VALUES ($1, $2, $3)
  63. RETURNING id
  64. `, githubUser.Login, githubUser.AvatarURL, githubUser.ID).Scan(&user.ID); err != nil {
  65. respondError(w, fmt.Errorf("could not insert user: %v", err))
  66. return
  67. }
  68. user.Username = githubUser.Login
  69. user.AvatarURL = githubUser.AvatarURL
  70. } else if err != nil {
  71. respondError(w, fmt.Errorf("could not query user by github ID: %v", err))
  72. return
  73. }
  74. if err = tx.Commit(); err != nil {
  75. respondError(w, fmt.Errorf("could not commit to finish github oauth: %v", err))
  76. return
  77. }
  78. exp := time.Now().Add(jwtLifetime)
  79. token, err := jwtSigner.Encode(jwt.Claims{
  80. Subject: user.ID,
  81. Expiration: json.Number(strconv.FormatInt(exp.Unix(), 10)),
  82. })
  83. if err != nil {
  84. respondError(w, fmt.Errorf("could not create token: %v", err))
  85. return
  86. }
  87. expiresAt, _ := exp.MarshalText()
  88. data := make(url.Values)
  89. data.Set("token", string(token))
  90. data.Set("expires_at", string(expiresAt))
  91. http.Redirect(w, r, "/callback?"+data.Encode(), http.StatusTemporaryRedirect)
  92. }

首先,我们会尝试使用之前保存的状态对 cookie 进行解码。并将其与查询字符串中的状态进行比较。如果它们不匹配,我们会返回一个 418 I'm teapot(未知来源)错误。

接着,我们使用授权码生成一个令牌。这个令牌被用于创建 HTTP 客户端来向 GitHub API 发出请求。所以最终我们会向 https://api.github.com/user 发送一个 GET 请求。这个端点将会以 JSON 格式向我们提供当前经过身份验证的用户信息。我们将会解码这些内容,一并获取用户的 ID、登录名(用户名)和头像 URL。

然后我们将会尝试在数据库上找到具有该 GitHub ID 的用户。如果没有找到,就使用该数据创建一个新的。

之后,对于新创建的用户,我们会发出一个将用户 ID 作为主题(Subject)的 JSON 网络令牌,并使用该令牌重定向到前端,查询字符串中一并包含该令牌的到期日(Expiration)。

这一 Web 应用也会被用在其他帖子,但是重定向的链接会是 /callback?token=&expires_at=。在那里,我们将会利用 JavaScript 从 URL 中获取令牌和到期日,并通过 Authorization 标头中的令牌以 Bearer token_here 的形式对 /api/auth_user 进行 GET 请求,来获取已认证的身份用户并将其保存到 localStorage。

Guard 中间件

为了获取当前已经过身份验证的用户,我们设计了 Guard 中间件。这是因为在接下来的文章中,我们会有很多需要进行身份认证的端点,而中间件将会允许我们共享这一功能。

   
   
     
  1. type ContextKey struct {
  2. Name string
  3. }
  4. var keyAuthUserID = ContextKey{"auth_user_id"}
  5. func guard(handler http.HandlerFunc) http.HandlerFunc {
  6. return func(w http.ResponseWriter, r *http.Request) {
  7. var token string
  8. if a := r.Header.Get("Authorization"); strings.HasPrefix(a, "Bearer ") {
  9. token = a[7:]
  10. } else if t := r.URL.Query().Get("token"); t != "" {
  11. token = t
  12. } else {
  13. http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
  14. return
  15. }
  16. var claims jwt.Claims
  17. if err := jwtSigner.Decode([]byte(token), &claims); err != nil {
  18. http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
  19. return
  20. }
  21. ctx := r.Context()
  22. ctx = context.WithValue(ctx, keyAuthUserID, claims.Subject)
  23. handler(w, r.WithContext(ctx))
  24. }
  25. }

首先,我们尝试从 Authorization 标头或者是 URL 查询字符串中的 token 字段中读取令牌。如果没有找到,我们需要返回 401 Unauthorized(未授权)错误。然后我们将会对令牌中的申明进行解码,并使用该主题作为当前已经过身份验证的用户 ID。

现在,我们可以用这一中间件来封装任何需要授权的 http.handlerFunc,并且在处理函数的上下文中保有已经过身份验证的用户 ID。

   
   
     
  1. var guarded = guard(func(w http.ResponseWriter, r *http.Request) {
  2. authUserID := r.Context().Value(keyAuthUserID).(string)
  3. })

获取认证用户

   
   
     
  1. func getAuthUser(w http.ResponseWriter, r *http.Request) {
  2. ctx := r.Context()
  3. authUserID := ctx.Value(keyAuthUserID).(string)
  4. var user User
  5. if err := db.QueryRowContext(ctx, `
  6. SELECT username, avatar_url FROM users WHERE id = $1
  7. `, authUserID).Scan(&user.Username, &user.AvatarURL); err == sql.ErrNoRows {
  8. http.Error(w, http.StatusText(http.StatusTeapot), http.StatusTeapot)
  9. return
  10. } else if err != nil {
  11. respondError(w, fmt.Errorf("could not query auth user: %v", err))
  12. return
  13. }
  14. user.ID = authUserID
  15. respond(w, user, http.StatusOK)
  16. }

我们使用 Guard 中间件来获取当前经过身份认证的用户 ID 并查询数据库。

这一部分涵盖了后端的 OAuth 流程。在下一篇帖子中,我们将会看到如何开始与其他用户的对话。

◈  源代码

via: https://nicolasparada.netlify.com/posts/go-messenger-oauth/

作者:Nicolás Parada 选题:lujun9972 译者:PsiACE 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

😻:还 在看吗?


登录查看更多
0

相关内容

【2020新书】实战R语言4,323页pdf
专知会员服务
100+阅读 · 2020年7月1日
【干货书】现代数据平台架构,636页pdf
专知会员服务
253+阅读 · 2020年6月15日
2019中国硬科技发展白皮书 193页
专知会员服务
81+阅读 · 2019年12月13日
通过Docker安装谷歌足球游戏环境
CreateAMind
11+阅读 · 2019年7月7日
用Now轻松部署无服务器Node应用程序
前端之巅
16+阅读 · 2019年6月19日
I2P - 适用于黑客的Android应用程序
黑白之道
30+阅读 · 2019年3月6日
C# 10分钟完成百度人脸识别
DotNet
3+阅读 · 2019年2月17日
如何编写完美的 Python 命令行程序?
CSDN
5+阅读 · 2019年1月19日
超级!超级!超级好用的视频标注工具
极市平台
8+阅读 · 2018年12月27日
Python | 爬爬爬:爬百度云,爬百度贴吧,爬爱奇艺
计算机与网络安全
3+阅读 · 2018年3月30日
用Python调用百度OCR接口实例
数据挖掘入门与实战
16+阅读 · 2018年1月29日
如何运用Python建一个聊天机器人?
七月在线实验室
17+阅读 · 2018年1月23日
Arxiv
9+阅读 · 2019年11月6日
Continual Unsupervised Representation Learning
Arxiv
7+阅读 · 2019年10月31日
dynnode2vec: Scalable Dynamic Network Embedding
Arxiv
14+阅读 · 2018年12月6日
Arxiv
3+阅读 · 2018年10月8日
Arxiv
6+阅读 · 2018年3月31日
Arxiv
7+阅读 · 2018年3月21日
VIP会员
相关VIP内容
相关资讯
通过Docker安装谷歌足球游戏环境
CreateAMind
11+阅读 · 2019年7月7日
用Now轻松部署无服务器Node应用程序
前端之巅
16+阅读 · 2019年6月19日
I2P - 适用于黑客的Android应用程序
黑白之道
30+阅读 · 2019年3月6日
C# 10分钟完成百度人脸识别
DotNet
3+阅读 · 2019年2月17日
如何编写完美的 Python 命令行程序?
CSDN
5+阅读 · 2019年1月19日
超级!超级!超级好用的视频标注工具
极市平台
8+阅读 · 2018年12月27日
Python | 爬爬爬:爬百度云,爬百度贴吧,爬爱奇艺
计算机与网络安全
3+阅读 · 2018年3月30日
用Python调用百度OCR接口实例
数据挖掘入门与实战
16+阅读 · 2018年1月29日
如何运用Python建一个聊天机器人?
七月在线实验室
17+阅读 · 2018年1月23日
Top
微信扫码咨询专知VIP会员