バイセル Tech Blog

バイセル Tech Blogは株式会社BuySell Technologiesのエンジニア達が知見・発見を共有する技術ブログです。

バイセル Tech Blog

gqlgen と Auth0 を利用した認証機能の話

こちらは バイセルテクノロジーズ Advent Calendar 2021 の 19 日目の記事です。
前日の記事は酒井さんの「React + TypeScript環境にGoogle Analyticsを導入してみた」でした。

こんにちは。開発 2 部の今井です。 バイセルでは主にサーバサイドの開発に携わっています。

本記事では、業務で検討する機会がありました「gqlgenAuth0 を利用した認証機能」についてご紹介したいと思います。

今回ご紹介する認証機能について

本システムは Auth0 を利用して認証認可の仕組みを組み込んでいます。
それを踏まえて、以下を考慮する必要がありました。

  • 本システムを利用する場合はメールアドレスとパスワードが必要となる想定のもと設計する。
  • ユーザの情報をDBへ保存後、招待メールを発行し、ユーザにパスワードを設定していただく必要があるためバックエンドの API から Auth0 に連携させる必要がある。

今回は DB への登録等は省略し、以下添付画像の認証部分に限り、検証した実装をご紹介します。

主要な技術スタック(一部抜粋)

リポジトリ

今回利用するコードの全体は以下リポジトリにアップしております。

github.com

前提

以下を前提とし、話を進めていきます。

  • Auth0 Machine to Machine アプリケーションが作成されていること
  • SendGrid の API キーが発行されていること

※ 上記の手順は以下のリンクが参考になるかと思います。 auth0.com docs.sendgrid.com

必要な環境変数を定義

以下4つの環境変数を .env ファイルに定義します。
Auth0 Management API を利用するにあたって必要な環境変数は ダッシュボード
Applications > 作成した M2M API > Settings より確認できます。
同時にSendGrid の API キーも定義します。

AUTH0_DOMAIN
AUTH0_CLIENT_ID
AUTH0_CLIENT_SECRET
SENDGRID_API_KEY

GraphQL スキーマ定義

今回は簡単にメールアドレスと名前をリクエストとし、Auth0 への登録時に生成されるユーザ ID をレスポンスとして返却します。

api/graph/schema/schema.gql

input UserInfo {
  email: String!
  familyName: String!
  givenName: String!
}

type Auth0UserID {
  Auth0UserID: ID!
}

type Mutation {
  registerUser(input: UserInfo!): Auth0UserID!
}

GraphQL サーバの実装

gqlgen を使ったサーバの実装、加えて Auth0 クライアントと SendGrid クライアントを作成します。

▶︎gqlgen_auth0_sample/server.go

func main() {
    ac, err := initAuth0Client()
    if err != nil {
        log.Fatalf("could not init auth0 client: %v", err)
    }

    mc, err := initMailerClient()
    if err != nil {
        log.Fatalf("could not init sendgrid client: %v", err)
    }

    srv := handler.NewDefaultServer(
        generated.NewExecutableSchema(
            generated.Config{
                Resolvers: &resolver.Resolver{
                    Auth0Client: ac,
                    MailClient:  mc,
                },
            },
        ),
    )
  // 中略
}

func initAuth0Client() (*auth0.Client, error) {
    var (
        domain       = os.Getenv("AUTH0_DOMAIN")
        clientID     = os.Getenv("AUTH0_CLIENT_ID")
        clientSecret = os.Getenv("AUTH0_CLIENT_SECRET")
    )

    if domain == "" || clientID == "" || clientSecret == "" {
        return nil, fmt.Errorf("plese set env vars for auth0")
    }

    m, err := management.New(domain, management.WithClientCredentials(clientID, clientSecret))
    if err != nil {
        return nil, fmt.Errorf("could not create an auth0 management: %w", err)
    }

    return auth0.NewClient(m), nil
}

func initMailerClient() (*mailer.Client, error) {
    var (
        apiKey = os.Getenv("SENDGRID_API_KEY")
    )
    if apiKey == "" {
        return nil, fmt.Errorf("please set env vars for sendgrid")
    }

    sg := sendgrid.NewSendClient(apiKey)
    return mailer.NewClient(sg), nil
}

gqlgen_auth0_sample/internal/auth0/client.go

package auth0
// 中略
type Client struct {
    management *management.Management
}

func NewClient(mgmt *management.Management) *Client {
    return &Client{management: mgmt}
}

gqlgen_auth0_sample/internal/mailer/client.go

package mailer
// 中略
type Client struct {
    sg *sendgrid.Client
}

func NewClient(c *sendgrid.Client) *Client {
    return &Client{sg: c}
}

Resolver の実装

ここからドメインロジックとなる部分の実装を進めていきます。 処理は大まかに以下の3つです。

① Auth0 Management API を使用し、Auth0 の DB にユーザを登録する。
② 作成されたユーザ ID をパラメータに、パスワード再設定用の URL を作成する。
③ パスワード再設定用の URL を SendGrid API を使用し、リクエストできたメールアドレスに送信する。

gqlgen_auth0_sample/internal/user/interface.go

package user
// 中略
type Auth0ManagementClient interface {
    CreateUser(ctx context.Context, u *User) (string, error)
    CreateInvitationUrl(ctx context.Context, auth0UserID string) (string, error)
}

type MailClient interface {
    Send(ctx context.Context, from *mail.Address, to *mail.Address, subject string, message string) error
}

gqlgen_auth0_sample/internal/graph/resolver/resolver.go

package resolver
// 中略
type Resolver struct {
    Auth0Client user.Auth0ManagementClient
    MailClient  user.MailClient
}

gqlgen_auth0_sample/internal/user/user.go

package user
// 中略
type User struct {
    Email      string
    FamilyName string
    GivenName  string
}

func (u *User) FullName() string {
    return strings.Trim(strings.Join([]string{u.FamilyName, u.GivenName}, " "), " ")
}

gqlgen_auth0_sample/internal/graph/resolver/schema.resolvers.go

package resolver
// 中略
func (r *mutationResolver) RegisterUser(ctx context.Context, input model.UserInfo) (*model.Auth0UserID, error) {
    var u = &user.User{
        Email:      input.Email,
        FamilyName: input.FamilyName,
        GivenName:  input.GivenName,
    }

    res, err := user.RegisterUser(ctx, &user.RegisterUserInput{
        User:                  u,
        Auth0ManagementClient: r.Auth0Client,
        MailClient:            r.MailClient,
    })
    if err != nil {
        return nil, fmt.Errorf("could not register user: %w", err)
    }

    return &model.Auth0UserID{
        Auth0UserID: res,
    }, nil
}

gqlgen_auth0_sample/internal/user/register_user.go

package user
// 中略

// 送信元
var (
    invitaionFrom = &mail.Address{
        Name:    "gqlgen-auth0",
        Address: "noreply@example.com",
    }
    invitaionSubject = "仮登録完了のお知らせ"
)

type RegisterUserInput struct {
    User *User
    Auth0ManagementClient
    MailClient
}

func RegisterUser(ctx context.Context, input *RegisterUserInput) (string, error) {
    var (
        amc = input.Auth0ManagementClient
        mc  = input.MailClient
    )

    auth0UserID, err := amc.CreateUser(ctx, input.User)
    if err != nil {
        return "", fmt.Errorf("failed to create user on auth0: %w", err)
    }

    invitaionUrl, err := amc.CreateInvitationUrl(ctx, auth0UserID)
    if err != nil {
        return "", fmt.Errorf("failed to send invitation: %w", err)
    }

    var (
        body = invitaionUrl
        to   = &mail.Address{
            Name:    input.User.FullName(),
            Address: input.User.Email,
        }
    )
    if err := mc.Send(ctx, invitaionFrom, to, invitaionSubject, body); err != nil {
        return "", fmt.Errorf("failed to send invitation: %w", err)
    }

    return auth0UserID, nil
}

① Auth0 Management API を使用し、Auth0 の DB にユーザを登録する。

この段階ではまだユーザのメールアドレスの認証とパスワードの設定が未完了のため、
management.User 構造体の EmailVerifiedVerifyEmail のフィールドはともに false で登録します。

またパスワード再設定用の URL を作成する際に生成されたユーザ ID が必要となる為、レスポンスとして返します。

connection 定数は以下の文言を参照しています。

gqlgen_auth0_sample/internal/auth0/client.go

package auth0
// 中略

const (
    connection       = "Username-Password-Authentication"
    invitationTTLSec = 86400
)

func (c *Client) CreateMember(ctx context.Context, u *user.User) (string , error) {
    // 招待メールからパスワードを設定するまでの間の仮パスワードを生成
    pass, err := generateRandomPassword()
    if err != nil {
        return "", fmt.Errorf("could not generate password: %w", err)
    }

    managementUser := &management.User{
        Connection:    auth0.String(connection),
        Email:         auth0.String(u.Email),
        Name:          auth0.String(u.FullName()),
        Password:      auth0.String(pass),
        EmailVerified: auth0.Bool(false),
        VerifyEmail:   auth0.Bool(false),
    }
    if err := c.management.User.Create(managementUser, management.Context(ctx)); err != nil {
        return "", fmt.Errorf("could not create a user on auth0: %w", err)
    }

    return auth0.StringValue(managementUser.ID), nil
}

func generateRandomPassword() (string, error) {
// 中略
}

② 作成されたユーザ ID をパラメータに、パスワード再設定用の URL を作成する。

management.Ticket 構造体の MarkEmailAsVerified フィールドを true にすることでメールアドレスの認証が完了となります。
またここでは定義していませんが、 ResultURL フィールドに設定した URL にリダイレクトさせることも可能です。

gqlgen_auth0_sample/internal/auth0/client.go

package auth0
// 中略
func (c *Client) CreateInvitationUrl(ctx context.Context, auth0UserID string) (string, error) {
    t := &management.Ticket{
        UserID:              auth0.String(auth0UserID),
        TTLSec:              auth0.Int(invitationTTLSec),
        MarkEmailAsVerified: auth0.Bool(true),
    }
    if err := c.management.Ticket.ChangePassword(t, management.Context(ctx)); err != nil {
        return "", fmt.Errorf("could not create ticket for change password: %w", err)
    }

    return auth0.StringValue(t.Ticket) + "invite", nil
}

③ パスワード再設定用の URL を SendGrid API を使用し、リクエストできたメールアドレスに送信する。

gqlgen_auth0_sample/internal/mailer/client.go

package mailer
// 中略
func (c *Client) Send(ctx context.Context, from *mail.Address, to *mail.Address, subject string, message string) error {
    msg := sgmail.NewSingleEmail(
        sgmail.NewEmail(from.Name, from.Address),
        subject,
        sgmail.NewEmail(to.Name, to.Address),
        subject,
        message)

    res, err := c.sg.SendWithContext(ctx, msg)
    if err != nil {
        return err
    }

    // OK(200) ではなく、Accepted(201) が返却される
    if res.StatusCode != http.StatusAccepted {
        fmt.Fprintf(os.Stderr, res.Body)
        return fmt.Errorf("unexpected status from sendgrid api: %d", res.StatusCode)
    }

    return nil
}

ミューテーションの実行及び確認

ここまでで必要な処理は揃いましたので動作確認をします。

  1. サーバを起動する。
    $ docker compose up

  2. GraphQL Playground (http://localhost:8081)を開き、registerUser ミューテーションを実行する。
    email フィールドは自分のメールアドレスを定義しています。
    実行すると Auth0 への登録時に生成されたユーザ ID が返却されていることが確認できますね。

  3. Auth0 にユーザが登録されていることを確認する。
    確認する際には Auth0 のダッシュボードより、User Management > Users を開きます。
    2 で返却されたユーザIDと同様のIDを持つユーザの登録が確認できます。
    また Details に記載されている Identity Provider Attributes の情報は、ID TokenAccess Token の情報としても流用できます。

  4. 招待メールが届いていることを確認する。
    届いている URL を開きます。

  5. パスワードの再設定をし、変更されることを確認する。

  6. 5 で設定したパスワードを利用し、実際にログインできることを確認する。
    確認する際には Auth0 のダッシュボードより、Authentication > Database > Database Connections > Try を開きます。 パスワード入力画面が表示されるので、パスワードを入力します。
    ログインが成功すると以下の画面が表示されます。
    リクエストで送った名前と、レスポンスとして返却された Auth0 のユーザ ID が確認できますね。

まとめ

いかがでしたでしょうか?
実運用では Auth0 へ登録する前段でユーザ情報を DB へ保存する、リダイレクト先は FE のアプリケーションにする等の処理が必要となってくるかとは思いますが、今回ご紹介した認証機能の部分が全体の肝となるためご紹介しました。
gqlgen や Auth0 を利用した認証機能の参考となれば幸いです。

最後に

バイセルでは一緒に新規サービスの開発をしてくれるエンジニアを募集しています。

herp.careers

明日のバイセルテクノロジーズ Advent Calendar 2021は、
玉利さんによる「内定者インターンが行った社内サービスのデザイン刷新」です。

参考にさせていただいたサイト