🔩 ねじき教室 Go と Web の教室

ユーザー登録とログイン

読了目安 約5分

bcrypt でパスワードをハッシュ化して保存し、ユーザー登録とログインの照合を作ります。

この章の目次

投稿者は全員仮ユーザーのままです。 ユーザー登録とログインを作ります。

この章で足すものは次の 3 つです。

  • パスワードのハッシュ化(bcrypt)
  • GET と POST の /register(ユーザー登録)
  • GET と POST の /login(パスワードの照合まで)

パスワードをそのまま保存しない

パスワードをそのまま(平文で)保存すると、DB が一度でも漏れたとき全ユーザーのパスワードが攻撃者の手に渡ります。

そこで、保存する前にハッシュ化します。 ハッシュ化とは、元の値に戻せない一方向の変換のことです。 照合は、入力を同じ方法でハッシュ化して保存値と比べます。

ハッシュ化には bcrypt という、パスワード保存のために設計されたハッシュ関数を使います。 特徴は 2 つです。

  • ソルト(ハッシュ化のたびに混ぜ込む乱数)を自動で扱うので、同じパスワードからでも毎回違うハッシュ値が生まれる
  • 計算がわざと遅く、漏れたハッシュ値から総当たりで元のパスワードを探すのに時間がかかる
  • 競プロの rolling hash とは目的が逆。速いハッシュ関数はパスワード保存には不向き

この「わざと遅い」は自分のサーバーにも効き、ログインが集中すると bcrypt の計算が CPU を占有します。 過去の ISUCON にも、bcrypt がボトルネックの一つになる出題がありました。

Go で使う関数は 2 つです。

  • bcrypt.GenerateFromPassword がハッシュ化、bcrypt.CompareHashAndPassword が照合
  • 同じパスワードでも毎回違うハッシュ値になるため、照合は自前の文字列比較では書けない。CompareHashAndPassword がハッシュ値に埋め込まれたソルトを使って正しく比べる

ユーザー登録

まず import を更新します。 mysql ドライバを名前付きに変えるのは、この章からそのエラー型を使うからです。

Go
import (
	"database/sql"
	"errors"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"time"

	"github.com/go-sql-driver/mysql"
	"golang.org/x/crypto/bcrypt"
)

main 関数のルーティングに 4 行足します。

Go
	mux.HandleFunc("GET /register", handleRegisterForm)
	mux.HandleFunc("POST /register", handleRegister)
	mux.HandleFunc("GET /login", handleLoginForm)
	mux.HandleFunc("POST /login", handleLogin)

templates/register.html を作ります。

HTML
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <title>ユーザー登録 | ねじ板</title>
  </head>
  <body>
    <h1>ユーザー登録</h1>
    <form action="/register" method="post">
      <p><label>ユーザー名 <input type="text" name="name"></label></p>
      <p><label>パスワード <input type="password" name="password"></label></p>
      <button type="submit">登録する</button>
    </form>
  </body>
</html>

ハンドラは 2 つです。

Go
func handleRegisterForm(w http.ResponseWriter, r *http.Request) {
	if err := templates.ExecuteTemplate(w, "register.html", nil); err != nil {
		log.Println(err)
	}
}

func handleRegister(w http.ResponseWriter, r *http.Request) {
	name := r.FormValue("name")
	password := r.FormValue("password")
	if name == "" || password == "" {
		http.Error(w, "ユーザー名とパスワードを入力してください", http.StatusBadRequest)
		return
	}

	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		serverError(w, err)
		return
	}

	_, err = db.Exec("INSERT INTO users (name, password_hash) VALUES (?, ?)", name, string(hash))
	if err != nil {
		var mysqlErr *mysql.MySQLError
		if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
			http.Error(w, "そのユーザー名はすでに使われています", http.StatusConflict)
			return
		}
		serverError(w, err)
		return
	}

	http.Redirect(w, r, "/login", http.StatusSeeOther)
}

ユーザー名の重複は、1 章で users.name に付けた UNIQUE 制約に任せています。

  • 「先に SELECT で確かめてから INSERT する」方法には、確認と挿入のあいだに別のリクエストが同じ名前を挿入する余地がある(トランザクション で見た同時実行の割り込み)。制約なら、割り込まれても矛盾したデータは入らない
  • エラーの見分けは、エラー処理errors.As でドライバのエラー型 *mysql.MySQLError を取り出し、番号 1062(重複を表す MySQL のエラー番号)なら 409 Conflict を返す
  • 登録に成功したら、2 章の PRG パターンと同じく 303 でログイン画面へ誘導する

ログイン

templates/login.html は登録フォームとほぼ同じです。

HTML
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <title>ログイン | ねじ板</title>
  </head>
  <body>
    <h1>ログイン</h1>
    <form action="/login" method="post">
      <p><label>ユーザー名 <input type="text" name="name"></label></p>
      <p><label>パスワード <input type="password" name="password"></label></p>
      <button type="submit">ログインする</button>
    </form>
  </body>
</html>

ハンドラでは、名前でユーザーを引いてから bcrypt で照合します。

Go
func handleLoginForm(w http.ResponseWriter, r *http.Request) {
	if err := templates.ExecuteTemplate(w, "login.html", nil); err != nil {
		log.Println(err)
	}
}

func handleLogin(w http.ResponseWriter, r *http.Request) {
	name := r.FormValue("name")
	password := r.FormValue("password")

	var userID int64
	var passwordHash string
	err := db.QueryRow("SELECT id, password_hash FROM users WHERE name = ?", name).Scan(&userID, &passwordHash)
	if errors.Is(err, sql.ErrNoRows) {
		http.Error(w, "ユーザー名かパスワードが違います", http.StatusUnauthorized)
		return
	}
	if err != nil {
		serverError(w, err)
		return
	}
	if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
		http.Error(w, "ユーザー名かパスワードが違います", http.StatusUnauthorized)
		return
	}

	fmt.Fprintln(w, name+" さん、ログインに成功しました")
}
  • 1 行だけの結果は、Go から SQL を使うQueryRow で受ける
  • 該当ユーザーがいないとき(sql.ErrNoRows)とパスワードが合わないときの応答メッセージはわざと同じにする。分けると攻撃者に「そのユーザー名は存在する」と教えてしまう

動作確認

サーバーを起動し直して、curl で一連の流れを試します。

シェル
# 登録する
curl -i -d 'name=alice' -d 'password=himitsu' http://localhost:8080/register

# 同じ名前でもう一度登録する
curl -i -d 'name=alice' -d 'password=betsu' http://localhost:8080/register

# 間違ったパスワードでログインする
curl -i -d 'name=alice' -d 'password=machigai' http://localhost:8080/login

# 正しいパスワードでログインする
curl -i -d 'name=alice' -d 'password=himitsu' http://localhost:8080/login

1 本目は 303 で /login へ、2 本目は 409、3 本目は 401、4 本目は 200 で「alice さん、ログインに成功しました」が返れば成功です。

DB の中も見ておきましょう。

SQL
SELECT name, password_hash FROM users WHERE name = 'alice';

password_hash 列には $2a$10$ で始まる 60 文字の文字列が入っていて、himitsu という元のパスワードはどこにも保存されていません。

ログイン状態が続かない問題

4 本目の curl の直後に、もう一度一覧を取ってみてください。

シェル
curl http://localhost:8080/

サーバーには、このリクエストがさっきログインに成功した人のものだと知るすべがありません。 HTTP を生で読む で見たとおり、HTTP は前のリクエストの記憶を持たないからです。 「ログインしたまま」にする仕組みは、次章のセッションで作ります。