1. x-color

    Posted

    x-color
Changes in title
+Go言語でEchoを用いて認証付きWebアプリの作成
Changes in tags
Changes in body
Source | HTML | Preview
@@ -0,0 +1,504 @@
+# 概要
+
+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で記述されています。