業務ではRails+deviseに頼りっきりの雑魚エンジニアが、GoでWebアプリ作るためログイン機能の実装に悪戦苦闘した記録です!
環境
OS macOS Catalina 10.15.7
Go 1.16.6
MySQL 8.0.26
目次
(1)パスワード保存 編
https://qiita.com/obr-note/items/8e4eb75585fba7efefdf
- パスワードの脆弱性と対策について
- パスワード文字数制限
- アカウントロック
- コード全文
(2)セッション管理 編
https://qiita.com/obr-note/items/6bc511f40c9086530c43
- セッション状態をどこに保管するか
- クッキーの概説
- 【お手頃】Cookieに平文保存
- XSS攻撃
- 【改ざんされない】JWTを使う
- 【情報がバレない】セッション用のライブラリを使う
- 再生攻撃
- コード全文
(3)トークン埋め込み 編
https://qiita.com/obr-note/items/ee566ec9c9cf1a5608de
- CSRF攻撃
- 全編のまとめコード
パスワードの脆弱性と対策について
パスワードの脆弱性については下記が知られている。
概説 | |
---|---|
ソーシャルエンジニアリング | なりすまし電話で聞き出したり、肩越しに画面を盗み見る |
フィッシング | 本物そっくりの偽サイトに入力させる |
ブルートフォース攻撃 | 文字列の組み合わせ全てを試す総当たり |
辞書攻撃 | パスワードに用いられやすい文字列を順に試す |
ジョーアカウント攻撃 | ユーザーIDと同じ文字列をパスワードに設定する |
リバースブルートフォース攻撃 | パスワードを固定してIDを変更していく総当たり |
パスワードスプレー攻撃 | IDをまとめて固定して一つのワードへの攻撃を時間をおいて繰り返す |
パスワードリスト攻撃 | 別のサイトから漏洩したIDとパスワードの組み合わせを試す |
これらの攻撃の詳しい説明と対策に関しては、徳丸本を読んでほしい。
この記事では、簡単に実装できる対策として
・パスワードの文字数を8文字以上に制限
・ログインに10回失敗したアカウントは30分間ロック
を実装する。
また、万が一データベースが第三者に盗まれた場合に、他のサイトでパスワードリスト攻撃に利用されないように、パスワードを暗号化してデータベースに保存する。
しかし、盗まれたデータベースは第三者がオフライン状態で解読できるので、下記の攻撃ように回数制限なく高速にパスワードの解読が行われてしまう。
概説 | |
---|---|
オフラインブルートフォース攻撃 | 文字列の組み合わせ全てを試す総当たり |
レインボーテーブル | あらかじめ作成しておいた逆引き表で総当たり |
ダミーのユーザーを多数登録してデータベースにパスワード辞書を作る |
対策として、ソルトとストレッチングが必要となる。
ソルトとはユーザーの入力したパスワードに、ユーザー毎に異なる文字列を追加してある程度の長さにして、それをハッシュ化する方法だ。
ストレッチングとはハッシュ化の速度を遅くすることで、第三者の解読に時間をかけさせる方法だ。
この記事では、ソルトもストレッチングもやってくれるBCryptのライブラリを利用して、パスワードをハッシュ化する。
パスワード文字数制限
実装は簡単。
password := r.FormValue("password")
if len(password) < 8 {
http.Error(w, "Your password is too short", http.StatusBadRequest)
}
アカウントロック
ログイン失敗回数とロック日時のカラムをデータベースに追加する。
USE dbname;
ALTER TABLE users
ADD COLUMN failed_attempts INT NOT NULL DEFAULT 0 AFTER password,
ADD COLUMN locked_at DATETIME NOT NULL DEFAULT '1000-01-01 00:00:00' AFTER failed_attempts;
$ mysql -u root < alter.sql
パスワードが違った場合は、10回以上ならロック日時を記録してロックをかける。10回未満なら、ログイン失敗回数を1回増やす。
if user.FailedAttempts >= 10 {
db.Exec("UPDATE users SET failed_attempts=?, locked_at=NOW() WHERE id=?", user.FailedAttempts+1, user.Id)
} else {
db.Exec("UPDATE users SET failed_attempts=? WHERE id=?", user.FailedAttempts+1, user.Id)
}
ログイン日時がロック日時から30分以内なら、エラーを返す。
var (
dbLayout = "2006-01-02 15:04:05"
jst, _ = time.LoadLocation("Asia/Tokyo")
)
now := time.Now()
if t, _ := time.ParseInLocation(dbLayout, user.LockedAt, jst); t.Add(30*time.Minute).After(now) {
// エラー処理
}
ログインに成功したら、ログイン失敗回数をリセットする。
db.Exec("UPDATE users SET failed_attempts=0 WHERE id=?", user.Id)
ソルトとストレッチング
ライブラリを使います。
https://pkg.go.dev/golang.org/x/crypto/bcrypt
// パスワード生成
hashed_password, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
// パスワード復号
err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.FormValue("password")));
脆弱性対策後のコード全文
まずは、データベースを作成する。
CREATE DATABASE IF NOT EXISTS dbname character SET utf8mb4 collate utf8mb4_bin;
USE dbname;
DROP TABLE IF EXISTS users;
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED NOT NULL PRIMARY KEY auto_increment,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
failed_attempts INT NOT NULL DEFAULT 0,
locked_at DATETIME NOT NULL DEFAULT '1000-01-01 00:00:00',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) character SET utf8mb4 collate utf8mb4_bin;
$ mysql -u root < init.sql # ユーザー名root、パスワード無しの場合
コード全文はこちら。
package main
import (
"database/sql"
"fmt"
"net/http"
"time"
_ "github.com/go-sql-driver/mysql"
"golang.org/x/crypto/bcrypt"
)
// 設定値
var (
layout = "2006-01-02 15:04:05"
jst, _ = time.LoadLocation("Asia/Tokyo")
)
type User struct {
Id int `json:"id"`
Email string `json:"email"`
Password string `json:"password"`
FailedAttempts int `json:"failed_attempts"`
LockedAt string `json:"locked_at"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func main() {
http.HandleFunc("/signup", signup)
http.HandleFunc("/login", login)
http.ListenAndServe(":8080", nil)
}
// サインアップ
func signup(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
db, _ := sql.Open("mysql", "root@tcp(localhost:3306)/dbname")
defer db.Close()
email := r.FormValue("email")
password := r.FormValue("password")
// パスワードが8文字未満か?
if len(password) < 8 {
http.Error(w, "Too short password", http.StatusBadRequest)
return
}
// 既に存在するemailか?
var exists bool
db.
QueryRow("SELECT EXISTS ( SELECT 1 FROM users WHERE email = ? LIMIT 1)", email).
Scan(&exists)
if exists {
http.Error(w, "Already used email", http.StatusBadRequest)
return
}
// パスワードを暗号化
hashed_password, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
// データベースへ登録
db.Exec("INSERT INTO users(email, password) VALUES(?, ?);", email, hashed_password)
fmt.Fprintln(w, "Registration completed")
return
}
// サインアップフォーム
tmpl := `
<!DOCTYPE html>
<body>
<h1>Sign Up</h1>
<form method="post" action="/signup">
<label for="email">email:</label><br>
<input type="email" id="email" name="email" pattern=".+@.+\..+" size="30" required><br><br>
<label for="password">password:</label><br>
<input type="password" id="password" name="password" minlength="8" required><br><br>
<input type="submit" value="Submit">
</form>
</body>`
fmt.Fprintln(w, tmpl)
}
// ログイン
func login(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
db, _ := sql.Open("mysql", "root@tcp(localhost:3306)/dbname")
defer db.Close()
// emailが一致するユーザーを検索
var user User
err := db.
QueryRow("SELECT id, email, password, failed_attempts, locked_at FROM users WHERE email=?", r.FormValue("email")).
Scan(&user.Id, &user.Email, &user.Password, &user.FailedAttempts, &user.LockedAt)
if err != nil {
http.Error(w, "Wrong email", http.StatusForbidden)
return
}
// ロックから30分以内のログインは許可しない
now := time.Now()
if t, err := time.ParseInLocation(layout, user.LockedAt, jst); err == sql.ErrNoRows || t.Add(30*time.Minute).After(now) {
http.Error(w, "Locked account", http.StatusForbidden)
return
}
// パスワードが正しいか?
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.FormValue("password"))); err != nil {
if user.FailedAttempts >= 10 {
// ログイン失敗回数が10回以上の場合はロックをかける
db.Exec("UPDATE users SET failed_attempts=?, locked_at=NOW() WHERE id=?", user.FailedAttempts+1, user.Id)
} else {
// ログイン失敗回数が10回未満の場合は失敗回数を増やす
db.Exec("UPDATE users SET failed_attempts=? WHERE id=?", user.FailedAttempts+1, user.Id)
}
http.Error(w, "Wrong password", http.StatusForbidden)
return
}
// ログインに成功した場合はログイン失敗回数をリセット
db.Exec("UPDATE users SET failed_attempts=0 WHERE id=?", user.Id)
fmt.Fprintln(w, "You are logged in as", user.Id)
return
}
// ログインフォーム
tmpl := `
<!DOCTYPE html>
<body>
<h1>Login</h1>
<form method="post" action="/login">
<label for="email">email:</label><br>
<input type="email" id="email" name="email" pattern=".+@.+\..+" size="30" required><br><br>
<label for="password">password:</label><br>
<input type="password" id="password" name="password" minlength="8" required><br><br>
<input type="submit" value="Submit">
</form>
</body>`
fmt.Fprintln(w, tmpl)
}
【セッション管理編】へ続く
https://qiita.com/obr-note/items/6bc511f40c9086530c43