概要
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/)
構成
処理の流れ
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
サーバーサイドのコード
ルーティング
package main
func main() {
router := newRouter()
router.Logger.Fatal(router.Start(":8080"))
}
下記ファイルは、リクエストに対するルーティングを定義している。
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認証を行うことを一括で指定することができる。
api := e.Group("/api")
api.Use(middleware.JWTWithConfig(handler.Config))
ユーザー登録と認証
以下のファイルは、ユーザー登録とユーザー認証の処理を定義している。
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
}
新規ユーザー登録
新規ユーザー登録では、以下の流れで処理を進める。
-
以下の項目の確認を行う
- ユーザー名とパスワードが指定されているか
- 既に同一のユーザー名が存在しないか
-
新規ユーザーをデータベースに登録する
ログイン処理
ログイン処理では、以下の流れで処理を進める。
-
以下の項目の確認を行う
- 指定されたユーザー名のユーザーがデータベース上に登録されているか
- 指定されたパスワードが正しいか
-
認証を完了し、JWTを返す
返すJWTのペイロードは以下のような形式になっている。
- uid: ユーザーID
- name: ユーザー名
- exp: JWTの有効期限
{
"uid": 1,
"name": "Bob",
"exp": 1554880448
}
API処理
以下のファイルは、APIへアクセスされた際の処理を定義している。
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の登録では、以下の流れで処理を進める。
-
以下の項目の確認を行う
- Todo名が指定されているか
- 受け取ったJWT内のユーザーIDがデータベースに存在するか
-
新規Todoをデータベースに登録する
-
登録されたTodoをユーザーに送信する
Todo一覧の取得
Todo一覧の取得では、以下の流れで処理を進める。
- 受け取ったJWT内のユーザーIDがデータベースに存在するかの確認する
- ユーザーが作成した全てのTodoをデータベースから取得する
- 全てのTodoをJSON形式で送信する
Todoの削除
Todoの削除では、以下の流れで処理を進める。
-
以下の項目の確認を行う
- 受け取ったJWT内のユーザーIDがデータベースに存在するか
- 指定されたURL上のIDが数字か
- ユーザーが作成した該当IDのTodoがデータベース上に存在するか
-
データベースから指定されたTodoを削除する
Todoの完了状態の変更
Todoの完了状態の変更では、以下の流れで処理を進める。
-
以下の項目の確認を行う
- 受け取ったJWT内のユーザーIDがデータベースに存在するか
- 指定されたURL上のIDが数字か
- ユーザーが作成した該当IDのTodoがデータベース上に存在するか
-
指定されたTodoの完了状態を変更しデータベースに反映する。
データベース処理
以下の3つのファイルは、データベース関連の処理を定義している。
- db.go: データベースの初期化処理を定義
- user.go: ユーザー情報を格納するデータ形式と処理を定義
- todo.go: Todo情報を格納するデータ形式と処理を定義
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{})
}
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
}
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
フィールドが実際に処理された行数を保持している。
そのため、処理に成功した行数に応じて処理を分岐したい場合、以下のように行う。
rows := db.Where(t).Delete(&Todo{}).RowsAffected // 削除された行数を取得
if rows == 0 {
// 何も削除されなかった場合の処理を行う
} else {
// 削除された場合の処理を行う
}
今回のWebアプリではTodoの削除処理時などに用いており、削除を実行したあと実際に削除対象が存在したのかの判定を行っている。
存在したのなら、正常に削除されたことを意味し、存在しなかったならば、指定されたIDが不正であったことを意味している。
クライアントサイドのコード
クライアントサイドのコードは記事が長くなりすぎるので割愛します。
コードが見たい場合は、 https://github.com/x-color/simple-webapp を確認してください。クライアントコードはVuejsで記述されています。