概要
この記事では、Go+ginで構築しているサーバーに認証の仕組みを構築して、ユーザーごとに一言日記の閲覧や登録ができるようにしていきます!
実装する内容は次のとおりです。
- ユーザーの登録・PWのハッシュ化保存
- ユーザーの認証・JWTの発行
- JWT認証でのAPI保護
この記事の背景
この記事は下記に投稿した記事の続きとなります!
これまでに作ったサーバーでは認証がなく、サンプルユーザーを使って動作検証を行っていました。
認証はできる限り自分で実装せず、信頼できるフレームワークやサービスを利用することをお勧めします。
自力の認証システムはセキュリティホールを作りかねません。
できる限りこの記事でも信頼できるフレームワークを利用していきますが、あくまで初心者による構築です。
今回は認証の流れを把握するために、DBテーブルの構築や認証ロジックを構築しています。認証の流れの把握という目線で読んでいただけますと幸いです。
必要なライブラリ
今回は、bcryptというハッシュ化ライブラリと、golang-jwtというJWT生成ライブラリを利用します。
下記のコマンドで取得します。
go get -u golang.org/x/crypto/bcrypt
go get -u github.com/golang-jwt/jwt/v5
ユーザーのログイン・APIリクエスト用のJWT取得
ユーザーの認証は次のとおり
-
/api/authへユーザー名とパスワードを受け取る -
ユーザー名を元にDBで当該ユーザーを検索
-
bcryptのメソッドを使ってDBのハッシュパスワードと送信されたパスワードを検証
-
検証OKであれば、JWTを生成
-
JWTをクライアントへ送信
ちなみにログインのロジックを先にして、サインアップのロジックを後に記載する理由は、ログインの操作(JWT発行)を、サインアップの処理の中で使う(ユーザーをDBに登録後、JWTを発行する)ためです。
このユーザー認証のロジックは次のように構築しました。
実際には複数のファイルに分けて書いていますが、関係のある箇所だけ抜粋して記載します。
// *****************************************
// src/controller/auth.go
// *****************************************
func (ac AuthController) Signin(c *gin.Context) {
var json model.User
if parse_err := c.ShouldBindJSON(&json); parse_err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": parse_err.Error()})
return
}
token, auth_err := ac.service.AuthorizeUser(json)
if auth_err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": auth_err.Error()})
}
c.JSON(http.StatusCreated, gin.H{"token": token})
}
func CreateAuthController(service service.AuthService) AuthController {
controller := AuthController{service: service}
return controller
}
// *****************************************
// src/service/auth.go
// *****************************************
type AuthService struct {
repos repository.DiaryRepository
secret string
}
func (s AuthService) AuthorizeUser(user model.User) (string, error) {
registerdUser, db_err := s.repos.UserGet(user.Name)
if db_err != nil {
return "", db_err
}
pw_err := bcrypt.CompareHashAndPassword(registerdUser.Password, user.Password)
if pw_err != nil {
return "", pw_err
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"username": user.Name,
"exp": time.Now().Add(time.Hour * 72).Unix(),
})
tokenStr, jwt_err := token.SignedString([]byte(s.secret))
return tokenStr, jwt_err
}
func CreateAuthService(repos repository.DiaryRepository) AuthService {
secret := os.Getenv("AUTH_SECRET");
s := AuthService{repos: repos, secret: secret}
return s
}
// *****************************************
// src/repository/auth.go
// *****************************************
func (d DiaryRepository) UserGet(username string) (model.User, error) {
var user model.User
err := d.db.Model(&model.User{}).Where("name = ?", username).First(&user).Error
return user, err
}
ユーザーの登録
ユーザー登録の流れは次のとおりです。
-
/api/auth/signupで、ユーザー名とパスワードを受け取る -
bcryptでパスワードをハッシュ化
-
ユーザー名とパスワードをDBに保存
-
JWTの発行(上記のService:
AuthorizeUser()を呼び出す)
ちなみにDBのモデルは次のように定義しています。
type User struct {
gorm.Model
ID uint `json:"id" gorm:"primaryKey,unique;autoIncrement"`
Name string `json:"name" gorm:"unique;notNull"`
Password []byte `json:"password"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index"`
Diaries []Diary
}
また、bcryptでのパスワードのハッシュ化についてはこちらの記事を参考にしました!
このユーザー登録のロジックは次のように構築しました。
こちらも実際には複数のファイルに分けて書いていますが、関係のある箇所だけ抜粋して記載します。
// *****************************************
// main.go
// *****************************************
func main() {
// 略
authService := service.CreateAuthService(repos)
authController := controller.CreateAuthController(authService)
// 略
+ v1.POST("/api/auth/signup", authController.Signup)
+ v1.POST("/api/auth/signin", authController.Signin)
// 略
}
// *****************************************
// src/controller/auth.go
// *****************************************
func (ac AuthController) Signup(c *gin.Context) {
var json model.User
if parse_err := c.ShouldBindJSON(&json); parse_err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": parse_err.Error()})
return
}
token, register_err := ac.service.Register(json)
if register_err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": register_err.Error()})
}
c.JSON(http.StatusCreated, gin.H{"token": token})
}
// *****************************************
// src/service/auth.go
// *****************************************
func (s AuthService) Register(user model.User) (string, error) {
hashedPass, _ := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
newUser := model.User{Name: user.Name, Password: hashedPass}
create_err := s.repos.UserCreate(newUser)
if create_err != nil {
return "", create_err
}
token, err := s.AuthorizeUser(user)
return token, err
}
// *****************************************
// src/repository/auth.go
// *****************************************
func (d DiaryRepository) UserCreate(user model.User) error {
err := gorm.G[model.User](d.db).Create(d.ctx, &user)
return err
}
小ネタ
ServiceのRegister()の最後にAuthorizeUserを呼び出していますが、これだとDBに余分なRead(ユーザー名での検索)処理が走っています。
本当は、AuthorizeUserからJWT生成部分を取り出し、別メソッドで定義することで、無駄なRead処理をやめられます。
この修正は後でやります😭
JWT生成用のシークレットキーの読み込み設定に関して
クライアントでユーザーの登録操作をしてみたところ、次のようなエラーが出ました。
"key is of invalid type: HMAC sign expects []byte"
ここからはエラー解決までの過程が含まれます。
必要のない方は「結論」まで飛んでいただいて大丈夫です。
データベースを確認してみたら、ユーザー自体は無事登録されていました。
上記の順番で考えると、次はJWTの生成部分が問題となります。
ここで、エラー文をGoogle先生へ尋ねてみたところ次の記事が出ました。
シークレットキーの生成の問題???
ちなみに、.envのAUTH_SECRETに指定した文字列は、私のキーボードが暴れて(自分の指で適当に打ち込んだ)値を設定していました。
ランダムに文字列を設定しすぎた?何か形式が必要??
ということで、SECRET文字列をちゃんと生成してもらえるように下記の記事を参考にしました。
これで生成した値をAUTH_SECRETに記載します。
もう一回、ユーザー登録を実施 → 変わらず同じエラーに.....
JWT発行エラーの結論
トークンを署名するメソッドに受け渡す値の型が間違っていました!
SignedStringの引数がany型となっていて、特にエラーがなかったので見逃していましたが、SECRETKEYの値は[]byteで指定する必要があるみたいです。
type AuthService struct {
repos repository.DiaryRepository
secret string // <--- シークレットキーをStringで定義していた
}
func (s AuthService) AuthorizeUser(user model.User) (string, error) {
// 省略
- tokenStr, jwt_err := token.SignedString(s.secret)
+ tokenStr, jwt_err := token.SignedString([]byte(s.secret)) // <---このメソッドには[]byteで渡す必要あり
ここまででできること
- ユーザーのログイン
- ユーザーのサインアップ
いずれの操作をしても、サーバーからはJWTが返却されるので、それをlocalStorageなり、sessionStorageなりに保存しましょう。
サインアウトは保持しているJWTを棄却すればOKです。
次は、このトークンを使って、これまで構築したAPIに認証付きアクセスを行います。
APIのJWT認証
クライアント(Typescript)からのリクエストのイメージは、例えば、Questionを新しく作る時は下記のようになります。
fetch('/v1/api/questions', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`, // <--- ここのtokenがサインイン処理で受けとったJWT
'Content-Type': 'application/json',
},
body: { /* 省略 */ }
})
APIをリクエストした時に行うJWT認証は次のような流れとなります。
-
(フロント側)リクエストにAuthorizationヘッダーを付与してJWTを送る
-
(ここからサーバー)AuthorizationヘッダーからJWTを取り出す
-
JWTの有効性やユーザーの存在を検証する
-
JWTの情報からユーザー情報を取り出す
-
検証で得られたユーザーIDを使って各テーブルへアクセス
JWTの解析の進め方はこちらの記事を参考にさせていただきました!
上記の記事ではトークンに含まれたID/PWからユーザーの認証まで行っていますが、
この記事では、JWTの解析からユーザー情報を取り出すという部分までとします。
JWTにPWを含めないようにして、有効なJWTであればユーザー認証がすでに済まされているという方針のためです。
JWTの確認は本記事では最低限としていますが、実際の利用場面では複数のクレーム情報を確認するようにしてください。JWTの危険性や必要な検証については別の記事を参考いただけると幸いです。
JWTの取り出しから、有効性確認、ユーザー情報の取り出しを実装した結果がこちらとなります。
type AuthHandler struct {
repos repository.DiaryRepository // <--- DBアクセス用リポジトリを依存注入します。
}
func (h AuthHandler) VerifyJwt(authHeader string, user *model.User) error {
if authHeader == "" {
return errors.New("bad request: Authorization Header does not exist")
}
// AuthorizationヘッダーからJWT部分を取り出す
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
if tokenStr == authHeader {
return errors.New("bearer token required")
}
// JWTを秘密鍵で検証して、情報を取り出す
token, jwt_err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
secret := os.Getenv("AUTH_SECRET")
return []byte(secret), nil
})
if jwt_err != nil {
return fmt.Errorf("jwt parse error : %s", jwt_err)
}
// トークンから情報を取り出す
claims, ok := token.Claims.(jwt.MapClaims);
if ok && token.Valid {
username := string(claims["username"].(string))
tokenUser, db_err := h.repos.UserGet(username)
if db_err != nil {
return fmt.Errorf("%s", db_err)
}
*user = tokenUser
} else {
return fmt.Errorf("%s", "token is invalid")
}
return nil
}
func CreateAuthHandler(repos repository.DiaryRepository) AuthHandler {
handler := AuthHandler{repos: repos} // ここでDBアクセス用リポジトリを依存注入
return handler
}
上記のAuthHandlerでJWTの検証と、JWTによるユーザー情報の取得ができるようになりました。
これを各コントローラーから呼び出し、Authorizationヘッダーを渡すことで、リクエストしたユーザーが特定できるようになります。
実際の流れを見ていきましょう。
GET /v1/api/diariesをアクセスしたときに、ユーザー情報に基づくデータを返すような流れは次のとおりです。
// *****************************************
// main.go
// *****************************************
func main() {
db := database.ConnectPostgres()
repos := repository.CreateRepository(db)
authHandler := authorization.CreateAuthHandler(repos)
diaryService := service.CreateDiaryService(repos)
diaryController := controller.CreateDiaryController(diaryService, authHandler)
// *****************************************
// src/controller/diaries.go
// *****************************************
type DiariesController struct {
service service.DiaryService
authHandler authorization.AuthHandler // <---- AuthHandlerを追加
}
func (qc DiariesController) Get(c *gin.Context) {
// Authorizationヘッダーを取得、AuthHandlerに渡して、ユーザー情報を受け取る。
authHeader := c.Request.Header.Get("Authorization");
user := model.User{}
if auth_err := qc.authHandler.VerifyJwt(authHeader, &user); auth_err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": auth_err})
return
}
// JWTから得られたユーザー情報をサービスへ渡す。
ques, _ := qc.service.Find(user.ID)
c.JSON(http.StatusOK, ques)
}
まとめ
この記事で、
- ユーザーの登録
- パスワードのハッシュ化
- ユーザーの認証
- APIアクセス用のJWTの発行
- JWT認証でのAPI保護
- JWTの検証
を一通り実装してきました。
クライアント側ではトークンを保持して、APIに付与することで、ユーザーごとに必要な情報を安全に返すことができるようになりました。
ちなみにクライアント側でのトークンの管理についても簡単に記事を書きましたのでよければご参考ください。
次の記事ではデプロイメントを行い、Publicアクセスが可能となるようにしていきたいと思います。
お疲れさまでした!