Skip to content

第三方登录集成 - 社交登录完整实现

1. 第三方登录概述

1.1 什么是第三方登录

第三方登录允许用户使用已有的社交账号(如Google、GitHub、Facebook)登录你的应用,无需创建新账户。它基于OAuth 2.0和OpenID Connect协议实现。

核心优势:

  • 降低注册门槛: 用户无需记忆新密码
  • 提高转化率: 简化注册流程
  • 获取用户信息: 可获取用户头像、邮箱等信息
  • 增强信任: 利用知名平台的信任度

1.2 常见第三方登录平台

typescript
// 主流平台
- Google
- GitHub
- Facebook
- Twitter/X
- Microsoft
- Apple
- LinkedIn

// 国内平台
- 微信
- QQ
- 微博
- 支付宝

1.3 技术选型

bash
# Passport.js - Node.js认证中间件
npm install passport passport-google-oauth20 passport-github2 passport-facebook

# NextAuth.js - Next.js认证解决方案
npm install next-auth

# Auth0 - 认证即服务
npm install @auth0/nextjs-auth0

2. Google 登录集成

2.1 创建 Google OAuth 应用

bash
# 1. 访问 Google Cloud Console
https://console.cloud.google.com/

# 2. 创建项目

# 3. 启用API
- Google+ API

# 4. 创建OAuth 2.0凭据
- 应用类型: Web应用
- 授权重定向URI: http://localhost:3000/auth/google/callback

# 5. 获取
- Client ID
- Client Secret

2.2 后端实现(Express + Passport)

bash
npm install passport passport-google-oauth20 express-session
typescript
// auth/google.strategy.ts
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';

passport.use(
  new GoogleStrategy(
    {
      clientID: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      callbackURL: '/auth/google/callback',
      scope: ['profile', 'email']
    },
    async (accessToken, refreshToken, profile, done) => {
      try {
        // 查找或创建用户
        let user = await User.findOne({ googleId: profile.id });
        
        if (!user) {
          user = await User.create({
            googleId: profile.id,
            email: profile.emails![0].value,
            name: profile.displayName,
            avatar: profile.photos![0].value,
            provider: 'google'
          });
        }
        
        return done(null, user);
      } catch (error) {
        return done(error, undefined);
      }
    }
  )
);

// 序列化用户
passport.serializeUser((user: any, done) => {
  done(null, user.id);
});

passport.deserializeUser(async (id: string, done) => {
  try {
    const user = await User.findById(id);
    done(null, user);
  } catch (error) {
    done(error, null);
  }
});
typescript
// routes/auth.ts
import express from 'express';
import passport from 'passport';

const router = express.Router();

// Google登录
router.get('/google',
  passport.authenticate('google', {
    scope: ['profile', 'email']
  })
);

// Google回调
router.get('/google/callback',
  passport.authenticate('google', {
    failureRedirect: '/login',
    failureMessage: true
  }),
  (req, res) => {
    // 登录成功
    res.redirect('/dashboard');
  }
);

// 登出
router.post('/logout', (req, res) => {
  req.logout((err) => {
    if (err) {
      return res.status(500).json({ error: 'Logout failed' });
    }
    res.redirect('/');
  });
});

export default router;
typescript
// server.ts
import express from 'express';
import session from 'express-session';
import passport from 'passport';
import authRoutes from './routes/auth';
import './auth/google.strategy';

const app = express();

// Session配置
app.use(session({
  secret: process.env.SESSION_SECRET!,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    maxAge: 24 * 60 * 60 * 1000 // 24小时
  }
}));

// 初始化Passport
app.use(passport.initialize());
app.use(passport.session());

// 路由
app.use('/auth', authRoutes);

app.listen(3000);

2.3 前端实现(React)

tsx
// GoogleLoginButton.tsx
export function GoogleLoginButton() {
  const handleLogin = () => {
    window.location.href = '/auth/google';
  };
  
  return (
    <button
      onClick={handleLogin}
      className="flex items-center gap-2 px-4 py-2 border rounded-lg hover:bg-gray-50"
    >
      <img src="/google-icon.svg" alt="Google" className="w-5 h-5" />
      <span>使用 Google 登录</span>
    </button>
  );
}

2.4 Next.js 实现(NextAuth.js)

bash
npm install next-auth
typescript
// pages/api/auth/[...nextauth].ts
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';

export default NextAuth({
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!
    })
  ],
  callbacks: {
    async signIn({ user, account, profile }) {
      // 自定义登录逻辑
      return true;
    },
    async jwt({ token, account, profile }) {
      if (account) {
        token.accessToken = account.access_token;
        token.id = profile?.sub;
      }
      return token;
    },
    async session({ session, token }) {
      session.user.id = token.id as string;
      session.accessToken = token.accessToken as string;
      return session;
    }
  },
  pages: {
    signIn: '/login'
  }
});
tsx
// components/GoogleLogin.tsx
import { signIn } from 'next-auth/react';

export function GoogleLogin() {
  return (
    <button onClick={() => signIn('google', { callbackUrl: '/dashboard' })}>
      使用 Google 登录
    </button>
  );
}
tsx
// pages/dashboard.tsx
import { useSession, signOut } from 'next-auth/react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';

export default function Dashboard() {
  const { data: session, status } = useSession();
  const router = useRouter();
  
  useEffect(() => {
    if (status === 'unauthenticated') {
      router.push('/login');
    }
  }, [status]);
  
  if (status === 'loading') {
    return <div>Loading...</div>;
  }
  
  return (
    <div>
      <h1>Welcome {session?.user?.name}</h1>
      <img src={session?.user?.image || ''} alt="Avatar" />
      <button onClick={() => signOut()}>登出</button>
    </div>
  );
}

3. GitHub 登录集成

3.1 创建 GitHub OAuth 应用

bash
# 1. 访问GitHub Settings
https://github.com/settings/developers

# 2. New OAuth App
- Application name: Your App Name
- Homepage URL: http://localhost:3000
- Authorization callback URL: http://localhost:3000/auth/github/callback

# 3. 获取
- Client ID
- Client Secret

3.2 实现 GitHub 登录

typescript
// auth/github.strategy.ts
import passport from 'passport';
import { Strategy as GitHubStrategy } from 'passport-github2';

passport.use(
  new GitHubStrategy(
    {
      clientID: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
      callbackURL: '/auth/github/callback',
      scope: ['user:email']
    },
    async (accessToken, refreshToken, profile, done) => {
      try {
        let user = await User.findOne({ githubId: profile.id });
        
        if (!user) {
          user = await User.create({
            githubId: profile.id,
            username: profile.username,
            email: profile.emails?.[0]?.value,
            avatar: profile.photos?.[0]?.value,
            provider: 'github'
          });
        }
        
        return done(null, user);
      } catch (error) {
        return done(error, undefined);
      }
    }
  )
);
typescript
// NextAuth.js配置
import GitHubProvider from 'next-auth/providers/github';

export default NextAuth({
  providers: [
    GitHubProvider({
      clientId: process.env.GITHUB_ID!,
      clientSecret: process.env.GITHUB_SECRET!
    })
  ]
});

4. Facebook 登录集成

4.1 创建 Facebook 应用

bash
# 1. 访问Facebook Developers
https://developers.facebook.com/

# 2. 创建应用
- 选择类型: Consumer

# 3. 添加Facebook Login产品

# 4. 配置
- Valid OAuth Redirect URIs: http://localhost:3000/auth/facebook/callback

# 5. 获取
- App ID
- App Secret

4.2 实现 Facebook 登录

typescript
// auth/facebook.strategy.ts
import passport from 'passport';
import { Strategy as FacebookStrategy } from 'passport-facebook';

passport.use(
  new FacebookStrategy(
    {
      clientID: process.env.FACEBOOK_APP_ID!,
      clientSecret: process.env.FACEBOOK_APP_SECRET!,
      callbackURL: '/auth/facebook/callback',
      profileFields: ['id', 'emails', 'name', 'picture']
    },
    async (accessToken, refreshToken, profile, done) => {
      try {
        let user = await User.findOne({ facebookId: profile.id });
        
        if (!user) {
          user = await User.create({
            facebookId: profile.id,
            email: profile.emails?.[0]?.value,
            name: `${profile.name?.givenName} ${profile.name?.familyName}`,
            avatar: profile.photos?.[0]?.value,
            provider: 'facebook'
          });
        }
        
        return done(null, user);
      } catch (error) {
        return done(error, undefined);
      }
    }
  )
);
typescript
// NextAuth.js配置
import FacebookProvider from 'next-auth/providers/facebook';

export default NextAuth({
  providers: [
    FacebookProvider({
      clientId: process.env.FACEBOOK_ID!,
      clientSecret: process.env.FACEBOOK_SECRET!
    })
  ]
});

5. 微信登录集成

5.1 微信开放平台配置

bash
# 1. 访问微信开放平台
https://open.weixin.qq.com/

# 2. 创建网站应用

# 3. 配置
- 授权回调域: yourdomain.com

# 4. 获取
- AppID
- AppSecret

5.2 实现微信登录

typescript
// auth/wechat.strategy.ts
import passport from 'passport';
import { Strategy as WeChatStrategy } from 'passport-wechat';

passport.use(
  new WeChatStrategy(
    {
      appID: process.env.WECHAT_APP_ID!,
      appSecret: process.env.WECHAT_APP_SECRET!,
      callbackURL: '/auth/wechat/callback',
      scope: 'snsapi_userinfo',
      state: 'state'
    },
    async (accessToken, refreshToken, profile, done) => {
      try {
        let user = await User.findOne({ wechatId: profile.id });
        
        if (!user) {
          user = await User.create({
            wechatId: profile.id,
            nickname: profile.nickname,
            avatar: profile.headimgurl,
            provider: 'wechat'
          });
        }
        
        return done(null, user);
      } catch (error) {
        return done(error, undefined);
      }
    }
  )
);

6. 多平台登录统一处理

6.1 用户模型设计

typescript
// models/User.ts
import mongoose from 'mongoose';

const userSchema = new mongoose.Schema({
  // 本地认证
  email: String,
  passwordHash: String,
  
  // 第三方认证
  googleId: String,
  githubId: String,
  facebookId: String,
  wechatId: String,
  
  // 通用信息
  name: String,
  username: String,
  avatar: String,
  
  // 元数据
  provider: {
    type: String,
    enum: ['local', 'google', 'github', 'facebook', 'wechat']
  },
  
  // OAuth令牌
  accessToken: String,
  refreshToken: String,
  
  createdAt: {
    type: Date,
    default: Date.now
  }
});

// 根据provider查找用户
userSchema.statics.findByProvider = function(provider: string, providerId: string) {
  const query: any = {};
  
  switch (provider) {
    case 'google':
      query.googleId = providerId;
      break;
    case 'github':
      query.githubId = providerId;
      break;
    case 'facebook':
      query.facebookId = providerId;
      break;
    case 'wechat':
      query.wechatId = providerId;
      break;
  }
  
  return this.findOne(query);
};

export const User = mongoose.model('User', userSchema);

6.2 统一的认证处理

typescript
// services/auth.service.ts
interface OAuthProfile {
  provider: string;
  id: string;
  email?: string;
  name?: string;
  avatar?: string;
  accessToken: string;
  refreshToken?: string;
}

export class AuthService {
  static async handleOAuthLogin(profile: OAuthProfile) {
    // 查找现有用户
    let user = await User.findByProvider(profile.provider, profile.id);
    
    if (!user) {
      // 检查邮箱是否已存在
      if (profile.email) {
        user = await User.findOne({ email: profile.email });
        
        if (user) {
          // 关联第三方账号
          await this.linkProvider(user, profile);
        } else {
          // 创建新用户
          user = await this.createUser(profile);
        }
      } else {
        // 没有邮箱,直接创建
        user = await this.createUser(profile);
      }
    } else {
      // 更新令牌
      await this.updateTokens(user, profile);
    }
    
    return user;
  }
  
  static async createUser(profile: OAuthProfile) {
    const userData: any = {
      provider: profile.provider,
      email: profile.email,
      name: profile.name,
      avatar: profile.avatar,
      accessToken: profile.accessToken,
      refreshToken: profile.refreshToken
    };
    
    // 设置provider ID
    userData[`${profile.provider}Id`] = profile.id;
    
    return User.create(userData);
  }
  
  static async linkProvider(user: any, profile: OAuthProfile) {
    user[`${profile.provider}Id`] = profile.id;
    user.accessToken = profile.accessToken;
    user.refreshToken = profile.refreshToken;
    
    await user.save();
    return user;
  }
  
  static async updateTokens(user: any, profile: OAuthProfile) {
    user.accessToken = profile.accessToken;
    user.refreshToken = profile.refreshToken;
    
    await user.save();
    return user;
  }
}

6.3 统一的登录组件

tsx
// SocialLogin.tsx
import { signIn } from 'next-auth/react';

const providers = [
  {
    id: 'google',
    name: 'Google',
    icon: '/icons/google.svg',
    color: 'bg-white border-gray-300'
  },
  {
    id: 'github',
    name: 'GitHub',
    icon: '/icons/github.svg',
    color: 'bg-gray-900 text-white'
  },
  {
    id: 'facebook',
    name: 'Facebook',
    icon: '/icons/facebook.svg',
    color: 'bg-blue-600 text-white'
  }
];

export function SocialLogin() {
  return (
    <div className="space-y-3">
      {providers.map((provider) => (
        <button
          key={provider.id}
          onClick={() => signIn(provider.id, { callbackUrl: '/dashboard' })}
          className={`w-full flex items-center justify-center gap-3 px-4 py-2 rounded-lg ${provider.color}`}
        >
          <img src={provider.icon} alt={provider.name} className="w-5 h-5" />
          <span>使用 {provider.name} 登录</span>
        </button>
      ))}
    </div>
  );
}

7. 账号绑定与解绑

7.1 绑定第三方账号

typescript
// routes/account.ts
import express from 'express';
import passport from 'passport';

const router = express.Router();

// 绑定Google账号
router.get('/link/google',
  ensureAuthenticated,
  passport.authorize('google', {
    scope: ['profile', 'email']
  })
);

router.get('/link/google/callback',
  passport.authorize('google', { failureRedirect: '/settings' }),
  async (req: any, res) => {
    const user = req.user;
    const account = req.account;
    
    // 关联账号
    user.googleId = account.id;
    await user.save();
    
    res.redirect('/settings?linked=google');
  }
);

// 解绑账号
router.post('/unlink/:provider',
  ensureAuthenticated,
  async (req: any, res) => {
    const { provider } = req.params;
    const user = req.user;
    
    // 检查是否至少保留一种登录方式
    const loginMethods = [
      user.googleId,
      user.githubId,
      user.facebookId,
      user.passwordHash
    ].filter(Boolean);
    
    if (loginMethods.length <= 1) {
      return res.status(400).json({
        error: '必须至少保留一种登录方式'
      });
    }
    
    // 解绑
    user[`${provider}Id`] = undefined;
    await user.save();
    
    res.json({ success: true });
  }
);

function ensureAuthenticated(req: any, res: any, next: any) {
  if (req.isAuthenticated()) {
    return next();
  }
  res.redirect('/login');
}

export default router;

7.2 账号绑定界面

tsx
// AccountSettings.tsx
import { useState, useEffect } from 'react';

export function AccountSettings() {
  const [linkedAccounts, setLinkedAccounts] = useState({
    google: false,
    github: false,
    facebook: false
  });
  
  useEffect(() => {
    fetch('/api/user/linked-accounts')
      .then(res => res.json())
      .then(data => setLinkedAccounts(data));
  }, []);
  
  const handleLink = (provider: string) => {
    window.location.href = `/account/link/${provider}`;
  };
  
  const handleUnlink = async (provider: string) => {
    if (confirm(`确定要解绑 ${provider} 账号吗?`)) {
      await fetch(`/account/unlink/${provider}`, {
        method: 'POST'
      });
      
      setLinkedAccounts({
        ...linkedAccounts,
        [provider]: false
      });
    }
  };
  
  return (
    <div className="space-y-4">
      <h2>已关联账号</h2>
      
      {Object.entries(linkedAccounts).map(([provider, isLinked]) => (
        <div key={provider} className="flex items-center justify-between p-4 border rounded">
          <div className="flex items-center gap-3">
            <img src={`/icons/${provider}.svg`} className="w-8 h-8" />
            <span className="capitalize">{provider}</span>
          </div>
          
          {isLinked ? (
            <button
              onClick={() => handleUnlink(provider)}
              className="px-4 py-2 text-red-600 border border-red-600 rounded"
            >
              解绑
            </button>
          ) : (
            <button
              onClick={() => handleLink(provider)}
              className="px-4 py-2 bg-blue-600 text-white rounded"
            >
              绑定
            </button>
          )}
        </div>
      ))}
    </div>
  );
}

8. 安全最佳实践

8.1 State参数防CSRF

typescript
// 生成随机state
const state = crypto.randomUUID();
req.session.oauthState = state;

// 验证state
if (req.query.state !== req.session.oauthState) {
  throw new Error('Invalid state parameter');
}

8.2 存储OAuth令牌

typescript
// ❌ 不要存储在前端
localStorage.setItem('oauth_token', token);

// ✅ 存储在服务器端
user.accessToken = encryptToken(token);
user.refreshToken = encryptToken(refreshToken);
await user.save();

// 加密令牌
function encryptToken(token: string): string {
  const cipher = crypto.createCipher('aes-256-cbc', process.env.ENCRYPTION_KEY!);
  let encrypted = cipher.update(token, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  return encrypted;
}

8.3 验证邮箱唯一性

typescript
async function handleOAuthCallback(profile: any) {
  // 检查邮箱是否已被其他用户使用
  const existingUser = await User.findOne({
    email: profile.email,
    provider: { $ne: profile.provider }
  });
  
  if (existingUser) {
    // 提示用户邮箱已被使用
    throw new Error('该邮箱已被其他账号使用');
  }
  
  // 继续处理...
}

9. 错误处理

typescript
// 统一错误处理
router.get('/google/callback',
  passport.authenticate('google', {
    failureRedirect: '/login',
    failureMessage: true
  }),
  (req, res) => {
    res.redirect('/dashboard');
  },
  (err: any, req: any, res: any, next: any) => {
    console.error('OAuth Error:', err);
    
    let errorMessage = '登录失败';
    
    if (err.message.includes('email')) {
      errorMessage = '无法获取邮箱信息';
    } else if (err.message.includes('token')) {
      errorMessage = '令牌获取失败';
    }
    
    res.redirect(`/login?error=${encodeURIComponent(errorMessage)}`);
  }
);

10. 测试

typescript
// oauth.test.ts
describe('OAuth Login', () => {
  it('should create user on first login', async () => {
    const profile = {
      provider: 'google',
      id: 'google123',
      email: 'user@example.com',
      name: 'Test User'
    };
    
    const user = await AuthService.handleOAuthLogin(profile);
    
    expect(user.googleId).toBe('google123');
    expect(user.email).toBe('user@example.com');
  });
  
  it('should link account if email exists', async () => {
    // 创建本地用户
    const localUser = await User.create({
      email: 'user@example.com',
      passwordHash: 'hashed',
      provider: 'local'
    });
    
    // 使用相同邮箱的Google登录
    const profile = {
      provider: 'google',
      id: 'google123',
      email: 'user@example.com'
    };
    
    const user = await AuthService.handleOAuthLogin(profile);
    
    expect(user.id).toBe(localUser.id);
    expect(user.googleId).toBe('google123');
  });
});

11. 总结

第三方登录集成的关键要点:

  1. 选择合适的平台: 根据目标用户选择主流平台
  2. 统一处理: 使用统一的用户模型和认证流程
  3. 安全存储: OAuth令牌加密存储在服务器端
  4. 账号关联: 支持多个第三方账号绑定到同一用户
  5. 错误处理: 优雅处理各种错误情况
  6. 用户体验: 提供清晰的UI和反馈

通过正确实施第三方登录,可以显著降低用户注册门槛,提高用户转化率。