LoginSignup
63
51

More than 5 years have passed since last update.

Go で JWT を使うユースケースとその実装例

Last updated at Posted at 2018-06-20

はじめに

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,
        }
    }
    // }

    // 新パスワードに更新する
    ...
}

参考

63
51
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
63
51