こんにちは。初投稿です。
来年から新卒でエンジニアになる予定の者です。
初めに
実務未経験、独学ですので、何か勘違いがあるかもしれないということを予めご了承ください。間違いがあれば指摘していただけますと幸いです。
またこの記事ではDockerやReactなどの詳しいことは触れていません。
この記事でやること
Go,Reactを学習したので習作物としてバックエンドGo,フロントエンドReactを使ったFirebase×JWT認証つきの簡単なTODOアプリを開発しました。
簡単にJWT(Json Web Token)だけ説明しますと、
- 認証用のトークンとして用いられる
- JSON形式でやり取りするため任意の情報を含むことができる
- 改ざんを検知できる
などの特徴があります
こちらの記事がハンズオン形式でわかりやすかったです。↓
完成イメージ
今回書いたコードはこちらに公開しました。
開発環境
開発環境は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コンテナ起動時に初期データを投入する
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を作成します。
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を作成してその中にデータベースとの接続処理を書いていきます。
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&...¶mN=valueN]
database.goで定義したDbConnect関数をmain.goのinit関数で実行してDbに値を入れていきます。
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
続いてデータベース操作の処理を書いていきます。
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を使ってます。
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()を実行してサーバーを立ち上げます。
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 //サーバー起動
ReactでUIを作る
Dockerfileから書いていきましょう。
Reactでの環境構築はこちらを参考にしました。
FROM node:18.12.0-alpine3.16
WORKDIR /usr/src/app
COPY ./front /usr/src/app
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にアクセスするとプロジェクトが立ち上がっています。
今回は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ページ
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ページ
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ページ
import Header from "./Header";
import TodoList from "./TodoList";
const Top = () => {
return (
<>
<Header/>
<TodoList/>
</>
)
}
export default Top;
TodoList & TodoItem
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;
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を再活用してフロントでルーティングを行います。
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で設定していきます。
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データを取得して表示します。
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
データベースにデータが入っていると以下のように表示されると思います。
当たり前ですが、まだCREATEやDELETEを押しても反応しないので処理を追加していきます。
作成・削除機能の追加
// ...
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のメール認証を使うので、メール/パスワードを有効にしておきます。
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に登録できるようにします。
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
を編集していきます。
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"にリダイレクトするようにします。
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というファイルを作成します。ここではユーザーの情報を共有するためのコンポーネントを書いています。
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情報をセットします。
// 省略
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を読み込んで囲みます。
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でのミドルウェアの書き方はこちらを参考にしました。
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 サービスへの認証を通すかんたんなやり方
services:
go:
# ... 省略
environment:
GOOGLE_CREDENTIALS_JSON: /key.json
volumes:
- ${GOOGLE_CREDENTIALS_JSON}:/key.json:ro
- ./api:/go/src/app
# ... 省略
auth.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を入れてサーバーに送る処理を追加しましょう。
// ... 省略
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が返ってくるはずです。
Reactで作ったUIからログインするとtodoの表示、作成、削除を一通り行えると思います。
いろいろ改善点はあると思いますが、とりあえずJWT認証つきの簡単なTODOアプリケーションは完成です!
今回はここまでです。ありがとうございました!