From f851c944b17ab811af9e5be652827b43e987b37a Mon Sep 17 00:00:00 2001 From: topi0247 Date: Sat, 25 Apr 2026 13:55:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AA=8D=E8=A8=BC=E3=82=92localStorage?= =?UTF-8?q?=E3=81=8B=E3=82=89httponly=20Cookie=E3=81=AB=E5=A4=89=E6=9B=B4?= =?UTF-8?q?=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../app/controllers/application_controller.rb | 11 +++ .../auth/omniauth_callbacks_controller.rb | 19 ++-- .../controllers/auth/sessions_controller.rb | 4 + back/config/initializers/cors.rb | 12 +-- front/src/__tests__/api/auth.test.ts | 19 ++-- front/src/api/auth.ts | 90 ++----------------- front/src/app/record/page.tsx | 24 +---- 7 files changed, 44 insertions(+), 135 deletions(-) diff --git a/back/app/controllers/application_controller.rb b/back/app/controllers/application_controller.rb index 1223f36..eef3bba 100644 --- a/back/app/controllers/application_controller.rb +++ b/back/app/controllers/application_controller.rb @@ -1,3 +1,14 @@ class ApplicationController < ActionController::API + include ActionController::Cookies include DeviseTokenAuth::Concerns::SetUserByToken + before_action :set_auth_headers_from_cookies + + private + + def set_auth_headers_from_cookies + request.headers['access-token'] ||= cookies['access-token'] + request.headers['client'] ||= cookies['client'] + request.headers['uid'] ||= cookies['uid'] + request.headers['expiry'] ||= cookies['expiry'] + end end diff --git a/back/app/controllers/auth/omniauth_callbacks_controller.rb b/back/app/controllers/auth/omniauth_callbacks_controller.rb index 5633240..23ebeb7 100644 --- a/back/app/controllers/auth/omniauth_callbacks_controller.rb +++ b/back/app/controllers/auth/omniauth_callbacks_controller.rb @@ -1,17 +1,14 @@ class Auth::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCallbacksController - # オーバーライド - # 後でよく確認する def redirect_callbacks user = User.from_omniauth(request.env['omniauth.auth']) if user.persisted? sign_in(user) - # トークンを生成 - client_id = SecureRandom.urlsafe_base64(nil, false) - token = SecureRandom.urlsafe_base64(nil, false) + client_id = SecureRandom.urlsafe_base64(nil, false) + token = SecureRandom.urlsafe_base64(nil, false) token_hash = BCrypt::Password.create(token) - expiry = (Time.now + DeviseTokenAuth.token_lifespan).to_i + expiry = (Time.now + DeviseTokenAuth.token_lifespan).to_i github_token = request.env['omniauth.auth']['credentials']['token'].to_s user.tokens[client_id] = { @@ -22,7 +19,13 @@ def redirect_callbacks user.github_token = Encryptor.encrypt(github_token) if user.save - redirect_to "#{ENV['FRONT_URL']}/record?uid=#{user.uid}&token=#{token}&client=#{client_id}&expiry=#{expiry}", allow_other_host: true + cookie_options = { httponly: true, secure: Rails.env.production? } + cookies['access-token'] = cookie_options.merge(value: token) + cookies['client'] = cookie_options.merge(value: client_id) + cookies['uid'] = cookie_options.merge(value: user.uid) + cookies['expiry'] = cookie_options.merge(value: expiry.to_s) + + redirect_to "#{ENV['FRONT_URL']}/record", allow_other_host: true else redirect_to "#{ENV['FRONT_URL']}/?status=error", allow_other_host: true end @@ -31,4 +34,4 @@ def redirect_callbacks redirect_to "#{ENV['FRONT_URL']}/?status=error", allow_other_host: true end end -end \ No newline at end of file +end diff --git a/back/app/controllers/auth/sessions_controller.rb b/back/app/controllers/auth/sessions_controller.rb index a525879..d96f4c0 100644 --- a/back/app/controllers/auth/sessions_controller.rb +++ b/back/app/controllers/auth/sessions_controller.rb @@ -2,5 +2,9 @@ class Auth::SessionsController < DeviseTokenAuth::SessionsController def destroy super + cookies.delete('access-token') + cookies.delete('client') + cookies.delete('uid') + cookies.delete('expiry') end end \ No newline at end of file diff --git a/back/config/initializers/cors.rb b/back/config/initializers/cors.rb index 02fbf3a..179d756 100644 --- a/back/config/initializers/cors.rb +++ b/back/config/initializers/cors.rb @@ -1,16 +1,10 @@ -# Be sure to restart your server when you modify this file. - -# Avoid CORS issues when API is called from the frontend app. -# Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin Ajax requests. - -# Read more: https://github.com/cyu/rack-cors - Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do - origins ENV["FRONT_URL"] || "http://localhost:3000" + origins ENV["FRONT_URL"] || "http://localhost:8000" resource "*", headers: :any, - methods: [:get, :post, :put, :patch, :delete, :options, :head] + methods: [:get, :post, :put, :patch, :delete, :options, :head], + credentials: true end end diff --git a/front/src/__tests__/api/auth.test.ts b/front/src/__tests__/api/auth.test.ts index 9668da6..ddab0af 100644 --- a/front/src/__tests__/api/auth.test.ts +++ b/front/src/__tests__/api/auth.test.ts @@ -3,16 +3,10 @@ import { authFetch } from '@/api/auth' describe('authFetch', () => { beforeEach(() => { - localStorage.clear() vi.restoreAllMocks() }) - it('localStorageの値をリクエストヘッダーに付加する', async () => { - localStorage.setItem('access-token', 'test-token') - localStorage.setItem('client', 'test-client') - localStorage.setItem('uid', 'test-uid') - localStorage.setItem('expiry', '9999999999') - + it('credentials: include でリクエストを送る', async () => { const mockFetch = vi.fn().mockResolvedValue({ status: 200, json: () => Promise.resolve({ success: true }), @@ -21,14 +15,11 @@ describe('authFetch', () => { await authFetch('/me') - const [, options] = mockFetch.mock.calls[0] as [string, RequestInit & { headers: Record }] - expect(options.headers['access-token']).toBe('test-token') - expect(options.headers['client']).toBe('test-client') - expect(options.headers['uid']).toBe('test-uid') - expect(options.headers['expiry']).toBe('9999999999') + const [, options] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(options.credentials).toBe('include') }) - it('localStorageが空の場合ヘッダーにaccess-tokenが含まれない', async () => { + it('Content-Type: application/json ヘッダーが付加される', async () => { const mockFetch = vi.fn().mockResolvedValue({ status: 200, json: () => Promise.resolve({}), @@ -38,6 +29,6 @@ describe('authFetch', () => { await authFetch('/me') const [, options] = mockFetch.mock.calls[0] as [string, RequestInit & { headers: Record }] - expect(options.headers['access-token']).toBeUndefined() + expect(options.headers['Content-Type']).toBe('application/json') }) }) diff --git a/front/src/api/auth.ts b/front/src/api/auth.ts index a5c8b5c..f0e260c 100644 --- a/front/src/api/auth.ts +++ b/front/src/api/auth.ts @@ -13,29 +13,15 @@ interface AuthResponse { user: { id: number; name: string }; } -function getAuthHeaders(): Record { - if (typeof window === "undefined") return {}; - const headers: Record = {}; - const accessToken = localStorage.getItem("access-token"); - const client = localStorage.getItem("client"); - const uid = localStorage.getItem("uid"); - const expiry = localStorage.getItem("expiry"); - if (accessToken) headers["access-token"] = accessToken; - if (client) headers["client"] = client; - if (uid) headers["uid"] = uid; - if (expiry) headers["expiry"] = expiry; - return headers; -} - export async function authFetch( path: string, options: FetchOptions = {} ): Promise<{ status: number; data: T }> { const res = await fetch(`${API_URL}${path}`, { ...options, + credentials: "include", headers: { "Content-Type": "application/json", - ...getAuthHeaders(), ...options.headers, }, }); @@ -49,6 +35,7 @@ async function baseFetch( ): Promise<{ status: number; data: T }> { const res = await fetch(`${BASE_API_URL}${path}`, { ...options, + credentials: "include", headers: { "Content-Type": "application/json", ...options.headers, @@ -67,81 +54,18 @@ export const useAuth = () => { async function autoLogin(): Promise { const res = await authFetch("/me"); - if (res.status !== 200) { - clearStorage(); - return false; - } - if (!res.data.success) { - clearStorage(); - return false; - } - setUser({ id: res.data.user.id, name: res.data.user.name }); - return true; - } - - async function currentUser({ - uid = "", - client = "", - token = "", - expiry = "", - }: { - uid?: string; - client?: string; - token?: string; - expiry?: string; - } = {}): Promise { - if (uid && client && token && expiry) { - setStorage({ accessToken: token, client, uid, expiry }); - } - const res = await authFetch("/me"); - if (res.status !== 200) { - clearStorage(); - return false; - } - if (!res.data.success) { - clearStorage(); - return false; - } + if (res.status !== 200) return false; + if (!res.data.success) return false; setUser({ id: res.data.user.id, name: res.data.user.name }); return true; } async function logout(): Promise { - const res = await baseFetch("/auth/sign_out", { - method: "DELETE", - headers: { - "access-token": localStorage.getItem("access-token") ?? "", - client: localStorage.getItem("client") ?? "", - uid: localStorage.getItem("uid") ?? "", - expiry: localStorage.getItem("expiry") ?? "", - }, - }); - if (res.status !== 200) { - return false; - } - clearStorage(); + const res = await baseFetch("/auth/sign_out", { method: "DELETE" }); + if (res.status !== 200) return false; setUser({ id: null, name: "" }); return true; } - const setStorage = (data: { - accessToken: string; - client: string; - uid: string; - expiry: string; - }) => { - localStorage.setItem("access-token", data.accessToken); - localStorage.setItem("client", data.client); - localStorage.setItem("uid", data.uid); - localStorage.setItem("expiry", data.expiry); - }; - - const clearStorage = () => { - localStorage.removeItem("access-token"); - localStorage.removeItem("client"); - localStorage.removeItem("uid"); - localStorage.removeItem("expiry"); - }; - - return { login, currentUser, logout, autoLogin }; + return { login, autoLogin, logout }; }; diff --git a/front/src/app/record/page.tsx b/front/src/app/record/page.tsx index 70aa1de..63fe500 100644 --- a/front/src/app/record/page.tsx +++ b/front/src/app/record/page.tsx @@ -9,34 +9,16 @@ import * as Shadcn from "@/components/shadcn"; export default function UserPage() { const user = useUserState(); - const { currentUser, autoLogin } = useAuth(); + const { autoLogin } = useAuth(); const router = useRouter(); const [records, setRecords] = useRecordsState(); const [isLoading, setIsLoading] = useState(true); const fetchUserData = useCallback(async () => { if (user.id) return; - - const queryParams = new URLSearchParams(window.location.search); - const uid = queryParams.get("uid"); - const client = queryParams.get("client"); - const token = queryParams.get("token"); - const expiry = queryParams.get("expiry"); - let logged = false; try { - if (uid && client && token && expiry) { - await currentUser({ uid, client, token, expiry }).then( - (res) => (logged = res) - ); - if (logged) { - router.push("/record"); - } - } else { - await autoLogin().then((res) => (logged = res)); - } - if (!logged) { - router.push("/"); - } + const logged = await autoLogin(); + if (!logged) router.push("/"); } catch (e) { alert("エラーが発生しました"); router.push("/");