使用go-oauth2实现一个极简的Oauth2授权服务

5,919 阅读13分钟

背景: Oauth2 授权协议(及其变种)广泛地被用于各种APP/网站的登录/注册。Oauth2 共有四种模式: 授权码模式, 密码模式, 客户端模式, 简易模式。其中最安全, 最复杂,也是应用最广泛的当属授权码模式。掌握授权码模式, 其他3种也就自然全懂。看完此教程, 你将有能力独立开发或是对接一个完整的Oauth2(及其变种)服务。 这篇文章致力于最快速地带你上手一个最基本oauth2服务, 其中各流程的细节, 各种安全性问题, 不做深入讨论, 以后我会出一篇深入源码级别的oauth2文章。

 

什么是 Oauth   ?

根据WIKI, OAuth 是一个 开放协议,允许用户让第三方应用访问该用户在某一网站上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和 密码 提供给第三方应。 比如你正在使用的掘金, 你用GitHub登录掘金, 掘金就拿到你GitHub的用户名和头像。这一套流程就依赖于GitHub提供的Oauth2协议(可能是Oauth2 的变种,但大差不差), 以及掘金对接GitHub的Oauth2 协议。

以GitHub登录掘金为例, 在一整套 Oauth2 流程中, 可以分为下面4个参与方,分别是 Resource Owner, Client, Authorization Server, Resource Server。

4个参与方的详细描述如下表所示:

参与方职责
Resource Owner  使用掘金的用户
Client客户端, 也就是请求获取GitHub昵称,以及头像的掘金客户端(或者网页版)
Authorization ServerGitHub方认证服务。 所谓认证就是确认是你在登录GitHub, 而不是别人冒充登录。 比如GitHub登录输入账号密码的服务, 就是认证服务
Resource Server  GitHub方资源服务, 认证完从数据库里调取昵称与头像的服务 

为什么使用Oauth

原因很简单, 简化用户注册与登录, 提高用户体验。  试想如果每一个APP都让你注册一个用户名密码,然后你需要记住每一个APP的账户名密码,这得有多麻烦。

如果你能有你熟悉的社交工具登录其他APP(比如Github登录掘金), 你就不需要注册用户名密码, 直接用社交工具就可以登陆,进一步用社交工具里面的头像昵称注册, 这就更方便了。

Oauth2 授权码模式流程

Oauth2 授权码模式的流程可以用下图表示

Oauth2.drawio.png

具体来说, 分为以下几步:

  1. 客户端携带client_id, response_type, scope, redirect_uri 请求第三方应用的 authorization server 授权

  2. 若第三方应用authorization server 未发现登录状态, 则重定向登陆页面

  3. 用户使用账号密码登录第三方应用

  4. 若登陆成功, 则保存登陆状态, 并跳转到请求授权页面

  5. 客户端再次携带client_id, response_type, scope, redirect_uri 请求授权

  6. 第三方应用authorization server 校验请求参数成功并发现登录状态, 于是返回code

  7. 客户端携带 code, grant_type, redirect_uri 请求 access_token

  8. 第三方应用authorization server校验请求参数成功, 于是返回access token

  9. 客户端携带 access token 向第三方应用的 Resource server 发球请求换取用户信息

  10. 第三方应用的 resource server 返回用户信息

下面详细解释每一个参数的意义:

参数意义
client_id第三方应用(Github)颁发给受信任的请求授权方的(掘金) 身份标识
response type  如果是授权码模式, 写死 为"code", 表示先返回code, 再用 code 换取 access token
scope  授权范围, 由第三方决定的一个字符串, 用来标识可以授权哪些信息
redirect_uri第三方应用的authorization server生成code以后重定向的地址。由于 code是私密的,事关用户信息, 所以第三方应用的authorization server会对这个重定向地址进行校验, 防止 code 落入非法地址。可以简单理解为第三方应用一定要亲自把 code 送到受信任的地方 
grant typeoauth2 的四种模式, 授权码模式写死为" authorization_code

动手实现一个Oauth2 授权服务

看到这里你已经明白了基本流程, 已经可以开始写代码了。 既然是在 掘金写博客, 那我们不妨模拟一下在 掘金中使用 GitHub登录的流程。

 

新建工程

创建新目录 oauth_demo 在该目录下打开命令行输入:


go mod init oauth_demo

go mod tidy

整个工程目录如下:


oauth_demo

    --main

        -- main.go (oauth 授权服务的入口)

    --server

        -- server.go  (oauth 授权服务)

    --static 

        -- juejin.html (掘金页面, 有一个使用GitHub登录的button)

        -- login.html (Github 账号密码登录页面)

        -- agree-auth.html (登陆成功后的页面, 有一个 同意授权 的button)

        -- code-touser-info.html (按下 同意授权的 button后, auth server 携带 code 重定向的地址,  

        会拿着 code 换取 access token, 拿着 access token 获取 用户信息)

    --go.mod

        --go.sum

static 静态文件

我们先来看看静态文件, 让大家对oauth的流程有个直观理解

juejin.html


<!DOCTYPE html>

<html lang="en">

<head>

    <meta charset="UTF-8">

    <title>Title</title>

</head>

<body>

    <H1> 掘金首页</H1>

    <input type="button" value="使用Github登录" onclick="javascrtpt:window.location.href='http://localhost:9000/oauth2/authorize?redirect_uri=http%3A%2F%2Flocalhost%3A9000%2Fcode-to-user-info.html&response_type=code&client_id=juejin&scope=read_user_info'" />

</body>
</html>

这个文件非常简单, 只有一个掘金标题和一个 button, 点下这个 button 以后会请求 GitHub的 auth server 进行授权, 也就是尝试获取用户信息。具体来说, 掘金客户端(这里的 web 端也是一种client)携带client_id, response_type, scope, redirect_uri 请求 github 的 authorization server 授权。

login.html


<!DOCTYPE html>

<html lang="en">

<head>

    <meta charset="UTF-8">

    <title>Title</title>

</head>

<body>

    <h1>Login</h1>

    <form action="/oauth2/login" method="post">

        <label for="username"> user name</label>

        <input type="text" name="username" >

        <br>

        <label for="password"> user name</label>

        <input type="text" name="password" >

        <br>

        <button type="submit">login</button>

    </form>


</body>
</html>

在 juejin.html 按下 button 后,  github 的authorization server 发现没有登陆状态, 则会跳转的 login.html页面。 login.html 也很简单, 只有一个表单, 让用户输入用户名和密码,  然后将请求发送到/oauth2/login,  请求登录 github。

agree-auth.html

<!DOCTYPE html>

<html lang="en">

<head>

    <meta charset="UTF-8">

    <title>Title</title>

</head>

<body>

<form action="http://localhost:9000/oauth2/authorize?redirect_uri=http%3A%2F%2Flocalhost%3A9000%2Fcode-to-user-info.html&response_type=code&client_id=juejin&scope=read_user_info" method="post">

     <button type="submit" class="btn btn-primary btn-lg" style="width: 200px">
         同意授权
    </button>

</form>

</body>
</html>

当 GitHub 的 /oauth2/login 对应的 handler 接收到传过来的 账户密码 并且校验成功后, 就会跳转到 auth.html 这个页面,等待用户去点击那个 同意授权 button。 这个button和 juejin.html中的 使用 GitHub 登录的 button 作用一样, 都是请求 GitHub的 authorization server 授权。 如果GitHub没有发现登陆状态都会跳转回 login.html。 一般来说, 用户在没有登陆状态的情况下会先经过 juejin.html, 再经过 auth.html。如果有登录状态, 则在 jeujin。html 按下 使用 github 登录状态后不经过 auth.html, 直接拿到用户信息用于注册/登录 掘金。

code-to-user-info.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
  <script>

      // 一个同步的http请求
      function httpRequest(address, reqType, asyncProc) {
          var req = window.XMLHttpRequest ? new XMLHttpRequest() : new ActiveXObject("Microsoft.XMLHTTP");

          if (asyncProc) {
              req.onreadystatechange = function() {
                  if (this.readyState == 4) {
                      asyncProc(this);
                  }
              };
          }
          req.open(reqType, address, !(!asyncProc));
          req.send();
          return req;
      }

      // 获取 code 参数
      var query = decodeURI(window.location.search.substring(1));
      var vars = query.split("&");
      var code =''
      for (var i = 0; i < vars.length; i++) {
           var pair = vars[i].split("=");
           if (pair[0] == "code") {
               console.log("code = ",pair[1])
               code=pair[1]
               break
           }
      }

      //  code 换取 access token
      var access_token

      var token_url ='http://localhost:9000/oauth2/token?code={Code}&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A9000%2Fcode-to-user-info.html&client_id=juejin&client_secret=xxxxxx'

      token_url =token_url.replace('{Code}',code)
      console.log("token_url = ",token_url)

      var req1 =httpRequest(token_url,"Get",false)
      if ( req1.status==200) {
          console.log(req1.response )
      }
      let token_data =JSON.parse(req1.response)
      access_token = token_data["access_token"]


      // access_token 换取用户信息
      var user_info_url ='http://localhost:9000/oauth2/getuserinfo?access_token={AccessToken}'
      user_info_url =user_info_url.replace('{AccessToken}',access_token)
      console.log("user_info_url = ",user_info_url)

      var req2 =httpRequest(user_info_url,"Get",false)
      if ( req2.status==200) {
          console.log("user info = " ,req2.response )
      }

      alert( req2.response)




  </script>

</body>
</html>

code-to-user-info.html/ 正如其名, 是用来接受 GitHub authorization server 返回的 code的, 也就是请求 code 中的redirect_uri 地址。其内部主要实现了两个功能, 接受code换取access token, 使用 access token 换取用户信息。 特别的是, 我在内部定义了一个 httpRequest 参数, 这个函数 使得 js 自带的 xmlHttpRequest 从一个异步函数强制变成了一个同步函数, 避免了还没有获取到 access token 就请求换取用户信息。最后, 我用一个 alert 将用户信息以弹框的形式展现出来。  

main 目录

main.go

package main

import (
   "fmt"
   "net/http"
   "oauth_demo/server"
)

func main() {
   server.Init()

   // auth_server 授权入口
   http.HandleFunc("/oauth2/authorize", server.AuthorizeHandler)

   // auth_server 发现未登录状态, 跳转到的登录handler
   http.HandleFunc("/oauth2/login", server.LoginHandler)

   // auth_server拿到 client以后重定向到的地址, 也就是 auth_client 获取到了code, 准备用code换取accesstoken
   //http.HandleFunc("/oauth2/code_to_token", server.CodeToToken)

   // auth_server 处理由code 换取access token 的handler
   http.HandleFunc("/oauth2/token", server.TokenHandler)

   // 登录完成, 同意授权的页面
   http.HandleFunc("/oauth2/agree-auth", server.AgreeAuthHandler)

   // access token 换取用户信息的handler
   http.HandleFunc("/oauth2/getuserinfo", server.GetUserInfoHandler)

   http.Handle("/", http.FileServer(http.Dir("./static"))) //http://localhost:9000/juejin.html

   errChan := make(chan error)
   go func() {
      errChan <- http.ListenAndServe(":9000", nil)
   }()
   err := <-errChan
   if err != nil {
      fmt.Println("Hello server stop running.")
   }

}

 main 函数的各个handler的作用我已经详细注释了。 结合上面的各个 html 文件, 我相信你应该可以梳理清楚整个 oauth2 登录的流程页面 和 后端对应的 handler 了。

server 目录

server.go  init 函数

package server

import (
   "context"
   "encoding/json"
   "errors"
   "github.com/go-oauth2/oauth2/v4"
   "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"
   "github.com/go-session/session"
   "log"
   "net/http"
   "os"
   "time"
)

var manager *manage.Manager

var srv *server.Server

// 用户信息结构体
type UserInfo struct {
   Username string `json:"username"`
   Gender   string `json:"gender"`
}

// 用一个 map 存储用户信息
var user_info_map = make(map[string]UserInfo)

func Init() {

   // 设置 client 信息
   client_store := store.NewClientStore()
   client_store.Set("juejin", &models.Client{ID: "juejin", Secret: "xxxxxx", Domain: "http://juejin.com"})

   // 设置 manager, manager 参与校验 code/access token 请求
   manager = manage.NewDefaultManager()

   // 校验 redirect_uri 和 client 的 Domain, 简单起见, 不做校验
   manager.SetValidateURIHandler(func(baseURI, redirectURI string) error {
      return nil
   })

   manager.MustTokenStorage(store.NewMemoryTokenStore())

   // manger 包含 client 信息
   manager.MapClientStorage(client_store)

   // server 也包含 manger, client 信息
   srv = server.NewServer(server.NewConfig(), manager)

   // 根据 client id 从 manager 中获取 client info, 在获取 access token 校验过程中会被用到
   srv.SetClientInfoHandler(func(r *http.Request) (clientID, clientSecret string, err error) {
      client_info, err := srv.Manager.GetClient(r.Context(), r.URL.Query().Get("client_id")) //r.URL.Query().Get("client_id")
      if err != nil {
         log.Println(err)
         return "", "", err
      }
      return client_info.GetID(), client_info.GetSecret(), nil
   })

   // 设置为 authorization code 模式
   srv.SetAllowedGrantType(oauth2.AuthorizationCode)

   // authorization code 模式,  第一步获取code,然后再用code换取 access token, 而不是直接获取 access token
   srv.SetAllowedResponseType(oauth2.Code)

   // 校验授权请求用户的handler, 会重定向到 登陆页面, 返回"", nil
   srv.SetUserAuthorizationHandler(userAuthorizationHandler)

   // 校验授权请求的用户的账号密码, 给 LoginHandler 使用, 简单起见, 只允许一个用户授权
   srv.SetPasswordAuthorizationHandler(func(username, password string) (userID string, err error) {
      if username == "Tom" && password == "123456" {
         return "0001", nil
      }
      return "", errors.New("username or password error")
   })

   // 允许使用 get 方法请求授权
   srv.SetAllowGetAccessRequest(true)

   // 储存用户信息的一个 map
   user_info_map["0001"] = UserInfo{
      "Tom", "Male",
   }

}

server.go 文件描述了 auth server 如何使用 oauth2 处理授权请求的完整过程。

我们定义了 三个全局变量, server , manager, user_info_map。 manager 主要负责 校验 code和 access token 的请求, 其内部包含了 client 信息。 server 中包含了 manger, 总揽全局, 整个授权过程的入口。 user_info_map 用一个map记录了用户信息。

在 Intit() 函数中,做了一些初始化工作。初始化了 client 信息(client store), manger 又包含了 client store, server 中又包含了 manger, 连起来看 表示 auth server 记录了哪些 client 信息, 允许哪些 client 的请求 。

另外, 我们给 manger 和 server 设置了很多 handler, 主要是重写了一些校验请求的 handler。详细含义我都在注释里面写明了。

server.go 其他函数

 

package server

import (
   "context"
   "encoding/json"
   "errors"
   "github.com/go-oauth2/oauth2/v4"
   "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"
   "github.com/go-session/session"
   "log"
   "net/http"
   "os"
   "time"
)

var manager *manage.Manager

var srv *server.Server

// 用户信息结构体
type UserInfo struct {
   Username string `json:"username"`
   Gender   string `json:"gender"`
}

// 用一个 map 存储用户信息
var user_info_map = make(map[string]UserInfo)

func Init() {

   // 设置 client 信息
   client_store := store.NewClientStore()
   client_store.Set("juejin", &models.Client{ID: "juejin", Secret: "xxxxxx", Domain: "http://juejin.com"})

   // 设置 manager, manager 参与校验 code/access token 请求
   manager = manage.NewDefaultManager()

   // 校验 redirect_uri 和 client 的 Domain, 简单起见, 不做校验
   manager.SetValidateURIHandler(func(baseURI, redirectURI string) error {
      return nil
   })

   manager.MustTokenStorage(store.NewMemoryTokenStore())

   // manger 包含 client 信息
   manager.MapClientStorage(client_store)

   // server 也包含 manger, client 信息
   srv = server.NewServer(server.NewConfig(), manager)

   // 从请求中获取clientID 和 clientSecret, 在获取 access token 校验过程中会被用到
   srv.SetClientInfoHandler(func(r *http.Request) (clientID, clientSecret string, err error) {
      clientID = r.URL.Query().Get("client_id")
      clientSecret = r.URL.Query().Get("client_secret")
      return clientID, clientSecret, nil
   })

   // 设置为 authorization code 模式
   srv.SetAllowedGrantType(oauth2.AuthorizationCode)

   // authorization code 模式,  第一步获取code,然后再用code换取 access token, 而不是直接获取 access token
   srv.SetAllowedResponseType(oauth2.Code)

   // 校验授权请求用户的handler, 会重定向到 登陆页面, 返回"", nil
   srv.SetUserAuthorizationHandler(userAuthorizationHandler)

   // 校验授权请求的用户的账号密码, 给 LoginHandler 使用, 简单起见, 只允许一个用户授权
   srv.SetPasswordAuthorizationHandler(func(username, password string) (userID string, err error) {
      if username == "Tom" && password == "123456" {
         return "0001", nil
      }
      return "", errors.New("username or password error")
   })

   // 允许使用 get 方法请求授权
   srv.SetAllowGetAccessRequest(true)

   // 储存用户信息的一个 map
   user_info_map["0001"] = UserInfo{
      "Tom", "Male",
   }

}

// 授权入口, juejin.html 和 agree-auth.html 按下 button 后
func AuthorizeHandler(w http.ResponseWriter, r *http.Request) {
   err := srv.HandleAuthorizeRequest(w, r)
   if err != nil {
      log.Println(err)
      http.Error(w, err.Error(), http.StatusBadRequest)
   }
}

// AuthorizeHandler 内部使用, 用于查看是否有登陆状态
func userAuthorizationHandler(w http.ResponseWriter, r *http.Request) (user_id string, err error) {
   store, err := session.Start(r.Context(), w, r)
   if err != nil {
      log.Println(err)
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
   }
   uid, ok := store.Get("LoggedInUserId")
   // 如果没有查询到登陆状态, 则跳转到 登陆页面
   if !ok {
      if r.Form == nil {
         r.ParseForm()
      }

      w.Header().Set("Location", "/oauth2/login")
      w.WriteHeader(http.StatusFound)
      return "", nil
   }
   // 若有登录状态, 返回 user id
   user_id = uid.(string)
   return user_id, nil
}

// 登录页面的handler
func LoginHandler(w http.ResponseWriter, r *http.Request) {
   store, err := session.Start(r.Context(), w, r)
   if err != nil {
      log.Println(err)
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
   }

   if r.Method == http.MethodPost {
      r.ParseForm()

      user_id, err := srv.PasswordAuthorizationHandler(r.Form.Get("username"), r.Form.Get("password"))
      if err != nil {
         log.Println(err)
         http.Error(w, err.Error(), http.StatusUnauthorized)
         return
      }
      store.Set("LoggedInUserId", user_id) // 保存登录状态
      store.Save()

      // 跳转到 同意授权页面
      w.Header().Set("Location", "/oauth2/agree-auth")
      w.WriteHeader(http.StatusFound)
      return
   }

   // 若请求方法错误, 提供login.html页面
   outputHTML(w, r, "static/login.html")
}

// 若发现登录状态则提供 agree-auth.html, 否则跳转到 登陆页面
func AgreeAuthHandler(w http.ResponseWriter, r *http.Request) {
   store, err := session.Start(r.Context(), w, r)
   if err != nil {
      log.Println(err)
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
   }

   // 如果没有查询到登陆状态, 则跳转到 登陆页面
   if _, ok := store.Get("LoggedInUserId"); !ok {
      w.Header().Set("Location", "/oauth2/login")
      w.WriteHeader(http.StatusFound)
      return
   }

   // 如果有登陆状态, 会跳转到 确认授权页面
   outputHTML(w, r, "static/agree-auth.html")
}

// code 换取 access token
func TokenHandler(w http.ResponseWriter, r *http.Request) {
   err := srv.HandleTokenRequest(w, r)
   if err != nil {
      log.Println(err)
      http.Error(w, err.Error(), http.StatusBadRequest)
   }
}

// access token 换取用户信息
func GetUserInfoHandler(w http.ResponseWriter, r *http.Request) {
   // 获取 access token
   access_token, ok := srv.BearerAuth(r)
   if !ok {
      log.Println("Failed to get access token from request")
      return
   }

   root_ctx := context.Background()
   ctx, cancle_func := context.WithTimeout(root_ctx, time.Second)
   defer cancle_func()

   // 从 access token 中获取 信息
   token_info, err := srv.Manager.LoadAccessToken(ctx, access_token)
   if err != nil {
      log.Println(err)
      return
   }

   // 获取 user id
   user_id := token_info.GetUserID()
   grant_scope := token_info.GetScope()

   user_info := UserInfo{}

   // 根据 grant scope 决定获取哪些用户信息
   if grant_scope != "read_user_info" {
      log.Println("invalid grant scope")
      w.Write([]byte("invalid grant scope"))
      return
   }

   user_info = user_info_map[user_id]
   resp, err := json.Marshal(user_info)
   w.Write(resp)
   return
}

// 提供 HTML 文件显示
func outputHTML(w http.ResponseWriter, req *http.Request, filename string) {
   file, err := os.Open(filename)
   if err != nil {
      log.Println(err)
      http.Error(w, err.Error(), 500)
      return
   }
   defer file.Close()
   fi, _ := file.Stat()
   http.ServeContent(w, req, file.Name(), fi.ModTime(), file)
}
 

为了方便理解, 这些 handler 的顺序和 用户的使用体验基本一致。

各个 handler 的详细作用, 除了注释以外, 我还在下面这张表中详细解释

handler作用 
   AuthorizeHandler juejin.html 和 agree-auth.html 按下 button 后, 成功携带 code 重定向到 redirect_uri  
  userAuthorizationHandler  AuthorizeHandler 内部使用, 查看是否有登录状态, 若有登录状态, 返回 user id, 否则跳转到登陆页面
LoginHandler登录handler, 若为post请求, 校验用户名和密码, 若校验通过, 则保存登陆状态, 跳转到 同意授权的  oauth2/agree-auth 路径; 否则请求方法不对, 提供登陆页面 
AgreeAuthHandler若发现登录状态则提供 agree-auth.html, 否则跳转到 登陆页面
TokenHandlercode 换取 access token 的 handler。 获取code 成功后, 会被重定向到 code-to-user-info.html, 由这个HTML 文件携带code 向 TokenHandler 发起请求
GetUserInfoHandler  access token 换取用户信息 的 handler。 code-to-user-info.html 获取access token成功后, 会拿着 access token 和其他参数向 GetUserInfoHandler  发起请求换取用户信息

实现效果

允许 main 文件以后, 浏览器输入 http://localhost:9000/juejin.html 你会看到下面这样:

image.png

点击 使用 Github 登录后, 你会看到登陆页面:

image.png

输入 Tom, 123456 以后你会来到 同意授权 页面:

image.png

点击同意授权后, 你会看到一个弹窗:

image.png

如果你此时再次访问  http://localhost:9000/juejin.html 点击使用 Github 登录后, 你应该会直接看到 用户信息的 弹窗。

至此, 你已经走完了一整个 oauth2 的授权流程hhh

巨人的肩膀

stackoverflow.com/questions/3…

en.wikipedia.org/wiki/OAuth

github.com/golang/oaut…

developer.aliyun.com/article/113…