21
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

「Go×Firebase×React」でJWT認証できる簡単なTODOアプリ作ってみた

Posted at

こんにちは。初投稿です。
来年から新卒でエンジニアになる予定の者です。

初めに

実務未経験、独学ですので、何か勘違いがあるかもしれないということを予めご了承ください。間違いがあれば指摘していただけますと幸いです。

またこの記事ではDockerやReactなどの詳しいことは触れていません。

この記事でやること

Go,Reactを学習したので習作物としてバックエンドGo,フロントエンドReactを使ったFirebase×JWT認証つきの簡単なTODOアプリを開発しました。

簡単にJWT(Json Web Token)だけ説明しますと、

  • 認証用のトークンとして用いられる
  • JSON形式でやり取りするため任意の情報を含むことができる
  • 改ざんを検知できる
    などの特徴があります

こちらの記事がハンズオン形式でわかりやすかったです。↓

完成イメージ

TODO操作デモ.gif
今回書いたコードはこちらに公開しました。

開発環境

開発環境はWSL2でUbuntu20.04とDocker-Desktop(4.10.1)を使っています

GoでWeb API開発

まずGoでWebAPIを作っていきます.
APIの開発にはこちらを参考にさせていただきました。
nginx + golangでWeb API開発
REST APIについて 0からREST APIについて調べてみた

簡単なディレクトリ構成は以下のようになっています。参考までに

/go-react-todo ルートディレクトリ
    ├── api
    ├── docker
    │   ├── go
    │   ├── mysql
    │   └── react
    └── front
        └── todoapp

goとmysqlのDockerfileを書いていきます。

FROM golang:1.19.2-alpine3.16
RUN apk update && apk add git 
WORKDIR /go/src/app
COPY ./api /go/src/app
FROM --platform=linux/x86_64 mysql:latest

データベース接続

続いてデータベースの設定を行っていきます。
まずDockerのMySQLコンテナが起動したときに初期データを投入するようにしておきます。参考 Docker MySQLコンテナ起動時に初期データを投入する

docker-compose.yml
mysql:
    build: 
      context: .
      dockerfile: ./docker/mysql/Dockerfile
    container_name: mysql
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: tododb
      MYSQL_PASSWORD: root
      TZ: 'Asia/Tokyo'
    volumes:
     - ./docker/mysql/initdb.d:/docker-entrypoint-initdb.d
    ports:
      - 3307:3307
    command: --port 3307
    links:
     - go

docker/mysqlの下にinitdb.dというディレクトリを作成して、その中にtodos.sqlを作成します。

docker/mysql/initdb.d/todos.sql
CREATE SCHEMA IF NOT EXISTS tododb;
USE tododb;
DROP TABLE IF EXISTS todos;

CREATE TABLE IF NOT  EXISTS todos (
  id INT UNSIGNED NOT NULL AUTO_INCREMENT,
  title VARCHAR(255) NOT NULL,
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL,
  PRIMARY KEY (id)
);

これでMysqlコンテナを起動したとにデータベースとテーブルが作成されます。
初期データを入れたければtodos.sql内に

INSERT INTO todos (titile) VALUES ("お掃除");

みたいな感じで続けば初期データも登録できます。

続いてgoでmysqlに接続していきます。
modelディレクトリの中にdatabase.goを作成してその中にデータベースとの接続処理を書いていきます。

/api/model/database.go
package model

import (
	"database/sql"
	"log"
	_ "github.com/go-sql-driver/mysql"
)


var Db *sql.DB
var err error

func DbConnect() {
	dsn := "root:root@tcp(mysql:3307)/tododb?charset=utf8&parseTime=true"
	Db, err = sql.Open("mysql", dsn)
	if err != nil {
		log.Fatalln(err)
	}
}

dnsはこのように記述します↓参考

[username[:password]@][protocol[(address)]]/dbname[?param1=value1&...&paramN=valueN]

database.goで定義したDbConnect関数をmain.goのinit関数で実行してDbに値を入れていきます。

/api/main.go
package main

import (
	"github.com/fumi7649/go-react-todo/model"
	"github.com/fumi7649/go-react-todo/utils"
)

func init () {
	utils.LoggingSettings("todoapp.log")
	model.DbConnect()
}

model

続いてデータベース操作の処理を書いていきます。

model/todo.go
package model

import (
	"log"
	"time"
)

type Todo struct {
	ID uint `json:"id"`
	Title string `json:"title"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}


func GetTodo(id uint) (todo Todo, err error) {
	err = Db.QueryRow("SELECT id, title, created_at, updated_at FROM todos WHERE id = ?", id).Scan(
		&todo.ID,
		&todo.Title,
		&todo.CreatedAt,
		&todo.UpdatedAt,
	)
	if err != nil {
		log.Fatalln(err)
	}
	return todo, err
}

func GetTodos() (todos []Todo, err error) {
	rows, err := Db.Query("SELECT id, title, created_at, updated_at FROM todos")
	if err != nil {
		log.Fatalln(err)
	}
	defer rows.Close()

	for rows.Next() {
		var todo Todo
		err = rows.Scan(
			&todo.ID,
			&todo.Title,
			&todo.CreatedAt,
			&todo.UpdatedAt,
		)
		todos = append(todos, todo)
	}

	return todos, err
}

func CreateTodo(title string) (err error) {
	now := time.Now()
	_, err = Db.Exec("INSERT INTO todos (title, created_at, updated_at) VALUES(?, ?, ?)", title, now, now)
	if err != nil {
		log.Fatalln(err)
	}
	return err
}


func (todo *Todo) UpdateTodo() error {
	_, err = Db.Exec("update todos set title = ? where id = ?", todo.Title, todo.ID)
	if err != nil {
		log.Fatalln(err)
	}
	return err
}

func (todo *Todo) DeleteTodo() error {
	_, err = Db.Exec("delete from todos where id = ?", todo.ID)
	if err != nil {
		log.Fatalln(err)
	}
	return err
}

これでgoからデータベースを操作できるようになりました。

Controller

続いてルーティングを行っていきます。フレームワークはginを使ってます。

api/controller/router.go
package controller

import (
	"log"
	"net/http"
	"strconv"
	"time"

	"github.com/fumi7649/go-react-todo/model"
	"github.com/gin-gonic/gin"
)

func StartServer() {
	router := gin.Default()
	
	v1 := router.Group("todo/api/v1")
	{
		v1.GET("/todos", todosGET)
		v1.POST("/todos", todoPOST)
		v1.PATCH("/todos/:id", todoPATCH)
		v1.DELETE("/todos/:id", todoDELETE)
	}
	router.Run(":8080")
}

func todosGET(c *gin.Context) {
	todos, err := model.GetTodos()
	if err != nil {
		log.Fatalln("err")
	}
	c.JSON(http.StatusOK, gin.H{"todos": todos})
}

func todoPOST(c *gin.Context) {
	title := c.PostForm("title")
	err := model.CreateTodo(title)
	if err != nil {
		log.Fatalln(err)
	}
}

func todoPATCH(c *gin.Context) {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		log.Fatalln(err)
	}
	todo, err := model.GetTodo(uint(id))
	if err != nil {
		log.Fatalln(err)
	}

	title := c.PostForm("title")
	now := time.Now()
	todo.Title = title
	todo.UpdatedAt = now
	todo.UpdateTodo()
	c.JSON(http.StatusOK, gin.H{"todo": todo})
}

func todoDELETE(c *gin.Context) {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		log.Fatalln(err)
	}
	todo, err := model.GetTodo(uint(id))
	if err != nil {
		log.Fatalln(err)
	}
	err = todo.DeleteTodo()
	if err != nil {
		log.Fatalln(err)
	}
	c.JSON(http.StatusOK, "Deleted")
}

main.goでStartServer()を実行してサーバーを立ち上げます。

main.go
package main

import (
	"github.com/fumi7649/go-react-todo/controller"
	"github.com/fumi7649/go-react-todo/model"
	"github.com/fumi7649/go-react-todo/utils"
)

func init () {
	utils.LoggingSettings("todoapp.log")
	model.DbConnect()
}

func main () {
	controller.StartServer()
}

ここまででざっくりとAPIは完成です。
Restlet ClientというChromeの拡張機能を使って作ったAPIを呼び出してみます。

$docker-compose up -d
$docker-compose exec go sh //goコンテナの中に入る
/go/src/app # go run main.go //サーバー起動

api.jpg
apiget.jpg
呼び出せますね!

ReactでUIを作る

Dockerfileから書いていきましょう。
Reactでの環境構築はこちらを参考にしました。

FROM  node:18.12.0-alpine3.16
WORKDIR /usr/src/app
COPY ./front /usr/src/app
docker-compose.yml
react:
    build: 
      dockerfile: ./docker/react/Dockerfile
    volumes:
      - ./front:/usr/src/app
    command: sh -c "cd todoapp && npm start"
    ports:
     - "3000:3000"
    stdin_open: true

以下のコマンドでプロジェクトを作成

$ docker-compose rum --rm react sh -c "npm install -g create-react-app && create-react-app todoapp"
$ docker-compose up -d react

localhost:3000にアクセスするとプロジェクトが立ち上がっています。
ReactLocal.jpg

今回はMui UIを使います。
コンテナを起動してコンテナの中に入ってライブラリをインストールしていきます。

$ docker-compose up -d react 
$ docker-compose exec react sh
/usr/src/app cd todoapp
/usr/src/app/todoapp npm install @mui/material @emotion/react @emotion/styled
/usr/src/app/todoapp npm install @mui/icons-material

後でiconも使いたいので@mui/icons-materialもインストールしておきます。
いい感じのiconがたくさんあります⇒Material Icons

Signupページ

front/todoapp/src/components/auth/Signup.js
import { Grid, Paper, Typography, TextField, Link, Button, Box } from '@mui/material';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';


const Signup = () => {
  const navigate = useNavigate();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleChangeEmail = (event) => {
    setEmail(event.target.value);
  }

  const handleChangePassword = (event) => {
    setPassword(event.target.value);
  }

  const handleSubmit = () => {
    navigate("/");
 } 
  return (
    <Grid>
      <Paper
        elevation={3}
        sx={{
          p: 4,
          height: "70vh",
          width: "50vh",
          m: " 20px auto"
        }}
      >
        <Typography variant={"h5"} sx={{ m: "30px" }}>Sign up</Typography>
        <TextField
          label="Email"
          variant="standard"
          fullWidth
          required
          value={email}
          onChange={handleChangeEmail}
        />
        <TextField
          type="password"
          label="Password"
          variant="standard"
          fullWidth
          value={password}
          onChange={handleChangePassword}
          required />
        <Box mt={4}>
          <Button type="submit" color="primary" variant="contained" fullWidth onClick={handleSubmit}>
            登録
          </Button>
          <Typography variant="caption">
            <Link href="/signin">アカウントをお持ちですか</Link>
          </Typography>
        </Box>
      </Paper>
    </Grid>
  );
}

export default Signup;

Signinページ

front/todoapp/src/components/auth/Signin.js
import { Grid, Paper, Typography, TextField, Link, Button, Box } from '@mui/material';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';

const Signin = () => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const navigate = useNavigate();

  const handleChangeEmail = (event) => {
    setEmail(event.target.value);
  }

  const handleChangePassword = (event) => {
    setPassword(event.target.value);
  }

  const handleSubmit = () => {
    navigate('/');
  }
  return (
    <Grid>
      <Paper
        elevation={3}
        sx={{
          p: 4,
          height: "70vh",
          width: "50vh",
          m: " 20px auto"
        }}
      >
        <Typography variant={"h5"} sx={{ m: "30px" }}>Sign in</Typography>
        <TextField
          label="Email"
          variant="standard"
          fullWidth
          required
          value={email}
          onChange={handleChangeEmail} />
        <TextField
          type="password"
          label="Password"
          variant="standard"
          fullWidth
          value={password}
          onChange={handleChangePassword}
          required />
        <Box mt={4}>
          <Button type="submit" color="primary" variant="contained" fullWidth onClick={handleSubmit}>
            ログイン
          </Button>
          <Typography variant="caption">
            アカウントを持っていませんか
            <Link href="#">アカウント作成</Link>
          </Typography>
        </Box>
      </Paper>
    </Grid>
  );
}

export default Signin;

Topページ

front/todoapp/src/components/views/Top.js
import Header from "./Header";
import TodoList from "./TodoList";

const Top = () => {
  return (
    <>
      <Header/>
      <TodoList/>
    </>
  )
}

export default Top;

TodoList & TodoItem

front/todoapp/src/components/views/TodoList.js
import { Button, Grid, TextField } from '@mui/material';
import { useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import TodoItem from './TodoItem';

const TodoList = () => {
  const [todo, setTodo] = useState('');
  const [todos, setTodos] = useState([]);

  const handleChangeTodo = (event) => {
    setTodo(event.target.value);
  }

  return (
    <>
      <Grid container alignItems="center" justifyContent="center" sx={{ m: 3 }}>
        <TextField
          label="todo"
          name='todo'
          value={todo}
          type="text"
          variant="outlined"
          size='small'
          onChange={handleChangeTodo}
        />
        <Button variant="contained" endIcon={<AddIcon />}>create</Button>
      </Grid>
      <TodoItem {...todos}/>
    </>
  );
}

export default TodoList;
front/todoapp/src/components/views/TodoItem.js
import { Button, Grid, List, ListItem, ListItemText } from "@mui/material"
import { Box } from "@mui/system";
import WorkIcon from '@mui/icons-material/Work';
import DeleteIcon from '@mui/icons-material/Delete';


const TodoItem = (props) => {
  const todos = props.todos;
  return (
    <Grid container alignItems="center" justifyContent="center">
      <Box
        sx={{ width: '100%', height: 400, maxWidth: 360, bgcolor: 'background.paper' }}>
        <List
          sx={{
            width: '100%',
            maxWidth: 360,
            bgcolor: 'background.paper',
            position: 'relative',
            overflow: 'auto',
            maxHeight: 300,
            '& ul': { padding: 0 },
          }}>
          {todos?.map((todo, index) => {
            return (
              <ListItem key={index}>
                <WorkIcon sx={{ p: 2 }} />
                <ListItemText primary={todo.title} />
                <Button onClick={() => props.onClick(todo.id)} endIcon={<DeleteIcon />}>Delete</Button>
              </ListItem>
            )
          })}
        </List>
      </Box>
    </Grid>
  );
}

export default TodoItem;

create-react-appコマンドを実行したときにデフォルトで生成されるApp.jsを再活用してフロントでルーティングを行います。

front/todoapp/src/App.js
import { BrowserRouter, Route, Routes } from "react-router-dom";
import Signin from "./components/auth/Signin";
import Signup from "./components/auth/Signup";
import Top from "./components/views/Top";

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Top />} />
        <Route path="/signup" element={<Signup />} />
        <Route path="/signin" element={<Signin />} />
      </Routes>
    </BrowserRouter>
  )
}

export default App;

React側でAPIをたたく

React側の処理を書いていく前に、Go側でCORSの設定をしておきます。CORSについてはこちらの記事が分かりやすかったです。

ざっくり説明するとセキュリティ上サーバ―側で、アクセスするクライアント側のサイトを許可する必要があります。今回はgithub.com/gin-contrib/corsで設定していきます。

api/controller/router.go
package controller

import (
	"log"
	"net/http"
	"strconv"
	"time"

	"github.com/fumi7649/go-react-todo/model"
	"github.com/gin-contrib/cors" //新しく追加
	"github.com/gin-gonic/gin"
)

func StartServer() {
	router := gin.Default()

// CORSの設定
	router.Use(cors.New(cors.Config{
		AllowOrigins: []string{
			"http://localhost:3000",
		},
		AllowMethods: []string{
			"GET",
			"POST",
			"DELETE",
			"PUT",
		},
		AllowHeaders: []string{
			"Authorization",
		},
	}))
	v1 := router.Group("/todo/api/v1")
	{
		v1.GET("/todos", todosGET)
		v1.POST("/todos", todoPOST)
		v1.PATCH("/todos/:id", todoPATCH)
		v1.DELETE("/todos/:id", todoDELETE)
	}
	router.Run(":8080")
}
// ... 以下省略

APIをたたくためにaxiosを導入します。
MuiUIを導入したときのようにコンテナ内に入ってインストールします。

$ npm install axios

todosデータ取得

goで作ったAPIからtodosデータを取得して表示します。

TodoList.js
import { Button, Grid, TextField } from '@mui/material';
import { useEffect, useState } from 'react';
import AddIcon from '@mui/icons-material/Add';
import TodoItem from './TodoItem';
import axios from 'axios'; //新しく追加

const TodoList = () => {
  const [todo, setTodo] = useState('');
  const [todos, setTodos] = useState([]);

  const handleChangeTodo = (event) => {
    setTodo(event.target.value);
  }

// ... 追加
  const getTodosData = async() => {
    await axios.get("http://localhost:8080/todo/api/v1/todos")
      .then((res) => {
        setTodos(res.data);
      }).catch((err) => {
        console.error(err);
      })
  }
  
  useEffect(() => {
    getTodosData();
  }, []);

// ... 省略

export default TodoList;

getTodosData()でtodosに値を入れます。ReactのuseEffect()を使って描画終了後にgetTodosDataを実行できるようにしています。

すべてのコンテナを立ち上げてlocalhost:3000にアクセスしてみます。

$ docker-compose up -d

データベースにデータが入っていると以下のように表示されると思います。
当たり前ですが、まだCREATEDELETEを押しても反応しないので処理を追加していきます。
getTodosDataデモ.jpg

作成・削除機能の追加

TodoList.js

// ...
const TodoList = () => {
  const [todo, setTodo] = useState('');
  const [todos, setTodos] = useState([]);

  const handleChangeTodo = (event) => {
    setTodo(event.target.value);
  }

  const getTodosData = async () => {
    await axios.get("http://localhost:8080/todo/api/v1/todos")
      .then((res) => {
        setTodos(res.data);
      }).catch((err) => {
        console.error(err);
      })
  }

  useEffect(() => {
    getTodosData();
  }, []);

// 追加
  const handleCreateTodo = async () => {
    const params = new URLSearchParams;
    params.append("title", todo);
    await axios.post("http://localhost:8080/todo/api/v1/todos", params)
      .then(() => {
        getTodosData();
        setTodo('');
      })
      .catch((err) => {
        console.error(err);
      });
    console.log("create todo");
  }

  const handleDeleteTodo = async (id) => {
    await axios.delete(`http://localhost:8080/todo/api/v1/todos/${id}`)
      .then(() => {
        getTodosData();
      })
      .catch((err) => {
        console.error(err);
      });
  }

// ...

  return (
    <>
      <Grid container alignItems="center" justifyContent="center" sx={{ m: 3 }}>
        <TextField
          label="todo"
          name='todo'
          value={todo}
          type="text"
          variant="outlined"
          size='small'
          onChange={handleChangeTodo}
        />
        <Button variant="contained" endIcon={<AddIcon />} onClick={ handleCreateTodo }>CREATE</Button>
// onClickにhandleCreateTodoを渡してクリックで実行
      </Grid>
// TodoItemにもhandleDeleteTodoを渡す
      <TodoItem {...todos} onClick={ handleDeleteTodo } />
    </>
  );
}

export default TodoList;

これでタスクを作ったり消したりできるようになりました。

認証機能追加

続いてメールアドレスを登録したり、ログインできるようにしていきます。
JWT認証機能を作るのにあたっては以下の記事をかなり参考にさせていただいてます。

Firebaseを設定

ReactでFirebaseを使うためにモジュールをインストールしておきます。

$ npm install firebase

Firebaseでプロジェクトを作成します。

ウェブアプリに Firebase を追加をクリックしてプロジェクト名を記入します。

firebase.jsというファイルを作成して、その中にプロジェクトを作成したときに表示される設定項目をコピーしておきます。
また、今回はAuthenticationのメール認証を使うので、メール/パスワードを有効にしておきます。

front/todoapp/src/components/auth/firebase.js
import { initializeApp } from "firebase/app";
import { getAuth } from "firebase/auth";

const firebaseConfig = {
  apiKey: "your_key",
  authDomain: "your_domain",
  projectId: "your_id",
  storageBucket: "your_storageBucket",
  messagingSenderId: "your_messagingSenderId",
  appId: "your_appId"
};

const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);

認証機能に必要なauth変数もgetAuth()で取得し公開しておきます。

サインアップ機能

Signup.jsのhandleSubmitを編集してメールアドレスとパスワードをFirebaseに登録できるようにします。

Signup.js
import { Grid, Paper, Typography, TextField, Link, Button, Box } from '@mui/material';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { createUserWithEmailAndPassword } from 'firebase/auth'; //新しく追加
import { auth } from './firebase'; // 新しく追加


const Signup = () => {
  const navigate = useNavigate();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleChangeEmail = (event) => {
    setEmail(event.target.value);
  }

  const handleChangePassword = (event) => {
    setPassword(event.target.value);
  }

// 編集
  const handleSubmit = async () => {
    await createUserWithEmailAndPassword(auth, email, password)
      .then(() => {
        navigate("/");
      }).catch((err) => {
        alert(err.message);
        console.error(err);
      });
 } 

// 省略

createUserWithEmailAndPassword()とfirebase.jsで公開したauthを使ってユーザーを作成していきます。
ユーザーの作成が無事成功したら"/"に飛ぶようにしています。

サインイン機能

サインアップ同様にSignin.jsでも同様にhandleSubmitを編集していきます。

Signin.js
import { Grid, Paper, Typography, TextField, Link, Button, Box } from '@mui/material';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { auth } from './firebase'; // 新しく追加
import { signInWithEmailAndPassword } from 'firebase/auth'; // 新しく追加

const Signin = () => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const navigate = useNavigate();

  const handleChangeEmail = (event) => {
    setEmail(event.target.value);
  }

  const handleChangePassword = (event) => {
    setPassword(event.target.value);
  }

// 編集
  const handleSubmit = async () => {
    await signInWithEmailAndPassword(auth, email, password)
      .then((userCredential) => {
        const user = userCredential.user;
        user.getIdToken().then(idToken => {
          localStorage.setItem('jwt', idToken.toString());
        });
        navigate("/");
      })
      .catch((err) => {
        alert(err.message);
        console.error(err);
      });
  };
// 省略

ログイン時に返ってきた情報 userCredential からサーバー認証に使うJWT(idToken)をローカルストレージに保存しておきます。
これは後でJWT認証機能を追加するときに使います。

サインアウト

Header.jsのhandleLogoutを編集します。
サインアウトはauth.signOut()を実行するだけです。
localStorageからJWTも削除し、"/signin"にリダイレクトするようにします。

Header.js
import { AppBar, Box, Button, Toolbar, Typography } from "@mui/material";
import { useNavigate } from 'react-router-dom';
import { auth } from "../auth/firebase"; // 新しく追加

const Header = () => {
  const navigate = useNavigate();

// 編集
  const handleLogout = () => {
    auth.signOut()
      .then(() => {
        localStorage.removeItem('jwt');
        navigate("/signin");
      })
      .catch((err) => {
        alert(err.message);
        console.error(err);
      })
  };

// 省略

これでメールアドレスの登録やログインできるようになりましたが、今の状態だと直接"/"にアクセスすれば、わざわざログインしなくてもアクセスできてしまいます。
ですので、現在ユーザーがログインしているかどうかという情報を、アプリケーション内で保持する必要があります。

ReactではReact HookのuseContext()を使用するとコンポーネント間でpropsをやり取りしなくてもグローバルなデータを扱うことができます。

useContext()を使ったユーザー情報の共有はこちらを参考にしました。

AuthContext.jsというファイルを作成します。ここではユーザーの情報を共有するためのコンポーネントを書いています。

front/todoapp/src/context/AuthContext.js
import { createContext, useContext, useEffect, useState } from "react";
import { auth } from "../components/auth/firebase";
const AuthContext = createContext();

export function useAuthContext() {
  return useContext(AuthContext);
}

export function AuthProvider({ children }) {
  const [user, setUser] = useState('');
  const [loading, setLoading] = useState(true);
  const value = {
    user,
    loading,
  }

  useEffect(() => {
    const unsubscribed = auth.onAuthStateChanged((user) => {
      setUser(user);
      setLoading(false);
    });
    return () => {
      unsubscribed();
    };
  }, []);

  if (loading) {
    return <p> loading ... </p>
  } else {
    return <AuthContext.Provider value={value}>{!loading && children}</AuthContext.Provider>
  }
}

auth.onAuthStateChanged()を使うとユーザー情報を取得することができます。useState()でuser情報をセットします。

App.js
// 省略

function App() {
  return (
    <AuthProvider>
      <BrowserRouter>
        <Routes>
          <Route path="/" element={<Top />} />
          <Route path="/signup" element={<Signup />} />
          <Route path="/signin" element={<Signin />} />
        </Routes>
      </BrowserRouter>
    </AuthProvider>
  )
}
// 省略

App.jsでAuthProviderを読み込んで囲みます。

Top.js
import React, { useState } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import { useAuthContext } from '../../context/Authcontext';
import TodoList from './TodoList';
import Header from './Header';

const Top = () => {
  const info = useAuthContext();

  if (!info.user) {
    return <Navigate to="/signin" />
  } else {
    return (
      <>
        <Header/>
        <TodoList/>
      </>
    );
  }
}
export default Top;

ここではユーザー情報をuseAuthContext()で取得しています。info.userに情報が入っていないとき、"/"にアクセスすると"/signin"にリダイレクトされるように条件分岐しておきます。
これでログインしていないとき、"/"にアクセスできないようになりました。

JWT認証でAPIサーバーをセキュアにする

現段階でGoで作ったAPIサーバーは誰でもたたけるようになっています。そこでこの記事の冒頭で紹介したJWTを使って、Reactで作ったアプリケーションでサインインしたユーザーのみがAPIをたたけるようにしたいと思います。

必要なパッケージの導入

$docker-compose up -d // docker起動
$docker-compose exec go sh // goコンテナに入る
/go/src/app # go get -u firebase.google.com/go
/go/src/app # go get -u google.golang.org/api/option

Firebase Admin SDK Goを使うためにFirebaseコンソールから認証情報が含まれるJSONファイル(鍵ファイル)をダウンロードする必要があります。詳しくは⇒サーバーにFirebaseAdminSDKを追加します

GoにJWT認証機能追加

Go側でcontrollerディレクトリの中にauth.goを作成します。auth.goにはJWT認証するためのミドルウェアを書いていきます。このミドルウェアでハンドラーをラップすればAPIに認証機能が追加されるようにします。
先ほど取得した鍵ファイルは環境変数にパスを記述することで読み込みます。Goではos.Getenv("環境変数")で読み込むことができます。ginでのミドルウェアの書き方はこちらを参考にしました。

api/controller/auth.go
package controller

import (
	"context"
	"log"
	"net/http"
	"os"
	"strings"

	firebase "firebase.google.com/go"
	"github.com/gin-gonic/gin"
	"google.golang.org/api/option"
)

func authMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		opt := option.WithCredentialsFile(os.Getenv("GOOGLE_CREDENTIALS_JSON"))

		app, err := firebase.NewApp(context.Background(), nil, opt)
		if err != nil {
			log.Printf("err: %v\n", err)
			os.Exit(1)
		}
		auth, err := app.Auth(context.Background())
		if err != nil {
			log.Printf("err: %v\n", err)
			os.Exit(1)
		}

		authHandler := c.Request.Header.Get("Authorization")
		idToken := strings.Replace(authHandler, "Bearer ", "", 1)

		token, err := auth.VerifyIDToken(context.Background(), idToken)
		if err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
				"Error": err.Error(),
			})
			return
		}
		log.Printf("Vertifed ID token: %v\n", token)
		c.Next()
	}
}

今回はdockerを使っているのでコンテナ内でも鍵ファイルの認証を通す必要があります。docker-composeを編集することで、外部の環境変数(今回はGOOGLE_CREDENTIALS_JSON)をそのままコンテナに持っていくことができます。参考 Docker 開発環境の中から GCP サービスへの認証を通すかんたんなやり方

docker-compose.yml
services:
  go:
     # ... 省略
    environment:
      GOOGLE_CREDENTIALS_JSON: /key.json
    volumes:
      - ${GOOGLE_CREDENTIALS_JSON}:/key.json:ro
      - ./api:/go/src/app
     # ... 省略

auth.goで書いたミドルウェアでラップします。

api/controller/router.go
// ... 省略
func StartServer() {
	router := gin.Default()
	router.Use(cors.New(cors.Config{
		AllowOrigins: []string{
			"http://localhost:3000",
		},
		AllowMethods: []string{
			"GET",
			"POST",
			"DELETE",
			"PUT",
		},
		AllowHeaders: []string{
			"Authorization",
		},
	}))

	v1 := router.Group("/todo/api/v1")
	v1.Use(authMiddleware()) // 追加
	{
		v1.GET("/todos", todosGET)
		v1.POST("/todos", todoPOST)
		v1.PATCH("/todos/:id", todoPATCH)
		v1.DELETE("/todos/:id", todoDELETE)
	}
	router.Run(":8080")
}
// ... 省略

v1.Use(authMiddleware())とするとv1のパスへのリクエストにJWT認証機能を付けることができます。

HTTPリクエストにJWTを追加する

まだこの段階ではサーバー側へJWTを送っていないので、クライアント側でログインしていたとしてもAPIは叩けません。
HTTPリクエストのHeaderにJWTを入れてサーバーに送る処理を追加しましょう。

front/src/components/TodoList.js
// ... 省略
const TodoList = () => {
  const [todo, setTodo] = useState('');
  const [todos, setTodos] = useState([]);

  const handleChangeTodo = (event) => {
    setTodo(event.target.value);
  }

  const getTodosData = async () => {
    await axios.get("http://localhost:8080/todo/api/v1/todos", {
      headers: { 'Authorization': `Bearer ${localStorage.getItem('jwt')}` }
    })
      .then((res) => {
        setTodos(res.data);
      }).catch((err) => {
        console.error(err);
      })
  }

  useEffect(() => {
    getTodosData();
  }, []);

  const handleCreateTodo = async () => {
    const params = new URLSearchParams;
    params.append("title", todo);
    await axios.post("http://localhost:8080/todo/api/v1/todos", params, {
      headers: { 'Authorization': `Bearer ${localStorage.getItem('jwt')}` }
    }).then(() => {
        getTodosData();
        setTodo('');
      })
      .catch((err) => {
        console.error(err);
      });
    console.log("create todo");
  }

  const handleDeleteTodo = async (id) => {
    await axios.delete(`http://localhost:8080/todo/api/v1/todos/${id}`, {
      headers: { 'Authorization': `Bearer ${localStorage.getItem('jwt')}`}
    }).then(() => {
        getTodosData();
      })
      .catch((err) => {
        console.error(err);
      });
  }
// ... 省略

getTodosData,handleCreateTodo,handleDeleteTodoのaxiosにheaders: { 'Authorization': `Bearer ${localStorage.getItem('jwt')}`}を追加します。サインイン時にローカルストレージに保存したJWTをサーバーに送ることで認証できます。

これで一通りJWT認証機能は完成しました。Restlet Clientを使って試してみましょう。
Restlet ClientからAPIを叩いた場合、リクエストにJWTを含んでいないためステータスコード401が返ってくるはずです。

apiTest.jpg
Reactで作ったUIからログインするとtodoの表示、作成、削除を一通り行えると思います。

いろいろ改善点はあると思いますが、とりあえずJWT認証つきの簡単なTODOアプリケーションは完成です!
今回はここまでです。ありがとうございました!

参考記事

21
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?