Firebase AuthenticationとFirebaseUIを使ってWebアプリの認証機能をを作る

Firebase Authenticationを使うと、パスワード・電話番号・Google・Facebook・Twitter等を使った認証システムを簡単に作成できます。

FirebaseUIを使うことにより、見慣れたUIを提供できます。

今回作成したプログラムのソースコードはこちら。

Firebaseのプロジェクトを作成する

Firebaseコンソールにログインし、新しいプロジェクトを作成します。

プロジェクトの概要→プロジェクトを設定→マイアプリ→ウェブアプリにFirebaseを追加します。
このとき、Firebase Hostingも設定します。

プロジェクトの用意

最終的には、次のようになります。

public/ 公開フォルダー
  firebaseui.css
  index.html    サインイン画面
  success.html  ユーザー画面
src/    プログラムのソースコード
  index.js      サインイン画面のJavaScript
  success.js    ユーザー画面のJavaScript
package-lock.json
package.json
webpack.dev.js

ソースコードを配置するフォルダーを作成します。(ここではauth-testフォルダーとします)

$ mkdir auth-test

作成したフォルダーに移動します。

$ cd auth-test

ライブラリのインストール

package.jsonを作成します。

$ npm init

webpackをインストールします。

$ npm install webpack webpack-cli --save-dev

firebase npmパッケージをインストールします。

$ npm install --save firebase

firebaseui npmパッケージをインストールします。

$ npm install firebaseui --save

Firebase Hostinの設定

Fireebase Hostingを使用するために、Firebase CLIをインストールします。

npm install -g firebase-tools

ログインします。

$ firebase login

Firebaseプロジェクトの初期設定を行います。
Hostingをチェックして、セットアップします。

$ firebase init

? Please select an option: Use an existing project
? Select a default Firebase project for this directory: auth-test-XXXX (auth-test)
i  Using project auth-test-XXXX (auth-test)

publicフォルダーが公開フォルダーになります。

ローカルで実行するときは、次のコマンドを実行します。

firebase serve

アプリをデプロイするときは、次のコマンドを実行します。

$ firebase deploy

http://localhost:5000にアクセスすると、開発しているサイトの動作を確認できます。

ファイル構成は次のようになります。

node_modules/
public/
firebase.json
package-lock.json
package.json

ビルド設定

ソースコードを配置するフォルダーを作成します。

mkdir src

srcフォルダー中のindex.jsとsuccess.jsをビルドして、publicフォルダー内に配置するようにします。

webpack.dev.jsを作成します。

module.exports = {
    mode: 'development',
    devtool: 'inline-source-map',
    entry: {
        index: './src/index.js',
        success: './src/success.js',
    },
    output: {
        path: __dirname + '/public',
        filename: '[name].js'
    }
};

package.jsonにscripts/buildを追加します。

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack --config webpack.dev"
  },

srcフォルダーの中にindex.jsとsuccess.jsを作成します。

$ touch src/index.js
$ touch src/success.js

ビルドします。

$ npm run build

publicフォルダーにindex.jsとsuccess.jsが作成されます。

今後、jsファイルを編集したら、都度ビルドします。

サインインページの作成

public/index.htmlを編集します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" type="text/css" href="./firebaseui.css">
    <script src="./index.js" defer></script>
    <title>サインイン</title>
</head>
<body>
    <!-- Firebase UIによって書き換えられる -->
    <div id="firebaseui-auth-container"></div>
    <!-- ウィジェットが表示されたら非表示にする -->
    <div id="loader">loading...</div>
</body>
</html>

FirebaseUIをインストールします。

$ npm install firebaseui --save

FirebaseUIのスタイルシートをコピーします。

$ cp node_modules/firebaseui/dist/firebaseui.css public/

サインインページを表示する

ブラウザでhttp://localhost:5000/にアクセスすると、サインイン画面が表示されます。
(現在は、loading…と表示されます。)

Firebaseのメール/パスワード認証を有効にする

Firebaseコンソールのプロジェクトを開き、開発→Authenntication→Sign-in method→メール/パスワードを選択して、有効にします。

メール/パスワード認証画面を表示する

src/index.jsを編集します。

Firebase AuthenticationとFirebaseUIのライブラリをインポートします。

import * as firebase from "firebase/app";
import "firebase/auth";
import * as firebaseui from "firebaseui";

Firebaseコンソール(https://console.firebase.google.com/)のプロジェクト→プロジェクトの概要→プロジェクトを設定からコピーして貼り付けます。

const firebaseConfig = {
    apiKey: "...",
    authDomain: "...",
    databaseURL: "...",
    projectId: "...",
    storageBucket: "...",
    messagingSenderId: "...",
    appId: "...",
    measurementId: "..."
};

Firebaseの初期化を初期化します。

firebase.initializeApp(firebaseConfig);

Firebase UIの設定を行います。
メール/パスワードによる認証を有効にします。

const uiConfig = {
    // サポートするプロバイダ
    signInOptions: [
        // メールプロバイダIDを追加
        {
            // FirebaseコンソールのAuthenticationセクションを開き、メール/パスワードによる認証を有効にする
            provider: firebase.auth.EmailAuthProvider.PROVIDER_ID,
        },
    ],
};

Firebase UIを初期化します。

const ui = new firebaseui.auth.AuthUI(firebase.auth());

Firebase UIを表示します。

ui.start('#firebaseui-auth-container', uiConfig);

プログラムをビルドします。

$ npm run build

ブラウザでhttp://localhost:8080/にアクセスすると、認証画面が表示されます。

ここまでのindex.jsは次のようになります。

import * as firebase from "firebase/app";
import "firebase/auth";
import * as firebaseui from "firebaseui";

// Firebaseコンソール(https://console.firebase.google.com/)のプロジェクト→プロジェクトの概要→プロジェクトを設定からコピー
const firebaseConfig = {
    apiKey: "...",
    authDomain: "...",
    databaseURL: "...",
    projectId: "...",
    storageBucket: "...",
    messagingSenderId: "...",
    appId: "...",
    measurementId: "..."
};
// Firebaseの初期化
firebase.initializeApp(firebaseConfig);

// Firebase UIの設定
const uiConfig = {
    // サポートするプロバイダ
    signInOptions: [
        // メールプロバイダIDを追加
        {
            // FirebaseコンソールのAuthenticationセクションを開き、メール/パスワードによる認証を有効にする
            provider: firebase.auth.EmailAuthProvider.PROVIDER_ID,
        },
    ],
};

// Firebase UIの初期化
const ui = new firebaseui.auth.AuthUI(firebase.auth());

// Firebase UIを表示する
ui.start('#firebaseui-auth-container', uiConfig);

メールプロバイダの設定を変更します。
初期値ではユーザーの表示名の入力を求めます。この機能を無効にします。

// サポートするプロバイダ
signInOptions: [
    // メールプロバイダIDを追加
    {
        // FirebaseコンソールのAuthenticationセクションを開き、メール/パスワードによる認証を有効にする
        provider: firebase.auth.EmailAuthProvider.PROVIDER_ID,
        // [オプション]ユーザーに表示名の入力を求めるかどうか。初期値はtrue。
        requireDisplayName: false,
    },
],

スクリプトをビルドして、ブラウザをリロードすると、ユーザー名の入力欄が表示されなくなります。

Google認証を追加する

Googleのアカウントでもサインインできるようにします。

Firebaseコンソールのプロジェクトを開き、開発→Authenntication→Sign-in method→Googleを選択して、有効にします。

GoogleプロバイダIDを追加します。

const uiConfig = {
    // サポートするプロバイダ
    signInOptions: [
        // メールプロバイダIDを追加
        {
          ...
        },
        // GoogleプロバイダIDを追加
        {
            // FirebaseコンソールのAuthenticationセクションを開き、Googleによる認証を有効にする
            provider: firebase.auth.GoogleAuthProvider.PROVIDER_ID,
        },

スクリプトをビルドして、ブラウザをリロードすると、Google認証が追加されます。

電話認証を追加する

Firebaseコンソールのプロジェクトを開き、開発→Authenntication→Sign-in method→電話番号を選択して、有効にします。

電話番号プロバイダを追加します。

const uiConfig = {
    // サポートするプロバイダ
    signInOptions: [
        // メールプロバイダIDを追加
        {
          ...
        },
        // GoogleプロバイダIDを追加
        {
          ...
        },
        // 電話番号ログインを追加
        {
            // FirebaseコンソールのAuthenticationセクションを開き、電話番号ログインを有効にする
            provider: firebase.auth.PhoneAuthProvider.PROVIDER_ID,
            // [オプション]reCAPTCHAの表示・非表示(デフォルトはnormal)
            // @see https://developers.google.com/recaptcha/docs/display
            recaptchaParameters: {
                type: 'image',
                size: 'invisible', // 'normal','invisible','compact'
                badge: 'bottomleft' // 'bottomleft','bottomright','inline'。sizeがinvisibleの場合に適用される
            }
        }

スクリプトをビルドして、ブラウザをリロードすると、Google認証が追加されます。

ウィジェットが表示されたときの処理

ウィジェットが表示されたとき、「loading…」を非表示にします。

const uiConfig = {
    // サポートするプロバイダ
    signInOptions: [
      ...
    ],
    callbacks: {
        /**
         * ウィジェットが表示されたとき
         */
        uiShown: function () {
            document.getElementById('loader').style.display = 'none';
        }
    },

サインインしたときに表示するページの設定

サインインしたら、success.htmlを表示するようにします。

const uiConfig = {
    // サポートするプロバイダ
    signInOptions: [
      ...
    ],
    callbacks: {
        /**
         * ユーザーが正常にサインインしたとき、自動的にリダイレクトするか開発者がハンドリングするかを決める
         * @param {firebaseui.auth.AuthResult} authResult 
         * @param {string|null} redirectUrl 
         * @returns {boolean} true:自動的にリダイレクトする false:開発者がハンドリングする
         */
        signInSuccessWithAuthResult: function (authResult, redirectUrl) {
            return true;
        },
        ...
    },
    // サインインしたときのリダイレクト先URL
    signInSuccessUrl: '/success.html',

ユーザー画面の作成

public/success.htmlを編集します。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="./success.js" defer></script>
    <title>サインインしました</title>
</head>
<body>
    <h1>サインインしました。</h1>
    <div>
        <button id="signOut">サインアウト</button>
    </div>
    <div>
        <a href="./index.html">サインインページ</a>
    </div>
</body>
</html>

src/success.jsを編集します。

初期化処理はサインインのときと同じです。

import * as firebase from "firebase/app";
import "firebase/auth";

// Firebaseコンソール(https://console.firebase.google.com/)のプロジェクト→プロジェクトの概要→プロジェクトを設定からコピー
const firebaseConfig = {
    apiKey: "...",
    authDomain: "...",
    databaseURL: "...",
    projectId: "...",
    storageBucket: "...",
    messagingSenderId: "...",
    appId: "...",
    measurementId: "..."
};

// Firebaseの初期化
firebase.initializeApp(firebaseConfig);

サインインしているユーザー情報を取得します。

// 現在ログインしているユーザーを取得する
// ユーザーが初期化中などの中間状態ではない
firebase.auth().onAuthStateChanged(function (user) {
    if (user) {
        showUser(user);
    } else {
        console.log('ログインしていません。');
    }
});

/**
 * ユーザー情報を表示する
 * @param {*} user 
 */
function showUser(user) {
    console.log(user.displayName);
    console.log(user.email);
    console.log(user.photoURL);
    console.log(user.emailVerified);
    console.log(user.uid); // FirebaseプロジェクトでユニークなID
}

currentUserでも取得できますが、ユーザーが初期化中などの中間状態の可能性があります。

// 現在ログインしているユーザーを取得する
// ユーザーが初期化中などの中間状態の可能性がある
const user = firebase.auth().currentUser;
if (user) {
    console.log(user);
} else {
    console.log('ログインしていません。');
}

サインアウトの処理を追加します。

/**
 * サインアウト
 */
function signOut() {
    firebase.auth().signOut().then(function () {
        // Sign-out successful.
        console.log('サインアウトしました。');
    }).catch(function (error) {
        // An error happened.
        console.log(error);
    });
}

document.getElementById('signOut').addEventListener('click', function () {
    signOut();
});

MySQLで条件に一致するレコードがなければ新規登録、あれば何もしない

INSERT構文にIGNOREキーワードを使用する

IGNOREキーワードを使用すると
UNIQUEインデックスまたはPRIMARY KEY値を複製にする行よって重複エラーが発生したとき、ステートメントは中止される。
重複する行は破棄され、エラーは発生しない。
(参考)MySQL :: MySQL 5.6 リファレンスマニュアル :: 13.2.5 INSERT 構文

重複チェックの対象はUNIQUEインデックスまたはPRIMARY KEY値であり、重複チェックするフィールドを指定できない。

例えば、次のようなテーブルがあったとき、

CREATE TABLE users (
    id INT AUTO_INCREMENT NOT NULL,
    name VARCHAR(255) UNIQUE,
    email VARCHAR(255),
    PRIMARY KEY(id)
);

1回目は追加される。

INSERT IGNORE INTO users(name, email) VALUES('鈴木', 'suzuki1@example.com');

2回目は”鈴木”がすでに登録されているため、追加されない。

INSERT IGNORE INTO users(name, email) VALUES('鈴木', 'suzuki2@example.com');
// 0 row(s) affected, 1 warning(s): 1062 Duplicate entry '鈴木' for key 'name'

Laravel 5.8ではinsertOrIgnoreが追加され、次のように書ける。

DB::table('users')->insertOrIgnore([
    ['name' => '鈴木', 'email' => 'suzuki@example.com'],
    ['name' => '田中', 'email' => 'tanaka@example.com'],
]);

(参考)Database: Query Builder – Laravel – The PHP Framework For Web Artisans

なお、INSERT IGNOREがユニーク制約以外のエラーも無視するようになるので、注意が必要だ。

次のSQLはエラーになり、登録されない。

INSERT INTO users(id, name, email) VALUES('a', '渡辺', 'watanabe@example.com');
// Error Code: 1366. Incorrect integer value: 'a' for column 'id' at row 1

次のSQLはエラーにならず、登録されてしまう。

INSERT IGNORE INTO users(id, name, email) VALUES('a', '渡辺', 'watanabe@example.com');

INSERT 〜 SELECT 〜 WHERE NOT EXISTS(〜)

例えば、次のようなテーブルがあるとする。
UNIQUE制約がなくても機能するため、UNIQUE制約をつけていない。

CREATE TABLE users (
    id INT AUTO_INCREMENT NOT NULL,
    name VARCHAR(255),
    email VARCHAR(255),
    PRIMARY KEY(id)
);

nameが’鈴木’に一致するレコードがなければ、登録するSQLは次のようになる。

INSERT INTO users(name, email) 
SELECT '鈴木', 'suzuki@example.com'
FROM dual
WHERE NOT EXISTS (
    SELECT 1
    FROM users
    WHERE name = '鈴木'
)

Laravelでは次のように書く。

$select = DB::table(DB::raw('dual'))
    ->select([
        DB::raw('鈴木'),
        DB::raw('suzuki@example.com')
    ])
    ->whereNotExists(function ($query) {
        $query->select(DB::raw(1))
            ->from('users')
            ->where('name', '鈴木');
    });
$bindings = $select->getBindings();
$insertQuery = 'INSERT INTO users (name, email)' . $select->toSql();
DB::insert($insertQuery, $bindings);

PHPで文字列を暗号化して、Android/iOSで復号化する

PHPで暗号化した文字列をAndroid/iOSで復号化する方法。

PHPで文字列を暗号化します。

暗号化した文字列と、暗号化キー・初期ベクトルをBASE64エンコードしてAndroidとiOSに渡します。

<?php
// 暗号化する文字列
$text = 'AES128はブロック長128bit(16byte)、鍵長128bit(16byte)のブロック暗号です。';
$cipher = 'AES-128-CBC';
$len = openssl_cipher_iv_length($cipher);
$key = openssl_random_pseudo_bytes($len); // 暗号化キー
$iv = openssl_random_pseudo_bytes($len); // 初期ベクトル
$options = 0;
$encryptedText = openssl_encrypt($text, $cipher, $key, $options, $iv);
if ($encryptedText == FALSE) {
    echo '失敗しました。';
} else {
    echo 'Base64エンコードした暗号化文字列<br>';
    echo $encryptedText . '<br><br>';
    echo 'Base64エンコードされた暗号化キー<br>';
    echo base64_encode($key) . '<br><br>';
    echo 'Base64エンコードされた初期ベクトル<br>';
    echo base64_encode($iv) . '<br><br>';

    echo openssl_decrypt($encryptedText, $cipher, $key, $options, $iv);
}

iOSで復号化します。

/**
 * 暗号化された文字列を復号化する
 * @param base64Source BASE64エンコードされた暗号化文字列
 * @param base64Key BASE64エンコードされた暗号化キー
 * @param base64Iv BASE64エンコードされた初期ベクトル
 * @return 復号化された文字列
 */
- (NSString*)decrypt:(NSString*)base64Source key:(NSString*)base64Key iv:(NSString*)base64Iv
{
    NSData* sourceData = [[NSData alloc] initWithBase64EncodedString:base64Source options:NSDataBase64DecodingIgnoreUnknownCharacters];
    NSData* keyData = [[NSData alloc] initWithBase64EncodedString:base64Key options:NSDataBase64DecodingIgnoreUnknownCharacters];
    NSData* ivData = [[NSData alloc] initWithBase64EncodedString:base64Iv options:NSDataBase64DecodingIgnoreUnknownCharacters];

    CCCryptorRef ref;
    CCCryptorStatus status = CCCryptorCreate(kCCDecrypt, kCCAlgorithmAES, kCCOptionPKCS7Padding, [keyData bytes], kCCKeySizeAES128, [ivData bytes], &ref);
    if (status != kCCSuccess) {
        [NSException raise:@"CCCryptorCreate Error" format:@"%@",[self getStatusText:status]];
    }

    NSUInteger length = [sourceData length];
    size_t dataOutAvailable = length + kCCBlockSizeAES128;

    void* response = malloc(dataOutAvailable*sizeof(size_t));
    size_t updateResultLength = 0;
    status = CCCryptorUpdate(ref, [sourceData bytes], [sourceData length], response, dataOutAvailable, &updateResultLength);
    if (status != kCCSuccess) {
        [NSException raise:@"CCCryptorUpdate Error" format:@"%@",[self getStatusText:status]];
    }

    size_t finalResultLength = 0;
    status = CCCryptorFinal(ref, response + updateResultLength, dataOutAvailable, &finalResultLength);
    if (status != kCCSuccess) {
        CCCryptorRelease(ref);
        [NSException raise:@"CCCryptorFinal Error" format:@"%@",[self getStatusText:status]];
    }

    NSUInteger len = (updateResultLength + finalResultLength);
    NSData* decryptedData = [NSData dataWithBytes:(const void *)response length:len];
    CCCryptorRelease(ref);
    return [[NSString alloc] initWithData:decryptedData encoding:NSUTF8StringEncoding];
}

Androidで復号化します。

/**
    * Base64エンコードされた暗号化された文字列を復号化する
    * @param base64Source Base64エンコードされた暗号化された文字列
    * @param base64Key Base64エンコードされたキー文字列
    * @param base64Iv Base64エンコードされたIV文字列
    * @return 復号化した文字列
    */
private String decrypt(String base64Source, String base64Key, String base64Iv) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException {
    byte[] sourceBytes = Base64.decode(base64Source, Base64.DEFAULT);
    byte[] keyBytes = Base64.decode(base64Key, Base64.DEFAULT);
    byte[] ivBytes = Base64.decode(base64Iv, Base64.DEFAULT);

    SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, "AES");
    IvParameterSpec ivParameterSpec = new IvParameterSpec(ivBytes);
    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec);
    byte[] decryptedBytes = cipher.doFinal(sourceBytes);
    return new String(decryptedBytes);
}

JavaScriptで入力された文字列を読み上げるサンプル

JavaScriptで入力された文字列を読み上げるサンプルプログラムです。

Vue.jsを使用しています。

スクリーンショット

ソースコード

<template>
  <div id="app">
    <label for="text">
      読み上げるテキスト<br />
      <input type="text" style="width: 100%; height: 2em" v-model="text" />
    </label>
    <label
      >読み上げる音声
      <select v-model="selectedVoiceIndex" style="width: 100%; height: 2em">
        <option v-for="(voice, index) in voices" :value="index" :key="index">
          {{ voice.name }}({{ voice.lang }})
        </option>
      </select>
    </label>
    <button @click="onButtonClick" style="width: 100%; height: 2em">
      読み上げる
    </button>
    <p>{{ errorMessage }}</p>
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      text: "Sample text",
      selectedVoiceIndex: 0,
      voices: [],
      errorMessage: ""
    };
  },
  mounted: function() {
    if (
      typeof speechSynthesis !== "undefined" &&
      speechSynthesis.onvoiceschanged !== undefined
    ) {
      window.speechSynthesis.onvoiceschanged = () => this.onVoiceChanged();
    } else {
      this.onVoiceChanged();
    }
  },
  methods: {
    onVoiceChanged() {
      if (typeof speechSynthesis === "undefined") {
        this.$data.errorMessage = "speechSynthesis is undefined";
        return;
      }

      const voices = speechSynthesis.getVoices();
      this.$data.voices = voices;
      this.$data.selectedVoiceIndex = 0;
    },
    onButtonClick() {
      if (typeof speechSynthesis === "undefined") {
        this.$data.errorMessage = "speechSynthesis is undefined";
        return;
      }
      const utterance = new SpeechSynthesisUtterance(this.$data.text);
      utterance.voice = this.$data.voices[this.$data.selectedVoiceIndex];
      speechSynthesis.speak(utterance);
    }
  }
};
</script>