Skip to content

Commit cb7dcd9

Browse files
authored
Merge pull request #10 from soranjiro:feat/auth
create auth feat
2 parents 68f8e98 + 865ee6b commit cb7dcd9

31 files changed

Lines changed: 1686 additions & 213 deletions

apps/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"migrations:apply": "wrangler d1 migrations apply tabitabi-db"
1010
},
1111
"dependencies": {
12+
"@tsndr/cloudflare-worker-jwt": "^3.2.0",
1213
"hono": "^4.6.14"
1314
},
1415
"devDependencies": {

apps/api/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Hono } from 'hono';
22
import { Env } from './utils';
33
import { corsMiddleware } from './middleware/cors';
4+
import auth from './routes/auth';
45
import itineraries from './routes/itineraries';
56
import steps from './routes/steps';
67

@@ -12,6 +13,7 @@ app.get('/health', (c) => {
1213
return c.json({ status: 'ok', service: 'tabitabi-api' });
1314
});
1415

16+
app.route('/api/v1/auth', auth);
1517
app.route('/api/v1/itineraries', itineraries);
1618
app.route('/api/v1/steps', steps);
1719

apps/api/src/middleware/auth.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { Context, Next } from 'hono';
2+
import { Env } from '../utils';
3+
import { verifyToken, extractBearerToken } from '../utils/jwt';
4+
5+
export async function authMiddleware(c: Context<{ Bindings: Env }>, next: Next) {
6+
const authHeader = c.req.header('Authorization');
7+
const token = extractBearerToken(authHeader);
8+
9+
if (!token) {
10+
return c.json({
11+
success: false,
12+
error: { code: 'UNAUTHORIZED', message: 'No token provided' }
13+
}, 401);
14+
}
15+
16+
const payload = await verifyToken(token, c.env.JWT_SECRET);
17+
18+
if (!payload) {
19+
return c.json({
20+
success: false,
21+
error: { code: 'UNAUTHORIZED', message: 'Invalid or expired token' }
22+
}, 401);
23+
}
24+
25+
c.set('shioriId', payload.shioriId);
26+
await next();
27+
}
28+
29+
export async function optionalAuthMiddleware(c: Context<{ Bindings: Env }>, next: Next) {
30+
const authHeader = c.req.header('Authorization');
31+
const token = extractBearerToken(authHeader);
32+
33+
if (token) {
34+
const payload = await verifyToken(token, c.env.JWT_SECRET);
35+
if (payload) {
36+
c.set('shioriId', payload.shioriId);
37+
}
38+
}
39+
40+
await next();
41+
}

apps/api/src/routes/auth.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Hono } from 'hono';
2+
import { Env } from '../utils';
3+
import { ItineraryService } from '../services/itinerary.service';
4+
import { generateToken, verifyToken, extractBearerToken } from '../utils/jwt';
5+
import type { PasswordAuthRequest } from '@tabitabi/types';
6+
7+
const auth = new Hono<{ Bindings: Env }>();
8+
9+
auth.post('/verify', async (c) => {
10+
const authHeader = c.req.header('Authorization');
11+
const token = extractBearerToken(authHeader);
12+
13+
if (!token) {
14+
return c.json({
15+
success: false,
16+
error: { code: 'INVALID_INPUT', message: 'Token is required' }
17+
}, 400);
18+
}
19+
20+
const payload = await verifyToken(token, c.env.JWT_SECRET);
21+
22+
if (!payload) {
23+
return c.json({
24+
success: false,
25+
error: { code: 'UNAUTHORIZED', message: 'Invalid or expired token' }
26+
}, 401);
27+
}
28+
29+
return c.json({
30+
success: true,
31+
data: { shioriId: payload.shioriId, valid: true }
32+
});
33+
});
34+
35+
auth.post('/password', async (c) => {
36+
const { shioriId, password }: PasswordAuthRequest = await c.req.json();
37+
38+
if (!shioriId || !password) {
39+
return c.json({
40+
success: false,
41+
error: { code: 'INVALID_INPUT', message: 'shioriId and password are required' }
42+
}, 400);
43+
}
44+
45+
const service = new ItineraryService(c.env.DB);
46+
const itinerary = await service.get(shioriId);
47+
48+
if (!itinerary) {
49+
return c.json({
50+
success: false,
51+
error: { code: 'NOT_FOUND', message: 'Itinerary not found' }
52+
}, 404);
53+
}
54+
55+
if (itinerary.password !== password) {
56+
return c.json({
57+
success: false,
58+
error: { code: 'UNAUTHORIZED', message: 'Invalid password' }
59+
}, 401);
60+
}
61+
62+
const token = await generateToken(shioriId, c.env.JWT_SECRET);
63+
64+
return c.json({
65+
success: true,
66+
data: { token }
67+
});
68+
});
69+
70+
export default auth;

apps/api/src/routes/itineraries.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Hono } from 'hono';
22
import { Env } from '../utils';
33
import { ItineraryService } from '../services/itinerary.service';
4+
import { authMiddleware } from '../middleware/auth';
5+
import { generateToken } from '../utils/jwt';
46

57
const itineraries = new Hono<{ Bindings: Env }>();
68

@@ -26,11 +28,23 @@ itineraries.post('/', async (c) => {
2628
const input = await c.req.json();
2729
const service = new ItineraryService(c.env.DB);
2830
const data = await service.create(input);
29-
return c.json({ success: true, data }, 201);
31+
32+
const token = await generateToken(data.id, c.env.JWT_SECRET);
33+
34+
return c.json({ success: true, data: { ...data, token } }, 201);
3035
});
3136

32-
itineraries.put('/:id', async (c) => {
37+
itineraries.put('/:id', authMiddleware, async (c) => {
3338
const id = c.req.param('id');
39+
const shioriId = c.get('shioriId');
40+
41+
if (id !== shioriId) {
42+
return c.json({
43+
success: false,
44+
error: { code: 'FORBIDDEN', message: 'You can only edit your own itinerary' }
45+
}, 403);
46+
}
47+
3448
const input = await c.req.json();
3549
const service = new ItineraryService(c.env.DB);
3650
const data = await service.update(id, input);
@@ -42,8 +56,17 @@ itineraries.put('/:id', async (c) => {
4256
return c.json({ success: true, data });
4357
});
4458

45-
itineraries.delete('/:id', async (c) => {
59+
itineraries.delete('/:id', authMiddleware, async (c) => {
4660
const id = c.req.param('id');
61+
const shioriId = c.get('shioriId');
62+
63+
if (id !== shioriId) {
64+
return c.json({
65+
success: false,
66+
error: { code: 'FORBIDDEN', message: 'You can only delete your own itinerary' }
67+
}, 403);
68+
}
69+
4770
const service = new ItineraryService(c.env.DB);
4871
const success = await service.delete(id);
4972

apps/api/src/routes/steps.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Hono } from 'hono';
22
import { Env } from '../utils';
33
import { StepService } from '../services/step.service';
4+
import { ItineraryService } from '../services/itinerary.service';
5+
import { authMiddleware } from '../middleware/auth';
46

57
const steps = new Hono<{ Bindings: Env }>();
68

@@ -34,7 +36,7 @@ steps.get('/:stepId', async (c) => {
3436
return c.json({ success: true, data });
3537
});
3638

37-
steps.post('/', async (c) => {
39+
steps.post('/', authMiddleware, async (c) => {
3840
const input = await c.req.json();
3941

4042
if (!input.itinerary_id || !input.title || !input.date || !input.time) {
@@ -47,39 +49,65 @@ steps.post('/', async (c) => {
4749
}, 400);
4850
}
4951

52+
const shioriId = c.get('shioriId');
53+
if (input.itinerary_id !== shioriId) {
54+
return c.json({
55+
success: false,
56+
error: { code: 'FORBIDDEN', message: 'You can only add steps to your own itinerary' }
57+
}, 403);
58+
}
59+
5060
const service = new StepService(c.env.DB);
5161
const data = await service.create(input);
5262
return c.json({ success: true, data }, 201);
5363
});
5464

55-
steps.put('/:stepId', async (c) => {
65+
steps.put('/:stepId', authMiddleware, async (c) => {
5666
const stepId = c.req.param('stepId');
5767
const input = await c.req.json();
5868
const service = new StepService(c.env.DB);
59-
const data = await service.update(stepId, input);
6069

61-
if (!data) {
70+
const existingStep = await service.get(stepId);
71+
if (!existingStep) {
6272
return c.json({
6373
success: false,
6474
error: { code: 'NOT_FOUND', message: 'Step not found' }
6575
}, 404);
6676
}
6777

78+
const shioriId = c.get('shioriId');
79+
if (existingStep.itinerary_id !== shioriId) {
80+
return c.json({
81+
success: false,
82+
error: { code: 'FORBIDDEN', message: 'You can only edit steps in your own itinerary' }
83+
}, 403);
84+
}
85+
86+
const data = await service.update(stepId, input);
6887
return c.json({ success: true, data });
6988
});
7089

71-
steps.delete('/:stepId', async (c) => {
90+
steps.delete('/:stepId', authMiddleware, async (c) => {
7291
const stepId = c.req.param('stepId');
7392
const service = new StepService(c.env.DB);
74-
const success = await service.delete(stepId);
7593

76-
if (!success) {
94+
const existingStep = await service.get(stepId);
95+
if (!existingStep) {
7796
return c.json({
7897
success: false,
7998
error: { code: 'NOT_FOUND', message: 'Step not found' }
8099
}, 404);
81100
}
82101

102+
const shioriId = c.get('shioriId');
103+
if (existingStep.itinerary_id !== shioriId) {
104+
return c.json({
105+
success: false,
106+
error: { code: 'FORBIDDEN', message: 'You can only delete steps in your own itinerary' }
107+
}, 403);
108+
}
109+
110+
const success = await service.delete(stepId);
83111
return c.json({ success: true, data: null });
84112
});
85113

apps/api/src/services/itinerary.service.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,15 @@ export class ItineraryService {
3030
id,
3131
title: input.title,
3232
theme_id: input.theme_id || 'minimal',
33+
memo: input.memo ?? null,
34+
password: input.password ?? null,
3335
created_at: now,
3436
updated_at: now,
3537
};
3638

3739
await this.db
38-
.prepare('INSERT INTO itineraries (id, title, theme_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)')
39-
.bind(itinerary.id, itinerary.title, itinerary.theme_id, itinerary.created_at, itinerary.updated_at)
40+
.prepare('INSERT INTO itineraries (id, title, theme_id, memo, password, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
41+
.bind(itinerary.id, itinerary.title, itinerary.theme_id, itinerary.memo, itinerary.password, itinerary.created_at, itinerary.updated_at)
4042
.run();
4143

4244
return itinerary;
@@ -58,6 +60,14 @@ export class ItineraryService {
5860
fields.push('theme_id = ?');
5961
values.push(input.theme_id);
6062
}
63+
if (input.memo !== undefined) {
64+
fields.push('memo = ?');
65+
values.push(input.memo);
66+
}
67+
if (input.password !== undefined) {
68+
fields.push('password = ?');
69+
values.push(input.password);
70+
}
6171

6272
if (fields.length > 1) {
6373
values.push(id);
@@ -84,6 +94,8 @@ export class ItineraryService {
8494
id: row.id,
8595
title: row.title,
8696
theme_id: row.theme_id,
97+
memo: row.memo,
98+
password: row.password,
8799
created_at: row.created_at,
88100
updated_at: row.updated_at,
89101
};

apps/api/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export interface Env {
22
DB: D1Database;
33
ALLOWED_ORIGINS?: string;
4+
JWT_SECRET?: string;
45
}
56

67
export function generateId(length: number = 32): string {

apps/api/src/utils/jwt.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { sign, verify } from '@tsndr/cloudflare-worker-jwt';
2+
3+
export interface JwtPayload {
4+
shioriId: string;
5+
iat: number;
6+
exp: number;
7+
}
8+
9+
const DEFAULT_SECRET = 'tabitabi-default-secret-change-in-production';
10+
const TOKEN_EXPIRY = 30 * 24 * 60 * 60;
11+
12+
export async function generateToken(shioriId: string, secret?: string): Promise<string> {
13+
const now = Math.floor(Date.now() / 1000);
14+
const payload: JwtPayload = {
15+
shioriId,
16+
iat: now,
17+
exp: now + TOKEN_EXPIRY,
18+
};
19+
20+
return await sign(payload, secret || DEFAULT_SECRET);
21+
}
22+
23+
export async function verifyToken(token: string, secret?: string): Promise<JwtPayload | null> {
24+
try {
25+
const isValid = await verify(token, secret || DEFAULT_SECRET);
26+
if (!isValid) return null;
27+
28+
const { payload } = await verify(token, secret || DEFAULT_SECRET, { throwError: true }) as { payload: JwtPayload };
29+
30+
if (payload.exp < Math.floor(Date.now() / 1000)) {
31+
return null;
32+
}
33+
34+
return payload;
35+
} catch {
36+
return null;
37+
}
38+
}
39+
40+
export function extractBearerToken(authHeader?: string): string | null {
41+
if (!authHeader?.startsWith('Bearer ')) return null;
42+
return authHeader.substring(7);
43+
}

apps/web/src/lib/api/auth.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { apiClient } from './client';
2+
import type { PasswordAuthRequest, PasswordAuthResponse } from '@tabitabi/types';
3+
4+
export const authApi = {
5+
async verifyToken(shioriId: string): Promise<boolean> {
6+
try {
7+
const response = await apiClient.post<{ valid: boolean }>('/auth/verify', {}, shioriId);
8+
return response.valid;
9+
} catch {
10+
return false;
11+
}
12+
},
13+
14+
async authenticateWithPassword(shioriId: string, password: string): Promise<string> {
15+
const data = await apiClient.post<PasswordAuthResponse>('/auth/password', {
16+
shioriId,
17+
password,
18+
} as PasswordAuthRequest);
19+
20+
return data.token;
21+
},
22+
};

0 commit comments

Comments
 (0)