Skip to content

Commit 5223049

Browse files
authored
Merge pull request #27 from coji/fix/security-improvements
fix: improve OAuth2 security implementation
2 parents ba2ff37 + dc739a7 commit 5223049

3 files changed

Lines changed: 94 additions & 25 deletions

File tree

app/libs/google-auth.ts

Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,35 @@
11
import { redirect } from 'react-router'
22

3+
const STORAGE_KEY = 'google_oauth_validation'
4+
35
// 検証用の値をローカルストレージに保存する
4-
const storeValidationValue = async (
5-
key: string,
6-
data: { state: string; nonce: string },
7-
) => {
8-
await localStorage.setItem(key, JSON.stringify(data))
6+
const storeValidationValue = (data: { state: string; nonce: string }) => {
7+
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
98
}
109

1110
// 検証用の値をローカルストレージから取得し、削除する
12-
const restoreValidationValue = async (key: string) => {
13-
const data = await localStorage.getItem(key)
11+
const restoreValidationValue = () => {
12+
const data = localStorage.getItem(STORAGE_KEY)
1413
if (!data) {
15-
throw new Error('state がありません')
14+
throw new Error(
15+
'認証セッションが見つかりません。再度ログインしてください。',
16+
)
1617
}
17-
await localStorage.removeItem(key)
18+
localStorage.removeItem(STORAGE_KEY)
1819
return JSON.parse(data) as { state: string; nonce: string }
1920
}
2021

22+
// JWT ペイロードをデコードする (署名検証は行わない)
23+
const decodeJwtPayload = (token: string): Record<string, unknown> => {
24+
const base64Url = token.split('.')[1]
25+
if (!base64Url) {
26+
throw new Error('無効なトークン形式です')
27+
}
28+
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
29+
const jsonPayload = atob(base64)
30+
return JSON.parse(jsonPayload)
31+
}
32+
2133
/**
2234
Google OpenID Connect Authenticator
2335
@@ -71,18 +83,19 @@ export const createGoogleAuthenticator = <User>({
7183
return url.toString()
7284
}
7385

74-
const authenticate = async (request: Request) => {
86+
const authenticate = (request: Request) => {
7587
const url = new URL(request.url)
7688
const callbackURL = buildCallbackURL(request)
7789

7890
// コールバックURL以外: 認可URLにリダイレクトし、コールバックさせる
7991
if (url.pathname !== callbackURL.pathname) {
8092
// コールバック時に state と nonce をチェックするために保存しておく
93+
// 暗号学的に安全な乱数を使用
8194
const validation = {
82-
state: String(Math.random()),
83-
nonce: String(Math.random()),
95+
state: crypto.randomUUID(),
96+
nonce: crypto.randomUUID(),
8497
}
85-
await storeValidationValue('v', validation)
98+
storeValidationValue(validation)
8699

87100
// 認可 URL にリダイレクトさせる。成功するとコールバックURLにリダイレクトされる
88101
throw redirect(
@@ -96,27 +109,37 @@ export const createGoogleAuthenticator = <User>({
96109
const url = new URL(request.url)
97110
const params = new URLSearchParams(url.hash.slice(1))
98111

99-
// state のチェック
100-
const validation = await restoreValidationValue('v')
112+
// Google からのエラーレスポンスをチェック
113+
const error = params.get('error')
114+
if (error) {
115+
const errorDescription = params.get('error_description') || error
116+
throw new Error(`認証エラー: ${errorDescription}`)
117+
}
118+
119+
// 保存した検証値を取得
120+
const validation = restoreValidationValue()
121+
122+
// state のチェック (CSRF 対策)
101123
if (validation.state !== params.get('state')) {
102-
throw new Error('state が一致しません')
124+
throw new Error('不正なリクエストです。再度ログインしてください。')
103125
}
104126

105127
// id トークンを取得
106128
const idToken = params.get('id_token')
107129
if (!idToken) {
108-
throw new Error('IDトークンがありません')
130+
throw new Error('認証情報が取得できませんでした')
109131
}
110132

111-
// id トークンのペイロードに含まれる nonce のチェック
112-
const jsonPayload = atob(
113-
idToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'),
114-
)
115-
if (JSON.parse(jsonPayload).nonce !== validation.nonce) {
116-
throw new Error('nonce が一致しません')
133+
// 1. まず Firebase で署名検証を含む認証を実行
134+
const user = await verifyUser(request, idToken)
135+
136+
// 2. 署名検証済みトークンから nonce を取得してチェック (リプレイ攻撃対策)
137+
const payload = decodeJwtPayload(idToken)
138+
if (payload.nonce !== validation.nonce) {
139+
throw new Error('不正なリクエストです。再度ログインしてください。')
117140
}
118141

119-
return verifyUser(request, idToken)
142+
return user
120143
}
121144

122145
return {

app/routes/welcome+/create_account/route.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,12 @@ export const clientAction = async ({
4848

4949
return redirect(`/${submission.value.handle}`)
5050
} catch (e) {
51+
console.error('アカウント作成エラー:', e)
5152
return {
5253
lastResult: submission.reply({
53-
formErrors: [`アカウントの作成に失敗しました: ${e}`],
54+
formErrors: [
55+
'アカウントの作成に失敗しました。しばらくしてから再度お試しください。',
56+
],
5457
}),
5558
}
5659
}

firestore.rules

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
rules_version = '2';
2+
3+
service cloud.firestore {
4+
match /databases/{database}/documents {
5+
6+
// アカウント
7+
match /accounts/{handle} {
8+
// 誰でも読み取り可能(公開プロフィール)
9+
allow read: if true;
10+
11+
// 作成: 認証済みユーザーのみ、自分のUIDで作成
12+
allow create: if request.auth != null
13+
&& request.resource.data.uid == request.auth.uid;
14+
15+
// 更新: 自分のアカウントのみ
16+
allow update: if request.auth != null
17+
&& resource.data.uid == request.auth.uid;
18+
19+
// 削除: 自分のアカウントのみ
20+
allow delete: if request.auth != null
21+
&& resource.data.uid == request.auth.uid;
22+
23+
// 投稿(サブコレクション)
24+
match /posts/{postId} {
25+
// 誰でも読み取り可能(公開投稿)
26+
allow read: if true;
27+
28+
// 作成: 認証済みで、親アカウントの所有者のみ
29+
allow create: if request.auth != null
30+
&& get(/databases/$(database)/documents/accounts/$(handle)).data.uid == request.auth.uid;
31+
32+
// 更新・削除: 認証済みで、親アカウントの所有者のみ
33+
allow update, delete: if request.auth != null
34+
&& get(/databases/$(database)/documents/accounts/$(handle)).data.uid == request.auth.uid;
35+
}
36+
}
37+
38+
// その他のドキュメントはアクセス禁止
39+
match /{document=**} {
40+
allow read, write: if false;
41+
}
42+
}
43+
}

0 commit comments

Comments
 (0)