你一定用过”微信登录”或”Google 登录”。点一下按钮,第三方应用就拿到了你的头像和昵称——但你从来没有把微信密码告诉过它。这是怎么做到的?
答案就是 OAuth 2.0。它不是什么新东西,2012 年就发布了 RFC 6749,但直到今天仍然是互联网授权的事实标准。
OAuth 2.0 到底解决什么问题 先说最朴素的场景:你在 A 网站存了一堆照片,现在想在 B 网站做个相册打印服务。B 网站需要读取你在 A 网站的照片。
最原始的办法 :把 A 网站的账号密码给 B 网站。B 网站拿着你的密码登录 A 网站,把照片拉过来。
这办法能用,但问题一大堆:
B 网站拿到了你的完整密码,它能在 A 网站干任何事——改密码、删照片、发消息
你改了 A 网站密码,B 网站就没法用了
你没法只授权”读照片”这个权限,B 网站拿到了一切
密码在传输和存储过程中可能泄露
OAuth 2.0 的核心思路是:不分享密码,只发令牌 。用户在授权服务器上登录并同意授权,授权服务器给第三方应用发一个有限权限的令牌(access_token),第三方应用拿着令牌去访问资源。令牌有有效期、有权限范围、可以随时撤销。
四个角色和几把”钥匙” OAuth 2.0 定义了四个核心角色:
graph LR
RO[资源拥有者<br/>Resource Owner] -->|1. 授权| AS[授权服务<br/>Authorization Server]
AS -->|2. 颁发令牌| C[第三方软件<br/>Client]
C -->|3. 携带令牌访问| RS[受保护资源服务<br/>Resource Server]
RS -->|4. 返回受保护数据| C
资源拥有者(Resource Owner) :就是你,照片的主人
第三方软件(Client) :B 网站,想读你照片的那个
授权服务(Authorization Server) :负责验证你的身份、颁发令牌
受保护资源服务(Resource Server) :存照片的地方,只认令牌不认人
在这个过程中,有几样东西在角色之间传递:
名称
作用
类比
授权码(code)
临时凭证,用来换令牌
银行取号条
访问令牌(access_token)
访问资源的凭证,有有效期
酒店房卡
刷新令牌(refresh_token)
用来换新的 access_token
酒店续住凭证
权限范围(scope)
限定令牌能做什么
房卡只能开某一层
回调地址(redirect_uri)
授权后跳回第三方应用的地址
快递收货地址
本文用到的示例项目(OAuth2-Tutorial )是一个 Go 实现的 OAuth 2.0 教学演示,结构很简单:
client(:8080)模拟第三方软件
server(:8081)模拟授权服务 + 受保护资源服务
两者用 Go 标准库 net/http 通信,没有引入任何框架。
授权码许可:最核心、最安全的流程 如果你只记一种流程,就记这个。授权码许可(Authorization Code)是 OAuth 2.0 四种流程里安全性最高、使用最广泛的,目前几乎所有主流平台的”XX 登录”都基于它。
整个流程的核心思想是:把”用户授权”和”颁发令牌”拆成两步 。第一步通过浏览器(前端通道,即用户可见的浏览器跳转)拿到授权码,第二步通过服务器(后端通道,即服务端到服务端的直接 HTTP 请求)用授权码换令牌。这样令牌永远不会经过浏览器,不会被截获。
sequenceDiagram
participant U as 用户浏览器
participant C as 第三方软件(:8080)
participant AS as 授权服务(:8081)
participant RS as 受保护资源(:8081)
U->>C: 1. 访问第三方软件首页
C->>AS: 2. 重定向到授权服务<br/>(response_type=code, app_id, scope, redirect_uri)
AS->>U: 3. 展示授权页面
U->>AS: 4. 用户点击同意授权
AS->>C: 5. 重定向回第三方软件<br/>(携带授权码 code)
C->>AS: 6. 后端请求:用 code 换 access_token<br/>(grant_type=authorization_code, app_id, app_secret)
AS->>C: 7. 返回 access_token + refresh_token
C->>RS: 8. 携带 access_token 请求受保护资源
RS->>C: 9. 返回受保护数据
第一步:第三方软件发起授权请求 用户在浏览器访问第三方软件首页 http://localhost:8080/,第三方软件构造一个授权请求 URL,然后通过 302 重定向把浏览器引导到授权服务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const OauthUrl = "http://localhost:8081/OauthServlet?reqType=oauth" const RedirectUrl = "http://localhost:8080/AppServlet" func AppIndexHandle (writer http.ResponseWriter, request *http.Request) { slog.Info("app index ..." ) oauthUrl, _ := url.Parse(OauthUrl) params := oauthUrl.Query() params.Add("response_type" , "code" ) params.Add("redirect_uri" , RedirectUrl) params.Add("app_id" , "APPID_RABBIT" ) params.Add("scope" , "today history" ) oauthUrl.RawQuery = params.Encode() writer.Header().Set("Location" , oauthUrl.String()) writer.WriteHeader(http.StatusFound) }
这里拼了四个参数:
response_type=code:告诉授权服务”我要走授权码流程”
redirect_uri:授权完成后跳回的地址,必须和注册时一致
app_id:第三方软件的唯一标识,授权服务靠它判断”你是谁”
scope:请求的权限范围,不能超出注册时声明的范围
注意,这一步不带 app_secret 。因为请求是通过浏览器重定向发出的,URL 会被用户看到,app_secret 不能暴露在前端。
第二步:授权服务校验请求 授权服务收到 GET 请求后,要校验三样东西:app_id 是否注册过、redirect_uri 是否匹配、scope 是否在注册范围内:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 func doGet (writer http.ResponseWriter, request *http.Request) { query := request.URL.Query() responseType := query.Get("response_type" ) redirectUri := query.Get("redirect_uri" ) appId := query.Get("app_id" ) scope := query.Get("scope" ) switch responseType { case "code" : if appMap["app_id" ] != appId { return } if appMap["redirect_uri" ] != redirectUri { return } if !checkScope(scope) { return } reqid := strconv.FormatInt(time.Now().UnixMilli(), 10 ) reqidMap[reqid] = reqid request.Header.Set("reqid" , reqid) request.Header.Set("response_type" , responseType) request.Header.Set("redirect_uri" , redirectUri) request.Header.Set("app_id" , appId) u, _ := url.Parse("http://localhost:8081/approve.html" ) proxy := httputil.ReverseProxy{ Director: func (request *http.Request) { request.URL = u }, } proxy.ServeHTTP(writer, request) } }
注册信息存在一个 map 里(实际项目应该是数据库):
1 2 3 4 5 6 func init () { appMap["app_id" ] = "APPID_RABBIT" appMap["app_secret" ] = "APPSECRET_RABBIT" appMap["redirect_uri" ] = "http://localhost:8080/AppServlet" appMap["scope" ] = "today history" }
校验通过后,授权服务生成一个 reqid(这是项目自身的实现细节,用于标识本次授权请求,不是 OAuth 协议标准概念),然后把页面代理到授权确认页 approve.html。这里用反向代理(ReverseProxy)而非普通重定向来渲染授权页,纯粹是 Go 实现上的选择——把请求内部转发到模板渲染处理器,避免多一次浏览器跳转。
第三步:用户授权同意 用户在浏览器看到授权确认页面,上面列出了第三方软件申请的权限范围,用户选择同意或拒绝:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <form action ="/OauthServlet" method ="post" > <input type ="hidden" name ="reqid" value ="{{.Regid}}" /> <input type ="hidden" name ="response_type" value ="{{.ResponseType}}" /> <input type ="hidden" name ="redirect_uri" value ="{{.RedirectUri}}" /> <input type ="hidden" name ="app_id" value ="{{.AppId}}" /> <input type ="hidden" name ="reqType" value ="approve" /> Are you sure you want the authorization code? <br > appid: {{.AppId}} <br > <input type ="checkbox" value ="today" name ="rscope" checked /> today<br > <input type ="checkbox" value ="history" name ="rscope" /> history<br > <br > <input type ="submit" value ="approve" /> <input type ="submit" value ="refuse" /> </form >
用户勾选权限后点击 approve,表单 POST 到授权服务。授权服务收到后,生成授权码并重定向回第三方软件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 if reqType == "approve" { reqid := request.PostFormValue("reqid" ) if _, ok := reqidMap[reqid]; !ok { return } if responseType == "code" { rscope := request.Form["rscope" ] if !checkRScope(rscope) { slog.Info("out of scope ..." ) return } code := generateCode(appId, "USERTEST" ) codeScopeMap[code] = rscope oauthUrl, _ := url.Parse(redirectUri) params := oauthUrl.Query() params.Add("code" , code) oauthUrl.RawQuery = params.Encode() writer.Header().Set("Location" , oauthUrl.String()) writer.WriteHeader(http.StatusFound) } }
这里有两层 scope 校验:用户提交的权限范围不能超出第三方软件注册时声明的范围。generateCode 生成一个 8 位随机数字作为授权码:
1 2 3 4 5 6 7 8 9 10 11 func generateCode (appId string , user string ) string { var sb strings.Builder for range 8 { n, _ := rand.Int(rand.Reader, big.NewInt(8 )) sb.WriteString(strconv.FormatInt(n.Int64(), 10 )) } code := sb.String() codeMap[code] = appId + "|" + user + "|" + strconv.FormatInt(time.Now().UnixMilli(), 10 ) return code }
授权码用 crypto/rand 生成(安全随机数),存储时绑定了 app_id、用户 ID 和时间戳。RFC 建议授权码有效期不超过 10 分钟,且一次性使用 。
第四步:用授权码换取访问令牌 浏览器被重定向回 http://localhost:8080/AppServlet?code=xxxx,第三方软件的后端拿到授权码后,向授权服务发起 POST 请求,用 code 换取 access_token:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 func AppServletHandle (writer http.ResponseWriter, request *http.Request) { query := request.URL.Query() code := query.Get("code" ) params := url.Values{} params.Add("code" , code) params.Add("grant_type" , "authorization_code" ) params.Add("app_id" , "APPID_RABBIT" ) params.Add("app_secret" , "APPSECRET_RABBIT" ) response, err := http.PostForm(OauthURl, params) if err != nil { slog.Error("post code for token failed" , "error" , err) return } defer response.Body.Close() accessToken, _ := io.ReadAll(response.Body) }
这一步是服务端到服务端的请求 ,不经过浏览器。所以这里可以安全地携带 app_secret——它是第三方软件的密钥,只有后端知道。
授权服务收到请求后,校验 app_id、app_secret 和授权码的有效性:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 case "authorization_code" : if appId != appMap["app_id" ] { io.WriteString(writer, "app_id is not available" ) return } if appSecret != appMap["app_secret" ] { io.WriteString(writer, "app_secret is not available" ) return } code := request.PostFormValue("code" ) if !isExistCode(code) { return } delete (codeMap, code) accessToken := generateAccessToken(appId, "USERTEST" ) TokenScopeMap[accessToken] = codeScopeMap[code] refreshToken := generateRefreshToken(appId, "USERTEST" ) io.WriteString(writer, accessToken+"|" +refreshToken)
授权码用一次就作废 (delete(codeMap, code))。如果同一个 code 被用第二次,直接拒绝,防止授权码被截获后的重放攻击。
令牌生成函数:
1 2 3 4 5 6 7 func generateAccessToken (appId string , user string ) string { uid, _ := uuid.NewUUID() accessToken := uid.String() expiresIn := "1" TokenMap[accessToken] = appId + "|" + user + "|" + strconv.FormatInt(time.Now().UnixMilli(), 10 ) + "|" + expiresIn return accessToken }
第五步:携带令牌访问受保护资源 拿到 access_token 后,第三方软件就可以用它来请求受保护资源了:
1 2 3 4 5 6 7 8 9 10 11 12 13 paramsMap := url.Values{} paramsMap.Add("app_id" , "APPID_RABBIT" ) paramsMap.Add("app_secret" , "APPSECRET_RABBIT" ) paramsMap.Add("token" , string (accessToken)) resp, err := http.PostForm(ProtectedURl, paramsMap) if err != nil { slog.Error("request protected resource failed" , "error" , err) return } defer resp.Body.Close()result, _ := io.ReadAll(resp.Body)
受保护资源服务收到请求后,根据令牌对应的权限范围执行不同的操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 func ProtectedHandle (writer http.ResponseWriter, request *http.Request) { accessToken := request.PostFormValue("token" ) scope := TokenScopeMap[accessToken] var sb strings.Builder for _, s := range scope { sb.WriteString(s) sb.WriteString("|" ) } scopeStr := sb.String() if strings.Contains(scopeStr, "query" ) { queryGoods("" ) } if strings.Contains(scopeStr, "add" ) { addGoods("" ) } if strings.Contains(scopeStr, "del" ) { delGoods("" ) } user := TokenMap[accessToken] io.WriteString(writer, queryOrders(user)) }
注意这段代码检查的 scope 值是 query/add/del,和前面授权时声明的 today/history 不一致——这是因为教学项目里不同流程混用了不同的 scope 示例。资源服务根据令牌绑定的 scope 决定允许哪些操作 ,scope 具体叫什么名字由业务定义。
令牌始终在后端通道传递,从不出现在浏览器的 URL 或前端 JavaScript 中。 授权码虽然经过浏览器,但它是短命的、一次性的,即使被截获,没有 app_secret 也换不到令牌。
隐式许可:省去授权码交换的”快捷模式” 隐式许可(Implicit)是为纯前端应用设计的——没有后端服务器,没法安全地保存 app_secret,也没法做服务端到服务端的令牌交换。
它的做法很直接:跳过授权码这个中间步骤,授权服务直接把 access_token 拼在重定向 URL 的 fragment 里返回 。
sequenceDiagram
participant U as 用户浏览器
participant C as 第三方软件(:8080)
participant AS as 授权服务(:8081)
U->>C: 1. 访问第三方软件
C->>AS: 2. 重定向<br/>(response_type=token, app_id, redirect_uri)
AS->>U: 3. 展示授权页面
U->>AS: 4. 用户同意
AS->>C: 5. 重定向回第三方软件<br/>(#access_token=xxxx)
C->>C: 6. 前端从 URL fragment 提取 token
第三方软件发起请求时,把 response_type 改为 token:
1 2 3 4 5 6 7 8 9 10 11 func AppIndexTokenHandle (writer http.ResponseWriter, request *http.Request) { oauthUrl, _ := url.Parse(OauthUrl) params := oauthUrl.Query() params.Add("response_type" , "token" ) params.Add("redirect_uri" , "http://localhost:8080/AppTokenServlet" ) params.Add("app_id" , "APPIDTEST" ) oauthUrl.RawQuery = params.Encode() writer.Header().Set("Location" , oauthUrl.String()) writer.WriteHeader(http.StatusFound) }
授权服务收到 response_type=token 后,直接生成 access_token 并拼到重定向 URL 中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 case "token" : if appId != "APPIDTEST" { return } accessToken := generateAccessToken(appId, "USERTEST" ) oauthUrl, _ := url.Parse(redirectUri) params := oauthUrl.Query() params.Add("redirect_uri" , redirectUri) params.Add("access_token" , accessToken) oauthUrl.RawQuery = params.Encode() writer.Header().Set("Location" , oauthUrl.String()) writer.WriteHeader(http.StatusFound)
第三方软件的回调端点直接从 URL 中提取 access_token:
1 2 3 4 5 func AppServletTokenHandle (writer http.ResponseWriter, request *http.Request) { query := request.URL.Query() accessToken := query.Get("access_token" ) slog.Info("accessToken" , "token" , accessToken) }
隐式许可省掉了授权码交换步骤,流程更简单,但代价是 access_token 直接暴露在前端。由于没有 app_secret 校验,也没有刷新令牌,令牌泄露的风险更高。OAuth 2.1 已经废弃了这个流程,推荐用授权码 + PKCE 替代。
资源拥有者凭据许可:直接交出用户名密码 这个流程最简单粗暴:用户把自己的用户名密码直接交给第三方软件,第三方软件拿着凭据去授权服务换令牌。
sequenceDiagram
participant C as 第三方软件(:8080)
participant AS as 授权服务(:8081)
C->>AS: POST grant_type=password<br/>(app_id, app_secret, username, password)
AS->>AS: 校验 app 凭据 + 用户凭据
AS->>C: 返回 access_token
第三方软件直接把用户名密码 POST 到授权服务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 func AppServletPasswordHandle (writer http.ResponseWriter, request *http.Request) { params := url.Values{} params.Add("grant_type" , "password" ) params.Add("app_id" , "APPIDTEST" ) params.Add("app_secret" , "APPSECRETTEST" ) params.Add("username" , "USERNAMETEST" ) params.Add("password" , "PASSWORDTEST" ) response, err := http.PostForm(OauthURl, params) if err != nil { slog.Error("post code for token failed" , "error" , err) return } defer response.Body.Close() accessToken, _ := io.ReadAll(response.Body) }
授权服务校验 app 凭据和用户凭据后直接发令牌:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 case "password" : username := request.PostFormValue("username" ) password := request.PostFormValue("password" ) if appSecret != "APPSECRETTEST" { io.WriteString(writer, "app_secret is not available" ) return } if username != "USERNAMETEST" { io.WriteString(writer, "username is not available" ) return } if password != "PASSWORDTEST" { io.WriteString(writer, "password is not available" ) return } accessToken := generateAccessToken(appId, "USERTEST" ) io.WriteString(writer, accessToken)
这个流程只适用于用户完全信任的场景,比如操作系统内置的邮件客户端登录自家邮箱服务。OAuth 2.1 也已废弃此流程。第三方软件直接接触到了用户的明文密码,这是它被废弃的根本原因。
客户端凭据许可:机器对机器的通行证 这个流程不涉及用户,纯机器对机器。第三方软件用自己的 app_id 和 app_secret 直接换取令牌,适用于服务间 API 调用、定时任务等场景。
sequenceDiagram
participant C as 第三方软件(:8080)
participant AS as 授权服务(:8081)
C->>AS: POST grant_type=client_credentials<br/>(app_id, app_secret)
AS->>AS: 校验 app 凭据
AS->>C: 返回 access_token
第三方软件的请求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 func AppServletClientCredentialsHandle (writer http.ResponseWriter, request *http.Request) { params := url.Values{} params.Add("grant_type" , "client_credentials" ) params.Add("app_id" , "APPIDTEST" ) params.Add("app_secret" , "APPSECRETTEST" ) response, err := http.PostForm(OauthURl, params) if err != nil { slog.Error("post code for token failed" , "error" , err) return } defer response.Body.Close() accessToken, _ := io.ReadAll(response.Body) }
授权服务的处理:
1 2 3 4 5 6 7 8 9 10 11 12 case "client_credentials" : if appId != "APPIDTEST" { io.WriteString(writer, "app_id is not available" ) return } if appSecret != "APPSECRETTEST" { io.WriteString(writer, "app_secret is not available" ) return } accessToken := generateAccessToken(appId, "USERTEST" ) io.WriteString(writer, accessToken)
注意,这个流程没有用户参与,令牌代表的是第三方软件自身而非某个用户。它和密码流程的代码几乎一样,区别只在于 grant_type 和是否提交用户凭据。
刷新令牌:让用户只授权一次 access_token 有有效期(示例中是 1 天),过期后用户就得重新走一遍授权流程。这体验很差。刷新令牌(refresh_token)就是解决这个问题的。
刷新令牌的有效期比 access_token 长得多(比如 30 天),它不出现在前端,只存在第三方软件的后端。access_token 过期后,第三方软件用 refresh_token 向授权服务换一个新的 access_token,全程不需要用户参与:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 case "refresh_token" : if appId != "APPIDTEST" { io.WriteString(writer, "app_id is not available" ) return } if appSecret != "APPSECRETTEST" { io.WriteString(writer, "app_secret is not available" ) return } refreshToken := request.PostFormValue("refresh_token" ) if _, ok := refreshTokenMap[refreshToken]; !ok { return } appStr := refreshTokenMap[refreshToken] if !strings.HasPrefix(appStr, appId+"|" +"USERTEST" ) { return } accessToken := generateAccessToken(appId, "USERTEST" ) io.WriteString(writer, accessToken)
刷新令牌的生成和存储:
1 2 3 4 5 6 7 func generateRefreshToken (appId string , user string ) string { uid, _ := uuid.NewUUID() refreshToken := uid.String() refreshTokenMap[refreshToken] = appId + "|" + user + "|" + strconv.FormatInt(time.Now().UnixMilli(), 10 ) return refreshToken }
刷新令牌的安全性要求比 access_token 更高,因为它能反复换取新的 access_token。在实际项目中,刷新令牌只应存储在后端,有效期可以长但必须可撤销。更好的做法是每次使用后旋转——发一个新的 refresh_token,旧的作废。
OIDC:OAuth 2.0 之上的身份认证层 前面所有流程解决的都是”授权”问题——第三方软件拿到令牌后能访问哪些资源。但有一个问题没解决:第三方软件怎么知道”这个令牌是谁的”?
OAuth 2.0 本身不负责身份认证。access_token 是一个不透明的字符串,第三方软件拿到它后只能去资源服务器换数据,不知道令牌对应的用户是谁。如果你用 access_token 来做”登录”,那就是在用授权协议干认证的活——这恰恰是很多安全漏洞的根源。
OIDC(OpenID Connect)在 OAuth 2.0 之上加了一层,核心就是多发一个 ID Token 。ID Token 是一个 JWT(JSON Web Token),里面直接包含了用户身份信息,第三方软件拿到后可以直接解析,不需要再额外请求。
sequenceDiagram
participant U as 用户浏览器
participant C as 第三方软件(:8080)
participant AS as 授权服务(:8081)
U->>C: 1. 访问第三方软件
C->>AS: 2. 重定向到授权服务
AS->>U: 3. 展示授权页面
U->>AS: 4. 用户同意
AS->>C: 5. 重定向回第三方软件(携带 code)
C->>AS: 6. 用 code 换 token(grant_type=authorization_code_2)
AS->>C: 7. 返回 access_token + id_token(JWT)
C->>C: 8. 解析 JWT 获取用户身份(sub)
C->>U: 9. 显示"你好,XXX"
OIDC 流程的前半段和授权码流程一模一样,区别在令牌交换那一步——授权服务除了返回 access_token,还会返回一个 ID Token。
授权服务生成 ID Token,用 JWT 签名:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 func generateIdToken (appId string , user string ) string { sharedTokenSecret := []byte ("hellooauthhellooauthhellooauthhellooauth" ) iat := time.Now().Unix() token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "iss" : "http://localhost:8081/" , "sub" : user, "aud" : appId, "iat" : iat, "exp" : iat + 60 *60 , }) tokenString, _ := token.SignedString(sharedTokenSecret) return tokenString }
JWT 的 payload 包含五个标准声明:
iss(issuer):签发者,标识是哪个授权服务发的
sub(subject):用户唯一标识,这就是第三方软件要拿到的”你是谁”
aud(audience):接收方,标识这个 token 是发给哪个第三方软件的
iat(issued at):签发时间
exp(expiration):过期时间
第三方软件收到 ID Token 后,验证签名并提取用户身份:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 func AppServletOIDCHandle (writer http.ResponseWriter, request *http.Request) { query := request.URL.Query() code := query.Get("code" ) params := url.Values{} params.Add("code" , code) params.Add("grant_type" , "authorization_code_2" ) params.Add("app_id" , "APPID_RABBIT" ) params.Add("app_secret" , "APPSECRET_RABBIT" ) response, err := http.PostForm(OauthURl, params) if err != nil { slog.Error("post code for token failed" , "error" , err) return } defer response.Body.Close() result, _ := io.ReadAll(response.Body) arry := strings.Split(string (result), "&" ) accessToken := arry[0 ] idToken := arry[1 ] m := parseJwt(idToken) sub := fmt.Sprintf("%v" , m["sub" ]) u, _ := url.Parse("http://localhost:8080/oidc.html" ) proxy := httputil.ReverseProxy{ Director: func (request *http.Request) { request.URL = u }, } proxy.ServeHTTP(writer, request) }
JWT 解析和验签:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 func parseJwt (jwtString string ) map [string ]any { sharedTokenSecret := []byte ("hellooauthhellooauthhellooauthhellooauth" ) token, _ := jwt.Parse(jwtString, func (token *jwt.Token) (any, error ) { return sharedTokenSecret, nil }) m := make (map [string ]any) if token == nil { return m } if _, ok := token.Header["alg" ].(string ); !ok || jwt.SigningMethodHS256.Alg() != token.Header["alg" ] { slog.Error("unexpected signing method" , "alg" , token.Header["alg" ]) return m } if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { return claims } return m }
这里验证了三样东西:签名是否正确(用共享密钥验证)、算法是否匹配(防止算法降级攻击)、token 是否有效。最后从 sub 字段拿到用户标识,渲染到欢迎页面:
1 2 3 <body > hello, {{.Sub}}, you have signed in successfully. </body >
OAuth 2.0 负责授权,OIDC 负责把”你是谁”传给第三方软件。你平时点的”微信登录”,底下跑的就是这两层。
安全最佳实践 最后梳理几个生产环境必须做到的安全实践:
1. HTTPS 全程加密。 示例项目用 HTTP 是为了教学方便,生产环境所有通信必须走 HTTPS。授权码、令牌、用户凭据在传输过程中都必须加密。
2. redirect_uri 严格匹配。 授权服务必须验证 redirect_uri 与注册时完全一致,不能做前缀匹配或通配。否则攻击者可以注册一个相似域名,把授权码截获到自己的服务器。
3. 授权码一次性使用。 代码中的 delete(codeMap, code) 就在做这件事。授权码被使用后立即作废,防止重放攻击。
4. 授权码有效期要短。 RFC 建议不超过 10 分钟。示例代码中记录了时间戳但没有做过期检查,生产环境必须补上。
5. scope 最小权限原则。 只授予必要的权限范围。代码中两层 scope 校验(请求时校验 + 用户授权时校验)就是这个思路。
6. PKCE 扩展。 对于纯前端应用(无法安全保存 app_secret 的场景),应使用 PKCE(Proof Key for Code Exchange)。它在授权码流程的基础上增加了一层动态验证,即使授权码被截获,没有 PKCE 的 verifier 也换不到令牌。这比隐式许可安全得多,是 OAuth 2.1 推荐的替代方案。
四种流程对比
流程
用户参与
app_secret
安全性
适用场景
OAuth 2.1
授权码
是
后端持有
高
Web 应用、有后端的服务
保留
隐式许可
是
不需要
低
纯前端 SPA
废弃
密码许可
是
需要
中
第一方应用
废弃
客户端凭据
否
需要
中
机器对机器
保留
如果你在设计一个新系统,记住两个选择:有后端用授权码流程 ,纯前端用授权码 + PKCE 。其他流程了解原理即可,新项目不应该再使用。
总结 OAuth 2.0 的核心是”不分享密码,只发令牌”。四种流程的区别在于令牌怎么发:授权码流程先发临时码再在后端换令牌,安全性最高,也是你现在最该用的;隐式许可跳过中间步骤直接发令牌,已被废弃;密码许可直接交用户名密码,同样废弃;客户端凭据不涉及用户,机器对机器场景仍在使用。
OIDC 在这之上多发一个 JWT 格式的 ID Token,让第三方软件能安全地知道”令牌是谁的”——这才是”微信登录”的完整实现。
回头再看那个登录按钮,每一步都清楚了:重定向到授权页,授权码换令牌,令牌换资源,ID Token 换身份。