昨天我們完成了 Google Cloud Console 的設定,今天我們會用 Passport.js 這個身份驗證套件來實作 Google 登入。
Passport.js 是 Node.js 中最流行的身份驗證中介軟體,它的核心概念是:
sequenceDiagram
participant U as 用戶
participant F as 前端
participant B as Tickeasy 後端
participant G as Google OAuth
U->>F: 點擊 "Google 登入"
F->>B: GET /api/v1/auth/google
B->>G: 重定向到 Google 授權頁面
G->>U: 顯示授權頁面
U->>G: 授權 Tickeasy 存取
G->>B: 回調 /api/v1/auth/google/callback + code
B->>G: 用 code 交換 access_token
G->>B: 回傳 access_token + 用戶資料
B->>B: 處理用戶資料 (創建/更新用戶)
B->>B: 生成 JWT token
B->>F: 重定向到前端 + JWT token
F->>U: 登入成功
npm install passport passport-google-oauth20 jsonwebtoken
npm install --save-dev @types/passport @types/passport-google-oauth20 @types/jsonwebtoken
# Google OAuth
GOOGLE_CLIENT_ID=你的用戶端編號
GOOGLE_CLIENT_SECRET=你的用戶端密碼
GOOGLE_CALLBACK_URL=http://localhost:3000/api/v1/auth/google/callback
# JWT
JWT_SECRET=your-secret-key
JWT_EXPIRES_IN=7d
# 前端網址
FRONTEND_URL=http://localhost:3001
// src/models/user.ts
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
@Column()
name: string;
@Column({ nullable: true, unique: true })
googleId: string;
@Column({ nullable: true })
picture: string;
@Column({ default: 'google' })
provider: string;
}
// src/utils/jwt.util.ts
import jwt from 'jsonwebtoken';
interface JwtPayload {
id: string;
email: string;
}
export class JwtUtil {
static generateToken(payload: JwtPayload): string {
return jwt.sign(payload, process.env.JWT_SECRET!, {
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
});
}
static verifyToken(token: string): JwtPayload {
return jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
}
}
// src/config/passport.ts
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { AppDataSource } from './database';
import { User } from '../models/user';
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
callbackURL: process.env.GOOGLE_CALLBACK_URL!,
},
async (accessToken, refreshToken, profile, done) => {
try {
const userRepository = AppDataSource.getRepository(User);
const email = profile.emails?.[0]?.value;
// 尋找或創建使用者
let user = await userRepository.findOne({
where: { googleId: profile.id },
});
if (!user && email) {
user = userRepository.create({
googleId: profile.id,
email,
name: profile.displayName,
picture: profile.photos?.[0]?.value,
});
await userRepository.save(user);
}
done(null, user);
} catch (error) {
done(error as Error);
}
}
)
);
export default passport;
重點說明:
Strategy
的第一個參數是設定(Client ID、Secret、Callback URL)done(null, user)
告訴 Passport 驗證成功,並傳遞使用者資料// src/controllers/auth.controller.ts
import { Request, Response } from 'express';
import { JwtUtil } from '../utils/jwt.util';
export class AuthController {
static googleCallback(req: Request, res: Response): void {
if (!req.user) {
return res.redirect(`${process.env.FRONTEND_URL}/login?error=auth_failed`);
}
// 生成 JWT
const token = JwtUtil.generateToken({
id: req.user.id,
email: req.user.email,
});
// 帶著 token 導回前端
res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${token}`);
}
}
// src/routes/auth.route.ts
import { Router } from 'express';
import passport from '../config/passport';
import { AuthController } from '../controllers/auth.controller';
const router = Router();
// 開始 Google 登入
router.get(
'/google',
passport.authenticate('google', {
scope: ['profile', 'email'],
session: false, // 使用 JWT,不需要 session
})
);
// Google 回調
router.get(
'/google/callback',
passport.authenticate('google', { session: false }),
AuthController.googleCallback
);
export default router;
重點說明:
/google
路由會自動導向 Google 授權頁面session: false
因為我們用 JWT,不需要 Sessionscope
指定要取得的權限(個人資料、Email)// src/index.ts
import express from 'express';
import cors from 'cors';
import passport from './config/passport';
import authRoutes from './routes/auth.route';
import { AppDataSource } from './config/database';
const app = express();
app.use(express.json());
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }));
app.use(passport.initialize());
app.use('/api/v1/auth', authRoutes);
const startServer = async () => {
await AppDataSource.initialize();
app.listen(3000, () => {
console.log('🚀 Server running on http://localhost:3000');
console.log('📝 Google Login: http://localhost:3000/api/v1/auth/google');
});
};
startServer();
http://localhost:3000/api/v1/auth/google
/api/v1/auth/google/callback
每種登入方式都是一個 Strategy:
// Google Strategy
passport.use(new GoogleStrategy({...}, verifyCallback));
// Facebook Strategy
passport.use(new FacebookStrategy({...}, verifyCallback));
// 本地 Strategy(帳密登入)
passport.use(new LocalStrategy({...}, verifyCallback));
這是你定義的邏輯,決定如何處理第三方回傳的資料:
async (accessToken, refreshToken, profile, done) => {
// 1. 從 profile 取得使用者資料
// 2. 在資料庫中尋找或創建使用者
// 3. 呼叫 done(null, user) 完成驗證
}
// 開始認證
passport.authenticate('google', { scope: [...] })
// Passport 會:
// 1. 導向 Google 授權頁面
// 2. 使用者授權後接收 callback
// 3. 用 code 交換 access_token
// 4. 取得使用者資料
// 5. 執行你的 verify callback
// 6. 將使用者資料掛到 req.user