别着急,坐和放宽
使用社交账号登录
本文将演示如何使用 Next.js (App Router) + Tailwind CSS 作为前端,以及 Go + Gin 作为后端,完成 Google OAuth2.0 登录并在前端展示用户信息。
参考官方文档: https://developers.google.com/identity/protocols/oauth2/web-server?hl=zh-cn#libraries
示例代码已上传至GIthub仓库: https://github.com/x-mingg/google-oauth
创建新项目

进入OAuth授权页面

配置 OAuth 同意屏幕(填写应用名称、支持邮箱等)

配置项目

创建 OAuth 客户端 ID

记录生成的 Client ID 和 Client Secret

图片6:记录凭据截图
图片7:添加测试账户
测试一次完整登录流程截图

图片8:完整登录流程截图
图片9:登录成功
git clone https://github.com/x-mingg/google-oauth.git
cd google-oauth
项目位置:backend/。
1) 依赖与初始化
2) 环境变量示例 .env
3) 关键代码(main.go)
项目位置:frontend/。
1) 使用 create-next-app 初始化
2) 环境变量(frontend/.env.local)
3) 关键页面与逻辑说明:
src/app/page.tsx(主页,含登录按钮与用户展示)src/app/api/auth/callback/google/page.tsx(回调页面,接收 code 并请求后端)后端(在 backend/):
前端(在 frontend/):
http://localhost:3000/api/auth/callback/google)。config.Exchange(...),并检查系统时间是否正确(影响 token 签名验证)。cd backend
go mod tidy
GOOGLE_CLIENT_ID=your_client_id
GOOGLE_CLIENT_SECRET=your_client_secret
REDIRECT_URL=http://localhost:3000/api/auth/callback/google
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
// UserInfo 保存从 Google 获取的用户信息
type UserInfo struct {
ID string `json:"id"`
Email string `json:"email"`
VerifiedEmail bool `json:"verified_email"`
Name string `json:"name"`
Picture string `json:"picture"`
}
var config *oauth2.Config
func init() {
// 加载 .env
_ = godotenv.Load()
config = &oauth2.Config{
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
RedirectURL: "http://localhost:3000/api/auth/callback/google", // 必须与 Google Cloud Console 一致
Scopes: []string{
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
},
Endpoint: google.Endpoint,
}
}
func main() {
r := gin.Default()
// 简单的 CORS 中间件(开发用)
r.Use(func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
// 返回 Google 授权 URL
r.GET("/auth/google/url", func(c *gin.Context) {
// state 可用于防 CSRF(示例中简单使用固定值)
url := config.AuthCodeURL("state")
c.JSON(http.StatusOK, gin.H{"url": url})
})
// OAuth 回调:接收 code,交换 token,获取用户信息,并返回给前端
r.GET("/auth/google/callback", func(c *gin.Context) {
code := c.Query("code")
if code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "code not found"})
return
}
token, err := config.Exchange(c.Request.Context(), code)
if err != nil {
log.Printf("exchange error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to exchange code"})
return
}
// 使用 token 获取用户信息
client := &http.Client{}
req, _ := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch userinfo"})
return
}
defer resp.Body.Close()
var user UserInfo
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decode userinfo"})
return
}
// 返回给前端 token 与 user(生产中不要直接返回原始 access token,需换成自家 session/JWT)
c.JSON(http.StatusOK, gin.H{"token": token.AccessToken, "user": user})
})
// 使用 Authorization: Bearer <token> 获取用户信息(前端通过此接口刷新用户显示)
r.GET("/auth/user", func(c *gin.Context) {
ah := c.GetHeader("Authorization")
if ah == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "no auth header"})
return
}
token := strings.TrimPrefix(ah, "Bearer ")
client := &http.Client{}
req, _ := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
defer resp.Body.Close()
var user UserInfo
_ = json.NewDecoder(resp.Body).Decode(&user)
c.JSON(http.StatusOK, user)
})
r.Run(":8080")
}
npx create-next-app@latest frontend --typescript --tailwind --eslint
NEXT_PUBLIC_GOOGLE_CLIENT_ID=your_client_id
NEXT_PUBLIC_API_URL=http://localhost:8080
// src/app/page.tsx
"use client"
import { useEffect, useState } from 'react'
import ProfileCard from '@/components/ProfileCard'
import { UserInfo } from '@/types/user'
export default function Home() {
const [user, setUser] = useState<UserInfo | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
const token = localStorage.getItem('token')
if (token) {
// 调用后端接口获取用户信息
fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/user`, {
headers: { Authorization: `Bearer ${token}` }
}).then(r => r.json()).then(data => setUser(data)).catch(() => {
localStorage.removeItem('token')
})
}
}, [])
const startLogin = async () => {
setLoading(true)
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/google/url`)
const data = await res.json()
// 重定向到 Google 登录
window.location.href = data.url
}
return (
<main className="p-8">
{user ? <ProfileCard userInfo={user} onLogout={() => { localStorage.removeItem('token'); setUser(null) }} /> : (
<button onClick={startLogin} disabled={loading}>使用 Google 登录</button>
)}
</main>
)
}
"use client"
import { useEffect } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
export default function Callback() {
const params = useSearchParams()
const router = useRouter()
useEffect(() => {
const code = params.get('code')
if (!code) return
// 将 code 发送到后端,由后端交换 token 并返回 user + token
fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/google/callback?code=${code}`)
.then(r => r.json())
.then(data => {
if (data.token) {
localStorage.setItem('token', data.token)
router.push('/')
} else {
router.push('/?error=auth_failed')
}
})
}, [params, router])
return <div>正在处理登录...</div>
}
cd backend
cp .env.example .env # 填写凭据
go run main.go
cd frontend
cp .env.example .env.local
npm install
npm run dev