業務でRails+deviseに頼りっきりの雑魚エンジニアが、Goでポートフォリオ作ろうとログイン機能の実装に悪戦苦闘した記録です!
環境
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攻撃
- 全編のまとめコード
セッション管理のデータをどこに保管するか
そのユーザーの識別情報とログイン中か否かという情報をどこに保存するかを考える。下図のようにクライアント側に保存する方法と、サーバー側に保存する方法がある。
クライアント側の場合はブラウザのCookieを利用する。サーバー側の場合は、サーバーのマシンにファイルやメモリで保存するか、データベースに保存する方法がある。
偏見で表にまとめてみた。
方式 | 場所 | 処理速度 | セキュリティ | 導入 | 特記事項 | |
---|---|---|---|---|---|---|
(1) | Cookie | ブラウザ | 速い | 低〜中 | 簡単 | 盗まれるリスクあり |
(2) | Redis | データベース | 普通 | 高い | 面倒 | 最有力な方法 |
(3) | DynamoDB | データベース | 普通 | 高い | 面倒 | AWSのサービス |
(4) | RDB(MySQL) | データベース | 遅い | 高い | 普通 | アクセス過多の心配あり |
(5) | ファイル | サーバー | 速~遅 | 高い | 簡単 | 大量データで遅延 |
(6) | メモリ | サーバー | 速い | 高い | 簡単 | 再起動により消失 |
こちらの記事を参考にした
https://qiita.com/shota_matsukawa_ga/items/a21c5cf49a1de6c9561a
https://speakerdeck.com/mu_zaru/setusiyontokutukifalsemi
Cookie保存なら導入が簡単で処理速度が速い。対策すれば、Cookieでもセキュリティはある程度保てるので、たいていの場合はCookie保存で良いと思う。RailsのdeviseもCookie保存(CookieStore方式)を採用している。
悪意のある第三者にCookieを盗まれるリスクを回避し、運営者の自由なタイミングでセッションを破棄できるようにするなら、サーバー側に保存する。
PHPならサーバーマシンのファイルに保存するメソッドがあり実装が簡単である。
https://www.w3schools.com/PhP/php_sessions.asp
データベースを使う場合はミドルウェアが必要となるため、導入に手間がかかる。
以降の説明では、Cookie保存の実装方法を紹介する。サーバー側に保存する場合は、別の記事を参考にしてほしい。
Cookieの概説
Cookieが分からない人がいるかもしれないが、超適当に説明するとChromeとかSafariとかのブラウザに保存されてるデータのことだ。サーバー側からレスポンスを返すときに、ヘッダーにSet-Cookieを設定すると、クライアント側のブラウザにドメインごとに保存される。後から、Keyを指定すればValueが取り出せる。
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
RailsもGoも簡単にCookieに値を保存したり、取り出したりするメソッドが定義されている。
https://railsdoc.com/cookie_cache
https://pkg.go.dev/net/http@go1.17.6
Cookieには属性(オプション)をつけることができる。
Expires=DATE Cookieの有効期限(日付)
Max-Age=DATE Cookieの有効期限(秒数)
Domain=DAMAIN Cookieを送信するドメイン
Path=PATH Cookieを送信するPATH
Secure https の通信を使用しているときだけクッキーを送信
HttpOnly document.cookieを使ってCookieを扱えなくする
SameSite=VAL 他サイト経由でリクエスト時にCookieを送信するかどうか
この中でも、Cookieをセッション管理に使う場合はHttpOnly属性は必ずつける。HttpOnly属性をつけることで、XSS攻撃でクッキー情報が盗まれることを防げる。
常時SSL化しているサイトなら、Secure属性をつけることで通信経路にクッキーが平文で流れることが無くなるので、中間攻撃でCookieを盗まれる危険性を大幅に減らせる。
その他の属性も同様に制限を厳しく付与することで、セキュリティを高めることができる。
参考記事 https://www.javadrive.jp/javascript/webpage/index18.html
【お手頃】Cookieに平文保存
平文でそのままCookieにユーザー情報を保存する。セキュリティ面が酷いがするが、とりあえず実装してみる。
実装
package main
import (
"fmt"
"log"
"net/http"
)
// 設定値
var (
testUserId = "f63644c8-1e80-7975-6637-681c173971c2" // ユーザーID
)
func main() {
http.HandleFunc("/secret", secret)
http.HandleFunc("/login", login)
http.HandleFunc("/logout", logout)
http.ListenAndServe(":8080", nil)
}
// ログイン状態でしか閲覧できないページ
func secret(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("Login")
if err != nil || cookie.Value != "true" {
http.Error(w, "Forbidden - Not logged in", http.StatusForbidden)
return
}
fmt.Fprintln(w, "Secret page")
}
// ログイン
func login(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "Login",
Value: "true",
HttpOnly: true,
})
http.SetCookie(w, &http.Cookie{
Name: "UserId",
Value: testUserId,
HttpOnly: true,
})
fmt.Fprintln(w, "You are logged in as", testUserId)
}
// ログアウト
func logout(w http.ResponseWriter, r *http.Request) {
cookie, _ := r.Cookie("Login")
cookie.MaxAge = -1 // Cookieを破棄する
http.SetCookie(w, cookie)
fmt.Fprintln(w, "You logged out")
}
テスト
$ go run main.go
$ curl -s http://localhost:8080/secret
Forbidden
$ curl -s -I http://localhost:8080/login
Set-Cookie: Login=true; HttpOnly
Set-Cookie: UserId=f63644c8-1e80-7975-6637-681c173971c2; HttpOnly
$ curl -s --cookie "UserId=f63644c8-1e80-7975-6637-681c173971c2;Login=true" http://localhost:8080/secret
Your user id is f63644c8-1e80-7975-6637-681c173971c2
メリット
- 実装が簡単
デメリット(セキュリティの問題点)
Cookieの値を書き換えられてしまう
# UserIdを書き換えて、他人になりすましてパスワード無しでログインできる
$ curl -s --cookie "UserId=secret-user-id;Login=true" http://localhost:8080/secret
Your user id is secret-user-id
XSS攻撃
CookieにhttpOnly属性をつけないと、XSS攻撃によりCookieを盗まれてしまう
func login(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "UserId",
Value: testUserId,
// HttpOnly: true,
})
}
func xss(w http.ResponseWriter, r *http.Request) {
// 通常ならエスケープ処理をするが、XSS攻撃の説明のため、あえて生の値を渡す
fmt.Fprint(w, r.FormValue("name"))
}
func main() {
http.HandleFunc("/login", login)
http.HandleFunc("/xss", xss)
http.ListenAndServe(":8080", nil)
}
パラメータにjavascriptを埋め込むと、Cookieが盗まれる!
http://localhost:8080/login
http://localhost:8080/xss?name=%3Cscript%3Ealert(document.cookie)%3C/script%3E
【改ざんされない】JWTを使う
雑魚の頭の中をフル回転した結果、JWTって技術を使えばなんか良さそうじゃない?という考えに至った。
JWTとは、著名つきで秘密鍵がなければ改ざんできないというもの。見た目も複雑な暗号っぽいし、これなら安全そうな予感。
https://jwt.io/
https://github.com/dgrijalva/jwt-go
実装
// User型を定義
type User struct {
UserId string
Login bool
jwt.StandardClaims
}
// JWTをデコード
var user User
token, _ := jwt.ParseWithClaims(cookie.Value, &user, func(token *jwt.Token) (interface{}, error) {
return []byte("32-byte-long-auth-key"), nil
})
// ログイン状態か?
if !token.Valid || !user.Login {
http.Error(w, "Forbidden - Not logged in", http.StatusForbidden)
return
}
// JWTトークンを作成
token := jwt.NewWithClaims(jwt.GetSigningMethod("HS256"), &User{
UserId: testUserId,
Login: true,
})
tokenString, _ := token.SignedString([]byte("32-byte-long-auth-key"))
実装の注意点
JWTの脆弱性として、alg=none攻撃やブルートフォース攻撃が知られている。
alg=none攻撃はjwt-goライブラリならデフォルトで拒否される。
短い秘密鍵だと、ブルートフォース攻撃で秘密鍵を特定されるので長い秘密鍵を設定する。
メリット
- 状態をDBに持たせなくても良い
- 改ざんされない
デメリット(セキュリティの問題点)
jwtのサイトで簡単にデコードできるので、デコードしたらUserIdが丸見え。他のUserIdが推測されないように連番などは避けて、秘密の情報は持たせてはいけない。
https://jwt.io/
【情報がバレない】セッション用のライブラリを使う
情報がバレるのは良く無いと思いいろいろと探したところ、セッションによく使われているライブラリにたどり着いた
https://github.com/gorilla/sessions
https://pkg.go.dev/github.com/gorilla/sessions
実装
参考 https://gowebexamples.com/sessions/
// テスト用設定値
var (
testUserId = "f63644c8-1e80-7975-6637-681c173971c2" // ユーザーID
store = sessions.NewCookieStore([]byte("32-byte-long-auth-key"))
)
// ログイン状態か?
if auth, ok := session.Values["Login"].(bool); !ok || !auth {
http.Error(w, "Forbidden - Not logged in", http.StatusForbidden)
return
}
// セッション情報を作成
session.Options = &sessions.Options{
HttpOnly: true,
}
session.Values["Login"] = true
session.Values["UserId"] = testUserId
// セッション情報をCookieへ保存
session.Save(r, w)
メリット
- 情報が見られない
デメリット(セキュリティの問題点)
ログアウト後にログアウト前のCookieを再利用してログインできてしまう(再生攻撃)
$ curl -s -I http://localhost:8080/logout
Set-Cookie: Session=MTY0MjE4...
# ログアウト後にログアウト前のCookieをつける
$ curl -s --cookie "Session=MTY0MjE4..." http://localhost:8080/secret
Your user id is f63644c8-1e80-7975-6637-681c173971c2
再生攻撃
クライアント側のCookieにセッション情報を保存するCookieStore方式だと、Cookieが何らかの形で第三者に盗まれた場合、ログイン前のCookieを利用されるCookie再生攻撃を防ぐことができない。ここらへんのことは、Railsのセキュリティガイドにも書いてある。
対策としては、有効期限を設定して都度延長する方法が現実的と思われる。その有効期限後なら第三者に再生攻撃をされる危険が無くなる。具体的には、
- セッション情報に有効期限の値を追加する(以降、セッション有効期限と呼ぶ)
- 次回以降の自動ログインを有効にするか否かをユーザーに選択させる
- 自動ログインを有効にしない場合は、CookieのMaxAge属性を0にすると共に、セッション有効期限を短く設定する(3時間くらい?)
- 自動ログインを有効にした場合は、CookieのMaxAge属性を一定の期間に設定すると共に、セッション有効期限もその期間に設定する(1週間くらい?)
CookieのMaxAge属性だけでなく、セッション有効期限も設定しなければならない理由は、CookieのMaxAge属性は平文のため改ざんが可能だからである。
実装
// テスト用設定値
var (
testUserId = "f63644c8-1e80-7975-6637-681c173971c2" // ユーザーID
store = sessions.NewCookieStore([]byte("32-byte-long-auth-key"))
layout = "2006-01-02 15:04:05 +0900 JST"
jst, _ = time.LoadLocation("Asia/Tokyo")
)
// 有効期限内のセッション情報か?
if str, ok := session.Values["ExpiredAt"].(string); !ok {
http.Error(w, "Forbidden - Expired", http.StatusForbidden)
return
} else {
// 期限を過ぎた場合はエラーを返す
now := time.Now()
if time, err := time.ParseInLocation(layout, str, jst); err != nil || time.Before(now) {
http.Error(w, "Forbidden - Expired", http.StatusForbidden)
return
}
}
// 有効期限をセッション情報に追加
if r.FormValue("is_autologin_on") == "true" {
// 自動ログインをオンにした場合
session.Values["ExpiredAt"] = time.Now().AddDate(0, 0, 7).Format(layout) // 1週間後に設定
session.Options.MaxAge = 60 * 60 * 24 * 7 // 1週間後に設定
} else {
// 自動ログインをオフにした場合
session.Values["ExpiredAt"] = time.Now().Add(time.Hour).Format(layout) // 1時間後に設定
session.Options.MaxAge = 0 // 0に設定すると、ブラウザを閉じたときに無効になる
}
脆弱性対策後のコード全文
package main
import (
"fmt"
"net/http"
"time"
"github.com/gorilla/sessions"
)
// テスト用設定値
var (
testUserId = 69 // ユーザーID
store = sessions.NewCookieStore([]byte("32-byte-long-auth-key"))
layout = "2006-01-02 15:04:05 +0900 JST"
jst, _ = time.LoadLocation("Asia/Tokyo")
)
// ログイン状態でしか閲覧できないページ
func secret(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "Session")
if auth, ok := session.Values["Login"].(bool); !ok || !auth {
http.Error(w, "Forbidden - Not logged in", http.StatusForbidden)
return
}
if str, ok := session.Values["ExpiredAt"].(string); !ok {
http.Error(w, "Forbidden - Expired", http.StatusForbidden)
return
} else {
// 期限を過ぎた場合はエラーを返す
now := time.Now()
if time, err := time.ParseInLocation(layout, str, jst); err != nil || time.Before(now) {
http.Error(w, "Forbidden - Expired", http.StatusForbidden)
return
}
}
fmt.Fprintln(w, "Your are logged in as", session.Values["UserId"])
}
func login(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "Session")
session.Options.HttpOnly = true
session.Values["Login"] = true
session.Values["UserId"] = testUserId
if r.FormValue("is_autologin_on") == "true" {
// 自動ログインをオンにした場合
session.Values["ExpiredAt"] = time.Now().AddDate(0, 0, 7).Format(layout) // 1週間後に設定
session.Options.MaxAge = 60 * 60 * 24 * 7 // 1週間後に設定
} else {
// 自動ログインをオフにした場合
session.Values["ExpiredAt"] = time.Now().Add(time.Hour).Format(layout) // 1時間後に設定
session.Options.MaxAge = 0 // 0に設定すると、ブラウザを閉じたときに無効になる
}
session.Save(r, w)
fmt.Fprintln(w, "You logged in")
}
func logout(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "Session")
session.Options.MaxAge = -1
session.Save(r, w)
fmt.Fprintln(w, "You logged out")
}
func main() {
http.HandleFunc("/secret", secret)
http.HandleFunc("/login", login)
http.HandleFunc("/logout", logout)
http.ListenAndServe(":8080", nil)
}
【トークン埋め込み 編】へ続く
https://qiita.com/obr-note/items/ee566ec9c9cf1a5608de