Skip to content

Commit

Permalink
feat: implement Google OAuth login with automatic user registration
Browse files Browse the repository at this point in the history
  • Loading branch information
jiaah committed Nov 25, 2024
1 parent 38b6636 commit 820fed1
Show file tree
Hide file tree
Showing 38 changed files with 606 additions and 153 deletions.
7 changes: 4 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,11 @@ web_modules/

# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.env.development
.env.development.local
.env.test
.env.production

# parcel-bundler cache (https://parceljs.org/)
.cache
Expand Down
56 changes: 35 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Freedivah: Project Overview

**“From Dive Tracking to Social Sharing”**
Freedivah is an interactive platform that allows freedivers to mark and share their dive locations on a global map using national flag icons.

Expand All @@ -9,9 +10,11 @@ The name 'Freedivah' combines 'freediving' with my identity as a developer, refl
- **Personalized Dive Map**: Pin and save your dive locations on a global map.
- **Sharing and Connecting**: Share your dive experiences and connect with divers worldwide.
- **Tracking and Documentation**: Log and track your dives across different countries to monitor your progress.

## Documentation

For more detailed information, refer to the following:

- [Technology Choices Rationale, API Specification, Functional Specification, Route Design etc](https://jiah827.notion.site/Project-Freedivah-10f4ef50e633807387d4c9307d622bdb?pvs=74)
- [Optimizing Freedivah’s Architecture with Feature-Sliced Design(FSD)](https://www.notion.so/jiah827/Optimizing-Freedivah-s-Architecture-with-Feature-Sliced-Design-1134ef50e63380b1b47bea0cc16f5f64)
- [Managing Shared Libraries: API Strategy with Exports and Aliases](https://www.notion.so/jiah827/exports-alias-API-1434ef50e63380a3aacad6eb9b7fec3b)
Expand All @@ -20,11 +23,14 @@ For more detailed information, refer to the following:
## Development Considerations

### Flexible and Scalable Architecture
- Single-direction dependencies and modularity.
- Loosely coupled systems through separation and abstraction of business logic, UI, and side effects.


- Single-direction dependencies and modularity.
- Loosely coupled systems through separation and abstraction of business logic, UI, and side effects.

## Architecture

### System Overview

```mermaid
graph TB
subgraph "Frontend (Next.js)"
Expand Down Expand Up @@ -59,7 +65,9 @@ Supabase[(Supabase)]
end
API --> Supabase
```

### Package Dependencies

```mermaid
graph TD
A[packages/web]
Expand All @@ -71,7 +79,9 @@ style A fill:#eb6b56,stroke:#333,stroke-width:2px
style B fill:#2196F3,stroke:#333,stroke-width:2px
style C fill:#47b39d,stroke:#333,stroke-width:2px
```

### Build Flow

```mermaid
graph LR
A[Build Shared] --> B[Build Web & API]
Expand All @@ -80,23 +90,29 @@ style A fill:#47b39d,stroke:#333,stroke-width:2px
style B fill:#2196F3,stroke:#333,stroke-width:2px
style C fill:#eb6b56,stroke:#333,stroke-width:2px
```

### Package Overview

- `@freedivah/shared`: Core utilities and types
- `@freedivah/web`: Next.js frontend application
- `@freedivah/api`: Express backend server

## Technologies Used
- **Frontend**: Next.js, TypeScript, Vanilla Extract
- **Backend**: Node.js, Express.js, Supabase
- **Testing**: Jest
- **DevOps**: Github Actions, Docker, AWS

- **Frontend**: Next.js, TypeScript, Vanilla Extract
- **Backend**: Node.js, Express.js, Supabase
- **Testing**: Jest
- **DevOps**: Github Actions, Docker, AWS

## How to Run

### Prerequisites
- Node.js 18+

- Node.js 18+
- PNPM 9.14.1+

### Installation & Development

1. Clone the repository
```
git clone https://github.com/f-lab-edu/Freedivah.git
Expand All @@ -110,21 +126,19 @@ style C fill:#eb6b56,stroke:#333,stroke-width:2px
```
pnpm dev
```
### Testing
```
yarn test
```
### Build
```
yarn build
```

## Design
<img src="docs/images/Freedivah_Design.webp" alt="Freedivah Design" style="width: auto; height: 600px" />


### Testing

```
yarn test
```

### Build

```
yarn build
```

## Design

<img src="docs/images/Freedivah_Design.webp" alt="Freedivah Design" style="width: auto; height: 600px" />
2 changes: 1 addition & 1 deletion packages/api/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import '@testing-library/jest-dom';
import "@testing-library/jest-dom";

// 전역 설정이나 모킹이 필요한 경우 여기에 추가
4 changes: 3 additions & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"scripts": {
"build": "tsc",
"dev": "nodemon",
"dev": "NODE_ENV=development PORT=4000 nodemon",
"start": "node dist/index.js",
"test": "jest",
"test:watch": "jest --watch",
Expand All @@ -13,10 +13,12 @@
"dependencies": {
"@freedivah/shared": "workspace:*",
"@supabase/supabase-js": "^2.39.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"nodemon": "^3.0.1",
"ts-node": "^10.9.1",
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./supabase";
27 changes: 27 additions & 0 deletions packages/api/src/config/supabase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createClient } from "@supabase/supabase-js";
import dotenv from "dotenv";
import path from "path";

const env = process.env.NODE_ENV || "development";
const envPath = path.resolve(__dirname, `../../../../.env.${env}`);

dotenv.config({ path: envPath });

if (!process.env.NEXT_PUBLIC_SUPABASE_URL) {
throw new Error("Missing env.NEXT_PUBLIC_SUPABASE_URL");
}

if (!process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
throw new Error("Missing env.NEXT_PUBLIC_SUPABASE_ANON_KEY");
}

export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
{
auth: {
detectSessionInUrl: true, // OAuth 인증 정보를 URL에서 제거
flowType: "pkce",
},
},
);
71 changes: 71 additions & 0 deletions packages/api/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { AuthProvider } from "@shared/types/authTypes";
import { NextFunction, Request, Response } from "express";
import { AuthService } from "../services/auth.service";

export class AuthController {
private authService: AuthService;

constructor() {
this.authService = new AuthService();
}

async providerAuthStart(req: Request, res: Response, next: NextFunction) {
try {
const { provider } = req.query;
if (!provider) {
throw new Error("Missing authentication provider");
}

const authUrl = await this.authService.getProviderAuthUrl(
provider as AuthProvider,
);
return res.status(200).json({ url: authUrl });
} catch (error) {
next(error)
}
}

async providerAuth(req: Request, res: Response, next: NextFunction) {
try {
const code = req.query.code;
const error = req.query.error;

if (error || !code) {
throw new Error(error?.toString() || "No auth code provided");
}

const { userId, access_token, refresh_token } = await this.authService.getUserSessionData(code as string);
const isNewUser = await this.authService.isNewUser(userId);

if (isNewUser) {
await this.authService.createUser({ id: userId });
}

// 쿠키 설정
res.cookie("s-access-token", access_token, {
maxAge: 60 * 1000, // 1분
secure: process.env.NODE_ENV === "production",
});

res.cookie("sb-refresh-token", refresh_token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 30 * 24 * 60 * 60 * 1000, // 30일
});

/**
* @info Supabase가 리다이렉트를 자동으로 수행하기 때문에 JSON으로 응답을 처리할 수 없습니다.
*/
return res.redirect(
`${process.env.NEXT_PUBLIC_WEB_URL}/auth/callback?isNewUser=${isNewUser}`
);
} catch (error) {
return res.redirect(
`${process.env.NEXT_PUBLIC_WEB_URL}/auth/callback?error=${encodeURIComponent(
error instanceof Error ? error.message : 'An unknown error occurred'
)}`
);
}
}
}
38 changes: 34 additions & 4 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
import { logger } from '@shared/utils';
import cors from "cors";
import dotenv from "dotenv";
import express from "express";
import path from "path";
import { errorMiddleware } from "./middlewares/error.middleware";
import { authRoutes } from "./routes/auth.routes";

logger('Shared is connected', {
state: 'ON',
});
const env = process.env.NODE_ENV || "development";
const envPath = path.resolve(__dirname, `../../../../.env.${env}`);

dotenv.config({ path: envPath });

const app = express();
const port = process.env.PORT || 4000;

app.use(
cors({
origin: process.env.NEXT_PUBLIC_WEB_URL,
credentials: true,
}),
);

app.use(express.json());
app.use("/api/auth", authRoutes);
app.use(errorMiddleware);

app.get("/api/health", (req, res) => {
res.json({ status: "ok" });
});

app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});

export { app };
45 changes: 45 additions & 0 deletions packages/api/src/middlewares/error.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { NextFunction, Request, Response } from "express";

export interface ApiError {
success: false;
error: {
code: string; // 애플리케이션 에러 코드 (예: AUTH_001, USER_002)
message: string; // 사용자에게 보여줄 메시지
details?: string; // 개발자를 위한 상세 메시지
timestamp: string; // 에러 발생 시간
path: string; // 에러가 발생한 엔드포인트
traceId: string; // 요청 추적을 위한 ID
stack?: string; // 개발 환경에서만 포함
};
}

const generateId = () => {
const timestamp = new Date().toISOString().replace(/[-:.]/g, "");
const random = Math.random().toString(36).substring(2, 8);
return `${timestamp}-${random}`;
};

export const errorMiddleware = (
error: any,
req: Request,
res: Response,
next: NextFunction,
) => {
const isDev = process.env.NODE_ENV === "development";
const status = error.status || 500;

const errorResponse: ApiError = {
success: false,
error: {
code: error.code || `ERR_${status}`,
message: error.message || "Internal Server Error",
details: error.details,
timestamp: new Date().toISOString(),
path: req.path,
traceId: generateId(),
},
...(isDev && { stack: error.stack }),
};

return res.status(status).json(errorResponse);
};
28 changes: 28 additions & 0 deletions packages/api/src/models/user.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { supabase } from "../config/supabase";

interface Profile {
/**
* @todo type 정의
*/
}

type SupabaseResponse<T> = {
data: T | null;
error: { message: string; code: string } | null;
};
export class UserModel {
async findByUserId(authUserId: string) {
return supabase.from("profiles").select("*").eq("id", authUserId);
}

async createUser(id: string): Promise<SupabaseResponse<Profile>> {
const now = new Date().toISOString();
return supabase.from("profiles").insert([
{
id,
created_at: now,
updated_at: now,
},
]);
}
}
12 changes: 12 additions & 0 deletions packages/api/src/routes/auth.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Router } from "express";
import { AuthController } from "../controllers/auth.controller";

const router = Router();
const authController = new AuthController();

router.get("/provider", authController.providerAuthStart.bind(authController));
router.get(
"/provider/callback",
authController.providerAuth.bind(authController),
);
export { router as authRoutes };
Loading

0 comments on commit 820fed1

Please sign in to comment.