第3章:OAuth 2.0与OpenID Connect
OAuth 2.0四种授权模式
1. 授权码模式(Authorization Code)
最常用、最安全
流程:
1. 用户点击"使用GitHub登录"
2. 重定向到GitHub授权页面
3. 用户同意授权
4. GitHub返回授权码(code)
5. 应用用code换取access_token
6. 使用access_token访问用户资源
Go实现:
// OAuth 2.0授权服务器
type OAuthServer struct {
clients map[string]*Client
codes map[string]*AuthorizationCode
tokens map[string]*AccessToken
}
// Client 客户端
type Client struct {
ID string
Secret string
RedirectURIs []string
}
// AuthorizationCode 授权码
type AuthorizationCode struct {
Code string
ClientID string
UserID int
RedirectURI string
ExpiresAt time.Time
Used bool
}
// AccessToken 访问令牌
type AccessToken struct {
Token string
ClientID string
UserID int
ExpiresAt time.Time
}
// 授权端点
func (s *OAuthServer) Authorize(w http.ResponseWriter, r *http.Request) {
clientID := r.URL.Query().Get("client_id")
redirectURI := r.URL.Query().Get("redirect_uri")
responseType := r.URL.Query().Get("response_type")
state := r.URL.Query().Get("state")
// 验证client
client, exists := s.clients[clientID]
if !exists {
http.Error(w, "Invalid client", 400)
return
}
// 验证redirect_uri
if !contains(client.RedirectURIs, redirectURI) {
http.Error(w, "Invalid redirect URI", 400)
return
}
// 用户登录并同意授权(简化)
userID := 123
// 生成授权码
code := generateCode()
s.codes[code] = &AuthorizationCode{
Code: code,
ClientID: clientID,
UserID: userID,
RedirectURI: redirectURI,
ExpiresAt: time.Now().Add(10 * time.Minute),
Used: false,
}
// 重定向回客户端
redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state)
http.Redirect(w, r, redirectURL, 302)
}
// Token端点
func (s *OAuthServer) Token(w http.ResponseWriter, r *http.Request) {
grantType := r.FormValue("grant_type")
code := r.FormValue("code")
clientID := r.FormValue("client_id")
clientSecret := r.FormValue("client_secret")
redirectURI := r.FormValue("redirect_uri")
// 验证client
client, exists := s.clients[clientID]
if !exists || client.Secret != clientSecret {
http.Error(w, "Invalid client credentials", 401)
return
}
// 验证授权码
authCode, exists := s.codes[code]
if !exists || authCode.Used || time.Now().After(authCode.ExpiresAt) {
http.Error(w, "Invalid or expired code", 400)
return
}
// 验证redirect_uri
if authCode.RedirectURI != redirectURI {
http.Error(w, "Redirect URI mismatch", 400)
return
}
// 标记授权码为已使用
authCode.Used = true
// 生成access_token
token := generateToken()
s.tokens[token] = &AccessToken{
Token: token,
ClientID: clientID,
UserID: authCode.UserID,
ExpiresAt: time.Now().Add(1 * time.Hour),
}
// 返回token
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": token,
"token_type": "Bearer",
"expires_in": 3600,
})
}
2. 隐式模式(Implicit)- 已废弃
不推荐使用,直接返回access_token,安全性差。
3. 密码模式(Password)
// 密码模式:直接用用户名密码换取token
func (s *OAuthServer) TokenPassword(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
clientID := r.FormValue("client_id")
// 验证用户名密码
userID, ok := s.authenticateUser(username, password)
if !ok {
http.Error(w, "Invalid credentials", 401)
return
}
// 生成token
token := generateToken()
s.tokens[token] = &AccessToken{
Token: token,
ClientID: clientID,
UserID: userID,
ExpiresAt: time.Now().Add(1 * time.Hour),
}
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": token,
"token_type": "Bearer",
"expires_in": 3600,
})
}
4. 客户端模式(Client Credentials)
// 客户端模式:服务间调用
func (s *OAuthServer) TokenClientCredentials(w http.ResponseWriter, r *http.Request) {
clientID := r.FormValue("client_id")
clientSecret := r.FormValue("client_secret")
// 验证客户端
client, exists := s.clients[clientID]
if !exists || client.Secret != clientSecret {
http.Error(w, "Invalid client", 401)
return
}
// 生成token(无用户)
token := generateToken()
s.tokens[token] = &AccessToken{
Token: token,
ClientID: clientID,
ExpiresAt: time.Now().Add(1 * time.Hour),
}
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": token,
"token_type": "Bearer",
"expires_in": 3600,
})
}
PKCE增强安全
什么是PKCE?
PKCE(Proof Key for Code Exchange,发音"pixie")是OAuth 2.0的安全增强,专为公共客户端(无法安全存储client_secret的应用)设计。
适用场景:
单页应用(SPA)
移动应用(iOS/Android)
桌面应用
任何无法安全存储密钥的客户端
解决问题:
防止授权码拦截攻击(Authorization Code Interception)
PKCE工作原理
传统授权码模式的风险:
攻击者可能拦截授权码(code),然后用client_secret换取token
┌──────┐ ┌─────────┐
│ App │──1. code────────────────>│Attacker │
└──────┘ └─────────┘
│
│ 2. 用client_secret
│ 换取access_token
↓
┌─────────┐
│ Server │
└─────────┘
PKCE解决方案:
使用动态生成的code_verifier和code_challenge
PKCE流程:
1. App生成随机字符串:code_verifier
2. 计算SHA256哈希:code_challenge = BASE64URL(SHA256(code_verifier))
3. 请求授权时携带:code_challenge
4. 换取token时携带:code_verifier
5. 服务器验证:SHA256(code_verifier) == code_challenge
完整流程:
┌─────┐ ┌────────┐
│ App │ │ Server │
└──┬──┘ └───┬────┘
│ │
│ 1. 生成code_verifier │
│ code_challenge │
│ │
│ 2. 授权请求 │
│ + code_challenge │
├────────────────────────────────>│
│ │
│ 3. 返回授权码(code) │
│<────────────────────────────────┤
│ │
│ 4. 换取token │
│ + code │
│ + code_verifier │
├────────────────────────────────>│
│ │
│ │ 5. 验证
│ │ SHA256(code_verifier)
│ │ == code_challenge
│ │
│ 6. 返回access_token │
│<────────────────────────────────┤
Go实现PKCE服务端
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
)
// PKCE授权码
type PKCEAuthorizationCode struct {
Code string
ClientID string
UserID int
RedirectURI string
CodeChallenge string
ChallengeMethod string
Used bool
}
// PKCE服务器
type PKCEServer struct {
codes map[string]*PKCEAuthorizationCode
tokens map[string]*AccessToken
}
// 1. 授权端点(接收code_challenge)
func (s *PKCEServer) Authorize(w http.ResponseWriter, r *http.Request) {
clientID := r.URL.Query().Get("client_id")
redirectURI := r.URL.Query().Get("redirect_uri")
codeChallenge := r.URL.Query().Get("code_challenge")
codeChallengeMethod := r.URL.Query().Get("code_challenge_method")
state := r.URL.Query().Get("state")
// 验证code_challenge
if codeChallenge == "" {
http.Error(w, "code_challenge required", 400)
return
}
// 支持S256(推荐)或plain
if codeChallengeMethod != "S256" && codeChallengeMethod != "plain" {
http.Error(w, "Invalid code_challenge_method", 400)
return
}
// 用户登录并授权(简化)
userID := 123
// 生成授权码
code := generateRandomString(32)
s.codes[code] = &PKCEAuthorizationCode{
Code: code,
ClientID: clientID,
UserID: userID,
RedirectURI: redirectURI,
CodeChallenge: codeChallenge,
ChallengeMethod: codeChallengeMethod,
Used: false,
}
// 重定向回客户端
redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state)
http.Redirect(w, r, redirectURL, 302)
}
// 2. Token端点(验证code_verifier)
func (s *PKCEServer) Token(w http.ResponseWriter, r *http.Request) {
code := r.FormValue("code")
codeVerifier := r.FormValue("code_verifier")
clientID := r.FormValue("client_id")
// 验证授权码
authCode, exists := s.codes[code]
if !exists || authCode.Used {
http.Error(w, "Invalid or expired code", 400)
return
}
// PKCE验证:验证code_verifier
if !s.verifyCodeVerifier(codeVerifier, authCode.CodeChallenge, authCode.ChallengeMethod) {
http.Error(w, "Invalid code_verifier", 400)
return
}
// 标记已使用
authCode.Used = true
// 生成access_token
token := generateRandomString(32)
s.tokens[token] = &AccessToken{
Token: token,
ClientID: clientID,
UserID: authCode.UserID,
}
// 返回token
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": token,
"token_type": "Bearer",
"expires_in": 3600,
})
}
// 验证code_verifier
func (s *PKCEServer) verifyCodeVerifier(verifier, challenge, method string) bool {
if method == "plain" {
// plain模式:直接比较
return verifier == challenge
}
// S256模式:SHA256哈希比较
hash := sha256.Sum256([]byte(verifier))
computed := base64.RawURLEncoding.EncodeToString(hash[:])
return computed == challenge
}
// 生成随机字符串
func generateRandomString(length int) string {
b := make([]byte, length)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)[:length]
}
Go实现PKCE客户端
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"net/url"
)
// PKCE客户端
type PKCEClient struct {
clientID string
redirectURI string
authURL string
tokenURL string
codeVerifier string
}
// 1. 生成code_verifier和code_challenge
func (c *PKCEClient) generatePKCE() (verifier, challenge string) {
// 生成code_verifier(43-128个字符)
b := make([]byte, 32)
rand.Read(b)
verifier = base64.RawURLEncoding.EncodeToString(b)
// 计算code_challenge = BASE64URL(SHA256(code_verifier))
hash := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(hash[:])
return verifier, challenge
}
// 2. 生成授权URL
func (c *PKCEClient) GetAuthorizationURL() string {
// 生成并保存code_verifier
verifier, challenge := c.generatePKCE()
c.codeVerifier = verifier
// 构造授权URL
params := url.Values{
"client_id": {c.clientID},
"redirect_uri": {c.redirectURI},
"response_type": {"code"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
"state": {generateRandomString(16)},
}
return c.authURL + "?" + params.Encode()
}
// 3. 用授权码换取token
func (c *PKCEClient) ExchangeToken(code string) (string, error) {
// 发送token请求(携带code_verifier)
resp, err := http.PostForm(c.tokenURL, url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {c.redirectURI},
"client_id": {c.clientID},
"code_verifier": {c.codeVerifier}, // PKCE关键
})
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
// 解析响应...
return result.AccessToken, nil
}
// 使用示例
func main() {
client := &PKCEClient{
clientID: "my-spa-app",
redirectURI: "http://localhost:3000/callback",
authURL: "https://auth.example.com/authorize",
tokenURL: "https://auth.example.com/token",
}
// 跳转到授权页面
authURL := client.GetAuthorizationURL()
fmt.Println("访问:", authURL)
// 授权后回调,获取code
// code := "..."
// 用code换取token
// token, _ := client.ExchangeToken(code)
}
JavaScript/SPA实现PKCE
// PKCE辅助函数
class PKCEHelper {
// 生成随机字符串
static generateRandomString(length) {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// 计算SHA256
static async sha256(plain) {
const encoder = new TextEncoder();
const data = encoder.encode(plain);
const hash = await crypto.subtle.digest('SHA-256', data);
return hash;
}
// Base64 URL编码
static base64URLEncode(buffer) {
const bytes = new Uint8Array(buffer);
const binary = String.fromCharCode(...bytes);
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// 生成code_verifier和code_challenge
static async generatePKCE() {
const codeVerifier = this.generateRandomString(43);
const hashed = await this.sha256(codeVerifier);
const codeChallenge = this.base64URLEncode(hashed);
return { codeVerifier, codeChallenge };
}
}
// OAuth客户端
class OAuthClient {
constructor(config) {
this.clientId = config.clientId;
this.redirectUri = config.redirectUri;
this.authUrl = config.authUrl;
this.tokenUrl = config.tokenUrl;
}
// 1. 开始授权流程
async login() {
// 生成PKCE参数
const { codeVerifier, codeChallenge } = await PKCEHelper.generatePKCE();
// 保存code_verifier到sessionStorage
sessionStorage.setItem('code_verifier', codeVerifier);
// 构造授权URL
const params = new URLSearchParams({
client_id: this.clientId,
redirect_uri: this.redirectUri,
response_type: 'code',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
scope: 'openid profile email',
state: PKCEHelper.generateRandomString(16)
});
// 跳转到授权页面
window.location.href = `${this.authUrl}?${params}`;
}
// 2. 处理回调
async handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (!code) {
throw new Error('No authorization code');
}
// 获取保存的code_verifier
const codeVerifier = sessionStorage.getItem('code_verifier');
if (!codeVerifier) {
throw new Error('No code_verifier found');
}
// 清除code_verifier
sessionStorage.removeItem('code_verifier');
// 用code换取token
const response = await fetch(this.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: this.redirectUri,
client_id: this.clientId,
code_verifier: codeVerifier, // PKCE
})
});
const data = await response.json();
return data.access_token;
}
}
// 使用示例
const client = new OAuthClient({
clientId: 'my-spa-app',
redirectUri: 'http://localhost:3000/callback',
authUrl: 'https://auth.example.com/authorize',
tokenUrl: 'https://auth.example.com/token',
});
// 登录按钮
document.getElementById('login-btn').addEventListener('click', () => {
client.login();
});
// 回调页面处理
if (window.location.pathname === '/callback') {
client.handleCallback()
.then(token => {
console.log('Access Token:', token);
// 保存token并跳转到主页
localStorage.setItem('access_token', token);
window.location.href = '/';
})
.catch(err => {
console.error('Login failed:', err);
});
}
React实现PKCE
import React, { useEffect, useState } from 'react';
// PKCE Hook
function usePKCE() {
const generateRandomString = (length) => {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
};
const sha256 = async (plain) => {
const encoder = new TextEncoder();
const data = encoder.encode(plain);
return await crypto.subtle.digest('SHA-256', data);
};
const base64URLEncode = (buffer) => {
const bytes = new Uint8Array(buffer);
const binary = String.fromCharCode(...bytes);
return btoa(binary)
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
};
const generatePKCE = async () => {
const codeVerifier = generateRandomString(43);
const hashed = await sha256(codeVerifier);
const codeChallenge = base64URLEncode(hashed);
return { codeVerifier, codeChallenge };
};
return { generatePKCE };
}
// OAuth登录组件
function OAuthLogin() {
const { generatePKCE } = usePKCE();
const handleLogin = async () => {
const { codeVerifier, codeChallenge } = await generatePKCE();
// 保存code_verifier
sessionStorage.setItem('code_verifier', codeVerifier);
// 跳转授权
const params = new URLSearchParams({
client_id: 'my-react-app',
redirect_uri: 'http://localhost:3000/callback',
response_type: 'code',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
scope: 'openid profile email',
});
window.location.href = `https://auth.example.com/authorize?${params}`;
};
return (
<button onClick={handleLogin}>
使用OAuth登录
</button>
);
}
// 回调页面组件
function OAuthCallback() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const handleCallback = async () => {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const codeVerifier = sessionStorage.getItem('code_verifier');
if (!code || !codeVerifier) {
setError('Invalid callback');
return;
}
try {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: 'http://localhost:3000/callback',
client_id: 'my-react-app',
code_verifier: codeVerifier,
})
});
const data = await response.json();
localStorage.setItem('access_token', data.access_token);
window.location.href = '/';
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
handleCallback();
}, []);
if (loading) return <div>登录中...</div>;
if (error) return <div>登录失败: {error}</div>;
return null;
}
export { OAuthLogin, OAuthCallback };
PKCE安全建议
// 1. code_verifier必须随机且足够长
func GenerateSecureCodeVerifier() string {
// 推荐43-128个字符
b := make([]byte, 32) // 生成32字节,编码后约43字符
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
// 2. 必须使用S256,不要使用plain
// plain模式不安全,仅用于调试
const codeChallengeMethod = "S256"
// 3. code_verifier只能使用一次
type AuthCode struct {
Code string
CodeVerifier string
Used bool // 标记已使用
ExpiresAt time.Time
}
// 4. 授权码必须短期有效
const codeExpiration = 10 * time.Minute
// 5. 服务端必须验证redirect_uri
func validateRedirectURI(provided, registered string) bool {
return provided == registered
}
OpenID Connect
OIDC vs OAuth 2.0
OAuth 2.0:授权框架(用于授权)
OpenID Connect:身份层(用于认证)
OIDC = OAuth 2.0 + ID Token
ID Token:包含用户身份信息的JWT
OIDC流程
// OIDC Token端点(返回ID Token + Access Token)
func (s *OIDCServer) Token(w http.ResponseWriter, r *http.Request) {
// ... OAuth 2.0流程 ...
// 生成ID Token
idToken, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": "https://example.com",
"sub": "user123",
"aud": clientID,
"exp": time.Now().Add(1 * time.Hour).Unix(),
"iat": time.Now().Unix(),
"email": "user@example.com",
"name": "John Doe",
}).SignedString([]byte("secret"))
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": accessToken,
"token_type": "Bearer",
"expires_in": 3600,
"id_token": idToken,
})
}
// UserInfo端点
func (s *OIDCServer) UserInfo(w http.ResponseWriter, r *http.Request) {
// 从Authorization Header获取access_token
token := extractToken(r)
// 验证token
accessToken, exists := s.tokens[token]
if !exists {
http.Error(w, "Invalid token", 401)
return
}
// 返回用户信息
json.NewEncoder(w).Encode(map[string]interface{}{
"sub": accessToken.UserID,
"email": "user@example.com",
"name": "John Doe",
})
}
第三方登录集成
GitHub登录示例
const (
GitHubClientID = "your_client_id"
GitHubClientSecret = "your_client_secret"
GitHubRedirectURI = "http://localhost:8080/callback"
)
// 跳转到GitHub授权页面
func HandleGitHubLogin(w http.ResponseWriter, r *http.Request) {
url := fmt.Sprintf(
"https://github.com/login/oauth/authorize?client_id=%s&redirect_uri=%s&scope=user:email",
GitHubClientID,
url.QueryEscape(GitHubRedirectURI),
)
http.Redirect(w, r, url, 302)
}
// GitHub回调
func HandleGitHubCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
// 用code换取access_token
tokenResp, _ := http.PostForm("https://github.com/login/oauth/access_token", url.Values{
"client_id": {GitHubClientID},
"client_secret": {GitHubClientSecret},
"code": {code},
})
// 解析access_token
body, _ := io.ReadAll(tokenResp.Body)
values, _ := url.ParseQuery(string(body))
accessToken := values.Get("access_token")
// 获取用户信息
req, _ := http.NewRequest("GET", "https://api.github.com/user", nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
userResp, _ := http.DefaultClient.Do(req)
var user struct {
ID int `json:"id"`
Login string `json:"login"`
Email string `json:"email"`
}
json.NewDecoder(userResp.Body).Decode(&user)
// 创建本地用户或登录
fmt.Fprintf(w, "Welcome, %s!", user.Login)
}
微信扫码登录完整实现
准备工作:
1. 注册微信开放平台账号
https://open.weixin.qq.com/
2. 创建网站应用
- 开发者资质认证(需要300元认证费)
- 创建网站应用 → 填写应用信息
- 获取AppID和AppSecret
3. 配置授权回调域
- 应用详情 → 授权回调域
- 填写:yourdomain.com(不带http://)
完整流程:
┌──────┐ ┌──────────┐ ┌──────────┐
│Browser│ │Your Server│ │ WeChat │
└───┬──┘ └─────┬────┘ └─────┬────┘
│ │ │
│ 1. 点击微信登录 │ │
├──────────────────>│ │
│ │ │
│ 2. 返回二维码页面 │ │
│<──────────────────┤ │
│ │ │
│ 3. 请求二维码 │ │
├───────────────────────────────────────>│
│ │ │
│ 4. 返回二维码 │ │
│<───────────────────────────────────────┤
│ │ │
│ 5. 轮询检查扫码 │ │
├──────────────────>│ │
│ │ 6. 检查扫码状态 │
│ ├────────────────────>│
│ │ 7. 返回状态 │
│ │<────────────────────┤
│ │ │
│(用户扫码确认) │ │
│ │ │
│ 8. 微信回调(code)│ │
│<──────────────────┤ │
│ │ │
│ 9. 换取access_token│ │
│──────────────────>│ 10. 换取token │
│ ├────────────────────>│
│ │ 11. 返回token │
│ │<────────────────────┤
│ │ 12. 获取用户信息 │
│ ├────────────────────>│
│ │ 13. 返回用户信息 │
│ │<────────────────────┤
│ 14. 登录成功 │ │
│<──────────────────┤ │
Go实现:
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
const (
WeChatAppID = "your_appid"
WeChatAppSecret = "your_appsecret"
WeChatRedirect = "http://yourdomain.com/wechat/callback"
)
// 微信登录管理器
type WeChatLoginManager struct {
qrCodes map[string]*QRCodeSession
}
type QRCodeSession struct {
State string
CreatedAt time.Time
Scanned bool
Code string
}
// 1. 生成微信登录二维码
func (m *WeChatLoginManager) HandleWeChatLogin(w http.ResponseWriter, r *http.Request) {
// 生成state(防CSRF)
state := generateRandomString(32)
// 保存session
m.qrCodes[state] = &QRCodeSession{
State: state,
CreatedAt: time.Now(),
Scanned: false,
}
// 构造微信授权URL
authURL := fmt.Sprintf(
"https://open.weixin.qq.com/connect/qrconnect?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_login&state=%s#wechat_redirect",
WeChatAppID,
url.QueryEscape(WeChatRedirect),
state,
)
// 返回HTML页面(展示二维码)
html := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<title>微信登录</title>
<script src="https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js"></script>
</head>
<body>
<h2>使用微信扫码登录</h2>
<div id="wechat-qrcode"></div>
<script>
new WxLogin({
self_redirect: false,
id: "wechat-qrcode",
appid: "%s",
scope: "snsapi_login",
redirect_uri: "%s",
state: "%s",
style: "black",
href: ""
});
// 轮询检查扫码状态
const checkInterval = setInterval(() => {
fetch('/wechat/check?state=%s')
.then(res => res.json())
.then(data => {
if (data.scanned) {
clearInterval(checkInterval);
window.location.href = "/dashboard";
}
});
}, 2000);
</script>
</body>
</html>
`, WeChatAppID, url.QueryEscape(WeChatRedirect), state, state)
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(html))
}
// 2. 检查扫码状态(轮询)
func (m *WeChatLoginManager) HandleCheckStatus(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
session, exists := m.qrCodes[state]
if !exists {
json.NewEncoder(w).Encode(map[string]bool{"scanned": false})
return
}
json.NewEncoder(w).Encode(map[string]bool{"scanned": session.Scanned})
}
// 3. 微信回调处理
func (m *WeChatLoginManager) HandleWeChatCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
// 验证state
session, exists := m.qrCodes[state]
if !exists {
http.Error(w, "Invalid state", 400)
return
}
// 用code换取access_token
tokenURL := fmt.Sprintf(
"https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code",
WeChatAppID,
WeChatAppSecret,
code,
)
resp, err := http.Get(tokenURL)
if err != nil {
http.Error(w, "Failed to get token", 500)
return
}
defer resp.Body.Close()
var tokenResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
OpenID string `json:"openid"`
Scope string `json:"scope"`
UnionID string `json:"unionid"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
json.NewDecoder(resp.Body).Decode(&tokenResp)
if tokenResp.ErrCode != 0 {
http.Error(w, tokenResp.ErrMsg, 400)
return
}
// 获取用户信息
userInfoURL := fmt.Sprintf(
"https://api.weixin.qq.com/sns/userinfo?access_token=%s&openid=%s",
tokenResp.AccessToken,
tokenResp.OpenID,
)
userResp, _ := http.Get(userInfoURL)
defer userResp.Body.Close()
var wechatUser struct {
OpenID string `json:"openid"`
Nickname string `json:"nickname"`
Sex int `json:"sex"`
Province string `json:"province"`
City string `json:"city"`
Country string `json:"country"`
HeadImgURL string `json:"headimgurl"`
Privilege []string `json:"privilege"`
UnionID string `json:"unionid"`
}
json.NewDecoder(userResp.Body).Decode(&wechatUser)
// 标记为已扫码
session.Scanned = true
session.Code = code
// 创建本地用户或登录
// TODO: 保存用户信息到数据库
// TODO: 创建本地session
fmt.Fprintf(w, "登录成功!欢迎 %s", wechatUser.Nickname)
}
// 辅助函数
func generateRandomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
for i := range b {
b[i] = charset[time.Now().UnixNano()%int64(len(charset))]
}
return string(b)
}
测试流程:
# 启动服务器
go run main.go
# 访问
http://localhost:8080/wechat/login
# 使用微信扫码
# 确认授权后会自动跳转并登录成功
Google OAuth 2.0登录完整实现
准备工作:
1. 访问Google Cloud Console
https://console.cloud.google.com/
2. 创建项目
- 新建项目 → 输入项目名称
3. 启用Google+ API
- API和服务 → 启用API和服务
- 搜索"Google+ API" → 启用
4. 创建OAuth 2.0客户端ID
- API和服务 → 凭据 → 创建凭据 → OAuth客户端ID
- 应用类型:Web应用
- 授权重定向URI:http://localhost:8080/google/callback
- 获取客户端ID和客户端密钥
使用官方SDK实现:
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
const (
GoogleClientID = "your_client_id.apps.googleusercontent.com"
GoogleClientSecret = "your_client_secret"
GoogleRedirectURL = "http://localhost:8080/google/callback"
)
var googleOauthConfig = &oauth2.Config{
ClientID: GoogleClientID,
ClientSecret: GoogleClientSecret,
RedirectURL: GoogleRedirectURL,
Scopes: []string{
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
},
Endpoint: google.Endpoint,
}
// 1. 跳转到Google授权页面
func HandleGoogleLogin(w http.ResponseWriter, r *http.Request) {
// 生成state(防CSRF)
state := generateRandomString(32)
// 保存state到session(简化版)
http.SetCookie(w, &http.Cookie{
Name: "oauth_state",
Value: state,
MaxAge: 300,
HttpOnly: true,
Secure: true,
})
// 生成授权URL
url := googleOauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
// 重定向到Google
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
// 2. Google回调处理
func HandleGoogleCallback(w http.ResponseWriter, r *http.Request) {
// 验证state
state := r.URL.Query().Get("state")
cookie, err := r.Cookie("oauth_state")
if err != nil || cookie.Value != state {
http.Error(w, "Invalid state", http.StatusBadRequest)
return
}
// 获取code
code := r.URL.Query().Get("code")
// 用code换取token
token, err := googleOauthConfig.Exchange(context.Background(), code)
if err != nil {
http.Error(w, "Failed to exchange token", http.StatusInternalServerError)
return
}
// 获取用户信息
client := googleOauthConfig.Client(context.Background(), token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
if err != nil {
http.Error(w, "Failed to get user info", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
var googleUser struct {
ID string `json:"id"`
Email string `json:"email"`
VerifiedEmail bool `json:"verified_email"`
Name string `json:"name"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
Picture string `json:"picture"`
Locale string `json:"locale"`
}
json.NewDecoder(resp.Body).Decode(&googleUser)
// 创建本地用户或登录
// TODO: 保存用户信息到数据库
// TODO: 创建本地session
fmt.Fprintf(w, "登录成功!\n")
fmt.Fprintf(w, "姓名: %s\n", googleUser.Name)
fmt.Fprintf(w, "邮箱: %s\n", googleUser.Email)
fmt.Fprintf(w, "头像: %s\n", googleUser.Picture)
}
func main() {
http.HandleFunc("/google/login", HandleGoogleLogin)
http.HandleFunc("/google/callback", HandleGoogleCallback)
fmt.Println("服务器启动: http://localhost:8080")
http.ListenAndServe(":8080", nil)
}
使用OIDC验证ID Token:
import (
"context"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
func HandleGoogleCallbackWithOIDC(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
// 配置OIDC Provider
provider, err := oidc.NewProvider(ctx, "https://accounts.google.com")
if err != nil {
http.Error(w, "Failed to get provider", 500)
return
}
// 配置OAuth2
oauth2Config := oauth2.Config{
ClientID: GoogleClientID,
ClientSecret: GoogleClientSecret,
RedirectURL: GoogleRedirectURL,
Endpoint: google.Endpoint,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
// 交换code
code := r.URL.Query().Get("code")
oauth2Token, err := oauth2Config.Exchange(ctx, code)
if err != nil {
http.Error(w, "Failed to exchange token", 500)
return
}
// 验证ID Token
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
http.Error(w, "No id_token", 500)
return
}
verifier := provider.Verifier(&oidc.Config{ClientID: GoogleClientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
http.Error(w, "Failed to verify ID Token", 500)
return
}
// 解析Claims
var claims struct {
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
Picture string `json:"picture"`
}
idToken.Claims(&claims)
fmt.Fprintf(w, "登录成功!\n")
fmt.Fprintf(w, "姓名: %s\n", claims.Name)
fmt.Fprintf(w, "邮箱: %s (%v)\n", claims.Email, claims.EmailVerified)
}
前端集成(HTML + JavaScript):
<!DOCTYPE html>
<html>
<head>
<title>社交登录</title>
<meta name="google-signin-client_id" content="YOUR_CLIENT_ID.apps.googleusercontent.com">
<script src="https://accounts.google.com/gsi/client" async defer></script>
</head>
<body>
<h2>选择登录方式</h2>
<!-- GitHub登录 -->
<button onclick="location.href='/github/login'">
使用GitHub登录
</button>
<!-- 微信登录 -->
<button onclick="location.href='/wechat/login'">
使用微信登录
</button>
<!-- Google登录(方式1:重定向) -->
<button onclick="location.href='/google/login'">
使用Google登录
</button>
<!-- Google登录(方式2:One Tap) -->
<div id="g_id_onload"
data-client_id="YOUR_CLIENT_ID.apps.googleusercontent.com"
data-callback="handleCredentialResponse">
</div>
<div class="g_id_signin" data-type="standard"></div>
<script>
function handleCredentialResponse(response) {
// response.credential 是JWT ID Token
fetch('/google/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
credential: response.credential
})
})
.then(res => res.json())
.then(data => {
console.log('登录成功:', data);
window.location.href = '/dashboard';
});
}
</script>
</body>
</html>
安全建议:
// 1. 验证ID Token(Google)
func VerifyGoogleIDToken(idToken string) (*GoogleUser, error) {
ctx := context.Background()
provider, _ := oidc.NewProvider(ctx, "https://accounts.google.com")
verifier := provider.Verifier(&oidc.Config{
ClientID: GoogleClientID,
})
token, err := verifier.Verify(ctx, idToken)
if err != nil {
return nil, err
}
var user GoogleUser
token.Claims(&user)
return &user, nil
}
// 2. 防止CSRF攻击
func generateState() string {
b := make([]byte, 32)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}
// 3. 使用HTTPS
// 生产环境必须使用HTTPS,否则OAuth不安全
// 4. 安全存储token
// 不要在localStorage存储access_token
// 使用HttpOnly Cookie存储session
面试问答
OAuth 2.0四种授权模式有什么区别?如何选择?
答案:
1. 授权码模式(推荐):
适用:Web应用
安全:最高
流程:code → access_token
2. 隐式模式(废弃):
适用:SPA(已不推荐)
安全:低
流程:直接返回access_token
3. 密码模式:
适用:自家应用
安全:中
流程:username/password → access_token
4. 客户端模式:
适用:服务间调用
安全:中
流程:client credentials → access_token
选择建议:
Web应用:授权码模式
SPA:授权码模式 + PKCE
移动App:授权码模式 + PKCE
服务间:客户端模式