HiHuo
首页
博客
手册
工具
关于
首页
博客
手册
工具
关于
  • SSO与身份认证

    • 单点登录(SSO)与身份认证教程
    • 第1章:认证与授权基础
    • 第2章:单点登录原理与实现
    • 第3章:OAuth 2.0与OpenID Connect
    • 第4章:企业级SSO方案
    • 第5章:微服务认证与安全

第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
 服务间:客户端模式

Prev
第2章:单点登录原理与实现
Next
第4章:企业级SSO方案