1
1

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-12-07

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

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

+---------------------+--------------+------+-----+---------+-------+
| 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に登録します。

controllers/requests/authentication.go
type User struct {
    UserName string `json:"user_name"`
    Password string `json:"password"`
}

リクエスト時はユーザー名とパスワードを送るようにします。

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()
}
controllers/responses/authentication.go
type NewUserResponse struct {
    AccessToken  string `json:"access_token"`
    RefreshToken string `json:"refresh_token"`
}

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

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分後に設定します。
最後に上記をデータベース登録して処理完了です。

ログイン

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

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エラーを返します。

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の有効期限を期限切れにすることで実装しました。

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

    models.Logout(accessToken)
    a.Ctx.Output.SetStatus(201)
    a.ServeJSON()
}
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です。

controllers/requests/authentication.go
type RefreshToken struct {
    RefreshToken string `json:"refresh_token"`
}
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()
}
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の文字列を返すだけの簡単なリクエストです。

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部分を作ります。

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"))
        }
    }
}
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を適用します。

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の検証が行われるようになります。

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

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")
)

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1