目次
Echoを使ったJWT認証のサンプル
WEBアプリケーションに必要不可欠なユーザー認証について、JWT認証を用いて実装します。
JWTとはJSON Web Tokenの略です。
セッションによる認証ではセッションIDをCookieに保存しますが、JWTにおいてはトークンをLocal storageなどに保存して利用します。
トークン内に任意の情報(クレーム)を保持することが可能で秘密鍵により署名されています。
とても説明しきれないので、ここでは詳しい説明については割愛します。
JWT特設サイト
https://jwt.io/
Echo cookbook > jwt
https://echo.labstack.com/cookbook/jwt
今回のサンプルについて
本当は前の記事で実装した全ての機能にJWT認証をつけて解説すべきですが、冗長になるのでポイントを絞って説明します。
WEBアプリケーションで認証が必要な領域と、そうでない領域を分ける場合、最小限の構成としては下記になるかと思います。
- 認証が不要な領域
- ユーザー登録
- ログイン画面
- 認証が必要な領域
- マイページ
ディレクトリ構造(変更)
上記の最小限の構成のみに絞ってソースを掲載します。
画面はユーザー登録(signup)、ログイン画面(login)+マイページ(mypage)となります。
└── src
└── sample
├── controller
│ └─ user_controller.go
├── go.mod
├── go.sum
├── main.go
├── model
│ ├── db.go
│ └── user.go
├── public
│ └── views
│ ├── mypage.html
│ └── signup.html
├── router
│ └── router.go
追加変更したソース
前々回の記事および前回の記事から追加変更したソースのみ記載します。
main.go、そしてmodelフォルダ内のdb.goは変更ありません。
controller/user_controller.go
GoでJWT認証を利用するためのライブラリjwt-goを使用しています。
https://github.com/dgrijalva/jwt-go
また、パスワードのハッシュ化にはgolang.org/x/crypto/bcryptを使用します。
https://pkg.go.dev/golang.org/x/crypto/bcrypt
トークンを返すのがfunc Restrictedになります。
※前の記事同様、本当はユーザー情報の登録やログイン情報の参照に際して、入力内容のバリデーションやサニタイズなどのセキュリティ対策をすべきです。ですのでこのサンプルソースをそのままプロダクトに利用しないでください。この記事ではあくまでもJWT認証の説明のみに焦点を当てています。
package controller
import (
"net/http"
"time"
"sample/model"
"github.com/dgrijalva/jwt-go"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"golang.org/x/crypto/bcrypt"
)
// jwtCustomClaims are custom claims extending default ones.
type jwtCustomClaims struct {
UID uint `json:"uid"`
Name string `json:"name"`
jwt.StandardClaims
}
var signingKey = []byte("secret")
var Config = middleware.JWTConfig{
Claims: &jwtCustomClaims{},
SigningKey: signingKey,
}
type User struct {
ID uint `json:"id" form:"id" query:"id"`
Name string `json:"name" form:"name" query:"name"`
Password string `json:"password" form:"password" query:"password"`
}
func MethodOverride(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if c.Request().Method == "POST" {
method := c.Request().PostFormValue("_method")
if method == "PUT" || method == "PATCH" || method == "DELETE" {
c.Request().Method = method
}
}
return next(c)
}
}
func Login(c echo.Context) error {
username := c.FormValue("name")
password := c.FormValue("password")
user := model.User{}
user.FirstByName(username)
// Throws unauthorized error
err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
if err != nil {
return echo.ErrUnauthorized
}
// Set custom claims
claims := &jwtCustomClaims{
user.ID,
user.Name,
jwt.StandardClaims{
ExpiresAt: time.Now().Add(time.Hour * 72).Unix(),
},
}
// Create token with claims
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Generate encoded token and send it as response.
t, err := token.SignedString(signingKey)
if err != nil {
return err
}
return c.JSON(http.StatusOK, echo.Map{
"token": t,
})
}
func Mypage(c echo.Context) error {
return c.Render(http.StatusOK, "mypage.html", map[string]interface{}{
"title": "Mypage",
})
}
func SignUp(c echo.Context) error {
return c.Render(http.StatusOK, "signup.html", map[string]interface{}{
"title": "Login / Signup Form",
})
}
func CreateUser(c echo.Context) error {
name := c.FormValue("name")
p := c.FormValue("password")
hashed, _ := bcrypt.GenerateFromPassword([]byte(p), 12)
password := string(hashed)
user := model.User{
Name: name,
Password: password,
}
user.Create()
return c.Render(http.StatusOK, "signup.html", map[string]interface{}{
"title": "Your account was created successfully",
})
}
func Restricted(c echo.Context) error {
user := c.Get("user").(*jwt.Token)
claims := user.Claims.(*jwtCustomClaims)
name := claims.Name
return c.JSON(http.StatusOK, echo.Map{
"username": name,
})
}
model/user.go
シンプルにポイントを絞るために、以前の記事から年齢(Age)を削除して、新たにパスワード(Password)を追加しています。
また、ユーザー名(Name)はユニーク(一意)としています。
ユーザー情報の更新、削除は割愛しました。
package model
import (
"time"
"gorm.io/gorm"
)
type User struct {
ID uint `json:"id"`
Name string `json:"name" gorm:"unique;type:varchar(255);not null"`
Password string `json:"password" gorm:"type:varchar(255);not null"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (p *User) FirstByName(name string) (tx *gorm.DB) {
return DB.Where("name = ?", name).First(&p)
}
func (p *User) Create() (tx *gorm.DB) {
return DB.Create(&p)
}
router/router.go
今回のJWT認証で特にポイントとなる箇所について説明します。
この記述より下のルーティングが、JWT認証が必要なルーティングとなります。
r := e.Group("/restricted")
r.Use(middleware.JWTWithConfig(controller.Config))
今回のサンプルでは、JWT認証結果を返す処理のみを追加しています。
割愛したユーザー情報の更新や削除も、今回の最小構成の応用で実装できるかと思います。
package router
import (
"io"
"sample/controller"
"text/template"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
// TemplateRenderer is a custom html/template renderer for Echo framework
type TemplateRenderer struct {
templates *template.Template
}
// Render renders a template document
func (t *TemplateRenderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
// Add global methods if data is a map
if viewContext, isMap := data.(map[string]interface{}); isMap {
viewContext["reverse"] = c.Echo().Reverse
}
return t.templates.ExecuteTemplate(w, name, data)
}
func Init() {
t := &TemplateRenderer{
templates: template.Must(template.ParseGlob("public/views/*.html")),
}
e := echo.New()
e.Pre(controller.MethodOverride)
e.Renderer = t
e.GET("/", controller.SignUp)
e.POST("/user", controller.CreateUser)
e.POST("/login", controller.Login)
e.GET("/mypage", controller.Mypage)
r := e.Group("/restricted")
r.Use(middleware.JWTWithConfig(controller.Config))
r.GET("", controller.Restricted)
e.Logger.Fatal(e.Start(":8080"))
}
public/views/mypage.html
マイページを表示するには、Local storageからトークン情報を取得して、Authorization: Bearer ヘッダをつけて認証リクエストをしています。
認証後のマイページでは、id=”greet”のところに、ユーザー名の入った挨拶が入るようにしています。
ログアウトボタンは、Local storageからトークン情報を削除して、ログイン前のページに遷移させています。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{index . "title"}}</title>
</head>
<body>
<h1>{{index . "title"}}</h1>
<div id="greet"></div>
<div>
<p><input type="button" name="button" class="btn" value="Logout"></p>
</div>
</body>
<script>
const btn = document.querySelector('.btn');
const token = getToken();
const url = 'restricted';
const headers = {'Authorization': `Bearer ${getToken()}`}
if (localStorage.getItem('token') === null) {
location.href = '/';
}
fetch(url, {headers}).then(response => {
if(response.ok) {
return response.json();
}
return [];
}).then(json => {
var greet = document.getElementById('greet');
greet.innerHTML = '<p>Hello ' + json.username +' !</p>';
})
btn.addEventListener('click', logout, false);
function getToken() {
return localStorage.getItem('token');
}
function logout() {
localStorage.removeItem('token');
location.href = '/';
}
</script>
</html>
public/views/signup.html
ログイン(Login)およびユーザー登録(Sign Up)フォームです。簡潔にするために一つのページにしています。
ユーザー登録などは本来であればバリデーションやセキュリティ対策など実装すべきですが、今回はJWT認証の説明のため簡略化しています。
トークンを利用した認証をするために、Authorization: Bearer ヘッダをつけてリクエストをしています。
認証が通ればマイページへ遷移します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{index . "title"}}</title>
</head>
<body>
<h1>{{index . "title"}}</h1>
<h2>Login</h2>
<form class="fetchForm">
<div>
<label for="name">Name:</label>
<input type="text" id="name" name="name">
</div>
<div>
<label for="password">Password:</label>
<input type="password" id="password" name="password">
</div>
<p><input type="button" name="button" class="btn" value="Login"></p>
</form>
<h2>Sign Up</h2>
<form method="POST" action="/user">
<div>
<label for="name">Name:</label>
<input type="text" id="name1" name="name">
</div>
<div>
<label for="password">Password:</label>
<input type="password" id="password1" name="password">
</div>
<p><input type="submit" name="submit" value="Sign Up"></p>
</form>
<script>
const fetchForm = document.querySelector('.fetchForm');
const btn = document.querySelector('.btn');
const url = '/login';
if (localStorage.getItem('token') !== null) {
location.href = '/mypage';
}
const postFetch = () => {
let formData = new FormData(fetchForm);
fetch(url, {
method: 'POST',
body: formData
}).then((response) => {
if (!response.ok) {
console.log('response error!');
return false;
} else {
console.log('response good!');
return response.json();
}
}).then((data) => {
if (token) {
console.log('tokenreq = ' + token);
this.rReq(token);
} else {
console.log("token error!");
return false;
}
}).catch((error) => {
console.log(error);
});
};
btn.addEventListener('click', postFetch, false);
function rReq(token) {
fetch('/restricted', {
method: "GET",
headers: {
Authorization:
"Bearer " + `${token}`,
},
}).then(function(response) {
return response.json();
}).then(function(json) {
localStorage.setItem('token', token);
location.href = '/mypage';
});
}
</script>
</body>
</html>
今回はJWT認証のトークンをLocal Storageに保存していますが、そのことに対して賛否両論があることを記載しておきます。こと認証機能については、ご自身でよく調べてメリット・デメリットを確認したうえで実装してください。
ここまでになります。
次回の記事ではTemplates機能を使用せず、フロント側にReactを使用して、フロントとバックエンドを完全に分ける形で実装予定です。