Go言語でEchoを用いて認証付きWebアプリの作成

# 概要

Go言語でEchoを用いて、JWTによる認証付きのWebアプリケーションの作成を行いました。
備忘録かつ誰かの参考になれば良いと思い、サーバーサイドの内容をまとめます。
作成物は https://github.com/x-color/simple-webapp にあげてあります。
クライアントサイドのコードを見たい場合は、上記URLから参照してください。

# 使用技術

- Go言語 (https://golang.org/)
- Echo (https://echo.labstack.com/)
- GORM (http://gorm.io/)
- SQLite (https://www.sqlite.org/index.html)
- JWT (https://jwt.io/)

# 構成

## 処理の流れ

![simple-todo-network.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279498/15999b5d-da4a-208b-4893-bfe87881f9b7.png)

## API

### 画面表示

- `GET /`: トップページの表示
- `GET /signup`: ユーザーの登録画面
- `GET /login`: ログイン画面
- `GET /todos`: Todo一覧画面

### ユーザー登録と認証

- `POST /signup`: ユーザー情報の登録
- `POST /login`: ログイン処理

### Todo操作

- `GET /api/todos`: ユーザーのTodo全ての取得
- `POST /api/todos`: 新たなTodoの作成
- `DELETE /api/todos/:id`: 指定IDのTodoの削除
- `PUT /api/todos/:id/completed`: 指定IDのTodoの完了状態の更新

## ディレクトリ構成

```
simple-webapp/
├── db
│   └── sample.db
├── handler
│   ├── auth.go
│   └── handler.go
├── model
│   ├── db.go
│   ├── todo.go
│   └── user.go
├── public
│   ├── assets
│   │   └── js
│   │       ├── login.js
│   │       ├── signup.js
│   │       └── todoList.js
│   ├── index.html
│   ├── login.html
│   ├── signup.html
│   └── todos.html
├── main.go
└── router.go
```

# サーバーサイドのコード

## ルーティング

```go:main.go
package main

func main() {
    router := newRouter()
    router.Logger.Fatal(router.Start(":8080"))
}
```

下記ファイルは、リクエストに対するルーティングを定義している。

```go:router.go
package main

import (
    "github.com/labstack/echo"
    "github.com/labstack/echo/middleware"
    "github.com/x-color/simple-webapp/handler"
)

func newRouter() *echo.Echo {
	e := echo.New()

    e.Use(middleware.Logger())
    e.Use(middleware.Recover())

    e.Static("/assets", "public/assets")

    e.File("/", "public/index.html") // GET /
    e.File("/signup", "public/signup.html") // GET /signup
    e.POST("/signup", handler.Signup) // POST /signup
    e.File("/login", "public/login.html") // GET /login
    e.POST("/login", handler.Login) // POST /login
    e.File("/todos", "public/todos.html") // GET /todos

    api := e.Group("/api")
    api.Use(middleware.JWTWithConfig(handler.Config)) // /api 下はJWTの認証が必要
    api.GET("/todos", handler.GetTodos) // GET /api/todos
    api.POST("/todos", handler.AddTodo) // POST /api/todos
    api.DELETE("/todos/:id", handler.DeleteTodo) // DELETE /api/todos/:id
    api.PUT("/todos/:id/completed", handler.UpdateTodo) // PUT /api/todos/:id/completed

	return e
}
```

### JWTによる認証処理

`url := e.Group(url)` とすることで、指定したURL下をグループ化することができる。
グループ化することにより、以下のように `/api` 下のURL(e.x. `/api/todos`)へのリクエスト時には必ずJWT認証を行うことを一括で指定することができる。

```go
api := e.Group("/api")
api.Use(middleware.JWTWithConfig(handler.Config))
```

## ユーザー登録と認証

以下のファイルは、ユーザー登録とユーザー認証の処理を定義している。

```go:auth.go
package handler

import (
	"net/http"
	"time"

	"github.com/dgrijalva/jwt-go"
	"github.com/labstack/echo"
	"github.com/labstack/echo/middleware"
	"github.com/x-color/simple-webapp/model"
)

type jwtCustomClaims struct {
	UID  int    `json:"uid"`
	Name string `json:"name"`
	jwt.StandardClaims
}

var signingKey = []byte("secret")

var Config = middleware.JWTConfig{
	Claims:     &jwtCustomClaims{},
	SigningKey: signingKey,
}

func Signup(c echo.Context) error {
	user := new(model.User)
	if err := c.Bind(user); err != nil {
		return err
	}

	if user.Name == "" || user.Password == "" {
		return &echo.HTTPError{
			Code:    http.StatusBadRequest,
			Message: "invalid name or password",
		}
	}

    if u := model.FindUser(&model.User{Name: user.Name}); u.ID != 0 {
        return &echo.HTTPError{
			Code:    http.StatusConflict,
			Message: "name already exists",
		}
    }

	model.CreateUser(user)
	user.Password = ""

	return c.JSON(http.StatusCreated, user)
}

func Login(c echo.Context) error {
	u := new(model.User)
	if err := c.Bind(u); err != nil {
		return err
	}

	user := model.FindUser(&model.User{Name: u.Name})
	if user.ID == 0 || user.Password != u.Password {
		return &echo.HTTPError{
			Code:    http.StatusUnauthorized,
			Message: "invalid name or password",
		}
	}

	claims := &jwtCustomClaims{
		user.ID,
		user.Name,
		jwt.StandardClaims{
			ExpiresAt: time.Now().Add(time.Hour * 72).Unix(),
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	t, err := token.SignedString(signingKey)
	if err != nil {
		return err
	}

	return c.JSON(http.StatusOK, map[string]string{
		"token": t,
	})
}

func userIDFromToken(c echo.Context) int {
	user := c.Get("user").(*jwt.Token)
	claims := user.Claims.(*jwtCustomClaims)
	uid := claims.UID
	return uid
}
```

### 新規ユーザー登録

新規ユーザー登録では、以下の流れで処理を進める。

1. 以下の項目の確認を行う
    - ユーザー名とパスワードが指定されているか
    - 既に同一のユーザー名が存在しないか

2. 新規ユーザーをデータベースに登録する

### ログイン処理

ログイン処理では、以下の流れで処理を進める。

1. 以下の項目の確認を行う
    - 指定されたユーザー名のユーザーがデータベース上に登録されているか
    - 指定されたパスワードが正しいか
   
2. 認証を完了し、JWTを返す

返すJWTのペイロードは以下のような形式になっている。

- uid: ユーザーID
- name: ユーザー名
- exp: JWTの有効期限

```json:JWTのPayload
{
    "uid": 1,
    "name": "Bob",
    "exp": 1554880448
}
```

## API処理

以下のファイルは、APIへアクセスされた際の処理を定義している。

```go:handler.go
package handler

import (
	"net/http"
	"strconv"

	"github.com/labstack/echo"
	"github.com/x-color/simple-webapp/model"
)

func AddTodo(c echo.Context) error {
	todo := new(model.Todo)
	if err := c.Bind(todo); err != nil {
		return err
	}

	if todo.Name == "" {
		return &echo.HTTPError{
			Code:    http.StatusBadRequest,
			Message: "invalid to or message fields",
		}
	}

	uid := userIDFromToken(c)
	if user := model.FindUser(&model.User{ID: uid}); user.ID == 0 {
		return echo.ErrNotFound
	}

    todo.UID = uid
    model.CreateTodo(todo)

	return c.JSON(http.StatusCreated, todo)
}

func GetTodos(c echo.Context) error {
	uid := userIDFromToken(c)
	if user := model.FindUser(&model.User{ID: uid}); user.ID == 0 {
		return echo.ErrNotFound
	}

	todos := model.FindTodos(&model.Todo{UID: uid})
	return c.JSON(http.StatusOK, todos)
}

func DeleteTodo(c echo.Context) error {
	uid := userIDFromToken(c)
	if user := model.FindUser(&model.User{ID: uid}); user.ID == 0 {
		return echo.ErrNotFound
	}

	todoID, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		return echo.ErrNotFound
	}

	if err := model.DeleteTodo(&model.Todo{ID: todoID, UID: uid}); err != nil {
		return echo.ErrNotFound
	}

	return c.NoContent(http.StatusNoContent)
}

func UpdateTodo(c echo.Context) error {
    uid := userIDFromToken(c)
	if user := model.FindUser(&model.User{ID: uid}); user.ID == 0 {
		return echo.ErrNotFound
	}

    todoID, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		return echo.ErrNotFound
	}

    todos := model.FindTodos(&model.Todo{ID: todoID, UID: uid})
    if len(todos) == 0 {
        return echo.ErrNotFound
    }
    todo := todos[0]
    todo.Completed = !todos[0].Completed
    if err := model.UpdateTodo(&todo); err != nil {
		return echo.ErrNotFound
	}

    return c.NoContent(http.StatusNoContent)
}
```

### 新規Todoの登録

新規Todoの登録では、以下の流れで処理を進める。

1. 以下の項目の確認を行う
    - Todo名が指定されているか
    - 受け取ったJWT内のユーザーIDがデータベースに存在するか

2. 新規Todoをデータベースに登録する
3. 登録されたTodoをユーザーに送信する

### Todo一覧の取得

Todo一覧の取得では、以下の流れで処理を進める。

1. 受け取ったJWT内のユーザーIDがデータベースに存在するかの確認する
2. ユーザーが作成した全てのTodoをデータベースから取得する
3. 全てのTodoをJSON形式で送信する

### Todoの削除

Todoの削除では、以下の流れで処理を進める。

1. 以下の項目の確認を行う
    - 受け取ったJWT内のユーザーIDがデータベースに存在するか
    - 指定されたURL上のIDが数字か
    - ユーザーが作成した該当IDのTodoがデータベース上に存在するか

2. データベースから指定されたTodoを削除する

### Todoの完了状態の変更

Todoの完了状態の変更では、以下の流れで処理を進める。

1. 以下の項目の確認を行う
    - 受け取ったJWT内のユーザーIDがデータベースに存在するか
    - 指定されたURL上のIDが数字か
    - ユーザーが作成した該当IDのTodoがデータベース上に存在するか

2. 指定されたTodoの完了状態を変更しデータベースに反映する。

## データベース処理

以下の3つのファイルは、データベース関連の処理を定義している。

- db.go: データベースの初期化処理を定義
- user.go: ユーザー情報を格納するデータ形式と処理を定義
- todo.go: Todo情報を格納するデータ形式と処理を定義

```go:db.go
package model

import (
    "github.com/jinzhu/gorm"
    _ "github.com/jinzhu/gorm/dialects/sqlite"
)

var db *gorm.DB

func init() {
    var err error
    db, err = gorm.Open("sqlite3", "db/sample.db")
    if err != nil {
      panic("failed to connect database")
    }
    db.AutoMigrate(&User{})
    db.AutoMigrate(&Todo{})
}
```

```go:user.go
package model

type User struct {
	ID       int   `json:"id" gorm:"praimaly_key"`
	Name     string `json:"name"`
	Password string `json:"password"`
}

func CreateUser(user *User) {
	db.Create(user)
}

func FindUser(u *User) User {
    var user User
    db.Where(u).First(&user)
	return user
}
```

```go:todo.go
package model

import "fmt"

type Todo struct {
    UID       int    `json:"uid"`
	ID        int    `json:"id" gorm:"praimaly_key"`
	Name      string `json:"name"`
	Completed bool   `json:"completed"`
}

type Todos []Todo

func CreateTodo(todo *Todo) {
    db.Create(todo)
}

func FindTodos(t *Todo) Todos {
	var todos Todos
	db.Where(t).Find(&todos)
	return todos
}

func DeleteTodo(t *Todo) error {
	if rows := db.Where(t).Delete(&Todo{}).RowsAffected; rows == 0 {
        return fmt.Errorf("Could not find Todo (%v) to delete", t)
    }
    return nil
}

func UpdateTodo(t *Todo) error {
    rows := db.Model(t).Update(map[string]interface{}{
        "name": t.Name,
        "completed": t.Completed,
    }).RowsAffected
    if rows == 0 {
        return fmt.Errorf("Could not find Todo (%v) to update", t)
    }
    return nil
}
```

GORMでは、`Insert()`, `Delete()` などのデータベースに対する処理を行うメソッドの返り値に存在する `RowsAffected` フィールドが実際に処理された行数を保持している。
そのため、処理に成功した行数に応じて処理を分岐したい場合、以下のように行う。

```go
rows := db.Where(t).Delete(&Todo{}).RowsAffected // 削除された行数を取得
if rows == 0 {
    // 何も削除されなかった場合の処理を行う
} else {
    // 削除された場合の処理を行う
}
```

今回のWebアプリではTodoの削除処理時などに用いており、削除を実行したあと実際に削除対象が存在したのかの判定を行っている。
存在したのなら、正常に削除されたことを意味し、存在しなかったならば、指定されたIDが不正であったことを意味している。

# クライアントサイドのコード

クライアントサイドのコードは記事が長くなりすぎるので割愛します。
コードが見たい場合は、 https://github.com/x-color/simple-webapp を確認してください。クライアントコードはVuejsで記述されています。