パスワードをどこのレイヤーで暗号化するか論争
最近転職をしまして、新しい会社ではGoを主に使います。
なので、Goの勉強、DDDの勉強を兼ねて、ユーザー登録・ログイン機能を持つアプリケーションを作ってみました。
昨今のアプリケーションでは、IDaaSに任せるのが主流かなと思いますが、今回はデモアプリなのでユーザー作成、ログインなど全て自分で実装しています。
実装している中で「パスワードをどこのレイヤーで暗号化・認証するか」という問題にぶつかりまして、それを自分なりに解決するまでの一連の流れを書き留めます。
主な技術スタックは以下の通りです。
- Go v1.16
- Gin v1.4.0
- gorm v1.23.5
極力今回の思考の流れをそのまま書くように心がけますので、意見・感想・共感等大歓迎でございます~~
みんなで設計の沼に浸かりましょう。
cipherパッケージで暗号化しようとする
まずは「Go 暗号化」で検索をしまして、一番最初に見つかったcipherパッケージで暗号化しようと考えました。
そして、レイヤードアーキテクチャではどこのレイヤーで暗号化を行うのがいいのかといろいろ調べた結果、DDDで有名な松岡さんの質問箱にこんな投稿が。
早速実装してみます。
package user
import (
"time"
)
type User struct {
UserName string `json:"id" gorm:"primaryKey; not null"`
Mail string `json:"mail" gorm:"not null"`
Password string `json:"password" gorm:"not null"`
CreatedAt time.Time `json:"created_at" gorm:"not null"`
UpdatedAt time.Time `json:"updated_at" gorm:"not null"`
}
func NewUser(userName string, mail string, password string) *User {
return &User{
UserName: userName,
Mail: mail,
Password: password,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
package helper
import (
"crypto/aes"
"crypto/cipher"
"fmt"
"hogehoge/bootcamp-template/backend/src/config"
)
type Cipher struct {
keyText string
block cipher.Block
}
func NewCipher(cfg config.Config) Cipher {
keyText := cfg.Cipher.KeyText
block, err := aes.NewCipher([]byte(keyText))
if err != nil {
fmt.Printf("Error: NewCipher(%d bytes) = %s", len(keyText), err)
panic(err)
}
return Cipher{
keyText: keyText,
block: block,
}
}
func (cipher *Cipher) Encrypt(plainText string) string {
cipherText := make([]byte, len(plainText))
cipher.block.Encrypt(cipherText, []byte(plainText))
return string(cipherText)
}
func (cipher *Cipher) Decrypt(cipherText string) string {
plainText := make([]byte, len(cipherText))
cipher.block.Decrypt(plainText, []byte(cipherText))
return string(plainText)
}
type UserRepository struct {
database *gorm.DB
cipher helper.Cipher
}
// ・・・
func (repo *UserRepository) Find(userName string) (*user.User, error) {
var user *user.User
result := repo.database.First(&user, userName)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, errors.New(fmt.Sprintf("404 Not Found: User Name '%s' is not found.", userName))
}
user.Password = repo.cipher.Decrypt(user.Password)
return user, result.Error
}
func (repo *UserRepository) Create(user *user.User) (*user.User, error) {
user.Password = repo.cipher.Encrypt(user.Password)
result := repo.database.Create(user)
return user, result.Error
}
cipherパッケージが使いやすいように、簡単にラップした構造体を作って、リポジトリレイヤーでInsertの直前に暗号化、Selectの直後に復号化しています。
こうすると、リポジトリレイヤーより上のレイヤーでは暗号化・復号化を意識しなくてよくなります。
よしよし、いい感じに出来たぞ。と走らせてみるとエラーが。
どうやらAESでの暗号化は16byteの固定長文字列しか出来ないらしいですね。
上記の記事ではAESと他の暗号化のモードを組み合わせることで、任意の長さの文字列を暗号化できるようになるらしいですが、「Goの暗号化ってそんなにいろいろやんの??」と不思議に思い、他の暗号化方法も調べてみることにしました。
bcryptでハッシュ化しようとする
ちょっと調べてみると、bcryptというパッケージがあることを知ります。
GenerateFromPassword
っていう関数もあるし、パスワードの暗号化これじゃん」
早速実装しようとしてみますが、問題が1つ。
bcryptではハッシュ化するので、復号化が出来ません。
なので、先程実装したリポジトリレイヤーで暗号化・復号化完結させよう大作戦が不可能になったわけです。
bcryptでは、
-
GenerateFromPassword
でパスワードをハッシュ化 -
CompareHashAndPassword
でハッシュ化したパスワードと平文のパスワードの比較
ができます。
これらをどこのレイヤーに任せるかということで小一時間悩みました。
思考の流れとしては以下の通り。
引き続きリポジトリレイヤーで頑張る
「暗号化する」という処理はドメインで扱うことでは無いし、ユースケースでもないよなぁ...と思い、最初にリポジトリレイヤーで頑張る方法を取ってみました。
方法としては、引き続きInsert
の直前で暗号化を行い、パスワード比較用のメソッドとしてIsValidPassword
を実装しました。
func (repo *UserRepository) Create(user *user.User) (*user.User, error) {
user.Password = bcrypt.GenerateFromPassword(user.Password, bcrypt.DefaultCost)
result := repo.database.Create(user)
return user, result.Error
}
func (repo *UserRepository) IsValidPassword(userName string, password string) bool {
var user *user.User
repo.database.First(&user, userName)
err := bcrypt.CompareHashAndPassword(user.Password, []byte(password))
return err != bcrypt.ErrMismatchedHashAndPassword
}
実装したあとに、リポジトリレイヤーにある関数を見てみるとこんな並び
- Create
- Find
- FindAll
- Update
- Delete
- IsValidPassword
いや... 明らかに気持ちが悪い…
ドメイン知識がリポジトリに漏れ出てる感がすごい…
この方法はやめました。
リポジトリレイヤーで暗号化、ユースケースレイヤーで比較
「比較」というのはユースケースなわけだから、ユースケース層でやればいいのでは?ということでやってみました。
しかし、これだと急にユースケースレイヤーにbcryptのパッケージが現れます。
しかもユースケース上では暗号化なんて一切していないのに、取り出したパスワードは暗号化パッケージで比較。
うーん。なんとも気持ちが悪い。
これもやめました。
ユースケースレイヤーで暗号化・比較
リポジトリでやるからダメなのか??じゃあどっちもユースケースか??という安易な考えで実行。
ここらへんから疲れてきてます。
実装してみて思うけども、やはり「暗号化する」ってのはユースケースじゃないだろう…
やめました。
そもそもなんでリポジトリで暗号化したんだっけ?
最初はcipherパッケージで暗号化をしようとしていたので、リポジトリレイヤーで暗号化・復号化がスッキリ出来ていましたが、bcryptの場合はそうはいかない。
そもそもリポジトリレイヤーで暗号化してたのって、暗号化処理を閉じ込めて、外部に意識させないためだよな??
ここでふと松岡さんの質問箱を読み返してみました。
エンティティが暗号化を意識する場合、例えば、ユーザーオブジェクトがパスワードという属性を持ち、この項目のみ暗号化した値をdbに保持するとします。この場合、エンティティとしては「パスワード」という値をStiringで保持するのではなく「暗号化済パスワード」という型を定義して保持させる方法もあります。
なるほど! エンティティで暗号化をやってしまうという手もあるのか!!
「パスワード」として扱うのではなくて、「暗号化済パスワード」として保持すればいいのか!!
エンティティで暗号化・比較
エンティティを作成するときに、暗号化をやってしまいます。
package user
import (
"time"
"golang.org/x/crypto/bcrypt"
)
type User struct {
UserName string `json:"user_name" gorm:"primaryKey; not null"`
Mail string `json:"mail" gorm:"not null"`
EncryptedPassword string `json:"encrypted_password" gorm:"not null"`
CreatedAt time.Time `json:"created_at" gorm:"not null"`
UpdatedAt time.Time `json:"updated_at" gorm:"not null"`
}
func NewUser(userName string, mail string, password string) *User {
return &User{
UserName: userName,
Mail: mail,
EncryptedPassword: encrypt(password),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
func encrypt(plainText string) string {
hash, err := bcrypt.GenerateFromPassword([]byte(plainText), bcrypt.DefaultCost)
if err != nil {
panic(err)
}
return string(hash)
}
func (user *User) IsValidPassword(password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(password))
return err != bcrypt.ErrMismatchedHashAndPassword
}
(本来はパスワードの値オブジェクトとかも作ったほうがいいんですけどね)
こうすると、今後Userを扱う場合は全てパスワードが暗号化されており、認証するときはUserの構造体のIsValidPassword
で比較をすればOK。
しかも構造体で初期化した時点で暗号化されているので、仮にあとから構造体にアクセスされたり、ログに吐いてしまったりしても、平文では無い安心感があります。
(これを思ってから、今までの実装方法危ねぇなと思いました)
これにて一件落着。設計がカッチリハマると嬉しいもんですね。
是非是非ご意見ご感想お待ちしております~~
告知
という勉強などを、入社してから技術に慣れるまで自由にやらせてくれる優しい会社なので宣伝しておきます。
ご興味ある方是非どうぞ~~
Discussion
最終的に
bcrypt.GenerateFromPassword
を採用しているので正しいのですが、暗号化したパスワードを復号[1]可能にするのは非常に危険です。DB情報とソースコードがセットで流出したら平文での流出と同義だからです。
セキュリティ的には復号不可能なハッシュ化で取り扱うのがベストですし、
bcrypt.GenerateFromPassword
はそれに基づいて設計されています。「パスワードの復号」は「ふぐの踊り食い」くらい厳禁だと気をつけたほうが良いです。
復号化は誤用であるとする人が多いです ↩︎
ご指摘ありがとうございます!
これすごいパワーワードですね… 一生忘れないと思いますw
実は社内レビューでも、ここは指摘して頂きまして、ぜひ今後気をつけて実装したいと思った所存ですm(_ _)m