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に登録します。
type User struct {
UserName string `json:"user_name"`
Password string `json:"password"`
}
リクエスト時はユーザー名とパスワードを送るようにします。
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()
}
type NewUserResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
controllerではリクエスト受けて、実際の処理部分(models)に送っています。
また、modelsから返ってきた値をレスポンスとして返します。
Signupでは新規作成された、アクセストークンとリフレッシュトークンを返します。
また、ユーザーが既に登録済みの場合は409エラーを返します。
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分後に設定します。
最後に上記をデータベース登録して処理完了です。
ログイン
ログイン機能はリクエストされたユーザー名とパスワードをデーターベースに登録されたものと同じであれば、アクセストークンとリフレッシュトークン再作成してレスポンスとして返す機能です。
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エラーを返します。
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の有効期限を期限切れにすることで実装しました。
func (a *AuthenticationController) Logout() {
accessToken := a.Ctx.Input.Header("Authorization")
models.Logout(accessToken)
a.Ctx.Output.SetStatus(201)
a.ServeJSON()
}
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です。
type RefreshToken struct {
RefreshToken string `json:"refresh_token"`
}
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()
}
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の文字列を返すだけの簡単なリクエストです。
func (h *HelloController) Hello() {
h.Data["json"] = map[string]string{"message": "Hello World"}
h.ServeJSON()
}
認証用のwebFilter機能
beego.InsertFilterのbeego.BeforeRouterにfilterを追加して、*/helloへのアクセス時にAccessTokenの認証を行います。
まず、filter部分を作ります。
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"))
}
}
}
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を適用します。
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の検証が行われるようになります。
最後になりますが、今回はエラーを一つにまとめています。
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")
)
基本的な実装は以上になります。
次はフロントアプリからこのサーバーを使用したログイン機能を実装したいと思います。