Rails5 + React(TypeScript)でログイン機能を実装する
今回はアプリ開発の上で頻繁に使うであろうログイン機能を作ってみました。意外と苦戦することも多かったのでその備忘録です。
主に参考にしたサイトは下のやつです。
qiita.com
作ったアプリはGithubに公開しています。
https://github.com/trmti/FileSharingApp3/tree/dev
ではやっていきます。
,,環境構築
まずは、front側とapi側の環境を作っていきます。
プロジェクトの作成
以下のコマンドを実行してrailsプロジェクトを作成します。今回はDockerを使って環境を作っていますが、Dockerがよくわからない人はruby2.5.xとrails5.2.x、React17.xを使って環境を作ってもらえれば同じ動作をすると思います。
まずはコマンドプロンプトで以下のコマンドを実行します。
mkdir rails-react-auth && cd rails-react-auth touch docker-compose.yml mkdir api && cd api touch Dockerfile Gemfile Gemfile.lock entrypoint.sh cd .. mkdir front && cd front touch Dockerfile
実行すると以下のようなファイル構成ができます。
rails-react-auth |__ docker-compose.yml |__ api |__ Dockerfile |__ Gemfile |__ Gemfile.lock |__ entrypoint.sh |_ front |_ Dockerfile
それぞれのファイルに必要な情報を記述していきます。
# Dockerfile(api) FROM ruby:2.5 RUN apt-get update -qq && apt-get install -y nodejs postgresql-client RUN mkdir /rails-react-app WORKDIR /rails-react-app COPY Gemfile /rails-react-app/Gemfile COPY Gemfile.lock /rails-react-app/Gemfile.lock RUN bundle install COPY . /rails-react-app # Add a script to be executed every time the container starts. COPY entrypoint.sh /usr/bin/ RUN chmod +x /usr/bin/entrypoint.sh ENTRYPOINT ["entrypoint.sh"] EXPOSE 3000 # Start the main process. CMD ["rails", "server", "-b", "0.0.0.0"]
# Gemfile source 'https://rubygems.org' gem 'rails', '~> 5.2.6', '>= 5.2.6.2'
# Gemfile.lock //何も書かなくて大丈夫です
# entrypoint.sh #!/bin/bash set -e # Remove a potentially pre-existing server.pid for Rails. rm -f /rails-react-app/tmp/pids/server.pid # Then exec the container's main process (what's set as CMD in the Dockerfile). exec "$@"
# Dockerfile(front) FROM node:17-alpine3.14 WORKDIR /usr/src/app/rails-react-app COPY ./rails-react-app/package*.json /usr/src/app/rails-react-app/
# docker-compose.yml version: '3' services: db: image: postgres volumes: - postgres-data:/var/lib/postgresql/data environment: POSTGRES_PASSWORD: password api: build: context: ./api/ dockerfile: Dockerfile command: /bin/sh -c "rm -f /rails-react-app/tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'" image: ikko3dayo/api volumes: - ./api:/rails-react-app - ./api/vendor/bundle:/rails-react-app/vendor/bundle environment: TZ: Asia/Tokyo RAILS_ENV: development ports: - 3000:3000 depends_on: - db front: build: context: ./front/ dockerfile: Dockerfile volumes: - ./front/rails-react-app:/usr/src/app/rails-react-app command: sh -c "npm start" ports: - '8000:3000' - '6006:6006' volumes: postgres-data: driver: local
これらのファイルを作成したら実際に環境を作っていきます。
プロジェクトルートに行き、以下のコマンドを実行します。
docker-compose run api rails new . --force --no-deps --database=postgresql --api docker-compose run --rm front sh -c "npm install -g create-react-app && create-react-app rails-react-app --template typescript" docker-compose build
これで環境の構築が完了しました。
railsとdatabaseを繋げる
api/config/database.ymlを以下のように編集します。
# database.yml default: &default adapter: postgresql encoding: unicode //ここから編集 host: db username: postgres password: password # For details on connection pooling, see Rails configuration guide # http://guides.rubyonrails.org/configuring.html#database-pooling pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
では、実際にサーバーを立ち上げて見ましょう。
docker-compose up
http://localhost:3000とhttp://localhost:8000にアクセスしてみて、それぞれreactとrailsの初期画面が出てくれば成功です。
apiサーバーの設定
CORSの設定
許可したサーバーからアクセスできるようにCORSを設定します。
Gemfileから
gem "rack-cors"
という部分を探し、コメントを外します。
次にapi/config/initializers/cors.rbというファイルを以下のように編集します。
ails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins 'http://localhost:8000' resource '*', headers: :any, expose: ["access-token", "expiry", "token-type", "uid", "client"], methods: [:get, :post, :put, :patch, :delete, :options, :head], credentials: true end end
これでlocalhost:8000 (reactのサーバー)からのアクセスが許可されました。
deviceのインストール
Gemfileに
gem "device" gem "device_token_auth"
というgemを追加します。
dockerを再起動します。
docker-compose up
Userモデルの作成
dockerのapiコンテナにアクセスします。
docker-compose exec api sh
アクセスしたコンテナの中で以下のコマンドを実行します。
rails g device:install rails g devise_token_auth:install User auth rails db:migrate
次にapi/app/config/initializers/device_token_auth.rbの45行目辺りのコメントを外します。
config.headers_names = {:'access-token' => 'access-token', :'client' => 'client', :'expiry' => 'expiry', :'uid' => 'uid', :'token-type' => 'token-type' }
各種コントローラーの作成
apiコンテナ内で以下のコマンドを実行してコントローラーを作成します。
rails g controller api/v1/auth/registration rails g controller api/v1/auth/sessions
それぞれのコントローラーを以下のように編集します。
# api/app/controllers/api/v1/auth/registration_controller.rb # アカウント作成用コントローラー class Api::V1::Auth::RegistrationsController < DeviseTokenAuth::RegistrationsController private def sign_up_params params.permit(:email, :password, :password_confirmation, :name) end end
# api/app/controllers/api/v1/auth/session_controller.rb # ログイン状態確認用コントローラー class Api::V1::Auth::SessionsController < ApplicationController def index if current_api_v1_user render json: { is_login: true, data: current_api_v1_user } else render json: { is_login: false, message: "ユーザーが存在しません" } end end end
# api/app/controllers/application_controller.rb class ApplicationController < ActionController::Base include DeviseTokenAuth::Concerns::SetUserByToken skip_before_action :verify_authenticity_token helper_method :current_user, :user_signed_in? end
ルーティングを設定
# api/app/config/routes.rb Rails.application.routes.draw do namespace :api do namespace :v1 do resources :test, only: %i[index] mount_devise_token_auth_for 'User', at: 'auth', controllers: { registrations: 'api/v1/auth/registrations' } namespace :auth do resources :sessions, only: %i[index] end end end end
動作確認
Postmanを使って動作確認をします。
https://orange-satellite-856034-1.postman.co/
こんな感じになっていれば成功です。ログインもやってみます。
いい感じですね。
front側
frontでは簡単化のためにantdというライブラリを使います。細かい部分は違うかも知れませんが大まかな画面構成は以下のようになります。
プロジェクトの初期設定
App.tsxを以下のように編集します。
# App.tsx import { VFC } from "react" export const App: VFC = () => { return <></> }
index.tsxも以下のように編集します。
import React from 'react'; import ReactDOM from 'react-dom'; import { App } from './App'; import 'antd/dist/antd.less'; import reportWebVitals from './reportWebVitals'; ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById('root') ); // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals reportWebVitals();
使わないファイルなどは消して大丈夫です。
次にtsconfig.jsonに以下のように記述します。
# tsconfig.json { "compilerOptions": { ... (省略) "baseUrl": "src" }, "include": ["src"] }
これによって、srcフォルダがこのプロジェクトのルートディレクトリになります。
次に、srcフォルダの中に必要なフォルダとファイルを作っていきます。以下のようなファイル構成を作ってください。
src |__ type.ts |__ auth |__ db |__ pages |__ components |__ atoms |__ molecules |__ organisms |__ templates
type.tsはプロジェクトの中に出てくる型を定義するために作ります。
apiの呼び出し
type.tsファイルを以下のように編集します。
export interface SignUpParams { name: string; email: string; password: string; passwordConfirmation: string; } // サインイン export interface SignInParams { email: string; password: string; } export interface User { id: number; uid: string; provider: string; email: string; name: string; nickname?: string; image?: string; allowPasswordChange: boolean; created_at?: Date; updated_at?: Date; } export type UserData = { data: User; }; export type FetchUserSuccess = { status: 'success'; data: User; }; export type FetchFailed = { status: 'error'; message: string; }; export type FetchUser = FetchUserSuccess | FetchFailed;
ユーザーデータが持つ型をUserと定義し、リクエストが作成した場合の型をFetchUserSuccess。リクエストが失敗した場合の方をFetchFailedと定義します。また、SignUpParamsにはユーザーを作成するときの引数の型を定義し、SignInParamsにはログインするときの型を定義します。
TypeScriptでhttpリクエストを使うためにaxiosというライブラリをインストールし、Cookieを扱うためにjs-cookieというライブラリをインストールします。
docker-compose exec front sh -c "npm i axios js-cookie"
また、APIのホストを環境変数として保持するためにrails-react-appフォルダの中に「.env」ファイルを作成し、以下のように記述します。
# .env # development REACT_APP_API_HOST="http://localhost:3000"
Reactでは.envファイルに「REACT_APP_」から続く変数を定義することで環境変数を作ることができるようになります。
# developmentというのはdevelopment環境での環境変数という意味です。他にもtest環境やproduction環境での変数も定義できます。
次に、db フォルダの中にuser.tsファイルを作成し、以下のように記述します。
# user.ts import axios, { AxiosResponse } from 'axios'; import { SignUpParams, SignInParams, UserData, FetchUserSuccess, FetchFailed, } from 'type'; import Cookies from 'js-cookie'; const api_url = `${process.env.REACT_APP_API_HOST}/api/v1`; export const loginUser = (params: SignInParams) => { const res = axios .post(`${api_url}/auth/sign_in`, params, { withCredentials: true }) .then((prop: AxiosResponse<UserData>): FetchUserSuccess => { const data = prop.data.data; const headers = prop.headers; Cookies.set('_access_token', headers['access-token']); Cookies.set('_client', headers.client); Cookies.set('_uid', headers.uid); return { status: 'success', data }; }) .catch((): FetchFailed => { return { status: 'error', message: 'ログインに失敗しました。', }; }); return res; }; export const signUpUser = (params: SignUpParams) => { const res = axios .post(`${api_url}/auth`, params, { withCredentials: true }) .then((prop: AxiosResponse<UserData>): FetchUserSuccess => { const data = prop.data.data; const headers = prop.headers; Cookies.set('_access_token', headers['access-token']); Cookies.set('_client', headers.client); Cookies.set('_uid', headers.uid); return { status: 'success', data }; }) .catch((): FetchFailed => { return { status: 'error', message: 'アカウント登録に失敗しました', }; }); return res; }; export const logOutUser = () => { const res = axios .delete(`${api_url}/auth/sign_out`, { headers: { 'access-token': Cookies.get('_access_token') || '', client: Cookies.get('_client') || '', uid: Cookies.get('_uid') || '', }, }) .then((prop: AxiosResponse<UserData>): FetchUserSuccess => { Cookies.remove('_access_token'); Cookies.remove('_client'); Cookies.remove('_uid'); const data = prop.data.data; return { status: 'success', data }; }) .catch((): FetchFailed => { return { status: 'error', message: 'ログアウトに失敗しました', }; }); return res; }; export const getCurrentUser = () => { if ( !Cookies.get('_access_token') || !Cookies.get('_client') || !Cookies.get('_uid') ) { const res: FetchFailed = { status: 'error', message: 'ユーザーの取得に失敗しました', }; return res; } const res = axios .get(`${api_url}/auth/sessions`, { headers: { 'access-token': Cookies.get('_access_token') || '', client: Cookies.get('_client') || '', uid: Cookies.get('_uid') || '', }, }) .then((prop: AxiosResponse) => { if (prop.data.is_login) { const res: FetchUserSuccess = { status: 'success', data: prop.data.data, }; return res; } else { const res: FetchFailed = { status: 'error', message: 'ユーザーが存在しません', }; return res; } }) .catch((): FetchFailed => { const res: FetchFailed = { status: 'error', message: 'ユーザーの取得に失敗しました', }; return res; }); return res; };
axios.getと記述することで指定したサーバーにgetリクエストを送ることができます。帰ってきたデータはprop変数に入ります。
ログインに必要なaccess_tokenなどの情報はheaderに入っているため、ログイン時やユーザー登録時に取り出し、Cookieに設定することでリロード時にもログイン状態を保持することができます。
Contextの作成
Contextとはデータを様々なところから扱えるようにするグローバル変数のようなものを定義するReactのHookです。createContextでContextを作成し、useContextで使用します。
ログイン状態などの情報は様々の場所で使用するため、Contextを使用します。
authフォルダの中にAuthUserContext.tsxファイルを作成します。そこに以下のように記述します。
import React, { createContext, useContext, useState } from 'react'; import { loginUser, logOutUser, signUpUser, getCurrentUser } from 'db/user'; import { User, SignInParams, SignUpParams } from 'type'; type Props = { login: (props: SignInParams) => Promise<void>; logout: () => Promise<void>; signup: (props: SignUpParams) => Promise<void>; updateUser: () => Promise<void>; authUser: User | null; loading: boolean; }; const AuthUserContext = createContext<Props>({ login: async (_) => console.error('Providerが設定されていません'), logout: async () => console.error('Providerが設定されていません'), signup: async () => console.error('Providerが設定されていません'), updateUser: async () => console.error('Providerが設定されていません'), authUser: null, loading: false, }); const AuthUserProvider: React.FC = ({ children }) => { const [authUser, setAuthUser] = useState<User | null>(null); const [loading, setLoading] = useState<boolean>(false); const login = async (props: SignInParams) => { setLoading(true); const res = await loginUser(props); if (res.status === 'success') { const data = res.data; setAuthUser(data); } else { console.error(res.message); throw new Error(); } setLoading(false); }; const logout = async () => { setLoading(true); const res = await logOutUser(); if (res.status === 'success') { setAuthUser(null); } else { console.error(res.message); throw new Error(); } setLoading(false); }; const signup = async (data: SignUpParams) => { setLoading(true); const res = await signUpUser(data); if (res.status === 'success') { setAuthUser(res.data); } else { console.error(res.message); throw new Error(); } setLoading(false); }; const updateUser = async () => { setLoading(true); const res = await getCurrentUser(); if (res.status === 'success') { setAuthUser(res.data); } else { console.error(res.message); setAuthUser(null); throw new Error(); } setLoading(false); }; return ( <AuthUserContext.Provider value={{ login, logout, signup, updateUser, loading, authUser }} > {children} </AuthUserContext.Provider> ); }; export const useLogin = () => useContext(AuthUserContext).login; export const useLogout = () => useContext(AuthUserContext).logout; export const useSignup = () => useContext(AuthUserContext).signup; export const useUpdateUser = () => useContext(AuthUserContext).updateUser; export const useLoading = () => useContext(AuthUserContext).loading; export const useAuthUser = () => useContext(AuthUserContext).authUser; export default AuthUserProvider;
<AuthUserContext.Provider value={{ login, logout, signup, updateUser, loading, authUser }} >
のようにContext.Providerを設定し、valueの中に使いたいデータや関数を定義することでその子コンポーネントはどこからでもそのデータを使えるようになります。
ここでは、login,logout,signup,updateUser,loading,authUserという6つのデータを渡しています。
また、createContextの引数にはContextの初期値を入れています。
それでは、設定したContextを使えるようにApp.tsxを編集します。
# App.tsx import { VFC } from "react" import AuthUserProvider from "auth/AuthUserProvider" export const App: VFC = () => { return ( <AuthUserProvider> <></> </AuthUserProvider> }
このようにすることでAuthUserProviderの子コンポーネントはuseContextで先程valueに設定したデータにアクセスできるようになります。
コンポーネントの作成
各コンポーネントはGithubに上げているため、気になる人はここから見てください。
GitHub - trmti/FileSharingApp3 at dev
ルーティング
作成したコンポーネントを表示するためのルーティングを作成するため、react-router-domをインストールします。
docker-compose exec front sh -c "npm i react-router-dom"
その後、App.tsxを以下のように編集します。
# App.tsx import { VFC, FC, useEffect } from 'react'; import { BrowserRouter, Routes, Navigate, Outlet, Route, RouteProps, } from 'react-router-dom'; import AuthUserProvider, { useLoading, useAuthUser, useUpdateUser, } from 'auth/AuthUserContext'; import Home from 'pages/Home'; import Signup from 'pages/Signup'; import Login from 'pages/Login'; import Logout from 'pages/Logout'; const PrivateRoute: FC<RouteProps> = () => { const updateUser = useUpdateUser(); const loading = useLoading(); const user = useAuthUser(); useEffect(() => { (async () => { await updateUser(); })(); }, []); if (!loading) { return user !== null ? <Outlet /> : <Navigate to="/login" />; } else { return <></>; } }; export const App: VFC = () => { return ( <> <AuthUserProvider> <BrowserRouter> <Routes> <Route path="/" element={<Home />} /> <Route path="/signup" element={<Signup />} /> <Route path="/login" element={<Login />} /> <Route path="/logout" element={<Logout />} /> <Route path="/user" element={<PrivateRoute />}> <Route path="/user" element={<Home />} /> </Route> </Routes> </BrowserRouter> </AuthUserProvider> </> ); };
このようにすることで「http://localhost:8000」にアクセスするとHomeコンポーネントを、「http://localhost:8000/signup」にアクセスするとSignupコンポーネントを表示するようになります。
今回は/userから始まるルートへアクセスするときに認証が必要なようにしています。
以上でログイン機能の実装が完了しました!お疲れ様です(T_T)