Help us understand the problem. What is going on with this article?

Go言語で認証機能を作ろう!

学習履歴

■ はじめに

Go 言語で、API を作る。

職場で、とある API を利用したことがある。

そのサービスは、以下のような仕組みになっていた。

  1. ユーザー登録
  2. ユーザー認証
  3. 認証後、token を発行
  4. token を使って、サービスを利用

上記の処理を Go 言語で実装する。

必要な機能は、以下の通りだ。

<必要な機能>
1. Signup
2. login
3. 何らかのサービス

■ パッケージのインストール

今回使う Go のパッケージ。

go get -u github.com/gorilla/mux
go get github.com/dgrijalva/jwt-go
go get github.com/lib/pq
go get -u github.com/davecgh/go-spew/spew
go get -u golang.org/x/crypto/bcrypt
go get github.com/subosito/gotenv

■ エンドポイントの作成

エンドポイントの作成に、gorilla/muxを使う。

main.go
package main

import "github.com/gorilla/mux"

func main() {
    // Django の urls.py っぽい
    router := mux.NewRouter()

    // endpoint(singup/loginは未実装なので、エラーになる)
    router.HandleFunc("/singup", signup).Methods("POST")
    router.HandleFunc("/login", login).Methods("POST")
    // 何らかの service
}

signup と login のエンドポイントを実装した。

ユーザーがリクエストを送ると、リクエストをさばく存在が必要になる。

gorilla router がリクエストをさばく役割を担ってくれる。

例えば、localhost:8000/login を POST 形式で送ると gorilla router がリクエストをさばいてくれる。

Django の urls.py みたいな存在だ。

■ リクエスト処理関数の実装

Django では、views.py にリクエストの処理を書くが、Go 言語は不慣れなので、ひとまず、main.go に記述する。

main.go
package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/gorilla/mux"
)

func signup(w http.ResponseWriter, r *http.Request) {
    fmt.Println("signup 関数実行")
}

func login(w http.ResponseWriter, r *http.Request) {
    fmt.Println("login 関数実行")
}

func main() {
    // urls.py
    router := mux.NewRouter()

    // endpoint
    router.HandleFunc("/singup", signup).Methods("POST")
    router.HandleFunc("/login", login).Methods("POST")
    // service はあとで記述する

    // console に出力する
    log.Println("サーバー起動 : 8000 port で受信")

    // log.Fatal は、異常を検知すると処理の実行を止めてくれる
    log.Fatal(http.ListenAndServe(":8000", router))
}

関数に使われている引数は、定型文なので、このまま覚えて欲しい。

参考: Introducing the net/http package (an interlude)

main.go
func signup(w http.ResponseWriter, r *http.Request) {

あと、起動確認をするため、Restlet Client を Chrome にインストールしよう。

コンソール上で、以下のコマンドを実行する。

# サーバー起動
$ go run main.go 
2019/05/05 18:54:41 サーバー起動 : 8000 port で受信

スクリーンショット 2019-05-05 19.01.36.png

URL を localhost:8000/signup とし、POST 状態で、Send ボタンを押下すると、
コンソールに結果が表示される。

$ go run main.go 
2019/05/05 18:56:22 サーバー起動 : 8000 port で受信
signup 関数実行

■ Model を作る

Django だと、models.py に Model クラス(データベースの元になるもの) を作成する。

Go 言語では、struct で同じようなことを行う。

main.go
type User struct {
    // 大文字だと Public 扱い
    ID       int    `json:"id"`
    Email    string `json:"email"`
    Password string `json:"password"`
}

type JWT struct {
    Token string `json:"token"`
}

type Error struct {
    Message string `json:"message"`
}

func signup(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("successfully called signup"))
}

func login(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("successfully called login"))
}

User, JWT, Error モデルを作成した。

まず前提として、API とのデータのやり取りは、JSON形式で行う。

json:"id" は、json と struct のデータ構造をパースするために指定している。
参考, 公式文章

JWTは、ユーザー認証後に Token を発行するので、その Token を格納する。

Error は、エラーメッセージ格納用だ。

■ データベースの導入

データベースには、postgreSQL を利用する。
Django でよく使うので。

今回は、一部無料で使える elephantsqlを利用する。

上記サイトから Sing up して、Instance を作成する。
* 筆者は無料枠を使ってます! 有料もあるので、気をつけてください。

スクリーンショット 2019-05-06 17.07.19.png

スクリーンショット 2019-05-06 17.07.55.png

スクリーンショット 2019-05-06 17.08.18.png

スクリーンショット 2019-05-06 17.08.33.png

golang-api という Instance を作成した。
作成に成功すると以下のような画面に入ることができる。

スクリーンショット 2019-05-06 17.13.50.png

画像に表示されている URL はあとで使う。

サブタイトルに BROWSER があり、ここから SQL を発行できる。

スクリーンショット 2019-05-06 17.18.57.png

# データベースを作成
create table users (
  id serial primary key,
  email text not null unique,
  password text not null
);

# テストデータ
insert into users (email, password) values ('first@sample.co.jp', 'golang');

# 確認
select * from users;

スクリーンショット 2019-05-06 17.21.26.png

GolangのDBの操作で、 go-sqlite3 を使った方法を勉強したので、こっちでも良かったが、URL を使って、データベースの登録ができるので、今回はこのサービスを使う。

■ DB 接続

以下は必須の作業ではないが、個人情報格納用のフォルダとファイルを作成する。

mkdir tool
touch tool/params.go
params.go
package tool

type Info struct {
    dburl string
}

func (u Info) GetDBUrl() string {
     // elephantSQL の Detail に表示されている URL を記述
    return "postgres:hogehoge"
}

続いて、elephantsqlのサイトに、Go による DB 接続のサンプルコードがあるので、これを参考に DB に接続する。

main.go
package main

import (
    "database/sql" # データベース操作に必要なパッケージ
    "log"
    "net/http"

    "github.com/gorilla/mux"
    "github.com/lib/pq" # database とコードの仲介役(多分psycopg2のようなもの)
    "github.com/hoge/goAuthentication2/tool" # 知られたくないデータ(params.go)のパッケージ
)


// dbインスタンス格納用
var db *sql.DB

func main() {
    // parmas.go から DB の URL を取得
    i := tool.Info{}

    // Convert
    // https://github.com/lib/pq/blob/master/url.go
    // "postgres://bob:secret@1.2.3.4:5432/mydb?sslmode=verify-full"
    // -> "user=bob password=secret host=1.2.3.4 port=5432 dbname=mydb sslmode=verify-full"
    pgUrl, err := pq.ParseURL(i.GetDBUrl())

    // 戻り値に err を返してくるので、チェック
    if err != nil {
        // エラーの場合、処理を停止する
        log.Fatal()
    }

    // DB 接続
    db, err = sql.Open("postgres", pgUrl)
    if err != nil {
        log.Fatal(err)
    }

    // DB 疎通確認
    err = db.Ping()

    if err != nil {
        log.Fatal(err)
    }

    // urls.py
    router := mux.NewRouter()
  ...
}

一応接続できるようになっているはずだが、データ登録する処理を実装してないので、動作確認はできない。

ひとまず、コードが壊れていないことを確認する。

go run main.go
2019/05/06 17:48:29 サーバー起動 : 8000 port で受信

Restlet Client から localhost:8000/signup を POST 形式で実行する。

スクリーンショット 2019-05-06 17.49.04.png

実行後、「successfully called signup」が表示されたので、コードは問題なさそう。

<参考>
pq

■ SignUp の実装1

SignUp を実装する。

SignUp は、ユーザーを新規に登録する機能だ。

ちなみに、SignIn は、既存ユーザーを認証する機能らしい。

main.go
package main

import (
    "database/sql"
    "encoding/json"
    "fmt"
    "log"
    "net/http"

    "github.com/davecgh/go-spew/spew"
    "github.com/gorilla/mux"
    "github.com/lib/pq"
    "github.com/hoge/goAuthentication2/tool"
)

// レスポンスにエラーを突っ込んで、返却するメソッド
func errorInResponse(w http.ResponseWriter, status int, error Error) {
    w.WriteHeader(status) // 400 とか 500 などの HTTP status コードが入る
    json.NewEncoder(w).Encode(error)
    return
}

func signup(w http.ResponseWriter, r *http.Request) {
    var user User
    var error Error

    // r.body に何が帰ってくるか確認
    fmt.Println(r.Body)

    // https://golang.org/pkg/encoding/json/#NewDecoder
    json.NewDecoder(r.Body).Decode(&user)

    if user.Email == "" {
        error.Message = "Email は必須です。"
        errorInResponse(w, http.StatusBadRequest, error)
        return
    }

    if user.Password == "" {
        error.Message = "パスワードは必須です。"
        errorInResponse(w, http.StatusBadRequest, error)
        return
    }

    // user に何が格納されているのか
    fmt.Println(user)

    // dump も出せる
    fmt.Println("---------------------")
    spew.Dump(user)

}

go run main.go でサーバを起動し、Restlet Client で、signup をしてみる。

json
{
  "email": "test1@example.co.jp",
  "password": "golang"
}

スクリーンショット 2019-05-06 18.08.25.png

とりあえず、エラーになっていない。

spew.Dump(user)の結果は、コンソール上で確認できる。

$ go run main.go 
2019/05/06 18:07:35 サーバー起動 : 8000 port で受信
&{0xc00016e000 <nil> <nil> false true {0 0} false false false 0x124e4e0}
{0 test1@example.co.jp golang}
---------------------
(main.User) {
 ID: (int) 0,
 Email: (string) (len=19) "test1@example.co.jp",
 Password: (string) (len=6) "golang"
}

データが送られてきていることがわかる。

尚、ユーザー情報を DB に登録する処理を書いていないので、DB には反映されていない。

Json.NewEncoder, json.NewDecoder で、エンコード(構造体から文字列)、デコード(文字列から構造体)の処理を行なっている。

参考

そして、errorInResponse 関数で、エラーハンドリングを行なっている。

スクリーンショット 2019-05-06 18.23.17.png

スクリーンショット 2019-05-06 18.23.27.png

■ SignUp の実装2

Sign Up の実装の続きだ。

具体的には、パスワードの暗号化とデータベースの登録処理を実装する。

main.go
package main

import (
    "database/sql"
    "encoding/json"
    "fmt"
    "log"
    "net/http"

    "golang.org/x/crypto/bcrypt" #データの暗号化

    "github.com/gorilla/mux"
    "github.com/lib/pq"
    "github.com/hoge/goAuthentication2/tool"
)

// JSON 形式で結果を返却
// data interface{} とすると、どのような変数の型でも引数として受け取ることができる
func responseByJSON(w http.ResponseWriter, data interface{}) {
    json.NewEncoder(w).Encode(data)
    return
}

func signup(w http.ResponseWriter, r *http.Request) {
    var user User
    var error Error

    // r.body に何が帰ってくるか確認
    fmt.Println(r.Body)

    // https://golang.org/pkg/encoding/json/#NewDecoder
    json.NewDecoder(r.Body).Decode(&user)

    if user.Email == "" {
        error.Message = "Email は必須です。"
        errorInResponse(w, http.StatusBadRequest, error)
        return
    }

    if user.Password == "" {
        error.Message = "パスワードは必須です。"
        errorInResponse(w, http.StatusBadRequest, error)
        return
    }

    // user に何が格納されているのか
    // fmt.Println(user)

    // dump も出せる
    fmt.Println("---------------------")
    // spew.Dump(user)

    // パスワードのハッシュを生成
    // https://godoc.org/golang.org/x/crypto/bcrypt#GenerateFromPassword
    hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), 10)

    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("パスワード: ", user.Password)
    fmt.Println("ハッシュ化されたパスワード", hash)

    user.Password = string(hash)
    fmt.Println("コンバート後のパスワード: ", user.Password)

    sql_query := "INSERT INTO USERS(EMAIL, PASSWORD) VALUES($1, $2) RETURNING ID;"

    // query 発行
    // Scan で、Query 結果を変数に格納
    err = db.QueryRow(sql_query, user.Email, user.Password).Scan(&user.ID)

    if err != nil {
        error.Message = "サーバーエラー"
        errorInResponse(w, http.StatusInternalServerError, error)
        return
    }

    // DB に登録できたらパスワードをからにしておく
    user.Password = ""
    w.Header().Set("Content-Type", "application/json")

    // JSON 形式で結果を返却
    responseByJSON(w, user)
}

go run main.go で、サーバを起動する。

そして、Restlet Client で、動作確認を行う。

スクリーンショット 2019-05-06 18.49.15.png

ElephantSQl も確認する。

スクリーンショット 2019-05-06 18.49.26.png

コンソールも確認する。

---------------------
パスワード:  golang
ハッシュ化されたパスワード [36 50 97 36 49 48 36 88 119 122 69 109 49 119 57 76 83 121 84 47 68 101 120 114 78 77 81 46 117 78 105 68 115 114 47 78 81 52 46 74 52 117 84 109 115 68 57 98 89 119 97 46 83 105 83 50 75 70 119 67]
コンバート後のパスワード:  $2a$10$XwzEm1w9LSyT/DexrNMQ.uNiDsr/NQ4.J4uTmsD9bYwa.SiS2KFwC

動作は、問題ないようだ。

■ Token 作成

Token を作成する。

ユーザー認証(login)後に、Token を発行する。

Token は、サービスを利用するためのパスポートみたいなもので、様々な Web サービスで利用されている。
(Linebotを利用するためのアクセストークン など)

main.go
package main

import (
    "database/sql"
    "encoding/json"
    "fmt"
    "log"
    "net/http"

    "github.com/davecgh/go-spew/spew"
    "github.com/dgrijalva/jwt-go" # JWT パッケージ

    "golang.org/x/crypto/bcrypt"

    "github.com/gorilla/mux"
    "github.com/lib/pq"
    "github.com/hoge/goAuthentication2/tool"
)

// Token 作成関数
func createToken(user User) (string, error) {
    var err error

    // 鍵となる文字列(多分なんでもいい)
    secret := "secret"

    // Token を作成
    // jwt -> JSON Web Token - JSON をセキュアにやり取りするための仕様
    // jwtの構造 -> {Base64 encoded Header}.{Base64 encoded Payload}.{Signature}
    // HS254 -> 証明生成用(https://ja.wikipedia.org/wiki/JSON_Web_Token)
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
        "email": user.Email,
        "iss":   "__init__", // JWT の発行者が入る(文字列(__init__)は任意)
    })

   //Dumpを吐く
    spew.Dump(token)

    tokenString, err := token.SignedString([]byte(secret))

    fmt.Println("-----------------------------")
    fmt.Println("tokenString:", tokenString)

    if err != nil {
        log.Fatal(err)
    }

    return tokenString, nil
}

func login(w http.ResponseWriter, r *http.Request) {
    var user User
    json.NewDecoder(r.Body).Decode(&user)
    token, err := createToken(user)

    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(token)
}

go run main.go で、サーバーを起動する。

SignUp 後のユーザーで、localhost:8000/login に Restlet Client でアクセスする。

スクリーンショット 2019-05-06 20.36.29.png

200 ok になっているので、ログインは成功していると思われる。

コンソールを確認する。

$ go run main.go
2019/05/06 20:35:58 サーバー起動 : 8000 port で受信
(*jwt.Token)(0xc00008f450)({
 Raw: (string) "",
 Method: (*jwt.SigningMethodHMAC)(0xc00000c4c0)({
  Name: (string) (len=5) "HS256",
  Hash: (crypto.Hash) 5
 }),
 Header: (map[string]interface {}) (len=2) {
  (string) (len=3) "typ": (string) (len=3) "JWT",
  (string) (len=3) "alg": (string) (len=5) "HS256"
 },
 Claims: (jwt.MapClaims) (len=2) {
  (string) (len=5) "email": (string) (len=19) "test1@example.co.jp",
  (string) (len=3) "iss": (string) (len=8) "__init__"
 },
 Signature: (string) "",
 Valid: (bool) false
})
-----------------------------
tokenString: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3QxQGV4YW1wbGUuY28uanAiLCJpc3MiOiJfX2luaXRfXyJ9.3tT0YZ-Lk-bgejXyQ5xrg-9JQB1wEmTxQCeVSDknoT4
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3QxQGV4YW1wbGUuY28uanAiLCJpc3MiOiJfX2luaXRfXyJ9.3tT0YZ-Lk-bgejXyQ5xrg-9JQB1wEmTxQCeVSDknoT4

Token が、{Base64 encoded Header}.{Base64 encoded Payload}.{Signature} の形式で出力されている。

■ Login の実装1

Login を実装する。

main.go
func login(w http.ResponseWriter, r *http.Request) {
    var user User
    var error Error

    json.NewDecoder(r.Body).Decode(&user)

    if user.Email == "" {
        error.Message = "Email は必須です。"
        errorInResponse(w, http.StatusBadRequest, error)
        return
    }

    if user.Password == "" {
        error.Message = "パスワードは、必須です。"
        errorInResponse(w, http.StatusBadRequest, error)
    }

    // 認証キー(Emal)のユーザー情報をDBから取得
    row := db.QueryRow("SELECT * FROM USERS WHERE email=$1;", user.Email)
    err := row.Scan(&user.ID, &user.Email, &user.Password)

    if err != nil {
        if err == sql.ErrNoRows { // https://golang.org/pkg/database/sql/#pkg-variables
            error.Message = "ユーザが存在しません。"
            errorInResponse(w, http.StatusBadRequest, error)
        } else {
            log.Fatal(err)
        }
    }
}

go run main.go でサーバーを起動して、動作確認を行う。

<登録済みユーザー>
スクリーンショット 2019-05-06 20.55.06.png

<存在しないユーザー>
スクリーンショット 2019-05-06 20.55.54.png

<その他>
スクリーンショット 2019-05-06 20.56.58.png

スクリーンショット 2019-05-06 20.57.16.png

ここまでは問題なさそう。

■ Login の実装2

今度は、token をレスポンス結果に加える。

Login 成功 -> Token 発行。

main.go
func login(w http.ResponseWriter, r *http.Request) {
    var user User
    var error Error
    var jwt JWT

    json.NewDecoder(r.Body).Decode(&user)

    if user.Email == "" {
        error.Message = "Email は必須です。"
        errorInResponse(w, http.StatusBadRequest, error)
        return
    }

    if user.Password == "" {
        error.Message = "パスワードは、必須です。"
        errorInResponse(w, http.StatusBadRequest, error)
    }

    // 追加(この位置であること)
    password := user.Password
    fmt.Println("password: ", password)

    // 認証キー(Emal)のユーザー情報をDBから取得
    row := db.QueryRow("SELECT * FROM USERS WHERE email=$1;", user.Email)
    // ハッシュ化している
    err := row.Scan(&user.ID, &user.Email, &user.Password)

    if err != nil {
        if err == sql.ErrNoRows { // https://golang.org/pkg/database/sql/#pkg-variables
            error.Message = "ユーザが存在しません。"
            errorInResponse(w, http.StatusBadRequest, error)
        } else {
            log.Fatal(err)
        }
    }

    // 追加(この位置であること)
    hasedPassword := user.Password
    fmt.Println("hasedPassword: ", hasedPassword)

    err = bcrypt.CompareHashAndPassword([]byte(hasedPassword), []byte(password))

    if err != nil {
        error.Message = "無効なパスワードです。"
        errorInResponse(w, http.StatusUnauthorized, error)
        return
    }

    token, err := createToken(user)

    if err != nil {
        log.Fatal(err)
    }

    w.WriteHeader(http.StatusOK)
    jwt.Token = token

    responseByJSON(w, jwt)
}

go run main.goで、サーバーを起動。

Restlet Client から Token が確認できるようになった。

スクリーンショット 2019-05-06 21.10.44.png

コンソールを確認する。

$ go run main.go
2019/05/06 21:08:04 サーバー起動 : 8000 port で受信
password:  golang
hasedPassword:  $2a$10$XwzEm1w9LSyT/DexrNMQ.uNiDsr/NQ4.J4uTmsD9bYwa.SiS2KFwC

ちゃんとパスワードが、ハッシュ化されている。

■ 何らかのサービス

Token を使ったサービスの実装が全く思いつかない。

とりあえず、Token を認証するサービス?みたいなものを作る。

main.go
// 認証結果をブラウザに返却
func verifyEndpoint(w http.ResponseWriter, r *http.Request) {
    utils.ResponseJSON(w, "認証OK")
}

// verifyEndpoint のラッパーみたいなもの
func tokenVerifyMiddleWare(next http.HandlerFunc) http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

        var errorObject Error

        // HTTP リクエストヘッダーを読み取る
        authHeader := r.Header.Get("Authorization")
        // Restlet Client から以下のような文字列を渡す
        // bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3Q5OUBleGFtcGxlLmNvLmpwIiwiaXNzIjoiY291cnNlIn0.7lJKe5SlUbdo2uKO_iLzzeGoxghG7SXsC3w-4qBRLvs
        bearerToken := strings.Split(authHeader, " ")
        fmt.Println("bearerToken: ", bearerToken)

        if len(bearerToken) == 2 {
            authToken := bearerToken[1]

            token, error := jwt.Parse(authToken, func(token *jwt.Token) (interface{}, error) {
                if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
                    return nil, fmt.Errorf("エラーが発生しました。")
                }
                return []byte("secret"), nil
            })

            if error != nil {
                errorObject.Message = error.Error()
                errorInResponse(w, http.StatusUnauthorized, errorObject)
                return
            }

            if token.Valid {
               // レスポンスを返す
                next.ServeHTTP(w, r)
            } else {
                errorObject.Message = error.Error()
                errorInResponse(w, http.StatusUnauthorized, errorObject)
                return
            }
        } else {
            errorObject.Message = "Token が無効です。"
            return
        }
    })
}

func main() {
   ...

    // 追加
    router.HandleFunc("/verify", tokenVerifyMiddleWare(verifyEndpoint)).Methods("GET")


   ...
}

go run main.goでサーバを起動する。

前回、取得したトークンを使用して、Restlet Client 上で動作確認を行う。

スクリーンショット 2019-05-06 21.45.22.png

問題なさそうだ。

■ まとめ

Go 言語に不慣れなので、色々と難しいところがあった。
もっとよく仕様を理解して、実装したい。

■参考

プログラミング記事

__init__
PythonとGo言語が一番好きです。どちらも仕事で使っています!
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした