写点什么

一文带你搞懂 OAuth2.0

作者:Barry Yan
  • 2022 年 8 月 07 日
  • 本文字数:8971 字

    阅读完需:约 29 分钟

一文带你搞懂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 总体流程:

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


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

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

然后我们画一个图:

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

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

3 四种角色

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

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

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

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

4 四种实现方式

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

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

  • (A) 用户访问客户端,客户端将用户重定向到认证服务器;

  • (B) 用户选择是否授权;

  • (C) 如果用户同意授权,认证服务器重定向到客户端事先指定的地址,而且带上授权码(code);

  • (D) 客户端收到授权码,带着前面的重定向地址,向认证服务器申请访问令牌;

  • (E) 认证服务器核对授权码与重定向地址,确认后向客户端发送访问令牌和更新令牌(可选)。

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:https://github.com/ibarryyan/oauth2

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,并请求和验证tokenfunc 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.Servervar manager *manage.Managervar 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")}
//authHandlerfunc 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


发布于: 2022 年 08 月 07 日阅读数: 3
用户头像

Barry Yan

关注

还未添加个人签名 2021.01.14 加入

还未添加个人简介

评论

发布
暂无评论
一文带你搞懂OAuth2.0_Go_Barry Yan_InfoQ写作社区