一文带你搞懂OAuth2.0

2,580 阅读10分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第16天,点击查看活动详情

最近好久没有发文章了,但并不意味着停止了学习,哈哈哈~

今天给大家带来了关于OAuth2.0的相关文章,说实话OAuth2.0我也是费了好大力气才稍稍理解的,虽然我们每天都会用到(使用QQ授权登录QQ音乐、和平精英等等),但是背后的设计实现思想还是蛮复杂的,并且有很多地方值得推敲,今天我就分几个方面带大家重新领略下OAuth2.0的设计实现流程和思想,希望能让大家一读就会!会了还想读!读了接着会!

话不多说,开始正文:

1 简单介绍

技术RFC:www.rfc-editor.org/rfc/rfc6749…

OAuth是Open Authorization,即“开放授权”的简写。OAUTH协议为用户资源的授权提供了一个安全的、开放而又简易的标准。OAuth2.0是OAuth协议的延续版本,但不向前兼容OAuth 1.0。OAuth 2.0授权框架支持第三方 应用程序获取对HTTP服务的有限访问,通过编排审批交互来代表资源所有者资源所有者和HTTP服务之间,或通过允许第三方应用程序以自己的名义获取访问。现在百度开放平台,腾讯开放平台等大部分的开放平台都是使用的OAuth 2.0协议作为支撑。

OAuth2.0解决的问题:

  • 需要第三方应用存储资源所有者的凭证以备将来使用,通常是密码以明文,服务器仍须支持密码验证 密码固有的安全弱点。

  • 第三方应用程序获得对资源的过度广泛访问所有者的受保护资源,使资源所有者没有任何的有限子集限制持续时间或访问的能力资源。

  • 资源所有者不能撤销对单个第三方的访问权限不撤销对所有第三方的访问,并且必须这样做修改第三方用户密码。

  • 任何第三方应用程序的妥协将导致妥协终端用户的密码以及所有受该密码保护的数据密码。

2 流程梳理

OAuth2.0总体流程: 在这里插入图片描述

我们来用现实的事件来举个例子——使用QQ登录CSDN(够现实了吧)

(1)首先我们了解状况:QQ的服务器和CSDN的服务器肯定不是同一个服务器,而且用户数据的存储方式也可能也不同

(2)其次我们在选择用QQ登录CSDN时会重定向出一个网页,这个网页是QQ的网页

然后我们画一个图:

其中,实线部分是我们用户真正操作的流程,而虚线部分则是服务内部的流程。 在这里插入图片描述 由此,我们知道,QQ服务器,作为我们将要登录的网站的第三方认证服务,必须事先保存我们用户的信息,以便认证时使用。

3 四种角色

  • resource owner(资源拥有者):用户,能够授予对受保护资源的访问权的实体。

  • resource server(资源服务器):将要访问的服务,托管受保护资源的服务器,能够接受以及使用访问令牌响应受保护的资源请求。

  • client(客户端):即Web浏览器,请求受保护资源的应用程序资源所有者及其授权。

  • authorization server(认证服务器):三方授权服务器,服务提供商专门用来处理认证授权的服务器,认证成功后向客户端发出访问令牌资源所有者身份验证,获取授权。

4 四种实现方式

在OAuth2.0中最常用的当属授权码模式,也就是我们上文讲述的实现,除此之外还有简化模式、密码模式、客户端模式等,模式的不同当然带来的就是流程和访问方式及请求参数的不同,由于其他三种模式并不常用,因此只讲述基本流程,重点还是在授权码模式中,下面我们开始分析:

4.1 授权码模式Authorization Code(最常用)

在这里插入图片描述

  • (A) 用户访问客户端,客户端将用户重定向到认证服务器;
  • (B) 用户选择是否授权;
  • (C) 如果用户同意授权,认证服务器重定向到客户端事先指定的地址,而且带上授权码(code);
  • (D) 客户端收到授权码,带着前面的重定向地址,向认证服务器申请访问令牌;
  • (E) 认证服务器核对授权码与重定向地址,确认后向客户端发送访问令牌和更新令牌(可选)。
参数名称参数含义是否必须
response_type授权类型,一般为code必须
client_id客户端ID,客户端到资源服务器注册的ID必须
redirect_uri重定向URI可选
scope申请的权限范围,多个逗号隔开可选
state客户端的当前状态,可以指定任意值,认证服务器会原封不动的返回这个值推荐

4.2 简化模式Implicit

在这里插入图片描述

  • (A) 客户端将用户导向认证服务器, 携带客户端ID及重定向URI;
  • (B) 用户是否授权;
  • (C) 用户同意授权后,认证服务器重定向到A中指定的URI,并且在URI的Fragment中包含了访问令牌;
  • (D) 浏览器向资源服务器发出请求,该请求中不包含C中的Fragment值;
  • (E) 资源服务器返回一个网页,其中包含了可以提取C中Fragment里面访问令牌的脚本;
  • (F) 浏览器执行E中获得的脚本,提取令牌;
  • (G) 浏览器将令牌发送给客户端。

4.3 密码模式Resource Owner Password Credentials

在这里插入图片描述

  • (A) 资源所有者提供用户名密码给客户端;
  • (B) 客户端拿着用户名密码去认证服务器请求令牌;
  • (C) 认证服务器确认后,返回令牌;

4.4 客户端模式Client Credentials

在这里插入图片描述

  • (A) 客户端发起身份认证,请求访问令牌;
  • (B) 认证服务器确认无误,返回访问令牌。

5 动手实现一个OAuth2.0鉴权服务

具体代码见GitHub:github.com/ibarryyan/o…

5.1 整体流程

在这里插入图片描述

5.2 代码

5.2.1 Client
package main

import (
   "golang.org/x/oauth2"
   "log"
   "net/http"
)

const (
   authServerURL = "http://localhost:9096"
)

var (
   config = oauth2.Config{
      ClientID:     "222222",
      ClientSecret: "22222222",
      Scopes:       []string{"all"},
      RedirectURL:  "http://localhost:9094/oauth2",
      Endpoint: oauth2.Endpoint{
         AuthURL:  authServerURL + "/oauth/authorize",
         TokenURL: authServerURL + "/oauth/token",
      },
   }
   globalToken *oauth2.Token // Non-concurrent security
)

func main() {
   //授权码模式Authorization Code
   //访问第三方授权页
   http.HandleFunc("/", index)
   //由三方鉴权服务重定向返回,拿到code,并请求和验证token
   http.HandleFunc("/oauth2", oAuth2)
   //刷新验证码
   http.HandleFunc("/refresh", refresh)
   http.HandleFunc("/try", try)

   //密码模式Resource Owner Password Credentials
   http.HandleFunc("/pwd", pwd)

   //客户端模式Client Credentials
   http.HandleFunc("/client", client)

   log.Println("Client is running at 9094 port.Please open http://localhost:9094")
   log.Fatal(http.ListenAndServe(":9094", nil))
}

handler

package main

import (
   "context"
   "crypto/sha256"
   "encoding/base64"
   "encoding/json"
   "fmt"
   "golang.org/x/oauth2"
   "golang.org/x/oauth2/clientcredentials"
   "io"
   "net/http"
   "time"
)

//index 重定向到三方授权服务器
func index(w http.ResponseWriter, r *http.Request) {
   u := config.AuthCodeURL("xyz",
      oauth2.SetAuthURLParam("code_challenge", genCodeChallengeS256("s256example")),
      oauth2.SetAuthURLParam("code_challenge_method", "S256"))
   http.Redirect(w, r, u, http.StatusFound)
}

//oAuth2 由三方鉴权服务返回,拿到code,并请求和验证token
func oAuth2(w http.ResponseWriter, r *http.Request) {
   r.ParseForm()
   state := r.Form.Get("state")
   if state != "xyz" {
      http.Error(w, "State invalid", http.StatusBadRequest)
      return
   }
   code := r.Form.Get("code")
   if code == "" {
      http.Error(w, "Code not found", http.StatusBadRequest)
      return
   }
   // 获取token
   token, err := config.Exchange(context.Background(), code, oauth2.SetAuthURLParam("code_verifier", "s256example"))
   if err != nil {
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
   }
   globalToken = token

   e := json.NewEncoder(w)
   e.SetIndent("", "  ")
   e.Encode(token)
}

func refresh(w http.ResponseWriter, r *http.Request) {
   if globalToken == nil {
      http.Redirect(w, r, "/", http.StatusFound)
      return
   }
   globalToken.Expiry = time.Now()
   token, err := config.TokenSource(context.Background(), globalToken).Token()
   if err != nil {
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
   }
   globalToken = token
   e := json.NewEncoder(w)
   e.SetIndent("", "  ")
   e.Encode(token)
}

func try(w http.ResponseWriter, r *http.Request) {
   if globalToken == nil {
      http.Redirect(w, r, "/", http.StatusFound)
      return
   }
   resp, err := http.Get(fmt.Sprintf("%s/test?access_token=%s", authServerURL, globalToken.AccessToken))
   if err != nil {
      http.Error(w, err.Error(), http.StatusBadRequest)
      return
   }
   defer resp.Body.Close()
   io.Copy(w, resp.Body)
}

func pwd(w http.ResponseWriter, r *http.Request) {
   token, err := config.PasswordCredentialsToken(context.Background(), "test", "test")
   if err != nil {
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
   }
   globalToken = token
   e := json.NewEncoder(w)
   e.SetIndent("", "  ")
   e.Encode(token)
}

func client(w http.ResponseWriter, r *http.Request) {
   cfg := clientcredentials.Config{
      ClientID:     config.ClientID,
      ClientSecret: config.ClientSecret,
      TokenURL:     config.Endpoint.TokenURL,
   }

   token, err := cfg.Token(context.Background())
   if err != nil {
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
   }

   e := json.NewEncoder(w)
   e.SetIndent("", "  ")
   e.Encode(token)
}

func genCodeChallengeS256(s string) string {
   s256 := sha256.Sum256([]byte(s))
   return base64.URLEncoding.EncodeToString(s256[:])
}
5.2.2 Server
package main

import (
   "context"
   "flag"
   "fmt"
   "github.com/go-oauth2/oauth2/v4/generates"
   "github.com/go-oauth2/oauth2/v4/models"
   "log"
   "net/http"

   "github.com/go-oauth2/oauth2/v4/errors"
   "github.com/go-oauth2/oauth2/v4/manage"
   "github.com/go-oauth2/oauth2/v4/server"
   "github.com/go-oauth2/oauth2/v4/store"
)

var (
   dumpvar   bool
   idvar     string
   secretvar string
   domainvar string
   portvar   int
)

var srv *server.Server
var manager *manage.Manager
var clientStore *store.ClientStore

func init() {
   flag.BoolVar(&dumpvar, "d", true, "Dump requests and responses")
   flag.StringVar(&idvar, "i", "222222", "The client id being passed in")
   flag.StringVar(&secretvar, "s", "22222222", "The client secret being passed in")
   flag.StringVar(&domainvar, "r", "http://localhost:9094", "The domain of the redirect url")
   flag.IntVar(&portvar, "p", 9096, "the base port for the server")
}

func InitManager() {
   clientStore = store.NewClientStore()
   clientStore.Set(idvar, &models.Client{
      ID:     idvar,
      Secret: secretvar,
      Domain: domainvar,
   })
   manager = manage.NewDefaultManager()
   manager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg)
   manager.MustTokenStorage(store.NewMemoryTokenStore())
   // generate jwt access token
   // manager.MapAccessGenerate(generates.NewJWTAccessGenerate("", []byte("00000000"), jwt.SigningMethodHS512))
   manager.MapAccessGenerate(generates.NewAccessGenerate())
   manager.MapClientStorage(clientStore)
}

func InitServer() {
   srv = server.NewServer(server.NewConfig(), manager)
   //密码登录
   srv.SetPasswordAuthorizationHandler(func(ctx context.Context, clientID, username, password string) (userID string, err error) {
      if username == "test" && password == "test" {
         userID = "test"
      }
      return
   })
   //
   srv.SetUserAuthorizationHandler(userAuthorizeHandler)
   srv.SetInternalErrorHandler(func(err error) (re *errors.Response) {
      log.Println("Internal Error:", err.Error())
      return
   })
   srv.SetResponseErrorHandler(func(re *errors.Response) {
      log.Println("Response Error:", re.Error.Error())
   })
}

func main() {
   flag.Parse()
   if dumpvar {
      log.Println("Dumping requests")
   }
   InitManager()
   InitServer()
   //登录页
   http.HandleFunc("/login", loginHandler)
   //授权页
   http.HandleFunc("/auth", authHandler)
   //重定向回去
   http.HandleFunc("/oauth/authorize", authorize)
   //验证token
   http.HandleFunc("/oauth/token", token)
   http.HandleFunc("/test", test)
   log.Printf("Server is running at %d port.\n", portvar)
   log.Printf("Point your OAuth client Auth endpoint to %s:%d%s", "http://localhost", portvar, "/oauth/authorize")
   log.Printf("Point your OAuth client Token endpoint to %s:%d%s", "http://localhost", portvar, "/oauth/token")
   log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", portvar), nil))
}

handler

package main

import (
   "encoding/json"
   "github.com/go-session/session"
   "io"
   "net/http"
   "net/http/httputil"
   "net/url"
   "os"
   "time"
)

var (
   loginName = "ymx"
   passWord  = "123"
)

//authorize 三方授权服务点击确认授权
func authorize(w http.ResponseWriter, r *http.Request) {
   if dumpvar {
      dumpRequest(os.Stdout, "authorize", r)
   }
   store, err := session.Start(r.Context(), w, r)
   if err != nil {
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
   }
   var form url.Values
   if v, ok := store.Get("ReturnUri"); ok {
      form = v.(url.Values)
   }
   r.Form = form
   store.Delete("ReturnUri")
   store.Save()
   //重定向
   err = srv.HandleAuthorizeRequest(w, r)
   if err != nil {
      http.Error(w, err.Error(), http.StatusBadRequest)
   }
}

func token(w http.ResponseWriter, r *http.Request) {
   if dumpvar {
      _ = dumpRequest(os.Stdout, "token", r) // Ignore the error
   }
   err := srv.HandleTokenRequest(w, r)
   if err != nil {
      http.Error(w, err.Error(), http.StatusInternalServerError)
   }
}

func dumpRequest(writer io.Writer, header string, r *http.Request) error {
   data, err := httputil.DumpRequest(r, true)
   if err != nil {
      return err
   }
   writer.Write([]byte("\n" + header + ": \n"))
   writer.Write(data)
   return nil
}

func userAuthorizeHandler(w http.ResponseWriter, r *http.Request) (userID string, err error) {
   if dumpvar {
      _ = dumpRequest(os.Stdout, "userAuthorizeHandler", r) // Ignore the error
   }
   store, err := session.Start(r.Context(), w, r)
   if err != nil {
      return
   }
   uid, ok := store.Get("LoggedInUserID")
   if !ok {
      if r.Form == nil {
         r.ParseForm()
      }

      store.Set("ReturnUri", r.Form)
      store.Save()

      w.Header().Set("Location", "/login")
      w.WriteHeader(http.StatusFound)
      return
   }

   userID = uid.(string)
   store.Delete("LoggedInUserID")
   store.Save()
   return
}

//loginHandler 三方授权登录
func loginHandler(w http.ResponseWriter, r *http.Request) {
   if dumpvar {
      _ = dumpRequest(os.Stdout, "login", r) // Ignore the error
   }
   store, err := session.Start(r.Context(), w, r)
   if err != nil {
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
   }

   if r.Method == "POST" {
      if r.Form == nil {
         if err := r.ParseForm(); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
         }
      }
      if !checkPwd(r.Form.Get("username"), r.Form.Get("password")) {
         outputHTML(w, r, "static/login.html")
      }
      store.Set("LoggedInUserID", r.Form.Get("username"))
      store.Save()

      w.Header().Set("Location", "/auth")
      w.WriteHeader(http.StatusFound)
      return
   }
   outputHTML(w, r, "static/login.html")
}

//authHandler
func authHandler(w http.ResponseWriter, r *http.Request) {
   if dumpvar {
      _ = dumpRequest(os.Stdout, "auth", r) // Ignore the error
   }
   store, err := session.Start(nil, w, r)
   if err != nil {
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
   }

   if _, ok := store.Get("LoggedInUserID"); !ok {
      w.Header().Set("Location", "/login")
      w.WriteHeader(http.StatusFound)
      return
   }

   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)
}

func test(w http.ResponseWriter, r *http.Request) {
   if dumpvar {
      _ = dumpRequest(os.Stdout, "test", r) // Ignore the error
   }
   token, err := srv.ValidationBearerToken(r)
   if err != nil {
      http.Error(w, err.Error(), http.StatusBadRequest)
      return
   }

   data := map[string]interface{}{
      "expires_in": int64(token.GetAccessCreateAt().Add(token.GetAccessExpiresIn()).Sub(time.Now()).Seconds()),
      "client_id":  token.GetClientID(),
      "user_id":    token.GetUserID(),
   }
   e := json.NewEncoder(w)
   e.SetIndent("", "  ")
   e.Encode(data)
}

//密码验证
func checkPwd(name, pwd string) bool {
   return loginName == name && pwd == passWord
}

login.html

<!DOCTYPE html>
<html lang="zh">

<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 class="container">
        <h1>Login In</h1>
        <form action="/login" method="POST">
            <div class="form-group">
                <label for="username">User Name</label>
                <input type="text" class="form-control" name="username" required placeholder="Please enter your user name">
            </div>
            <div class="form-group">
                <label for="password">Password</label>
                <input type="password" class="form-control" name="password" placeholder="Please enter your password">
            </div>
            <button type="submit" class="btn btn-success">Login</button>
        </form>
    </div>
</body>

</html>

auth.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 class="container">
      <div class="jumbotron">
        <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"
              class="btn btn-primary btn-lg"
              style="width:200px;"
            >
              Allow
            </button>
          </p>
        </form>
      </div>
    </div>
  </body>
</html>

6 小总结

OK,到这里OAuth2.0的讲解就快要结束了,当然由于时间关系,文章中有些内容讲解的可能不够详细,希望读者朋友能够给予指出。文中的代码案例主要采用Go语音进行实现,除此之外Spring社区中也有相关的实现,语言并不是局限。在实际的项目中可能会更加的复杂,但是思想都是一致的,在业务上可能或多或少有所补充,这就需要我们一起在工作中不断学习了。

最后,有一个小思考想分享一下,为什么用户在第三方认证完成后使用返回的Code换取Token,而不是直接使用Code进行后续的步骤呢?

在这里我先给出我的思考和一位前辈的指点:

  • 首先当然是安全,一般Code只能兑换一次token,如果你获取Code后,无法授权,则系统自然会发现被黑客攻击了,会重新授权,那么之前的token就无效了。
  • 其次还是为了安全,Code是服务端生成的,防止Code被拿到后多次请求被认为是恶意请求,而token每次请求后都会变化,且有过期时间。
  • (接下来的原因还请读者朋友们积极讨论)

参考:

razeen.me/posts/oauth…

www.rfc-editor.org/rfc/rfc6749…

zhuanlan.zhihu.com/p/509212673

www.zhihu.com/question/27…