本文由claude code编写,仅供参考。 在现代应用开发中,提供第三方登录已经成为标配功能。本文将详细介绍如何在Go语言中实现Google和Apple的OAuth登录,以及如何处理iOS和Android的原生登录。

一、OAuth 2.0 基础概念

OAuth 2.0是一个授权框架,允许应用在用户授权的情况下访问用户在第三方服务上的资源,而无需获取用户的密码。主要流程包括:

  1. 授权请求:引导用户到OAuth提供商的授权页面
  2. 授权确认:用户同意授权
  3. 获取授权码:重定向回应用并携带授权码
  4. 交换令牌:使用授权码换取访问令牌
  5. 访问资源:使用访问令牌获取用户信息

二、Google OAuth登录实现

2.1 前期准备

首先需要在Google Cloud Console创建项目并配置OAuth 2.0凭据:

  1. 访问 Google Cloud Console
  2. 创建新项目或选择现有项目
  3. 启用Google+ API或Google Identity服务
  4. 创建OAuth 2.0客户端ID(Web应用、iOS、Android分别创建)
  5. 配置授权重定向URI

2.2 安装依赖

go get golang.org/x/oauth2
go get golang.org/x/oauth2/google
go get google.golang.org/api/oauth2/v2

2.3 Web端Google登录实现

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"

    "golang.org/x/oauth2"
    "golang.org/x/oauth2/google"
    oauth2api "google.golang.org/api/oauth2/v2"
    "google.golang.org/api/option"
)

var googleOauthConfig *oauth2.Config

func init() {
    googleOauthConfig = &oauth2.Config{
        ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
        ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
        RedirectURL:  "http://localhost:8080/auth/google/callback",
        Scopes: []string{
            "https://www.googleapis.com/auth/userinfo.email",
            "https://www.googleapis.com/auth/userinfo.profile",
        },
        Endpoint: google.Endpoint,
    }
}

// 处理Google登录请求
func handleGoogleLogin(w http.ResponseWriter, r *http.Request) {
    // 生成随机state参数,防止CSRF攻击
    state := generateRandomState()
    
    // 将state存储到session中
    saveStateToSession(r, w, state)
    
    // 生成授权URL
    url := googleOauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
    http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}

// 处理Google回调
func handleGoogleCallback(w http.ResponseWriter, r *http.Request) {
    // 验证state参数
    state := r.FormValue("state")
    savedState := getStateFromSession(r)
    
    if state != savedState {
        http.Error(w, "Invalid state parameter", http.StatusBadRequest)
        return
    }
    
    // 获取授权码
    code := r.FormValue("code")
    if code == "" {
        http.Error(w, "Code not found", http.StatusBadRequest)
        return
    }
    
    // 交换令牌
    token, err := googleOauthConfig.Exchange(context.Background(), code)
    if err != nil {
        http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError)
        return
    }
    
    // 获取用户信息
    userInfo, err := getGoogleUserInfo(token)
    if err != nil {
        http.Error(w, "Failed to get user info: "+err.Error(), http.StatusInternalServerError)
        return
    }
    
    // 处理用户信息(创建或更新用户)
    handleUserLogin(w, r, userInfo)
}

// 获取Google用户信息
func getGoogleUserInfo(token *oauth2.Token) (*GoogleUserInfo, error) {
    ctx := context.Background()
    
    // 创建OAuth2服务
    oauth2Service, err := oauth2api.NewService(ctx, 
        option.WithTokenSource(googleOauthConfig.TokenSource(ctx, token)))
    if err != nil {
        return nil, err
    }
    
    // 获取用户信息
    userInfo, err := oauth2Service.Userinfo.Get().Do()
    if err != nil {
        return nil, err
    }
    
    return &GoogleUserInfo{
        ID:            userInfo.Id,
        Email:         userInfo.Email,
        VerifiedEmail: userInfo.VerifiedEmail,
        Name:          userInfo.Name,
        GivenName:     userInfo.GivenName,
        FamilyName:    userInfo.FamilyName,
        Picture:       userInfo.Picture,
    }, nil
}

type GoogleUserInfo 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"`
}

2.4 Android/iOS原生Google登录处理

对于移动端原生登录,客户端会使用Google SDK获取ID Token,然后发送给后端验证:

import (
    "google.golang.org/api/idtoken"
)

// 验证Google ID Token(移动端)
func verifyGoogleIDToken(w http.ResponseWriter, r *http.Request) {
    var req struct {
        IDToken string `json:"id_token"`
    }
    
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request", http.StatusBadRequest)
        return
    }
    
    ctx := context.Background()
    
    // 验证ID Token
    payload, err := idtoken.Validate(ctx, req.IDToken, os.Getenv("GOOGLE_CLIENT_ID"))
    if err != nil {
        http.Error(w, "Invalid ID token: "+err.Error(), http.StatusUnauthorized)
        return
    }
    
    // 提取用户信息
    userInfo := &GoogleUserInfo{
        ID:    payload.Subject,
        Email: payload.Claims["email"].(string),
        Name:  payload.Claims["name"].(string),
    }
    
    // 处理用户登录
    handleUserLogin(w, r, userInfo)
}

三、Apple Sign In实现

3.1 前期准备

  1. 加入Apple Developer Program
  2. 在Apple Developer Console配置Sign in with Apple
  3. 创建App ID并启用Sign in with Apple功能
  4. 创建Service ID(用于Web登录)
  5. 配置Return URLs和验证域名
  6. 生成私钥用于验证token

3.2 Apple登录的特殊性

重要提示:Apple Sign In有一个非常重要的特性需要注意:

  • 首次授权:用户第一次通过Apple登录时,Apple会在回调中返回user参数,包含用户的姓名(firstName/lastName)和邮箱
  • 后续授权:从第二次开始,Apple不会再返回用户的姓名信息,只返回ID Token中的基本信息(sub、email等)
  • 数据持久化要求:必须在首次登录时将用户的完整信息(姓名、邮箱)存储到数据库中,因为之后无法再次获取

这个设计的原因是Apple重视用户隐私,认为用户信息只需要传递一次。如果你的应用在首次登录时没有正确保存这些信息,用户需要:

  1. 在iOS设备的"设置 > Apple ID > 密码与安全性 > 使用Apple登录的App"中撤销授权
  2. 重新登录才能再次获取到姓名信息

3.3 Web端Apple登录实现

package main

import (
    "crypto/ecdsa"
    "crypto/x509"
    "encoding/pem"
    "fmt"
    "io/ioutil"
    "net/url"
    "time"

    "github.com/golang-jwt/jwt/v4"
)

type AppleOAuthConfig struct {
    ClientID     string // Service ID
    TeamID       string
    KeyID        string
    PrivateKey   *ecdsa.PrivateKey
    RedirectURL  string
}

var appleOauthConfig *AppleOAuthConfig

func initAppleOAuth() error {
    // 读取私钥文件
    keyData, err := ioutil.ReadFile("AuthKey_XXXXXXXXXX.p8")
    if err != nil {
        return err
    }
    
    // 解析私钥
    block, _ := pem.Decode(keyData)
    privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
    if err != nil {
        return err
    }
    
    appleOauthConfig = &AppleOAuthConfig{
        ClientID:     os.Getenv("APPLE_CLIENT_ID"),
        TeamID:       os.Getenv("APPLE_TEAM_ID"),
        KeyID:        os.Getenv("APPLE_KEY_ID"),
        PrivateKey:   privateKey.(*ecdsa.PrivateKey),
        RedirectURL:  "https://yourdomain.com/auth/apple/callback",
    }
    
    return nil
}

// 生成Apple Client Secret
func generateAppleClientSecret() (string, error) {
    now := time.Now()
    
    claims := jwt.MapClaims{
        "iss": appleOauthConfig.TeamID,
        "iat": now.Unix(),
        "exp": now.Add(180 * 24 * time.Hour).Unix(), // 6个月有效期
        "aud": "https://appleid.apple.com",
        "sub": appleOauthConfig.ClientID,
    }
    
    token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
    token.Header["kid"] = appleOauthConfig.KeyID
    
    return token.SignedString(appleOauthConfig.PrivateKey)
}

// 处理Apple登录请求
func handleAppleLogin(w http.ResponseWriter, r *http.Request) {
    state := generateRandomState()
    saveStateToSession(r, w, state)
    
    // 构建授权URL
    params := url.Values{
        "client_id":     {appleOauthConfig.ClientID},
        "redirect_uri":  {appleOauthConfig.RedirectURL},
        "response_type": {"code"},
        "state":         {state},
        "scope":         {"name email"},
        "response_mode": {"form_post"},
    }
    
    authURL := "https://appleid.apple.com/auth/authorize?" + params.Encode()
    http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
}

// 处理Apple回调
func handleAppleCallback(w http.ResponseWriter, r *http.Request) {
    if err := r.ParseForm(); err != nil {
        http.Error(w, "Failed to parse form", http.StatusBadRequest)
        return
    }
    
    // 验证state
    state := r.FormValue("state")
    savedState := getStateFromSession(r)
    if state != savedState {
        http.Error(w, "Invalid state parameter", http.StatusBadRequest)
        return
    }
    
    code := r.FormValue("code")
    if code == "" {
        http.Error(w, "Code not found", http.StatusBadRequest)
        return
    }
    
    // 交换令牌
    token, err := exchangeAppleToken(code)
    if err != nil {
        http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError)
        return
    }
    
    // 解析ID Token
    userInfo, err := parseAppleIDToken(token.IDToken)
    if err != nil {
        http.Error(w, "Failed to parse ID token: "+err.Error(), http.StatusInternalServerError)
        return
    }
    
    // 【关键】首次登录时,Apple会在user参数中提供姓名和邮箱
    // 这是唯一能获取到用户姓名的机会,后续登录不会再提供
    // 必须在这里保存到数据库中
    var firstName, lastName string
    if userParam := r.FormValue("user"); userParam != "" {
        var appleUser AppleUserData
        if err := json.Unmarshal([]byte(userParam), &appleUser); err == nil {
            firstName = appleUser.Name.FirstName
            lastName = appleUser.Name.LastName
            userInfo.Name = firstName + " " + lastName
            userInfo.FirstName = firstName
            userInfo.LastName = lastName
            
            // 如果ID Token中没有email,也从user参数中获取
            if userInfo.Email == "" && appleUser.Email != "" {
                userInfo.Email = appleUser.Email
            }
        }
    }
    
    handleUserLogin(w, r, userInfo)
}

// 交换Apple Token
func exchangeAppleToken(code string) (*AppleTokenResponse, error) {
    clientSecret, err := generateAppleClientSecret()
    if err != nil {
        return nil, err
    }
    
    data := url.Values{
        "client_id":     {appleOauthConfig.ClientID},
        "client_secret": {clientSecret},
        "code":          {code},
        "grant_type":    {"authorization_code"},
        "redirect_uri":  {appleOauthConfig.RedirectURL},
    }
    
    resp, err := http.PostForm("https://appleid.apple.com/auth/token", data)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    var tokenResp AppleTokenResponse
    if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
        return nil, err
    }
    
    return &tokenResp, nil
}

type AppleTokenResponse struct {
    AccessToken  string `json:"access_token"`
    TokenType    string `json:"token_type"`
    ExpiresIn    int    `json:"expires_in"`
    RefreshToken string `json:"refresh_token"`
    IDToken      string `json:"id_token"`
}

type AppleUserData struct {
    Name struct {
        FirstName string `json:"firstName"`
        LastName  string `json:"lastName"`
    } `json:"name"`
    Email string `json:"email"`
}

3.3 验证Apple ID Token

import (
    "crypto/rsa"
    "encoding/base64"
    "encoding/json"
    "math/big"
    "net/http"
)

// Apple公钥结构
type ApplePublicKey struct {
    Kty string `json:"kty"`
    Kid string `json:"kid"`
    Use string `json:"use"`
    Alg string `json:"alg"`
    N   string `json:"n"`
    E   string `json:"e"`
}

type ApplePublicKeys struct {
    Keys []ApplePublicKey `json:"keys"`
}

// 获取Apple公钥
func getApplePublicKeys() (*ApplePublicKeys, error) {
    resp, err := http.Get("https://appleid.apple.com/auth/keys")
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    var keys ApplePublicKeys
    if err := json.NewDecoder(resp.Body).Decode(&keys); err != nil {
        return nil, err
    }
    
    return &keys, nil
}

// 解析并验证Apple ID Token
func parseAppleIDToken(idToken string) (*AppleUserInfo, error) {
    // 解析token(不验证签名)
    token, _, err := new(jwt.Parser).ParseUnverified(idToken, jwt.MapClaims{})
    if err != nil {
        return nil, err
    }
    
    claims, ok := token.Claims.(jwt.MapClaims)
    if !ok {
        return nil, fmt.Errorf("invalid claims")
    }
    
    // 获取Apple公钥并验证签名
    keys, err := getApplePublicKeys()
    if err != nil {
        return nil, err
    }
    
    // 完整验证token
    token, err = jwt.Parse(idToken, func(token *jwt.Token) (interface{}, error) {
        // 验证签名算法
        if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
        }
        
        // 获取kid
        kid, ok := token.Header["kid"].(string)
        if !ok {
            return nil, fmt.Errorf("kid header not found")
        }
        
        // 查找对应的公钥
        for _, key := range keys.Keys {
            if key.Kid == kid {
                return convertApplePublicKey(key)
            }
        }
        
        return nil, fmt.Errorf("public key not found")
    })
    
    if err != nil || !token.Valid {
        return nil, fmt.Errorf("invalid token: %v", err)
    }
    
    // 验证claims
    if !claims.VerifyAudience(appleOauthConfig.ClientID, true) {
        return nil, fmt.Errorf("invalid audience")
    }
    
    if !claims.VerifyIssuer("https://appleid.apple.com", true) {
        return nil, fmt.Errorf("invalid issuer")
    }
    
    // 提取用户信息
    userInfo := &AppleUserInfo{
        ID:    claims["sub"].(string),
        Email: claims["email"].(string),
    }
    
    if emailVerified, ok := claims["email_verified"].(bool); ok {
        userInfo.EmailVerified = emailVerified
    }
    
    return userInfo, nil
}

// 转换Apple公钥为RSA公钥
func convertApplePublicKey(key ApplePublicKey) (*rsa.PublicKey, error) {
    nBytes, err := base64.RawURLEncoding.DecodeString(key.N)
    if err != nil {
        return nil, err
    }
    
    eBytes, err := base64.RawURLEncoding.DecodeString(key.E)
    if err != nil {
        return nil, err
    }
    
    n := new(big.Int).SetBytes(nBytes)
    e := new(big.Int).SetBytes(eBytes).Int64()
    
    return &rsa.PublicKey{
        N: n,
        E: int(e),
    }, nil
}

type AppleUserInfo struct {
    ID            string `json:"id"`
    Email         string `json:"email"`
    EmailVerified bool   `json:"email_verified"`
    Name          string `json:"name,omitempty"`
    FirstName     string `json:"first_name,omitempty"`
    LastName      string `json:"last_name,omitempty"`
}

3.4 iOS原生Apple登录处理

iOS端使用AuthenticationServices框架获取授权,然后将凭证发送给后端:

// 验证iOS原生Apple登录
func verifyAppleNativeLogin(w http.ResponseWriter, r *http.Request) {
    var req struct {
        IdentityToken     string          `json:"identity_token"`
        AuthorizationCode string          `json:"authorization_code"`
        User              *AppleUserData  `json:"user,omitempty"` // 【重要】仅首次登录时提供
        FullName          *AppleNameParts `json:"full_name,omitempty"` // iOS客户端可能单独传递
    }
    
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request", http.StatusBadRequest)
        return
    }
    
    // 验证并解析Identity Token
    userInfo, err := parseAppleIDToken(req.IdentityToken)
    if err != nil {
        http.Error(w, "Invalid identity token: "+err.Error(), http.StatusUnauthorized)
        return
    }
    
    // 【关键】处理首次登录时的用户信息
    // Apple只在首次授权时提供fullName和email
    // 必须在这里保存,否则后续无法获取
    if req.User != nil {
        // 从user对象中获取姓名
        userInfo.FirstName = req.User.Name.FirstName
        userInfo.LastName = req.User.Name.LastName
        userInfo.Name = req.User.Name.FirstName + " " + req.User.Name.LastName
        
        // 如果有email,也要保存(有些情况下用户可能隐藏email)
        if req.User.Email != "" {
            userInfo.Email = req.User.Email
        }
    } else if req.FullName != nil {
        // iOS客户端也可能单独传递fullName
        userInfo.FirstName = req.FullName.GivenName
        userInfo.LastName = req.FullName.FamilyName
        userInfo.Name = req.FullName.GivenName + " " + req.FullName.FamilyName
    }
    
    // 处理用户登录(会将信息保存到数据库)
    handleUserLogin(w, r, userInfo)
}

// iOS传递的姓名结构(可能的格式)
type AppleNameParts struct {
    GivenName  string `json:"givenName"`
    FamilyName string `json:"familyName"`
}

四、完整的路由设置

package main

import (
    "log"
    "net/http"
)

func main() {
    // 初始化配置
    if err := initAppleOAuth(); err != nil {
        log.Fatal("Failed to initialize Apple OAuth:", err)
    }
    
    // Web端OAuth路由
    http.HandleFunc("/auth/google/login", handleGoogleLogin)
    http.HandleFunc("/auth/google/callback", handleGoogleCallback)
    http.HandleFunc("/auth/apple/login", handleAppleLogin)
    http.HandleFunc("/auth/apple/callback", handleAppleCallback)
    
    // 移动端原生登录路由
    http.HandleFunc("/api/auth/google/verify", verifyGoogleIDToken)
    http.HandleFunc("/api/auth/apple/verify", verifyAppleNativeLogin)
    
    // 启动服务器
    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

五、用户登录处理和JWT生成

import (
    "database/sql"
    "time"
    
    "github.com/golang-jwt/jwt/v4"
)

type User struct {
    ID            int64
    Email         string
    Name          string
    Picture       string
    Provider      string // "google" or "apple"
    ProviderID    string
    CreatedAt     time.Time
    UpdatedAt     time.Time
}

// 处理用户登录(通用函数)
func handleUserLogin(w http.ResponseWriter, r *http.Request, authInfo interface{}) {
    var email, name, picture, provider, providerID string
    
    // 根据不同的登录类型提取信息
    switch info := authInfo.(type) {
    case *GoogleUserInfo:
        email = info.Email
        name = info.Name
        picture = info.Picture
        provider = "google"
        providerID = info.ID
        
    case *AppleUserInfo:
        email = info.Email
        name = info.Name
        provider = "apple"
        providerID = info.ID
        
    default:
        http.Error(w, "Unsupported auth provider", http.StatusBadRequest)
        return
    }
    
    // 查找或创建用户
    user, err := findOrCreateUser(email, name, picture, provider, providerID)
    if err != nil {
        http.Error(w, "Database error: "+err.Error(), http.StatusInternalServerError)
        return
    }
    
    // 生成JWT token
    jwtToken, err := generateJWT(user)
    if err != nil {
        http.Error(w, "Failed to generate token: "+err.Error(), http.StatusInternalServerError)
        return
    }
    
    // 返回token和用户信息
    response := map[string]interface{}{
        "token": jwtToken,
        "user": map[string]interface{}{
            "id":      user.ID,
            "email":   user.Email,
            "name":    user.Name,
            "picture": user.Picture,
        },
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(response)
}

// 查找或创建用户
func findOrCreateUser(email, name, picture, provider, providerID string) (*User, error) {
    db := getDB() // 获取数据库连接
    
    var user User
    
    // 先尝试通过provider和providerID查找
    err := db.QueryRow(`
        SELECT id, email, name, picture, provider, provider_id, created_at, updated_at
        FROM users
        WHERE provider = ? AND provider_id = ?
    `, provider, providerID).Scan(
        &user.ID, &user.Email, &user.Name, &user.Picture,
        &user.Provider, &user.ProviderID, &user.CreatedAt, &user.UpdatedAt,
    )
    
    if err == sql.ErrNoRows {
        // 用户不存在,创建新用户
        result, err := db.Exec(`
            INSERT INTO users (email, name, picture, provider, provider_id, created_at, updated_at)
            VALUES (?, ?, ?, ?, ?, NOW(), NOW())
        `, email, name, picture, provider, providerID)
        
        if err != nil {
            return nil, err
        }
        
        userID, err := result.LastInsertId()
        if err != nil {
            return nil, err
        }
        
        user.ID = userID
        user.Email = email
        user.Name = name
        user.Picture = picture
        user.Provider = provider
        user.ProviderID = providerID
        
    } else if err != nil {
        return nil, err
    } else {
        // 【重要】用户已存在,更新信息
        // 对于Apple用户,只有在name不为空时才更新
        // 因为后续登录不会返回name,避免覆盖为空
        updateQuery := `UPDATE users SET email = ?, updated_at = NOW()`
        args := []interface{}{email}
        
        if name != "" {
            // 只有提供了name才更新(防止Apple后续登录覆盖为空)
            updateQuery += `, name = ?`
            args = append(args, name)
        }
        
        if picture != "" {
            updateQuery += `, picture = ?`
            args = append(args, picture)
        }
        
        updateQuery += ` WHERE id = ?`
        args = append(args, user.ID)
        
        _, err = db.Exec(updateQuery, args...)
        if err != nil {
            return nil, err
        }
        
        // 更新内存中的用户对象
        user.Email = email
        if name != "" {
            user.Name = name
        }
        if picture != "" {
            user.Picture = picture
        }
    }
    
    return &user, nil
}

// 生成JWT Token
func generateJWT(user *User) (string, error) {
    claims := jwt.MapClaims{
        "user_id":  user.ID,
        "email":    user.Email,
        "provider": user.Provider,
        "exp":      time.Now().Add(24 * time.Hour).Unix(), // 24小时过期
        "iat":      time.Now().Unix(),
    }
    
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    
    // 使用密钥签名
    jwtSecret := []byte(os.Getenv("JWT_SECRET"))
    return token.SignedString(jwtSecret)
}

六、辅助函数实现

import (
    "crypto/rand"
    "encoding/base64"
    "net/http"
    
    "github.com/gorilla/sessions"
)

var store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_SECRET")))

// 生成随机state
func generateRandomState() string {
    b := make([]byte, 32)
    rand.Read(b)
    return base64.URLEncoding.EncodeToString(b)
}

// 保存state到session
func saveStateToSession(r *http.Request, w http.ResponseWriter, state string) {
    session, _ := store.Get(r, "oauth-session")
    session.Values["state"] = state
    session.Save(r, w)
}

// 从session获取state
func getStateFromSession(r *http.Request) string {
    session, _ := store.Get(r, "oauth-session")
    if state, ok := session.Values["state"].(string); ok {
        return state
    }
    return ""
}

// 数据库连接(示例)
var dbConn *sql.DB

func getDB() *sql.DB {
    if dbConn == nil {
        var err error
        dbConn, err = sql.Open("mysql", os.Getenv("DATABASE_URL"))
        if err != nil {
            log.Fatal("Failed to connect to database:", err)
        }
    }
    return dbConn
}

七、数据库表结构

CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(255) NOT NULL,
    name VARCHAR(255),
    picture VARCHAR(500),
    provider VARCHAR(50) NOT NULL,
    provider_id VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY unique_provider (provider, provider_id),
    INDEX idx_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

重要说明

  1. name字段允许为NULL,因为:

    • Apple用户可能在后续登录时没有提供姓名
    • 首次登录时必须保存name,后续更新时如果name为空则不更新此字段
  2. email字段也可能为空(虽然设置为NOT NULL,但在实际使用中要考虑Apple用户可能选择"隐藏我的邮箱")

  3. unique_provider索引确保同一个provider的用户不会重复创建

八、移动端集成示例

8.1 iOS客户端调用示例(Swift)

import AuthenticationServices

class LoginViewController: UIViewController, ASAuthorizationControllerDelegate {
    
    // Apple登录
    func handleAppleSignIn() {
        let provider = ASAuthorizationAppleIDProvider()
        let request = provider.createRequest()
        request.requestedScopes = [.fullName, .email]
        
        let controller = ASAuthorizationController(authorizationRequests: [request])
        controller.delegate = self
        controller.performRequests()
    }
    
    func authorizationController(controller: ASAuthorizationController, 
                                didCompleteWithAuthorization authorization: ASAuthorization) {
        if let credential = authorization.credential as? ASAuthorizationAppleIDCredential {
            let identityToken = String(data: credential.identityToken!, encoding: .utf8)!
            let authorizationCode = String(data: credential.authorizationCode!, encoding: .utf8)!
            
            // 【关键】Apple只在首次授权时提供fullName和email
            // 必须在这里发送到后端保存,否则后续无法再获取
            var userData: [String: Any]?
            if let fullName = credential.fullName {
                // fullName只在首次登录时有值
                userData = [
                    "name": [
                        "firstName": fullName.givenName ?? "",
                        "lastName": fullName.familyName ?? ""
                    ]
                ]
            }
            
            // email也可能为空(用户选择隐藏邮箱)
            if let email = credential.email {
                if userData == nil {
                    userData = [:]
                }
                userData!["email"] = email
            }
            
            // 发送到后端验证
            sendToBackend(identityToken: identityToken, 
                         authorizationCode: authorizationCode,
                         userData: userData)
        }
    }
    
    func sendToBackend(identityToken: String, authorizationCode: String, userData: [String: Any]?) {
        let url = URL(string: "https://yourapi.com/api/auth/apple/verify")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        
        var body: [String: Any] = [
            "identity_token": identityToken,
            "authorization_code": authorizationCode
        ]
        
        if let user = userData {
            body["user"] = String(data: try! JSONSerialization.data(withJSONObject: user), 
                                 encoding: .utf8)!
        }
        
        request.httpBody = try? JSONSerialization.data(withJSONObject: body)
        
        URLSession.shared.dataTask(with: request) { data, response, error in
            guard let data = data, error == nil else {
                print("Error: \(error?.localizedDescription ?? "Unknown error")")
                return
            }
            
            if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
                // 解析响应,保存token
                if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
                   let token = json["token"] as? String {
                    // 保存token到UserDefaults或Keychain
                    self.saveToken(token)
                }
            }
        }.resume()
    }
    
    func saveToken(_ token: String) {
        UserDefaults.standard.set(token, forKey: "auth_token")
    }
}

8.2 Android客户端调用示例(Kotlin)

import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.auth.api.signin.GoogleSignInClient
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.tasks.Task
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException

class LoginActivity : AppCompatActivity() {
    
    private val RC_SIGN_IN = 9001
    private lateinit var googleSignInClient: GoogleSignInClient
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // 配置Google登录
        val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
            .requestIdToken(getString(R.string.google_client_id))
            .requestEmail()
            .build()
        
        googleSignInClient = GoogleSignIn.getClient(this, gso)
    }
    
    // 启动Google登录
    fun signInWithGoogle() {
        val signInIntent = googleSignInClient.signInIntent
        startActivityForResult(signInIntent, RC_SIGN_IN)
    }
    
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        
        if (requestCode == RC_SIGN_IN) {
            val task = GoogleSignIn.getSignedInAccountFromIntent(data)
            handleSignInResult(task)
        }
    }
    
    private fun handleSignInResult(completedTask: Task<GoogleSignInAccount>) {
        try {
            val account = completedTask.getResult(ApiException::class.java)
            val idToken = account?.idToken
            
            if (idToken != null) {
                // 发送ID Token到后端验证
                sendTokenToBackend(idToken)
            }
        } catch (e: ApiException) {
            // 登录失败
            println("Google sign in failed: ${e.statusCode}")
        }
    }
    
    private fun sendTokenToBackend(idToken: String) {
        val client = OkHttpClient()
        
        val json = JSONObject()
        json.put("id_token", idToken)
        
        val requestBody = json.toString()
            .toRequestBody("application/json".toMediaTypeOrNull())
        
        val request = Request.Builder()
            .url("https://yourapi.com/api/auth/google/verify")
            .post(requestBody)
            .build()
        
        client.newCall(request).enqueue(object : Callback {
            override fun onFailure(call: Call, e: IOException) {
                println("Request failed: ${e.message}")
            }
            
            override fun onResponse(call: Call, response: Response) {
                response.use {
                    if (!response.isSuccessful) {
                        println("Unexpected response: $response")
                        return
                    }
                    
                    val responseBody = response.body?.string()
                    val jsonResponse = JSONObject(responseBody)
                    val token = jsonResponse.getString("token")
                    
                    // 保存token
                    saveToken(token)
                    
                    // 在主线程更新UI
                    runOnUiThread {
                        // 跳转到主页面
                        navigateToMainActivity()
                    }
                }
            }
        })
    }
    
    private fun saveToken(token: String) {
        val sharedPreferences = getSharedPreferences("auth", MODE_PRIVATE)
        sharedPreferences.edit().putString("auth_token", token).apply()
    }
    
    private fun navigateToMainActivity() {
        val intent = Intent(this, MainActivity::class.java)
        startActivity(intent)
        finish()
    }
}

8.3 Android build.gradle配置

dependencies {
    // Google Sign-In
    implementation 'com.google.android.gms:play-services-auth:20.7.0'
    
    // OkHttp for network requests
    implementation 'com.squareup.okhttp3:okhttp:4.12.0'
}

8.4 iOS Podfile配置

# Podfile
platform :ios, '13.0'

target 'YourApp' do
  use_frameworks!
  
  # Google Sign-In (如果需要)
  pod 'GoogleSignIn', '~> 7.0'
end

九、环境变量配置

创建 .env 文件来管理配置:

# Google OAuth配置
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-google-client-secret

# Apple OAuth配置
APPLE_CLIENT_ID=com.yourcompany.yourapp.service
APPLE_TEAM_ID=YOUR_TEAM_ID
APPLE_KEY_ID=YOUR_KEY_ID

# JWT配置
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production

# Session配置
SESSION_SECRET=your-super-secret-session-key-change-this-in-production

# 数据库配置
DATABASE_URL=user:password@tcp(localhost:3306)/dbname?parseTime=true

加载环境变量(使用godotenv):

import "github.com/joho/godotenv"

func init() {
    // 加载.env文件
    if err := godotenv.Load(); err != nil {
        log.Println("No .env file found")
    }
}

安装依赖:

go get github.com/joho/godotenv

十、安全最佳实践

10.1 HTTPS要求

生产环境必须使用HTTPS:

func main() {
    // 生产环境使用HTTPS
    if os.Getenv("ENV") == "production" {
        log.Fatal(http.ListenAndServeTLS(":443", "cert.pem", "key.pem", nil))
    } else {
        log.Fatal(http.ListenAndServe(":8080", nil))
    }
}

10.2 Token刷新机制

实现refresh token功能:

type RefreshTokenRequest struct {
    RefreshToken string `json:"refresh_token"`
}

func handleRefreshToken(w http.ResponseWriter, r *http.Request) {
    var req RefreshTokenRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request", http.StatusBadRequest)
        return
    }
    
    // 验证refresh token
    claims, err := validateRefreshToken(req.RefreshToken)
    if err != nil {
        http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
        return
    }
    
    // 生成新的access token
    user := &User{
        ID:    claims["user_id"].(int64),
        Email: claims["email"].(string),
    }
    
    newToken, err := generateJWT(user)
    if err != nil {
        http.Error(w, "Failed to generate token", http.StatusInternalServerError)
        return
    }
    
    response := map[string]string{"token": newToken}
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(response)
}

10.3 速率限制

防止暴力攻击:

import (
    "golang.org/x/time/rate"
    "sync"
)

var limiters = make(map[string]*rate.Limiter)
var mu sync.Mutex

func getRateLimiter(ip string) *rate.Limiter {
    mu.Lock()
    defer mu.Unlock()
    
    limiter, exists := limiters[ip]
    if !exists {
        limiter = rate.NewLimiter(1, 5) // 每秒1个请求,突发5个
        limiters[ip] = limiter
    }
    
    return limiter
}

func rateLimitMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ip := r.RemoteAddr
        limiter := getRateLimiter(ip)
        
        if !limiter.Allow() {
            http.Error(w, "Too many requests", http.StatusTooManyRequests)
            return
        }
        
        next(w, r)
    }
}

// 使用中间件
http.HandleFunc("/api/auth/google/verify", rateLimitMiddleware(verifyGoogleIDToken))

十一、错误处理和日志记录

11.1 结构化错误响应

type ErrorResponse struct {
    Error   string `json:"error"`
    Message string `json:"message"`
    Code    int    `json:"code"`
}

func sendErrorResponse(w http.ResponseWriter, code int, errorType, message string) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(code)
    
    response := ErrorResponse{
        Error:   errorType,
        Message: message,
        Code:    code,
    }
    
    json.NewEncoder(w).Encode(response)
}

11.2 日志记录

import (
    "log"
    "os"
)

var (
    infoLog  *log.Logger
    errorLog *log.Logger
)

func initLogger() {
    infoLog = log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile)
    errorLog = log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile)
}

func logInfo(format string, v ...interface{}) {
    infoLog.Printf(format, v...)
}

func logError(format string, v ...interface{}) {
    errorLog.Printf(format, v...)
}

十二、测试示例

12.1 单元测试

package main

import (
    "testing"
    "net/http/httptest"
    "net/http"
)

func TestGenerateRandomState(t *testing.T) {
    state1 := generateRandomState()
    state2 := generateRandomState()
    
    if state1 == state2 {
        t.Error("Generated states should be unique")
    }
    
    if len(state1) == 0 {
        t.Error("State should not be empty")
    }
}

func TestHandleGoogleLogin(t *testing.T) {
    req := httptest.NewRequest("GET", "/auth/google/login", nil)
    w := httptest.NewRecorder()
    
    handleGoogleLogin(w, req)
    
    resp := w.Result()
    
    if resp.StatusCode != http.StatusTemporaryRedirect {
        t.Errorf("Expected status 307, got %d", resp.StatusCode)
    }
    
    location := resp.Header.Get("Location")
    if location == "" {
        t.Error("Expected redirect location header")
    }
}

12.2 集成测试

func TestGoogleOAuthFlow(t *testing.T) {
    // 这里需要使用mock服务器模拟Google OAuth流程
    // 实际测试时应使用测试账号或mock服务
}

总结

本文详细介绍了在Go语言中实现Google和Apple OAuth登录的完整流程,包括:

  1. OAuth 2.0基础概念:理解授权流程
  2. Google OAuth实现:Web端和移动端的完整实现
  3. Apple Sign In实现:包括客户端密钥生成和token验证
  4. 移动端集成:iOS(Swift)和Android(Kotlin)的完整示例
  5. 安全最佳实践:HTTPS、token刷新、速率限制等
  6. 数据库设计:用户表结构和数据管理
  7. 错误处理和日志:生产环境必备的监控机制

关键要点

  • 始终验证state参数防止CSRF攻击
  • 生产环境必须使用HTTPS
  • 妥善保管私钥和密钥
  • 实现适当的错误处理和日志记录
  • 为移动端和Web端提供不同的验证端点
  • 【重要】Apple登录特殊性
    • Apple只在首次授权时返回用户姓名(firstName/lastName)和邮箱
    • 后续登录不会再提供这些信息,只返回ID Token中的基本字段
    • 必须在首次登录时立即保存用户的完整信息到数据库
    • 更新用户信息时要检查字段是否为空,避免覆盖已保存的姓名
    • 如果错过保存,用户需要在设备设置中撤销授权后重新登录
  • 使用JWT管理用户会话,设置合理的过期时间

完整依赖清单

# Go后端依赖
go get golang.org/x/oauth2
go get golang.org/x/oauth2/google
go get google.golang.org/api/oauth2/v2
go get google.golang.org/api/idtoken
go get github.com/golang-jwt/jwt/v4
go get github.com/gorilla/sessions
go get github.com/joho/godotenv
go get github.com/go-sql-driver/mysql
go get golang.org/x/time/rate

通过本文的指导,你应该能够在你的Go应用中成功实现Google和Apple的OAuth登录功能。记住在生产环境中要特别注意安全性和用户隐私保护。