Goで作るパスキー

注意
この記事ではgo-webauthnを使用したバックエンド側のWebAuthnの実装について解説しています。
1からWebAuthnを実装したい場合は RFC や W3C を参照してください。


この記事は、四工大アドベントカレンダーのカレンダー | Advent Calendar 2023 - Qiita の9日目です。

qiita.com


こんにちは、id:cateiru です。元四工大で、今はWebエンジニアとして働いています。

突然ですが、皆さんはパスキーを使っていますか?パスキーはFIDOの認証をiCloudのようなクラウド環境で同期することで複数端末でパスワードレスでログインできるようにする multi-device FIDO credential です。
技術としては、FIDO2が使われており、Webで使う場合はWebAuthnを使うことで簡単に実装することが可能です。

本記事では、WebAuthnのバックエンド側をGoで実装していこうと思います。

前提知識

Webuathは以下のように登録・認証されます。バックエンド側でチャレンジというランダムな文字列を作成し、それを使用して認証するような形です。

Goで実装する

早速実装していこうと思いますが、WebAuthnの認証を1から実装するとものすごい大変なのでgo-webauthnを使っていきます。

github.com

作る必要があるエンドポイントは4つで、

  • 登録時のチャレンジを返す
  • 登録する
  • ログイン認証時にチャレンジを返す
  • ログイン認証する

が必要です。

登録時のチャレンジを返す

WebAuthnには、サーバーがチャレンジを生成し、返すというフローがあります。Goのgo-webauthnでは、BeginRegistrationというメソッドが用意されており、そこにUserを渡します。渡すUserはUserのInterfaceを満たす必要があります。これは自分で頑張りましょう。

コード例はこのようになります。

github.com

handlerの実装は以下のようになり、BeginRegistrationの戻り値optionsはjsonとして返します。また、sessionは次の登録する際に必要となるため、RedisやRDSに保存しておきます。jsonとして保存しておくのがおすすめです。このsessionは一意のトークンを付与してCookieなどに保存しましょう。また、Userも使用するので保存しておきます。

type User struct {
    ID          []byte `json:"id"`
    Name        string `json:"name"`
    DisplayName string `json:"display_name,omitempty"`

    Credentials []webauthn.Credential `json:"-"`
}

user := &User{
    ID:          []byte(RandomString(20)),
    Name:        name,
    DisplayName: displayName,
}

options, session, err := webauthn.BeginRegistration(user)
if err != nil {
    return err
}

...

登録する

次に、実際にWebAuthnを登録するHandlerを作成します。

このHandlerはapplication/jsonでリクエストを受け取ることをおすすめします。そうすることで、protocol.ParseCredentialCreationResponseBodyで受け取ることができます。

response, err := protocol.ParseCredentialCreationResponseBody(req.Body)
 err != nil {
    return err
}

その後、Cookieからsessionを呼び出します。さらに、チャレンジ時に生成したUserも使うので呼び出します。

credential, err := webauthn.CreateCredential(user, session, response)
if err != nil {
    return err
}

このcredentialにユーザーの認証情報が入っています。この認証情報をUserのWebAuthnCredentialsメソッドで返せるように実装します。

ログイン認証時にチャレンジを返す

次に、ログインです。登録時と同じく、ログイン時にもチャレンジを生成できる便利なメソッドが用意されています。BeginDiscoverableLoginを使うことで、生成できます。今回もcredentialはレスポンスで返し、sessionはDBなどに保存します。

credential, session, err := webauthn.BeginDiscoverableLogin()
if err != nil {
    return err
}

ログイン認証する

ログインでは、ValidateDiscoverableLoginValidateLoginの2つのメソッドが用意されています。ValidateDiscoverableLoginは引数にDiscoverableUserHandlerを指定することができ、このHandler内でDBからユーザーを引いてくるといったことができます。逆にValidateLoginは引数にUserを指定する必要があります。

GitHubのようなパスキー単体でログインできるような場合はValidateDiscoverableLoginを使用し、Googleなど最初にメールアドレスを入力してからパスキーを求める場合などはValidateLoginを使用します。

ValidateDiscoverableLoginで実装すると以下のようになります。

var loggedInUser *User = nil
handler := func(rawID, userHandle []byte) (user webauthn.User, err error) {
    u, err := GetUserById(userHandle)
    if err != nil {
        return nil, err
    }
    loggedInUser = u
    return u, nil
}

_, err = webauthn.ValidateDiscoverableLogin(handler, session, response)
if err != nil {
    return err
}

これで、パスキーを使ったログインができようになります。

終わりに

いかがでしたでしょうか。パスキーはユーザーのログインは手軽ですが、実装が手間です。しかし、今後普及するかと思うのでぜひ実装してみてくださいね。

今回説明したコードなどは、以下のリポジトリにあるので気になったらぜひ見てください!

github.com

ではでは ノシ