Go 實現自己的授權認證系統 go-oauth2

01 前言

這篇文章,讓我們來學習如何創建自己的授權認證系統吧!在使用 Go 語言時,我們可以藉助一個叫做 go-oauth2 的開源框架來快速實現這個目標。

一種開放協議,允許以簡單和標準的方法從 Web、移動和桌面應用程序進行安全授權。

https://github.com/go-oauth2/oauth2

02 什麼是 OAuth 2.0 呢?

OAuth2.0 (Open Authorization 2.0)是一種開放標準的授權協議,用於在不共享密碼的情況下,讓用戶授權第三方應用訪問他們在另外一個服務提供商的受保護資源。OAuth 2.0 廣泛應用於各種互聯網服務,包括社交媒體平臺、API 提供商、雲服務等。

在傳統的身份驗證方式中,用戶需要提供用戶名和密碼來授權第三方應用訪問他們的賬戶。但是,這種方式存在一些安全風險,因爲用戶需要將密碼直接提供給第三方應用,可能導致密碼泄露或濫用。

OAuth 2.0 的工作原理如下:

  1. 用戶訪問第三方應用並請求訪問某個受保護資源。

  2. 第三方應用將重定向用戶到授權服務器(身份提供者),要求用戶進行身份驗證。

  3. 用戶提供身份驗證信息(例如用戶名和密碼)給授權服務器。

  4. 授權服務器驗證用戶身份,並向用戶顯示請求的權限範圍(例如訪問用戶信息)。

  5. 用戶授權第三方應用訪問請求的權限。

  6. 授權服務器生成一個訪問令牌(access token)並將其發送給第三方應用。

  7. 第三方應用使用訪問令牌來向資源服務器(服務提供商)請求訪問受保護的資源。

  8. 資源服務器驗證訪問令牌的有效性,並根據授權範圍決定是否授權訪問請求的資源。

通過 OAuth 2.0,用戶不再直接將密碼提供給第三方應用,而是通過授權服務器生成的訪問令牌來訪問資源。這種方式更安全,因爲訪問令牌具有一定的有效性,且不包含用戶的密碼信息。

03 如何設計呢?

下面,我們試圖詢問一下 ChatGPT,看看它給出什麼建議,用好 AI 很重要。

04 大廠如何做呢?

前面我們瞭解完 OAuth 2.0 的原理以及該如何做之後,我們基本知道一個大致的流程了。

下面,我們以微信網頁授權爲例子,看看微信這邊是如何設計的。

網頁授權 | 微信開發文檔

https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webpage_authorization.html

網頁授權流程分爲四步:

  1. 引導用戶進入授權頁面同意授權,獲取 code。

  2. 通過 code 換取網頁授權 access_token(與基礎支持中的 access_token 不同)。

  3. 如果需要,開發者可以刷新網頁授權 access_token,避免過期。

  4. 通過網頁授權 access_token 和 openid 獲取用戶基本信息(支持 UnionID 機制)。

第一步:用戶同意授權,獲取 code。

在這一步,使用 GET 請求訪問 /oauth2/authorize 接口,攜帶 app_id,redirect_uri,response_type,scope 及 state 等。當用戶同意授權,頁面將跳轉至 redirect_uri/?code=CODE&state=STATE。進而獲取到 code 值。

第二步:通過 code 獲取網頁授權 access_token。

通過上一步獲取到的 code,使用 GET 請求訪問 /oauth2/access_token 接口,攜帶 app_id,secret,code 及 grant_type=authorization_code 等。最終獲取到 access_token 及 refresh_token 。

第三步:刷新 access_token (如果需要)。

一般情況,獲取的 access_toekn 擁有較短的有效期,而 refresh_token 的有效期則較長(例如:30 天),因此可以通過 refresh_token 進行刷新。

在微信開發平臺中,其通過 GET 請求訪問 /oauth2/refresh_token 接口,攜帶 app_id,grant_type=refresh_token 及 refresh_token 刷新 access_token。

一些其他的大廠的做法將刷新 access_token 的接口與獲取 access_token 的接口做爲同一個接口,通過 grant_type 類型值作區分。

第四步:通過 access_token 訪問其他接口。

在 access_token 有效期內,就可以通過 access_token 去訪問其他接口啦。

其他大廠開放平臺借鑑

Google 開發平臺:https://developers.google.com/identity/protocols/oauth2/web-server?hl=zh-cn#httprest_1

抖音開發平臺:https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/openapi/account-permission/get-access-token/

05 協議

至此,在瞭解完所有知識之後,接下來將來設計我們需要的接口。

  1. 登錄頁面,提供用戶名和密碼輸入。

  2. 登錄接口,用於校驗用戶名和密碼。

  3. 授權頁面,提供允許用戶授權的按鈕。

  4. 授權接口,當用戶允許授權時,回調到 redirect_uri 接口,獲取 code。

  5. 獲取 access_token 接口,通過上一步獲取到 code 換取 access_token。

  6. 刷新 access_token 接口,通過上一步獲取到的 refresh_token,用於刷新 access_token。

以上就是我們的接口設計了,下面是部分代碼,後續我一個個講解下 handler。

func main() {
  mux := http.NewServeMux()
  mux.HandleFunc("/oauth/authorize", authorizeHandler)
  mux.HandleFunc("/login", loginHandler)
  mux.HandleFunc("/oauth/access_token", accessTokenHandler)
  mux.HandleFunc("/oauth/refresh_token", refreshTokenHandler)
  mux.HandleFunc(oauth2.DefaultLoginPageUrl, loginPageHandler)
  mux.HandleFunc(oauth2.DefaultAuthPageUrl, authPageHandler)
  server := http.Server{
    Addr:    ":8088",
    Handler: mux,
  }
  fmt.Println("Start server at http://localhost:8088/")
  server.ListenAndServe()
}

登錄頁面:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Login</title>
    <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
    <script src="//code.jquery.com/jquery-2.2.4.min.js"></script>
    <script src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js"></script>
</head>
<body>
    <div>
        <h1>Login In</h1>
        <form action="/login" method="POST">
            <div>
                <label for="username">User Name</label>
                <input type="text" >
            </div>
            <div>
                <label for="password">Password</label>
                <input type="password" >
            </div>
            <button type="submit">Login</button>
        </form>
    </div>
</body>
</html>

授權頁面

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Auth</title>
    <link
      rel="stylesheet"
      href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css"
    />
    <script src="//code.jquery.com/jquery-2.2.4.min.js"></script>
    <script src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js"></script>
  </head>
  <body>
    <div>
      <div>
        <form action="/oauth/authorize" method="POST">
          <h1>Authorize</h1>
          <p>The client would like to perform actions on your behalf.</p>
          <p>
            <button
              type="submit"
             
             
            >
              Allow
            </button>
          </p>
        </form>
      </div>
    </div>
  </body>
</html>

06 go-oauth2 使用

講了這麼多,下面來簡單介紹一下 go-oauth2,其目錄結構如下:

其核心分爲了 manage、server 以及 store。

manage 提供了以下很多方法:

下面,我簡單介紹一下將會使用到的幾個方法。

同樣的 server 也提供了以下很多屬性和方法:

下面,我簡單介紹一下將會使用到的幾個方法。

store 存儲

go-oauth 支持使用多種存儲方式,例如 memory、redis 等。在本篇文章中,將使用 go-session 方式配置存儲:Go 一個高效、安全且易於使用的 Go 會話庫。

07 oauth server

定義參數配置信息:

package oauth2
import (
  "github.com/go-oauth2/oauth2/v4/manage"
  "time"
)
var (
  // ClusterType means redis cluster.
  ClusterType = "cluster"
  // NodeType means redis node.
  NodeType = "node"
  // DefaultAuthorizeCodeTokenCfg is the default authorization code grant token config.
  DefaultAuthorizeCodeTokenCfg = &manage.Config{AccessTokenExp: time.Hour * 2, RefreshTokenExp: time.Hour * 24 * 30, IsGenerateRefresh: true}
  // DefaultRefreshTokenCfg is the default refresh token config.
  DefaultRefreshTokenCfg = &manage.RefreshingConfig{IsGenerateRefresh: false, IsRemoveAccess: false, IsRemoveRefreshing: false}
  // DefaultTokenStoragePrefixKey is the default token storage prefix key.
  DefaultTokenStoragePrefixKey = "oauth2:token:"
  // DefaultRedisStorePrefixKey is the default session redis prefix key.
  DefaultRedisStorePrefixKey = "oauth2:store:"
  // DefaultAuthorizeForm is the default authorization form.
  DefaultAuthorizeForm = "DefaultAuthorizeForm"
  // DefaultLoginUserId is the default login user id.
  DefaultLoginUserId = "DefaultLoginUserId"
  DefaultLoginPageUrl = "/page/login"
  DefaultAuthPageUrl  = "/page/auth"
)
// RedisConf defines the redis configuration.
type RedisConf struct {
  Addrs []string `json:"addrs"`
  Pass  string   `json:"pass,optional"`
  Type  string   `json:"type,default=node"`
}

DefaultAuthorizeCodeTokenCfg:配置 access_token 的有效期爲 2 個小時,refresh_token 的有效期爲 30 天,且自動生成 refresh_token.

DefaultRefreshTokenCfg:配置不自動更新 refresh_token,不移除 access_token 和 refresh_token。

其他是自定義的變量名。

創建屬於自己的 oauth2 server

package oauth2
import (
  "context"
  "github.com/bytedance/sonic"
  "github.com/go-oauth2/oauth2/v4/errors"
  "github.com/go-oauth2/oauth2/v4/generates"
  "github.com/go-oauth2/oauth2/v4/manage"
  "github.com/go-oauth2/oauth2/v4/models"
  "github.com/go-oauth2/oauth2/v4/server"
  "github.com/go-oauth2/oauth2/v4/store"
  oredis "github.com/go-oauth2/redis/v4"
  "github.com/go-redis/redis/v8"
  sredis "github.com/go-session/redis/v3"
  "github.com/go-session/session/v3"
  "net/http"
)
type Server struct {
  *manage.Manager
  *server.Server
}
// NewServer 創建oauth2 server
func NewServer(conf RedisConf) *Server {
  initSession(conf)
  // 1. create to default authorization management instance.
  manager := manage.NewDefaultManager()
  // 1.1 set the authorization code grant token config.
  manager.SetAuthorizeCodeTokenCfg(DefaultAuthorizeCodeTokenCfg)
  // 1.2 set the refresh token config.
  manager.SetRefreshTokenCfg(DefaultRefreshTokenCfg)
  // 1.3 mapping the token store interface, set token store (redis).
  if conf.Type == ClusterType {
    manager.MapTokenStorage(oredis.NewRedisClusterStore(&redis.ClusterOptions{Addrs: conf.Addrs, Password: conf.Pass}, DefaultTokenStoragePrefixKey))
  } else {
    manager.MapTokenStorage(oredis.NewRedisStore(&redis.Options{Addr: conf.Addrs[0], Password: conf.Pass}, DefaultTokenStoragePrefixKey))
  }
  // 1.4 mapping the access token generate interface, generate access_token.
  manager.MapAccessGenerate(generates.NewAccessGenerate())
  // 1.5 mapping the client store interface, set client store.
  clientStore := store.NewClientStore()
  clientId, clientSecret, domain := "123456", "abcdef", "localhost:8080"
  clientStore.Set("123456", &models.Client{
    ID:     clientId,
    Secret: clientSecret,
    Domain: domain,
  })
  manager.MapClientStorage(clientStore)
  // 2. create authorization server.
  config := server.NewConfig()
  config.AllowGetAccessRequest = true
  server := server.NewServer(config, manager)
  // server needs to implement UserAuthorizationHandler and PasswordAuthorizationHandler.
  // 2.1 set the password authorization handler(get user id from username and password).
  server.SetPasswordAuthorizationHandler(func(ctx context.Context, clientID, username, password string) (userId string, err error) {
    return "user_id", nil
  })
  // 2.2 set the user authorization handler(get user id from request).
  server.SetUserAuthorizationHandler(func(w http.ResponseWriter, r *http.Request) (userId string, err error) {
    store, err := session.Start(r.Context(), w, r)
    if err != nil {
      return "", err
    }
    // 2.3.1 it's from /oauth/authorize?client_id=xxx and need to redirect to /login.
    if r.Method == "GET" {
      marshal, err := sonic.Marshal(r.Form)
      if err != nil {
        return "", err
      }
      store.Set(DefaultAuthorizeForm, string(marshal))
      store.Save()
      w.Header().Set("Location", DefaultLoginPageUrl)
      w.WriteHeader(http.StatusFound)
      return "", nil
    }
    // 2.3.2 it's allow auth and from /login redirect /oauth/authorize, will get code to redirect_uri.
    if r.Method == "POST" {
      if r.Form == nil {
        err = r.ParseForm()
        if err != nil {
          return "", err
        }
      }
      userID, ok := store.Get(DefaultLoginUserId)
      if !ok {
        // not userID in session, redirect to /login.
        w.Header().Set("Location", DefaultLoginPageUrl)
        w.WriteHeader(http.StatusFound)
        return
      }
      store.Delete(DefaultLoginUserId)
      store.Save()
      return userID.(string), nil
    }
    return "", nil
  })
  // 2.3 set client info handler
  server.SetClientInfoHandler(func(r *http.Request) (clientID, clientSecret string, err error) {
    // 從請求頭獲取clientId、clientSecret等
    clientID = r.FormValue("client_id")
    if len(clientID) == 0 {
      return "", "", errors.ErrInvalidRequest
    }
    clientSecret = r.FormValue("client_secret")
    if len(clientSecret) == 0 {
      return "", "", errors.ErrInvalidRequest
    }
    return
  })
  return &Server{Manager: manager, Server: server}
}
// use redis as session manager.
func initSession(conf RedisConf) {
  var store session.ManagerStore
  if conf.Type == ClusterType {
    store = sredis.NewRedisClusterStore(&sredis.ClusterOptions{Addrs: conf.Addrs, Password: conf.Pass}, DefaultRedisStorePrefixKey)
  } else {
    store = sredis.NewRedisStore(&sredis.Options{Addr: conf.Addrs[0], Password: conf.Pass}, DefaultRedisStorePrefixKey)
  }
  session.InitManager(session.SetStore(store))
}

上面代碼已經把整個流程都寫清楚了,server 使用了很多 handler ,方便開發者自定義參數配置。通過 session 會話庫,保存請求的跳轉狀態。同時,也使用到 redis 去存儲 code、access_token、refresh_token 等信息。

08 客戶端調用

在前面,我們已經定義了要使用到的幾個接口,下面看看接口的具體參數以及如何調用上一步封裝好的 server。

請求參數和響應結果如下:

package types
// AuthorizeReq 用戶授權請求,如果用戶同意授權,頁面將跳轉至 redirect_uri/?code=CODE&state=STATE。
type AuthorizeReq struct {
  ClientId     string `query:"client_id"`
  RedirectUri  string `query:"redirect_uri"`
  ResponseType string `query:"response_type,default=code"`
  Scope        string `query:"scope,default=scope"`
  State        string `query:"state,optional"`
}
type AuthorizeResp struct {
}
// LoginReq 登錄請求
type LoginReq struct {
  Username string `form:"username"`
  Password string `form:"password"`
}
type LoginResp struct {
}
// AccessTokenReq 通過code換取access_token
type AccessTokenReq struct {
  ClientId     string `json:"client_id"`
  ClientSecret string `json:"client_secret"`
  Code         string `json:"code"`
  GrantType    string `json:"grant_type,default=authorization_code"`
  RedirectUri  string `json:"redirect_uri"`
}
type AccessTokenRsp struct {
}
// RefreshTokenReq 刷新access_token
type RefreshTokenReq struct {
  ClientId     string `json:"client_id"`
  ClientSecret string `json:"client_secret"`
  RefreshToken string `json:"refresh_token"`
  GrantType    string `json:"grant_type,default=refresh_token"`
  RedirectUri  string `json:"redirect_uri"`
}
type RefreshTokenRsp struct {
}

**客戶端接口
**

下面我使用原生的 ServerMux 來演示,如果你使用 gin 框架或者 go-zero 等框架,原理也一樣。

package main
import (
  "fmt"
  "github.com/bytedance/sonic"
  "github.com/go-session/session/v3"
  "net/http"
  "net/url"
  "oauth2"
  "oauth2/server/types"
  "oauth2/server/utils"
  "os"
)
var server *oauth2.Server
func init() {
  server = oauth2.NewServer(oauth2.RedisConf{Addrs: []string{"127.0.0.1:6379"}})
}
func main() {
  mux := http.NewServeMux()
  mux.HandleFunc("/oauth/authorize", authorizeHandler)
  mux.HandleFunc("/login", loginHandler)
  mux.HandleFunc("/oauth/access_token", accessTokenHandler)
  mux.HandleFunc("/oauth/refresh_token", refreshTokenHandler)
  mux.HandleFunc(oauth2.DefaultLoginPageUrl, loginPageHandler)
  mux.HandleFunc(oauth2.DefaultAuthPageUrl, authPageHandler)
  server := http.Server{
    Addr:    ":8088",
    Handler: mux,
  }
  fmt.Println("Start server at http://localhost:8088/")
  server.ListenAndServe()
}
// 授權接口
func authorizeHandler(w http.ResponseWriter, r *http.Request) {
  // There will be two steps here, one is to enter through the /authorize interface,
  // and the other is to enter when obtaining the code
  store, err := session.Start(r.Context(), w, r)
  if err != nil {
    http.Error(w, err.Error(), http.StatusInternalServerError)
    return
  }
  var form url.Values
  v, ok := store.Get(oauth2.DefaultAuthorizeForm)
  if !ok {
    // oauth2 access GET /oauth/authorize?client_id=123456&redirect_uri=http://localhost:8080/redirect.html&response_type=code&scope=scope
    // Do some verification, such as client_id, redirect_uri, etc.
    var req types.AuthorizeReq
    err = utils.Parse(r, &req)
    if err != nil {
      return
    }
  } else {
    // oauth2 access POST /oauth/authorize get code to redirect_uri
    err = sonic.Unmarshal([]byte(v.(string)), &form)
    if err != nil {
      return
    }
  }
  r.Form = form
  store.Delete(oauth2.DefaultAuthorizeForm)
  store.Save()
  err = server.HandleAuthorizeRequest(w, r)
  if err != nil {
    http.Error(w, err.Error(), http.StatusBadRequest)
  }
}
// 登錄接口,校驗用戶名密碼
func loginHandler(w http.ResponseWriter, r *http.Request) {
  var req types.LoginReq
  err := utils.Parse(r, &req)
  if err != nil {
    return
  }
  store, err := session.Start(r.Context(), w, r)
  if err != nil {
    http.Error(w, err.Error(), http.StatusInternalServerError)
    return
  }
  if r.Form == nil {
    if err := r.ParseForm(); err != nil {
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
    }
  }
  if req.Username != "admin" || req.Password != "123456" {
    w.WriteHeader(http.StatusForbidden)
    return
  }
  store.Set(oauth2.DefaultLoginUserId, r.Form.Get("username"))
  store.Save()
  w.Header().Set("Location", oauth2.DefaultAuthPageUrl)
  w.WriteHeader(http.StatusFound)
}
// 獲取access_token
func accessTokenHandler(w http.ResponseWriter, r *http.Request) {
  var req types.AccessTokenReq
  err := utils.Parse(r, &req)
  if err != nil {
    http.Error(w, err.Error(), http.StatusBadRequest)
    return
  }
  if err := r.ParseForm(); err != nil {
    http.Error(w, err.Error(), http.StatusBadRequest)
    return
  }
  form := url.Values{}
  form.Set("client_id", req.ClientId)
  form.Set("client_secret", req.ClientSecret)
  form.Set("code", req.Code)
  form.Set("grant_type", req.GrantType)
  form.Set("redirect_uri", req.RedirectUri)
  r.Form = form
  err = server.HandleTokenRequest(w, r)
  if err != nil {
    http.Error(w, err.Error(), http.StatusBadRequest)
    return
  }
}
// 刷新access_token
func refreshTokenHandler(w http.ResponseWriter, r *http.Request) {
  var req types.RefreshTokenReq
  err := utils.Parse(r, &req)
  if err != nil {
    http.Error(w, err.Error(), http.StatusBadRequest)
    return
  }
  if err := r.ParseForm(); err != nil {
    http.Error(w, err.Error(), http.StatusBadRequest)
    return
  }
  form := url.Values{}
  form.Set("client_id", req.ClientId)
  form.Set("client_secret", req.ClientSecret)
  form.Set("refresh_token", req.RefreshToken)
  form.Set("grant_type", req.GrantType)
  form.Set("redirect_uri", req.RedirectUri)
  r.Form = form
  err = server.HandleTokenRequest(w, r)
  if err != nil {
    http.Error(w, err.Error(), http.StatusBadRequest)
    return
  }
}
// 登錄頁面
func loginPageHandler(w http.ResponseWriter, r *http.Request) {
  outputHTML(w, r, "static/login.html")
}
// 授權頁面
func authPageHandler(w http.ResponseWriter, r *http.Request) {
  outputHTML(w, r, "static/auth.html")
}
func outputHTML(w http.ResponseWriter, req *http.Request, filename string) {
  file, err := os.Open(filename)
  if err != nil {
    http.Error(w, err.Error(), 500)
    return
  }
  defer file.Close()
  fi, _ := file.Stat()
  http.ServeContent(w, req, file.Name(), fi.ModTime(), file)
}

使用方式:

1、瀏覽器訪問授權接口:http://localhost:8088/oauth/authorize?client_id=123456&redirect_uri=http://localhost:8080/redirect.html&response_type=code&scope=scope,將會重定向到登錄頁面。

2、在登錄頁面輸入用戶名密碼,代碼中設定的是 admin 和 123456。登錄成功將會跳轉向到授權頁面。

3、在授權頁面中允許授權,將會請求授權接口,並重定向到 redirect_uri 地址。

4、在重定向的地址,將獲取到 code,例如:http://localhost:8080/redirect.html?code=ZDVMZGMWMJQTZTC0MI0ZOWJJLTG5ZTKTN2FKMJG1MJC1ODMY

5、通過 code 去獲取 access_token。

curl --location --request GET 'http://localhost:8088/oauth/access_token' \
--header 'Content-Type: application/json' \
--data '{
    "client_id": "123456",
    "client_secret": "abcdef",
    "code": "ZDVMZGMWMJQTZTC0MI0ZOWJJLTG5ZTKTN2FKMJG1MJC1ODMY",
    "grant_type": "authorization_code",
    "redirect_uri":"http://localhost:8080/redirect.html"
}'
{
    "access_token": "NJDKZGEZMJQTZMI2MC0ZY2VILTKYNWYTY2YXMGU3NDRMYZBJ",
    "expires_in": 604800,
    "refresh_token": "N2Q5NZCYZMMTOTVKZI01OWE3LTLIOTCTNMJHNMMWZWI2OTM5",
    "scope": "scope",
    "token_type": "Bearer"
}

6、通過 refresh_token 去刷新 access_token。

curl --location --request GET 'http://localhost:8088/oauth/refresh_token' \
--header 'Content-Type: application/json' \
--data '{
    "client_id": "123456",
    "client_secret": "abcdef",
    "refresh_token": "N2Q5NZCYZMMTOTVKZI01OWE3LTLIOTCTNMJHNMMWZWI2OTM5",
    "grant_type": "refresh_token",
    "redirect_uri":"http://localhost:8080/redirect.html"
}'
{
    "access_token": "YWMXZJDKMDMTNZVINS0ZOTJLLTG4OGUTN2FKYJG2NGU4NJGX",
    "expires_in": 604800,
    "scope": "scope",
    "token_type": "Bearer"
}

至此,完整的流程就結束了,希望對你有幫助,我把代碼放在 github 上了,感興趣的小夥伴可以查看,麻煩點個 star 支持一下:https://github.com/fliyu/oauth2

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/aPxdzaKvKJqg6VaQ2SxF-g