beegoでOauthログイン機能サーバー開発

GoでOauthなログイン機能バックエンドサーバーを作りました。

実装内容とシーケンスは下記の記事を参考にさせていただきました。
https://dev.classmethod.jp/articles/persistent-login-for-mobileapp/

# 環境
go v1.17
beego v1.12.1
 
# 実装機能
バックエンドには下記機能を作ります。
・サインアップ
・ログイン
・ログアウト
・accessToken更新用のapi
・コンテンツ（hello worldを返すだけのapi）
・認証用のwebFilter機能

全体のコードは下記になります。
https://github.com/fu-yuta/authentication_backend
ひとつずつ解説してきたいと思います。

## DB
ユーザーの情報を保存するためのテーブルは下記になります。
ログインの認証用のユーザー名とパスワードと次のリクエストから認証に使用するアクセストークン、リフレッシュトークンを保存できるようにします。
また、アクセストークンの有効期限も保存し、リクエスト毎の検証に使用します。

```s
+---------------------+--------------+------+-----+---------+-------+
| Field               | Type         | Null | Key | Default | Extra |
+---------------------+--------------+------+-----+---------+-------+
| username            | varchar(255) | NO   | PRI | NULL    |       |
| password            | varchar(255) | YES  |     | NULL    |       |
| access_token        | varchar(255) | YES  | UNI | NULL    |       |
| refresh_token       | varchar(255) | YES  | UNI | NULL    |       |
| access_token_expire | bigint       | YES  |     | NULL    |       |
+---------------------+--------------+------+-----+---------+-------+
```
DBの操作ではgormを使用しています。
https://gorm.io/ja_JP/docs/index.html

## サインアップ
サインアップはユーザーの新規作成機能になります。
ユーザー名とパスワードを受け取って、DBに同名のユーザー名がなければ、新しいユーザーとしてDBに登録します。

```go:controllers/requests/authentication.go
type User struct {
	UserName string `json:"user_name"`
	Password string `json:"password"`
}
```
リクエスト時はユーザー名とパスワードを送るようにします。

```go:controllers/authentication.go
func (a *AuthenticationController) Signup() {
	var req requests.User
	json.Unmarshal(a.Ctx.Input.RequestBody, &req)
	user, err := models.SignUp(req.UserName, req.Password)
	if err != nil {
		if err == errorValues.AlreadyExistUserError {
			a.Ctx.Output.SetStatus(409)
			a.ServeJSON()
		} else {
			a.Ctx.Output.SetStatus(500)
			a.ServeJSON()
		}
	}
	res := responses.NewUserResponse{
		AccessToken:  user.AccessToken,
		RefreshToken: user.RefreshToken,
	}
	a.Data["json"] = res
	a.ServeJSON()
}
```

```go:controllers/responses/authentication.go
type NewUserResponse struct {
	AccessToken  string `json:"access_token"`
	RefreshToken string `json:"refresh_token"`
}
```

controllerではリクエスト受けて、実際の処理部分(models)に送っています。
また、modelsから返ってきた値をレスポンスとして返します。
Signupでは新規作成された、アクセストークンとリフレッシュトークンを返します。
また、ユーザーが既に登録済みの場合は409エラーを返します。

```go:models/authentication.go
type User struct {
	Username          string `gorm:"primary_key"`
	Password          string
	AccessToken       string `gorm:"unique"`
	RefreshToken      string `gorm:"unique"`
	AccessTokenExpire int64
}

func SignUp(name string, password string) (*User, error) {
	var user User
	db.Find(&user, "username = ?", name)
	if user.Username != "" {
		log.Println("すでにユーザー登録されています。")
		return nil, errorValues.AlreadyExistUserError
	}

	user = User{
		Username:          name,
		Password:          password,
		AccessToken:       uuid.New().String(),
		RefreshToken:      uuid.New().String(),
		AccessTokenExpire: time.Now().Add(30 * time.Minute).Unix(),
	}

	err := db.Create(&user).Error
	if err != nil {
		log.Println("CreateUserError error")
		return nil, errorValues.CreateUserError
	}
	return &user, nil
}
```
実際の処理部分（models）です。
まず、リクエストされたユーザー名でデータベース内を検索し、同一ユーザーがいないかを確認します。
次に、AccessTokenとRefreshTokenをuuidで作成し、AccessTokenExpire（アクセストークンの有効期限）を30分後に設定します。
最後に上記をデータベース登録して処理完了です。

## ログイン
ログイン機能はリクエストされたユーザー名とパスワードをデーターベースに登録されたものと同じであれば、アクセストークンとリフレッシュトークン再作成してレスポンスとして返す機能です。

```go:controllers/authentication.go
func (a *AuthenticationController) Login() {
	var req requests.User
	json.Unmarshal(a.Ctx.Input.RequestBody, &req)
	user, err := models.Login(req.UserName, req.Password)
	if err != nil {
		if err == errorValues.NotRegisterUserError {
			a.Ctx.Output.SetStatus(404)
			a.ServeJSON()
		}
		if err == errorValues.MissmatchPasswordError {
			a.Ctx.Output.SetStatus(403)
			a.ServeJSON()
		} else {
			a.Ctx.Output.SetStatus(500)
			a.ServeJSON()
		}
	}
	res := responses.NewUserResponse{
		AccessToken:  user.AccessToken,
		RefreshToken: user.RefreshToken,
	}
	a.Data["json"] = res
	a.ServeJSON()
}
```
controllersの役割はサインアップ時と同じです。
ユーザーが登録されていない時は404エラー、パスワードが間違っているときは403エラーを返します。

```go:models/authentication.go
func Login(name, password string) (*User, error) {
	var user User
	db.Find(&user, "username = ?", name)
	if user.Username == "" {
		log.Println("ユーザーが登録されていません")
		return nil, errorValues.NotRegisterUserError
	}

	if user.Password != password {
		log.Println("パスワードが違います")
		return nil, errorValues.MissmatchPasswordError
	}

	user.AccessToken = uuid.New().String()
	user.RefreshToken = uuid.New().String()
	user.AccessTokenExpire = time.Now().Add(30 * time.Minute).Unix()

	err := db.Save(&user).Error
	if err != nil {
		log.Println("ユーザーのトークン保存に失敗しました")
		return nil, errorValues.LoginError
	}

	return &user, nil
}
```
modelsでは、ユーザー名でデータベースを検索し、パスワードの比較を行います。
最後にAccessToken、RefreshToken、AccessTokenExpireを新規作成し、データベースの値を更新します。

## ログアウト
ログアウトは、リクエストされたAccessTokenの有効期限を期限切れにすることで実装しました。

```go:controllers/authentication.go
func (a *AuthenticationController) Logout() {
	accessToken := a.Ctx.Input.Header("Authorization")

	models.Logout(accessToken)
	a.Ctx.Output.SetStatus(201)
	a.ServeJSON()
}
```

```go:models/authentication.go
func Logout(accessToken string) {
	var user User
	db.Where("access_token = ?", accessToken).First(&user)
	if user.Username == "" {
		return
	}

	user.AccessTokenExpire = 0

	db.Save(&user)
}
```
AccessTokenはAuthorizationヘッダーで受け取るようにしています。
また有効期限切れは有効期限を0にすることで実装しています。

## accessToken更新用のapi
これは、リクエストされたRefreshTokenを元にAccessTokenと有効期限を更新して、それをレスポンスするapiです。

```go:controllers/requests/authentication.go
type RefreshToken struct {
	RefreshToken string `json:"refresh_token"`
}
```

```go:controllers/authentication.go
func (a *AuthenticationController) RefreshToken() {
	var req requests.RefreshToken
	json.Unmarshal(a.Ctx.Input.RequestBody, &req)
	user, err := models.RefreshToken(req.RefreshToken)
	if err != nil {
		a.Ctx.Output.SetStatus(401)
		a.ServeJSON()
	}
	res := responses.NewUserResponse{
		AccessToken:  user.AccessToken,
		RefreshToken: user.RefreshToken,
	}
	a.Data["json"] = res
	a.ServeJSON()
}
```

```go:models/authentication.go
func RefreshToken(refreshToken string) (*User, error) {
	var user User
	db.Where("refresh_token = ?", refreshToken).First(&user)
	if user.Username == "" {
		log.Println("ユーザーが見つかりません")
		return nil, errorValues.NotRegisterUserError
	}

	user.AccessToken = uuid.New().String()
	user.AccessTokenExpire = time.Now().Add(30 * time.Minute).Unix()

	err := db.Save(&user).Error
	if err != nil {
		log.Println("ユーザーのトークン保存に失敗しました")
		return nil, errorValues.LoginError
	}

	return &user, nil
}
```

## コンテンツ（hello worldを返すだけのapi）
これは、認証機能をテストするために実装したgetリクエストに対してHello Worldの文字列を返すだけの簡単なリクエストです。

```go:controllers/hello.go
func (h *HelloController) Hello() {
	h.Data["json"] = map[string]string{"message": "Hello World"}
	h.ServeJSON()
}
```

## 認証用のwebFilter機能
beego.InsertFilterのbeego.BeforeRouterにfilterを追加して、*/helloへのアクセス時にAccessTokenの認証を行います。
まず、filter部分を作ります。

```go:filter/filter.go
var AuthenticationFilter = func(ctx *context.Context) {
	accessToken := ctx.Input.Header("Authorization")
	err := models.AuthenticationAccessToken(accessToken)
	if err != nil {
		if err == errorValues.NotRegisterUserError {
			ctx.ResponseWriter.WriteHeader(404)
			ctx.ResponseWriter.Write([]byte("404 NotRegisterUser\n"))
		} else if err == errorValues.AccessTokenExpireError {
			ctx.ResponseWriter.WriteHeader(401)
			ctx.ResponseWriter.Write([]byte("401 AccessTokenExpireError\n"))
		} else {
			ctx.ResponseWriter.WriteHeader(403)
			ctx.ResponseWriter.Write([]byte("403 Authentication Error\n"))
		}
	}
}
```

```go:models/authentication.go
func AuthenticationAccessToken(accessToken string) error {
	var user User
	db.Where("access_token = ?", accessToken).First(&user)
	if user.Username == "" {
		log.Println("ユーザーが見つかりません")
		return errorValues.NotRegisterUserError
	}

	if time.Now().Unix() > user.AccessTokenExpire {
		log.Println("トークンの期限が切れています")
		return errorValues.AccessTokenExpireError
	}

	return nil
}
```
下記の二つのチェックを行っています。
・AccessTokenが保存されているか確認（されていない場合は404エラー）する。
・AccessTokenの有効期限を確認（期限切れの場合は401エラー）する。

次に、このAuthenticationFilterを適用します。

```go:main.go
func main() {
	if beego.BConfig.RunMode == "dev" {
		beego.BConfig.WebConfig.DirectoryIndex = true
		beego.BConfig.WebConfig.StaticDir["/swagger"] = "swagger"
	}
	beego.InsertFilter("*/hello", beego.BeforeRouter, filter.AuthenticationFilter) //この部分
	beego.Run()
}
```
これで、*/helloにリクエストされる前にAuthenticationFilterが適用され、AccessTokenの検証が行われるようになります。

最後になりますが、今回はエラーを一つにまとめています。

```go:errorValues/errorValue.go
package errorValues

import (
	"errors"
)

var (
	AlreadyExistUserError  = errors.New("already exit this user")
	CreateUserError        = errors.New("Failed Create New User")
	NotRegisterUserError   = errors.New("Not Registered User")
	MissmatchPasswordError = errors.New("Password Missmatch")
	LoginError             = errors.New("Failed Login")
	AccessTokenExpireError = errors.New("AccessToken is Expire")
)
```

基本的な実装は以上になります。
次はフロントアプリからこのサーバーを使用したログイン機能を実装したいと思います。