一文带你搞懂 OAuth2.0

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

1 简单介绍

技术RFC: https://www.rfc-editor.org/rfc/rfc6749.html,本文部分内容会参考该文档部分内容。

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

OAuth2.0解决的问题:
  • 需要第三方应用存储资源所有者的凭证以备将来使用,通常是密码以明文,服务器仍须支持密码验证
    密码固有的安全弱点。
  • 第三方应用程序获得对资源的过度广泛访问所有者的受保护资源,使资源所有者没有任何的有限子集限制持续时间或访问的能力资源。
  • 资源所有者不能撤销对单个第三方的访问权限不撤销对所有第三方的访问,并且必须这样做修改第三方用户密码。
  • 任何第三方应用程序的妥协将导致妥协终端用户的密码以及所有受该密码保护的数据密码。

2 流程梳理

OAuth2.0总体流程:
image.png

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

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

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

然后我们画一个图:

其中,实线部分是我们用户真正操作的流程,而虚线部分则是服务内部的流程。

image.png

由此,我们知道,QQ服务器,作为我们将要登录的网站的第三方认证服务,必须事先保存我们用户的信息,以便认证时使用。

3 四种角色

  • resource owner(资源拥有者):用户,能够授予对受保护资源的访问权的实体。
  • resource server(资源服务器):将要访问的服务,托管受保护资源的服务器,能够接受以及使用访问令牌响应受保护的资源请求。
  • client(客户端):即Web浏览器,请求受保护资源的应用程序资源所有者及其授权。
  • authorization server(认证服务器):三方授权服务器,服务提供商专门用来处理认证授权的服务器,认证成功后向客户端发出访问令牌资源所有者身份验证,获取授权。

4 四种实现方式

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

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

image.png

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

4.2 简化模式Implicit

image.png

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

4.3 密码模式Resource Owner Password Credentials

image.png

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

4.4 客户端模式Client Credentials

image.png

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

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

具体代码见GitHub: https://github.com/ibarryyan/oauth2

5.1 整体流程

image.png

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每次请求后都会变化,且有过期时间。
  • (接下来的原因还请读者朋友们积极讨论)
参考:

https://razeen.me/posts/oauth2-protocol-details

https://www.rfc-editor.org/rfc/rfc6749.html

https://zhuanlan.zhihu.com/p/509212673

https://www.zhihu.com/question/275041157/answer/1342887745

相关文章
|
6月前
|
存储 安全 Java
一文带你搞懂OAuth2.0
一文带你搞懂OAuth2.0
144 0
|
20天前
|
数据安全/隐私保护
OAuth2.0实战案例
OAuth2.0实战案例
31 0
OAuth2.0实战案例
|
5月前
|
存储 缓存 数据库
【万字长文】微服务整合Shiro+Jwt,源码分析鉴权实战
介绍如何整合Spring Boot、Shiro和Jwt,以实现一个支持RBAC的无状态认证系统。通过生成JWT token,实现用户无状态登录,并能根据用户角色动态鉴权,而非使用Shiro提供的注解,将角色和权限信息硬编码。此外,文章还探讨了如何对Shiro的异常进行统一捕获和处理。作为应届生,笔者在学习Shiro的过程中进行了一些源码分析,尽管可能存在不足和Bug,但希望能为同样需要实现权限管理的开发者提供参考,并欢迎各位大佬指正完善。
350 65
【万字长文】微服务整合Shiro+Jwt,源码分析鉴权实战
|
安全 前端开发 小程序
OAuth2基础知识
OAuth2基础知识
71 0
|
安全 前端开发 Java
一篇搞懂springsecurity
一篇搞懂springsecurity
152 0
|
安全 Java 数据安全/隐私保护
马老师手写第二版Spring Security OAuth2.0认证授权教程
先是给大家基本概念,然后是基于Session的认证方式,紧接着会带着大家去快速的上手Spring Security,然后回去给大家详解解释Spring Security应用、然后就是分布式系统认证方案以及OAuth2.0,最后是Spring Security实现分布式系统授权!
|
存储 安全 NoSQL
前后端分离认证实践指南:Spring Security和JWT详解(上)
前后端分离认证实践指南:Spring Security和JWT详解
826 0
|
SQL 存储 NoSQL
前后端分离认证实践指南:Spring Security和JWT详解(下)
前后端分离认证实践指南:Spring Security和JWT详解
217 0
|
存储 JSON 安全
妹子始终没搞懂OAuth2.0,今天整合Spring Cloud Security 一次说明白!
妹子始终没搞懂OAuth2.0,今天整合Spring Cloud Security 一次说明白!
|
存储 API 数据库
OAuth 2 实现单点登录,通俗易懂...(上)
OAuth 2 实现单点登录,通俗易懂...(上)
577 0
OAuth 2 实现单点登录,通俗易懂...(上)