4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

第4回「FirebaseAuthによるユーザ作成機能導入」@FirebaseAuth+Nuxt.js+Go(v1.11)+GAE開発シリーズ

Posted at

お題

前回の記事でFirebaseAuthを使った単純なSignIn/Outを実装した。
今回は、SignUp(つまり、FirebaseAuth管理ユーザの新規登録)を実装する。
前回はFirebaseAuthのコンソール上で事前にログイン用ユーザを作成したけど、例えばある種の管理画面などではログイン可能なユーザの作成機能を持たせたりするものなので。
 
SignIn/OutはJavaScriptのSDKで実装したのでSignUpも合わせようと思うところだけど、JavaScriptのSDKでSignUpを行うと**作った途端にそのユーザで自動的にログインした状態になるので、これは使えない。
(つまり、ユーザ作成権限を持つ”ユーザA”でログインして”ユーザB”を作ると、その時点で(”ユーザA”でログインしていたのに)Firebase上は”ユーザB”でログインした状態に変わってしまう。)
なので、SignUpはサーバーサイド側で行う。今回はWebAPIとしてGoを使っているので、
Go用のSDKでユーザ作成**機能を実装する。

FirebaseAuth+Nuxt.js+Go(v1.11)+GAE開発シリーズIndex

前提

以下は他にいくらでもよい記事があるので省略。

  • 開発環境の構築(GolangやらYarnやらのインストール等)
  • Google Cloud Platform環境の取得(App Engine有効化)
  • Firebase環境の取得
  • Vue.jsやNuxt.jsのチュートリアル

開発環境

# OS

$ cat /etc/os-release 
NAME="Ubuntu"
VERSION="18.04.1 LTS (Bionic Beaver)"

# 依存モジュールのバージョン

package.jsonの内容より、以下の通り。

    "nuxt": "^2.3.4",
    "vuetify": "^1.3.14",
    "vuetify-loader": "^1.0.8",
    "@nuxtjs/axios": "^5.3.6"

# Yarn

$ yarn -v
1.12.3

# Golang

$ go version
go version go1.11.4 linux/amd64

実践

■ソース全量

今回作ったソースの全量は下記。
https://github.com/sky0621/Dotato-di-una-libreria/tree/faa9e6567718a7ed69847593315b950d0d7e5dca

■フロントエンドにユーザ作成フォームを実装

ユーザ作成画面の見た目

こんな感じにしてみた。
Vuetifyを使っているので、ほとんど手間をかけずともそれなりの画面が作れる。
あと、バリデーションはVuelidateを使った。
ソースは後ほど。

screenshot-localhost-3000-2019.02.10-16-28-28.png

画面表示に関する部分のソース

/frontend/pages/signup.vue

pagesに「ユーザ作成画面」としてのテンプレートを書き、「ユーザ作成フォーム」の部分だけコンポーネントとして切り出し。
(今後、新規作成と変更とを分けて、フォーム部分を流用することを想定してみたけど、実際のところは、そのときになってみないと、この判断が正しかったかはわからない。。。)

[frontend/pages/signup.vue]
<template>
  <v-container fluid>
    <v-layout
      align-start
      justify-start
      column
    >
      <v-flex>
        <v-label>ユーザー登録</v-label>
      </v-flex>
      <v-flex>
        <signup />
      </v-flex>
    </v-layout>
  </v-container>
</template>

/frontend/components/signup.vue

上記pagesのsignup.vueで「<signup />」としていた箇所の中身に当たる。

[frontend/components/signup.vue]
<template>
  <form>
    <v-text-field
      v-model="name"
      :error-messages="nameErrors"
      :counter="10"
      label="ニックネーム"
      required
      @input="$v.name.$touch()"
      @blur="$v.name.$touch()"
    />
    <v-text-field
      v-model="email"
      type="email"
      :error-messages="emailErrors"
      label="メールアドレス"
      required
      @input="$v.email.$touch()"
      @blur="$v.email.$touch()"
    />
    <v-text-field
      v-model="password"
      type="password"
      :error-messages="passwordErrors"
      label="パスワード"
      required
      @input="$v.password.$touch()"
      @blur="$v.password.$touch()"
    />

    <v-btn
      class="lime lighten-2"
      @click="submit"
    >
      登録
    </v-btn>
  </form>
</template>

フォームのロジックに関する部分のソース

以降、フォーム内の3つの要素「ニックネーム」、「メールアドレス」、「パスワード」及び「登録」ボタンそれぞれについて、1要素ずつテンプレートとロジックを抜粋して説明。
抜粋しない完全版のソースは下記参照。
https://github.com/sky0621/Dotato-di-una-libreria/blob/faa9e6567718a7ed69847593315b950d0d7e5dca/frontend/components/signup.vue

●「ニックネーム」テキストフィールド

[frontend/components/signup.vue]
<template>
  <form>
    <v-text-field
      v-model="name"
      :error-messages="nameErrors"
      :counter="10"
      label="ニックネーム"
      required
      @input="$v.name.$touch()"
      @blur="$v.name.$touch()"
    />
  </form>
</template>

<script>
import { required, maxLength } from 'vuelidate/lib/validators'

export default {
  validations: {
    name: { required, maxLength: maxLength(10) },
  },

  data() {
    return {
      name: '',
    }
  },

  computed: {
    nameErrors() {
      const errors = []
      if (!this.$v.name.$dirty) return errors
      !this.$v.name.maxLength && errors.push('ニックネームは10文字以内にしてください')
      !this.$v.name.required && errors.push('ニックネームは必須です')
      return errors
    },
  },
}
</script>

テーマ外なのでバリデーションの具体的な仕組みについては解説なしで。↓参照。
https://monterail.github.io/vuelidate/#getting-started

ともあれ、上記のように実装すると以下のように入力状態に応じてチェックしてくれる。

screenshot-localhost-3000-2019.02.11-14-37-15.png
screenshot-localhost-3000-2019.02.11-14-37-50.png

●「メールアドレス」テキストフィールド

[frontend/components/signup.vue]
<template>
  <form>
    <v-text-field
      v-model="email"
      type="email"
      :error-messages="emailErrors"
      label="メールアドレス"
      required
      @input="$v.email.$touch()"
      @blur="$v.email.$touch()"
    />
  </form>
</template>

<script>
import { required, email } from 'vuelidate/lib/validators'

export default {
  validations: {
    email: { required, email },
  },

  data() {
    return {
      email: '',
    }
  },

  computed: {
    emailErrors() {
      const errors = []
      if (!this.$v.email.$dirty) return errors
      !this.$v.email.email && errors.push('適切なメールアドレスを入力してください')
      !this.$v.email.required && errors.push('メールアドレスは必須です')
      return errors
    },
  },

上記のように実装すると以下のように入力状態に応じてチェックしてくれる。

screenshot-localhost-3000-2019.02.11-14-39-29.png
screenshot-localhost-3000-2019.02.11-14-39-52.png

●「パスワード」テキストフィールド

[frontend/components/signup.vue]
<template>
  <form>
    <v-text-field
      v-model="password"
      type="password"
      :error-messages="passwordErrors"
      label="パスワード"
      required
      @input="$v.password.$touch()"
      @blur="$v.password.$touch()"
    />
  </form>
</template>

<script>
import { required, minLength } from 'vuelidate/lib/validators'

export default {
  validations: {
    password: { required, minLength: minLength(8) }
  },

  data() {
    return {
      password: ''
    }
  },

  computed: {
    passwordErrors() {
      const errors = []
      if (!this.$v.password.$dirty) return errors
      !this.$v.password.minLength && errors.push('パスワードは8文字以上にしてください')
      !this.$v.password.required && errors.push('パスワードは必須です')
      return errors
    }
  },
}
</script>

上記のように実装すると以下のように入力状態に応じてチェックしてくれる。

screenshot-localhost-3000-2019.02.11-15-08-40.png
screenshot-localhost-3000-2019.02.11-15-09-11.png

●「登録」ボタン

this.$v.$touch()でバリデーションを発動させ、if (this.$v.$invalid) returnにより、チェックに引っかかったら処理中断。
あとは、バックエンド側のAPI(パス:/users)にフォームの中身をPOSTするだけ。

[frontend/components/signup.vue]
<template>
  <form>
    <v-btn
      class="lime lighten-2"
      @click="submit"
    >
      登録
    </v-btn>
  </form>
</template>

<script>
import * as types from "~/store/types";

export default {
  data() {
    return {
      name: '',
      email: '',
      password: ''
    }
  },

  methods: {
    submit() {
      this.$v.$touch()
      if (this.$v.$invalid) return

      console.log('validate success')

      this.$axios
        .post(process.env.apiBaseUrl + '/users', {
          'name': this.name,
          'email': this.email,
          'password': this.password
        })
        .then((res) => {
          console.log(res)
          this.$store.dispatch(types.ACTIVATE_INFO_NOTIFICATION, {message: 'ユーザー「' + this.name + '」を登録しました。'});
          this.$router.push('/')
        })
        .catch((err) => {
          console.log(err)
          this.$store.dispatch(types.ACTIVATE_ERROR_NOTIFICATION, {message: err});
        })
    }
  }
}
</script>

●「通知」スナックバー

ユーザ登録成功後は、スナックバー(メッセージ:「ユーザー「○○」を登録しました。」)を表示する。
ユーザ登録失敗時は、やはりスナックバー(メッセージ:サーバから帰ってきたエラーそのもの)を表示する。

[frontend/components/signup.vue]
<script>
  methods: {
    submit() {
      this.$axios
        .post(process.env.apiBaseUrl + '/users', { 〜〜省略〜〜 })
        .then((res) => {
          this.$store.dispatch(types.ACTIVATE_INFO_NOTIFICATION, {message: 'ユーザー「' + this.name + '」を登録しました。'});
          this.$router.push('/')
        })
        .catch((err) => {
          this.$store.dispatch(types.ACTIVATE_ERROR_NOTIFICATION, {message: err});
        })
    }
  }
}
</script>

このスナックバーの表示内容はコンポーネントないしページをまたいで状態を共有するのでストアに定義。

[frontend/store/index.js]
import * as types from './types'

export const strict = false

export const state = () => ({
  notification: {
    activate: false,
    message: '',
    color: ''
  }
})

export const getters = {
  notification: (state) => {
    return state.notification
  }
}

export const mutations = {
  setNotification(state, data) {
    const cdata = _.clone(data)
    state.notification = {
      activate: cdata.activate,
      message: cdata.message,
      color: cdata.color
    }
  }
}

export const actions = {
  // 通知を活性化する
  activateNotification({ commit }, data) {
    commit(types.SET_NOTIFICATION, { activate: true, message: data.message, color: data.color })
  },

  // INFO通知を活性化する
  activateInfoNotification({ commit }, data) {
    commit(types.SET_NOTIFICATION, { activate: true, message: data.message, color: 'green' })
  },

  // ERROR通知を活性化する
  activateErrorNotification({ commit }, data) {
    commit(types.SET_NOTIFICATION, { activate: true, message: data.message, color: 'red' })
  },

  // 通知を非活性化する
  deactivateNotification({ commit }) {
    commit(types.SET_NOTIFICATION, { activate: false, message: '', color: '' })
  }
}

スナックバー自体を表示するコンポーネントは下記。

[frontend/components/notification.vue]
<template>
  <!-- 通知用スナックバー -->
  <v-snackbar
    v-model="snackbar"
    :color="color"
    :timeout="timeout"
  >
    {{ message }}
    <v-btn
      dark
      flat
      @click="closeSnackbar"
    >
      閉じる
    </v-btn>
  </v-snackbar>
</template>

<script>
import * as types from '~/store/types'

export default {
  data() {
    return {
      timeout: 5000,

      snackbar: false,
      message: '',
      color: ''
    }
  },

  computed: {
    notification() {
      return this.$store.getters[types.NOTIFICATION]
    }
  },

  watch: {
    notification(val) {
      this.apply(val)
    }
  },

  mounted() {
    const val = this.$store.getters[types.NOTIFICATION]
    this.apply(val)
  },

  methods: {
    closeSnackbar() {
      this.$store.dispatch(types.DEACTIVATE_NOTIFICATION)
    },

    apply(val) {
      this.message = val.message
      this.color = val.color
      this.snackbar = val.activate
      // TODO: スナックバーのタイムアウト発動タイミングをフックする方法が不明のため、苦肉の策で「同じ時間経過後にスナックバーを非活性化」する対応を仕込む
      if (this.snackbar) {
        console.log('setTimeout')
        setTimeout(this.closeSnackbar, this.timeout)
      }
    }
  }
}
</script>

これにより、ユーザー登録完了後は、トップページ(お知らせ画面)に遷移して、↓のようなスナックバーが表示される。

screenshot-localhost-3000-2019.02.11-15-28-57.png

また、エラーが発生した場合は、ユーザー登録画面上で↓のようなスナックバーが表示される。
(※FirebaseAuth上に登録済みのメールアドレスで再度登録を試みた結果)

screenshot-localhost-3000-2019.02.11-15-33-27.png

■バックエンドにユーザ作成APIを実装

バックエンド側の構造は「MVC+S」としている。
各リクエストは controller パッケージ下の機能別ソースで受け付けて、トランザクション境界を持つ service パッケージ下の機能別ソースを呼び出す。
service パッケージ下のソースは適宜、 model パッケージ下のソースを組み合わせて要件を実現する。
前回まででログイン後のトップページに表示する「お知らせ」機能は実装済み。
今回同じような構成で「ユーザ管理」機能を実装する。

実装後のパッケージ構成(MVC+S部分のみ抜粋)

├── controller
│   ├── apierror.go
│   ├── form
│   │   ├── form.go
│   │   └── user.go
│   ├── notice.go
│   ├── response
│   ├── router.go
│   └── user.go
├── main.go
├── model
│   ├── dto.go
│   ├── notice.go
│   └── user.go
├── service
│   ├── notice.go
│   └── user.go

backend/controller/router.go

[backend/main.go【抜粋】]
func main() {
	〜〜省略〜〜
	// https://echo.labstack.com/guide
	e := echo.New()
	〜〜省略〜〜

	// ルーティング設定の起点
	controller.Routing(e)

	appengine.Main()
}
[backend/controller/router.go]
func Routing(e *echo.Echo) {
	〜〜省略〜〜
	http.Handle("/", e)

	// 個人の認証を要するWebAPI用のルート
	authGroup := e.Group("/api/v1")
	〜〜省略〜〜

	// 「ユーザ」機能のルーティング("/users")
	HandleUser(authGroup)
}

backend/controller/user.go

[backend/controller/user.go]
func HandleUser(g *echo.Group) {
	g.POST("/users", createUser)
}

func createUser(c echo.Context) error {
	ctx := middleware.GetCustomContext(c)
	user := &form.User{}
	if errUser := parse(c, user); errUser != nil {
		return c.JSON(http.StatusBadRequest, errorJSON(http.StatusBadRequest, errUser.Error()))
	}

	if err := service.NewUser(ctx, c.Request().Context()).CreateUser(user.ParseToDto()); err != nil {
		return c.JSON(http.StatusBadRequest, errorJSON(http.StatusBadRequest, err.Error()))
	}

	return c.JSON(http.StatusOK, "{}")
}

backend/service/user.go

トランザクション境界を設け、最初にDBへのユーザー情報登録、成功したらFirebaseAuthのSDKを使ってFirebase上にユーザー作成という流れ。

[backend/service/user.go]
〜〜インターフェース定義や構造体定義New関数等は省略〜〜

func (s *userService) CreateUser(u *model.User) error {
	tx := s.db.Begin()
	defer func() {
		if tx != nil {
			db := tx.Commit()
			if err := db.Error; err != nil {
				s.lgr.Errorw("Transaction commit failed.", "error", err)
			}
		}
	}()

	u.ID = util.CreateUniqueID()
	err := model.NewUserDao(s.lgr, tx, s.firebaseApp).CreateUser(u)
	if err != nil {
		tx.Rollback()
		return err
	}

	fbAuth, err := s.firebaseApp.Auth(s.requestCtx)
	if err != nil {
		tx.Rollback()
		return err
	}
	fbUser := &auth.UserToCreate{}
	fbUser.Email(u.Mail)
	fbUser.Password(u.Password)
	_, err = fbAuth.CreateUser(s.requestCtx, fbUser)
	if err != nil {
		tx.Rollback()
		return err
	}

	return nil
}

■動作確認

登録画面にて

screenshot-localhost-3000-2019.02.11-16-08-10.png

ユーザー情報を入力して「登録」ボタン押下

screenshot-localhost-3000-2019.02.11-16-09-07.png

トップページにて登録完了が通知される。

screenshot-localhost-3000-2019.02.11-16-09-20.png

データベース上の登録を確認

mysql> select * from user where name='taro';
+----------------------------------+------+------------------+-------------+---------------------+-------------+---------------------+------------+
| id                               | name | mail             | create_user | created_at          | update_user | updated_at          | deleted_at |
+----------------------------------+------+------------------+-------------+---------------------+-------------+---------------------+------------+
| c0a5867c55064ff4b8fb944e89770e82 | taro | taro@example.com |             | 2019-02-11 16:09:10 |             | 2019-02-11 16:09:10 | NULL       |
+----------------------------------+------+------------------+-------------+---------------------+-------------+---------------------+------------+
1 row in set (0.00 sec)

FirebaseAuth上も登録を確認

screenshot-console.firebase.google.com-2019-02-11-16-16-19-809.png

まとめ

現在ログインしているユーザーとは別の新規ユーザーをFirebase上に登録することができた。
ユーザー管理系に触れたので、次回以降は、ユーザーの削除や、メールアドレス、パスワードの変更などを試していきたい。
が、その前に、
今の実装では、ログイン機能はあっても、ログインしているかどうかノーチェックで画面遷移もAPIコールもできてしまう。
これでは実用には耐えられないので、Firebaseログイン後、JWTがもらえるので、それをもとにログインチェック機能を実装しよう。

4
3
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
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?