Help us understand the problem. What is going on with this article?

FIDO2でパスワードレス認証のサーバサイド(Golang)

はじめに

[CA Tech Dojo/Challenge/JOB Advent Calendar 2019]の3日目はハゲが書かせていただきます。私は今年の夏に「CA Tech Dojo (Go)」に参加させていただきました。自走力が鍛えられる内容で、その後の学習の生産性が向上するのでおススメです。その時の「参加レポート」があるので、興味のある人はぜひ見てください。

本題

今日はFIDOと呼ばれる新しい認証の仕組みについて紹介します。

IDとパスワードを用いた認証の問題点

  • リスト攻撃に弱いこと
    あらかじめ入手してリスト化したID・パスワードを利用して、アクセスされてしまう。

  • フィッシング攻撃に弱いこと
    ユーザが正規のサイトによく似たサイトにIDとパスワードを入力してしまい、盗まれる。

  • 利用しているサービスの数だけパスワードを記憶しておかなければならないこと
    紙媒体に書いてしまったり、同じものを使いまわしてしまったり、忘れてしまったりする。→ユーザ体験の品質が下がる。

  • 入力する手間がめんどくさいこと
    ユーザ体験の品質が下がる。

FIDO(Fast IDentity Online)とは

セキュリティとユーザ体験を共存させる認証技術を提供する仕組みのことです。パスワードレスな認証にすることでパスワード認証の問題を解決しました。

FIDOの余談

現在ではFIDO2も誕生し、Web認証(Webauthn)やデバイス間連携(CTAP2)に対応したことでFIDO認証が広く使われるようになりました。今後はAndroidなどのネイティブアプリ上でFIDO認証ができるようにするそうです。今回扱うのはFIDO2(webauthn+CTAP2)です。

FIDOの登録・認証モデル

①登録のフロー

FIDO2の登録フロー.png
アテステーションがわからない方は[こちらの記事]を参考にしてください。

②認証のフロー

FIDO2の認証フロー.png
登録と認証でフローに大きな違いはありません。以下にざっくりとした流れを説明します。

①利用者は事前に認証器を認証サーバに登録する。

②この際に、認証器は秘密鍵と公開鍵のペアを生成し、公開鍵とユーザIDを紐づけて認証サーバに送付する。

③認証の際には、認証サーバが一度だけ有効なランダムな文字列(challenge)を生成し、認証器に送付する。

④認証器が生体情報などで本人性を検証したら、このchallengeに対して秘密鍵で署名を生成し、署名付きchallengeを認証サーバに送る。

⑤認証サーバは公開鍵によって復号し、適切な署名であることを検証出来たら認証が成功する。

従来の認証モデルとFIDOの認証モデルの違い

従来の認証は、利用者が通信路を介して、ID、クレデンシャル情報(パスワードや生体情報など)を認証サーバに送付する仕組みだったので、利用者と認証サーバでクレデンシャル情報を共有していました。

しかし、FIDOは、認証器によってローカルで本人性を認証するプロセスと、認証サーバに検証結果を送付するというプロセスを分離しているので、ネットワーク上にクレデンシャル情報が流れることはないという強みがあります。

生体認証に関する余談

生体情報を用いた認証は、パスワードを用いる認証に比べて、パスワードを記憶する必要が無かったり、入力する手間が省けたりと最高のユーザ体験を提供していましたが、生体情報は変更が難しいため、一度流出すると二度と使えなくなることから、管理コストが高くなってしまうという問題点がありました。しかし、FIDOを用いることによって通信経路で生体情報が流出する可能性がなくなったので生体認証が普及しやすくなりました。とてもありがたいです。

FIDO2のサーバサイド実装(Golang)

環境
Windows10
Golang1.13.3

今回はWebauthnを使ってWeb上でFIDO認証をできるようにしたあと、CTAP2によってデバイス間連携での認証を実装します。
まずはライブラリをインポートします。
go get github.com/koesie10/webauthn
Webフレームワークのechoを使います。

Webauthn

チャレンジの生成や、ポリシーの生成、認証器の登録等はライブラリのメソッド内で行われています。また、鍵の生成や、生体認証の部分はクライアント側で実装するため、サーバサイドはとてもシンプルになります。FIDOサーバのAPIを呼び出すか、ブラウザとの通信を行うことがメインになります。今回使用するコードは「こちらのリポジトリ」から引用しています。

Step0. 準備

まず、認証器とユーザを格納するエンティティを定義します。

storage.go
package main

import (
    "encoding/hex"
    "fmt"

    "github.com/koesie10/webauthn/webauthn"
)

type User struct {
    Name           string                    `json:"name"`
    Authenticators map[string]*Authenticator `json:"-"`
}

type Authenticator struct {
    User         *User
    ID           []byte
    CredentialID []byte
    PublicKey    []byte
    AAGUID       []byte
    SignCount    uint32
}

type Storage struct {
    users          map[string]*User
    authenticators map[string]*Authenticator
}

func (s *Storage) AddAuthenticator(user webauthn.User, authenticator webauthn.Authenticator) error {
    authr := &Authenticator{
        ID:           authenticator.WebAuthID(),
        CredentialID: authenticator.WebAuthCredentialID(),
        PublicKey:    authenticator.WebAuthPublicKey(),
        AAGUID:       authenticator.WebAuthAAGUID(),
        SignCount:    authenticator.WebAuthSignCount(),
    }
    key := hex.EncodeToString(authr.ID)

    u, ok := s.users[string(user.WebAuthID())]
    if !ok {
        return fmt.Errorf("user not found")
    }

    if _, ok := s.authenticators[key]; ok {
        return fmt.Errorf("authenticator already exists")
    }

    authr.User = u

    u.Authenticators[key] = authr
    s.authenticators[key] = authr

    return nil
}

func (s *Storage) GetAuthenticator(id []byte) (webauthn.Authenticator, error) {
    authr, ok := s.authenticators[hex.EncodeToString(id)]
    if !ok {
        return nil, fmt.Errorf("authenticator not found")
    }
    return authr, nil
}

func (s *Storage) GetAuthenticators(user webauthn.User) ([]webauthn.Authenticator, error) {
    u, ok := s.users[string(user.WebAuthID())]
    if !ok {
        return nil, fmt.Errorf("user not found")
    }

    var authrs []webauthn.Authenticator
    for _, v := range u.Authenticators {
        authrs = append(authrs, v)
    }
    return authrs, nil
}

func (u *User) WebAuthID() []byte {
    return []byte(u.Name)
}

func (u *User) WebAuthName() string {
    return u.Name
}

func (u *User) WebAuthDisplayName() string {
    return u.Name
}

func (a *Authenticator) WebAuthID() []byte {
    return a.ID
}

func (a *Authenticator) WebAuthCredentialID() []byte {
    return a.CredentialID
}

func (a *Authenticator) WebAuthPublicKey() []byte {
    return a.PublicKey
}

func (a *Authenticator) WebAuthAAGUID() []byte {
    return a.AAGUID
}

func (a *Authenticator) WebAuthSignCount() uint32 {
    return a.SignCount
}

その次にセッション周りを整えます。

session.go
package main

import (
    "github.com/gorilla/sessions"
    "github.com/labstack/echo"
    "github.com/labstack/echo-contrib/session"
)

//コンテキストから値をとりだすまたは格納する時のキーペア
var contextKeySession = "webauthn-demo-session"

func SessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        sess, _ := session.Get("session", c)
        sess.Options = &sessions.Options{
            Path:     "/",
            MaxAge:   2592000, // 30 days
            HttpOnly: true,
        }
        c.Set(contextKeySession, sess)

        c.Response().Before(func() {
            sess.Save(c.Request(), c.Response())
        })

        err := next(c)

        c.Set(contextKeySession, nil)

        return err
    }
}

//セッション管理用
func SessionFromContext(c echo.Context) *sessions.Session {
    sess, ok := c.Get(contextKeySession).(*sessions.Session)
    if !ok {
        return nil
    }
    return sess
}

Step1. RPサーバはブラウザから利用者のIDを受け取る

main.go
e.POST("/webauthn/registration/start/:name", func(c echo.Context) error {
        name := c.Param("name")
        u, ok := storage.users[name]
        //ユーザエンティティ初期化
        if !ok {
            u = &User{
                Name:           name,
                Authenticators: make(map[string]*Authenticator),
            }
            storage.users[name] = u
        }
        //keyが指定されているセッションが返される(金庫が渡されるイメージ)
        sess := SessionFromContext(c)
}

ユーザがブラウザ上でNameを入力し、/webauthn/registration/start/:nameのAPIを叩きます。そうするとクエリパラメータから値を取得し、それによってユーザエンティティを初期化する。

Step2. FIDOサーバのAPIにアクセスし、FIDOサーバで作成されたポリシーとチャレンジを受け取り、ブラウザへ送信する。

main.go
e.POST("/webauthn/registration/start/:name", func(c echo.Context) error {
        name := c.Param("name")
        u, ok := storage.users[name] 
        //ユーザエンティティ初期化
        if !ok {
            u = &User{
                Name:           name,
                Authenticators: make(map[string]*Authenticator),
            }
            storage.users[name] = u
        }
        //keyが指定されているセッションが返される(金庫が渡されるイメージ)
        sess := SessionFromContext(c)

//以下を追加
        w.StartRegistration(c.Request(), c.Response(), u, webauthn.WrapMap(sess.Values))
        return nil
    })

FIDO鍵登録要求は、インポートしたライブラリのStartRegistrationを呼び出すことで始まる。このメソッドの引数の三つ目はWebauthnライブラリのUserエンティティで、第四引数はsessionだが、今回はgorillaのセッションを用いるため、webauthn.WrapMap(sess.Values)というようにwebauthn用にラップして渡します。
StartRegistration内では次の処理が行われている
①チャレンジ生成
②追加のクレデンシャル情報を生成
③クレデンシャル情報の生成オプションを指定(認証ポリシー的なものでこの中にチャレンジが含まれている)
④storage.goのGetAuthenticatorsが呼ばれて、ユーザが利用可能な認証器が返される。
⑤公開鍵のクレデンシャルタイプやクレデンシャルIDを持つディスクリプタを認証器の数だけ生成する。
⑥一つの認証器で持つことができるクレデンシャル情報の上限を決める。
⑦チャレンジとユーザIDをセッションにSETする
⑧レスポンス用の構造体にチャレンジとポリシーを書きこむ。

Step3. 署名付きのチャレンジと認証用公開鍵をブラウザから受け取り、FIDOサーバへ送信する。

クライアントの処理
ブラウザからチャレンジやポリシーが送られてきたら、認証器で生体情報による認証を行い、/webauthn/registration/finish/:nameのAPIを叩きます。その際にチャレンジに署名を加えたものと認証用の公開鍵をRPに送ります。
サーバサイドの処理
クライアントから/webauthn/registration/finish/:nameにアクセスが来たら、まずユーザ情報とセッション情報を取得し、インポートしたライブラリのFinishRegistrationを呼び出す。

main.go
    e.POST("/webauthn/registration/finish/:name", func(c echo.Context) error {
        name := c.Param("name")
        u, ok := storage.users[name]
        if !ok {
            return c.NoContent(http.StatusNotFound)
        }

        sess := SessionFromContext(c)

        w.FinishRegistration(c.Request(), c.Response(), u, webauthn.WrapMap(sess.Values))
        return nil
    })

FinishRegistration内では次の処理が行われている。

認証のフローで登録のフローと違う部分は、認証器による認証後、つまりStep3の署名付きのチャレンジと認証用公開鍵をブラウザから受け取りFIDOサーバへ送信する部分です。登録時に公開鍵はすでにFIDOサーバに保管されているため、ブラウザからは公開鍵は送られず、署名付きのチャレンジを送付するだけで済みます。

認証を実装する際は、StartLoginFinishLoginを代わりに呼びます。
以下にコードを記載しておきます。このコードはwebauthnのデモを実装してある「GitHubのリポジトリ」から引用しました。

main.go
e.POST("/webauthn/login/start/:name", func(c echo.Context) error {
        name := c.Param("name")
        u, ok := storage.users[name]

        sess := SessionFromContext(c)

        if ok {
            w.StartLogin(c.Request(), c.Response(), u, webauthn.WrapMap(sess.Values))
        } else {
            w.StartLogin(c.Request(), c.Response(), nil, webauthn.WrapMap(sess.Values))
        }
        return nil
    })

    e.POST("/webauthn/login/finish/:name", func(c echo.Context) error {
        name := c.Param("name")
        u, ok := storage.users[name]

        sess := SessionFromContext(c)

        var authenticator webauthn.Authenticator
        if ok {
            authenticator = w.FinishLogin(c.Request(), c.Response(), u, webauthn.WrapMap(sess.Values))
        } else {
            authenticator = w.FinishLogin(c.Request(), c.Response(), nil, webauthn.WrapMap(sess.Values))
        }
        if authenticator == nil {
            return nil
        }

        authr, ok := authenticator.(*Authenticator)
        if !ok {
            return c.NoContent(http.StatusInternalServerError)
        }

        return c.JSON(http.StatusOK, authr.User)
    })

CTAP2(スマホ上で指紋認証)

こちらは時間のある時に実装しようと思います。

参考文献

[2]WebAuthnでパスワードレスなサイトを作る。安全なオンライン認証を導入するFIDOの基本
[3]FIDO認証によるパスワードレスログイン実装入門
[4]Mercari Engineering Blog
[5]FIDO 認証とその技術
[6]アテステーションとは
[7]GitHub koesie10/webauthn-demo

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした