本文由claude code编写,仅供参考。 在现代应用开发中,提供第三方登录已经成为标配功能。本文将详细介绍如何在Go语言中实现Google和Apple的OAuth登录,以及如何处理iOS和Android的原生登录。
一、OAuth 2.0 基础概念
OAuth 2.0是一个授权框架,允许应用在用户授权的情况下访问用户在第三方服务上的资源,而无需获取用户的密码。主要流程包括:
- 授权请求:引导用户到OAuth提供商的授权页面
- 授权确认:用户同意授权
- 获取授权码:重定向回应用并携带授权码
- 交换令牌:使用授权码换取访问令牌
- 访问资源:使用访问令牌获取用户信息
二、Google OAuth登录实现
2.1 前期准备
首先需要在Google Cloud Console创建项目并配置OAuth 2.0凭据:
- 访问 Google Cloud Console
- 创建新项目或选择现有项目
- 启用Google+ API或Google Identity服务
- 创建OAuth 2.0客户端ID(Web应用、iOS、Android分别创建)
- 配置授权重定向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 前期准备
- 加入Apple Developer Program
- 在Apple Developer Console配置Sign in with Apple
- 创建App ID并启用Sign in with Apple功能
- 创建Service ID(用于Web登录)
- 配置Return URLs和验证域名
- 生成私钥用于验证token
3.2 Apple登录的特殊性
重要提示:Apple Sign In有一个非常重要的特性需要注意:
- 首次授权:用户第一次通过Apple登录时,Apple会在回调中返回
user参数,包含用户的姓名(firstName/lastName)和邮箱 - 后续授权:从第二次开始,Apple不会再返回用户的姓名信息,只返回ID Token中的基本信息(sub、email等)
- 数据持久化要求:必须在首次登录时将用户的完整信息(姓名、邮箱)存储到数据库中,因为之后无法再次获取
这个设计的原因是Apple重视用户隐私,认为用户信息只需要传递一次。如果你的应用在首次登录时没有正确保存这些信息,用户需要:
- 在iOS设备的"设置 > Apple ID > 密码与安全性 > 使用Apple登录的App"中撤销授权
- 重新登录才能再次获取到姓名信息
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;
重要说明:
-
name字段允许为NULL,因为:- Apple用户可能在后续登录时没有提供姓名
- 首次登录时必须保存name,后续更新时如果name为空则不更新此字段
-
email字段也可能为空(虽然设置为NOT NULL,但在实际使用中要考虑Apple用户可能选择"隐藏我的邮箱") -
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登录的完整流程,包括:
- OAuth 2.0基础概念:理解授权流程
- Google OAuth实现:Web端和移动端的完整实现
- Apple Sign In实现:包括客户端密钥生成和token验证
- 移动端集成:iOS(Swift)和Android(Kotlin)的完整示例
- 安全最佳实践:HTTPS、token刷新、速率限制等
- 数据库设计:用户表结构和数据管理
- 错误处理和日志:生产环境必备的监控机制
关键要点
- 始终验证
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登录功能。记住在生产环境中要特别注意安全性和用户隐私保护。