はじめに
JWT{JSON Web Token} は RFC7519 で規定されている、JSON をセキュアにやり取りするための仕様です。特徴は次の通り。
- 鍵になる文字列(以降 secret )か公開鍵ペアのどちらかを使って署名することで「セキュアにやり取り」できるようにしている。前者は HMAC アルゴリズム、後者は RSA か ECDSA が使われる。
- 「セキュアにやり取り」の意味はこの JSON が適切な者によって発行されて、改ざんもされていないことが保証されるということ
- JSON は署名されるだけで暗号化されるわけではないので、中身は誰でも閲覧可能な点に注意が必要
JWT は eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
のような文字列です。ドットで区切られた 3 つのパートから構成されます。
- Base64-URL 文字列である。
- それぞれ Header.Payload.Signature を表す。
- Header には署名に利用されるアルゴリズムが記載される。例えば HS256 は
HMAC using SHA-256 hash
のこと。
{
"alg": "HS256",
"typ": "JWT"
}
- Payload は実際に JSON としてやり取りしたい内容が入る。JSON のキーに相当する値のことを claims と呼んでいる。claims には種類があって、特に Registerd claims と Private claims は理解しておく必要がある。Registered claims は RFC で規定されている claim で必須ではないが推奨される。go-jwt などのライブラリは Registered claims を渡すと規定にしたがってよしなに処理してくれる。Private claims は利用者が自由に追加できる claim のこと。例えば、以下にある claim の意味は次の通り。
- iat と exp は Registered claim。iat は Issued At の略で JWT の発行時間。exp は Expiration Time の略で JWT の expire 時間。exp の時間を過ぎた JWT は処理してはいけないと決まっている。
- user_id は Private claim
{
"iat": "1516239022",
"exp": "1234567890",
"user_id": "1",
}
- Signature は署名で、Header/Payload/鍵情報/指定したアルゴリズムの情報を利用して生成される。HS256 の場合の署名生成の式は次の通り。
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
署名に secret を使うか、公開鍵ペアを使うか
secret を使う場合は、JWT をやり取りする当事者間両方がその鍵を持っていないと、署名の検証ができません。一方で、secret を使うのは公開鍵ペアよりも実装がシンプルです。そのため、例えば次のように使い分けると良いです。
- JWT の生成と検証が 1 つのアプリケーション内部で完結している場合は secret を使う。
- JWT の生成と検証が別々のアプリケーション間で行われる場合は公開鍵ペアを使う。生成元で秘密鍵を、検証先で公開鍵を使う。
以降では 1 つのアプリケーション内部で完結する機能の実装に利用する例を示すので、 secret を使っていきます。
ユースケース
大きくは次の 2 パターンがあります。
認証・認可
- ユーザーがログイン後に、JWT トークンをセッションに保存。以後の通信ではそのトークンをやり取りする。詳細は後述。
- メリットは次の通り
- パスワードなどを毎回やり取りしなくて済むのでセキュア。exp を短くしておけば JWT トークンが外部に漏洩しても被害が最小限に抑えられる
- 認証のための DB アクセスを署名検証で置き換えられる。一般に署名検証のほうが高速で省エネ。
- 認可情報などを Private claims に含めておけばさらに DB アクセスなどを省略できる
セキュアな情報のやり取り
- Private(or Public) claims を利用すると、例えば、パスワードリセットのためのメールに含まれる URL トークンとして使える。詳細は後述。
- メリットは次の通り
- JWT トークンに必要な情報を含めた上で有効期限も設定できるので DB などを利用しないで実現できる
実装例
認証に使う JWT の生成
ログインに成功したら、iat, exp, user_id を含めた JWT トークンを生成する。以後のリクエストではクライアントからは user_id と JWT トークンを受け取って、署名が検証できた JSON の user_id と受け取った user_id が一致していたら認証成功とする。
exp を過ぎたら認証エラーとしてクライアントは再度ログインを行う必要がある。また iat よりあとにパスワードを更新したら、この場合も認証エラーにする。
package authtoken
import (
"time"
"github.com/dgrijalva/jwt-go"
)
const (
// secret は openssl rand -base64 40 コマンドで作成した。
secret = "2FMd5FNSqS/nW2wWJy5S3ppjSHhUnLt8HuwBkTD6HqfPfBBDlykwLA=="
// userIDKey はユーザーの ID を表す。
userIDKey = "user_id"
// iat と exp は登録済みクレーム名。それぞれの意味は https://tools.ietf.org/html/rfc7519#section-4.1 を参照。{
iatKey = "iat"
expKey = "exp"
// }
// lifetime は jwt の発行から失効までの期間を表す。
lifetime = 30 * time.Minute
)
// Auth は署名前の認証トークン情報を表す。
type Auth struct {
UserID string
Iat int64
}
// Generate は認証に利用する JWT トークンを生成して返す。
func Generate(userID string, now time.Time) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
userIDKey: userID,
iatKey: now.Unix(),
expKey: now.Add(lifetime).Unix(),
})
return token.SignedString([]byte(secret))
}
// Parse は jwt トークンから元になった認証情報を取り出す。
func Parse(signedString string) (*Auth, error) {
token, err := jwt.Parse(signedString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return "", err.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secret), nil
})
if err != nil {
if ve, ok := err.(*jwt.ValidationError); ok {
if ve.Errors&jwt.ValidationErrorExpired != 0 {
return nil, err.Wrapf(err, "%s is expired", signedString)
} else {
return nil, err.Wrapf(err, "%s is invalid", signedString)
}
} else {
return nil, err.Wrapf(err, "%s is invalid", signedString)
}
}
if token == nil {
return nil, err.Errorf("not found token in %s:", signedString)
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, err.Errorf("not found claims in %s", signedString)
}
userID, ok := claims[userIDKey].(string)
if !ok {
return nil, err.Errorf("not found %s in %s", userIDKey, signedString)
}
iat, ok := claims[iatKey].(float64)
if !ok {
return nil, err.Errorf("not found %s in %s", iatKey, signedString)
}
return &Auth{
UserID: userID,
Iat: int64(iat),
}, nil
}
func authenticate(userID, authorization string) error {
// 正しい認証トークンであるかを確認する {
auth, err := authtoken.Parse(authorization)
if err != nil {
return err.Wrapf(err, "failed to parse authorization")
}
if userID != auth.UserID {
return err.Errorf("failed authentication, userID=%s, userIDPayload=%s", userID, auth.UserID)
}
// }
// 認証トークン発行後にパスワードが更新されていたら認証エラーとする {
passwordUpdateUnixTime, err := GetUserPasswordUpdateTime(ctx, userID)
if auth.Iat <= passwordUpdateUnixTime {
return err.Errorf("need to login, iat=%d, passwordUpdateUnixTime=%d", auth.Iat, passwordUpdateUnixTime)
}
// }
return nil
}
パスワードリセット URL の生成
URL パラメーターに JWT トークンを使う。JWT トークンにはユーザーを特定するメールアドレスと iat と exp を含める。署名に成功した JSON のメールアドレスでパスワードを変更するユーザーを特定して、パスワード変更リクエストを処理すればいい。
exp を過ぎていたらパスワードリセットを認めない。iat とパスワード変更時間を比較して、iat のあとにパスワードを変更していたらパスワードリセットを認めない。同じ URL で 1 回以上パスワードリセットを認めないということ。こうするとセキュアで良い。
パスワードリセット状態でメールアドレスが変わることはあまり想定できないが、いちおうその場合のエラー処理も考慮しておくと良い。ちなみにメールアドレスの代わりに user_id 相当のものを使わないのはユーザーが間違ったメールアドレス(自身が所有していない)にパスワードリセット URL を送った場合にリセットできないようにするため。
余談として、パスワード変更が完了したら変更完了のメールを送るとなお良い。
実装的には認証に使う JWT 生成とほぼ同じ。違う点は以下。
- user_id の代わりに email を使う
- IsExpired を含む PasswordResetToken を Parse の返り値にする。これは expire 時に呼び出し元でエラーメッセージを変えるため。
package userpasswordresettoken
import (
"time"
"github.com/dgrijalva/jwt-go"
)
const (
// secret は openssl rand -base64 40 コマンドで作成した。
secret = "2FMd5FNSqS/nW2wWJy5S3ppjSHhUnLt8HuwBkTD6HqfPfBBDlykwLA=="
// emailKey はメールアドレスを表す。
emailKey = "email"
// iat と exp は登録済みクレーム名。それぞれの意味は https://tools.ietf.org/html/rfc7519#section-4.1 を参照。{
iatKey = "iat"
expKey = "exp"
// }
// lifetime は jwt の発行から失効までの期間を表す。
lifetime = 24 * time.Hour
)
// PasswordResetToken は署名前のパスワードリセットトークン情報を表す。
type PasswordResetToken struct {
Email string
Iat int64
IsExpired bool
}
// Generate はパスワードリセットに利用する jwt トークンを生成して返す。
func Generate(email string, now time.Time) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
emailKey: email,
iatKey: now.Unix(),
expKey: now.Add(lifetime).Unix(),
})
return token.SignedString([]byte(secret))
}
// Parse は jwt トークンから元になったパスワードリセット情報を取り出す。
func Parse(signedString string) (*PasswordResetToken, error) {
token, err := jwt.Parse(signedString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return "", err.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secret), nil
})
isExpired := false
if err != nil {
// 有効期限が切れたことを理由とするエラーはスキップする {
if ve, ok := err.(*jwt.ValidationError); ok {
if ve.Errors&jwt.ValidationErrorExpired != 0 {
isExpired = true
} else {
return nil, err.Wrapf(err, "%s is invalid", signedString)
}
} else {
return nil, err.Wrapf(err, "%s is invalid", signedString)
}
// }
}
if token == nil {
return nil, err.Errorf("not found token in %s:", signedString)
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, err.Errorf("not found claims in %s", signedString)
}
email, ok := claims[emailKey].(string)
if !ok {
return nil, err.Errorf("not found %s in %s", emailKey, signedString)
}
iat, ok := claims[iatKey].(float64)
if !ok {
return nil, err.Errorf("not found %s in %s", iatKey, signedString)
}
return &PasswordResetToken{
Email: email,
Iat: int64(iat),
IsExpired: isExpired,
}, nil
}
// SendMailResetUserPassword メソッドはユーザーのパスワードを再設定するためのメールを送信する
func SendMailResetUserPassword(ctx context.Context, email string) error {
passwordResetToken, err := userpasswordresettoken.Generate(email, time.Now())
if err != nil {
return err.Wrapf(err, "[BUG] failed to generate token from email=%s", email)
}
// リセット URL のパラメーターに passwordResetToken を含めてメールを送信する
...
}
// ResetUserPassword メソッドはユーザーのパスワードをリセットする
func ResetUserPassword(ctx context.Context, token, new string) error {
// トークンをパースする {
resetPassword, err := userpasswordresettoken.Parse(token)
if err != nil {
return nil, &dnerrors.InvalidPasswordResetTokenErr{
Token: token,
Err: err.Wrap(err),
}
}
if resetPassword.IsExpired {
return nil, &dnerrors.ExpiredPasswordResetTokenErr{
Token: token,
Err: err.Wrap(err),
}
}
// }
// ユーザー情報を取得する {
auth, err := GetUserAuthFromEmail(ctx, resetPassword.Email)
if err != nil {
return nil, err.Wrapf(err, "failed to get auth from email=%s", resetPassword.Email)
}
passwordUpdateUnixTime := auth.PasswordUpdateTime
if resetPassword.Iat < passwordUpdateUnixTime {
return nil, &dnerrors.AlreadyChangedAfterPasswordResetTokenIssueErr{
Iat: resetPassword.Iat,
PasswordUpdateUnix: passwordUpdateUnixTime,
}
}
// }
// 新パスワードに更新する
...
}