背景: 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 Server GitHub方认证服务。 所谓认证便是确认是你在登录GitHub, 而不是他人假充登录。 比方GitHub登录输入账号暗码的服务, 便是认证服务
Resource Server GitHub方资源服务, 认证完从数据库里调取昵称与头像的服务

为什么运用Oauth

原因很简略, 简化用户注册与登录, 进步用户体会。 试想假如每一个APP都让你注册一个用户名暗码,然后你需要记住每一个APP的账户名暗码,这得有多麻烦。

假如你能有你了解的交际东西登录其他APP(比方Github登录), 你就不需要注册用户名暗码, 直接用交际东西就能够登陆,进一步用交际东西里面的头像昵称注册, 这就更方便了。

Oauth2 授权码形式流程

Oauth2 授权码形式的流程能够用下图表明

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

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

  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 type oauth2 的四种形式, 授权码形式写死为” 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'
 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 其他函数

// 授权进口, 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, 不然跳转到 登陆页面
TokenHandler code 交换 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 你会看到下面这样:

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

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

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

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

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

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

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

假如你此时再次访问 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…