Google Cloud Functions + Google Cloud FireStore + Firebase AuthenticationとGoで作る認証付きデータ保存、取得API
データベースとそれにアクセスする認証付き API を作成します。使用するサービスは、データベースが Cloud FireStore、API 作成に Google Cloud Functions、認証に Firebase Authentication です。 Cloud FireStore と Google Cloud Functions は Google Cloud Platform(以下 GCP)で Always Free の枠があり、Firebase Authentication も無料枠があります。つまり、小規模に使うなら無料で使うことができます。
Firebase にも FireStore と Cloud Functions For Firebase があるため GCP を使わず Firebase だけでも同じように構築可能です。ただ、Firebase の方の Functions は今のところ JavaScript のみ対応となっており、今回は Go の勉強も兼ねたかったので GCP + Firebase の構成にしています。
記事の前提と注意点
GCP と Firebase が使えるよう会員登録済みが前提になります。GCP は新規登録時にクレジットカードが必要で、今回使用するサービスを使用するには課金を有効にする必要があります(無料枠内でも必要です)。このあたり不安な場合は、料金体系がわかりやい Firebase のみでの構築をお勧めします。
この記事の React のサンプルコードは TypeScript を使用しています。通常の JavaScript で動かす時は型情報を取り除いてください
// 型情報あり
const Auth: React.FC = () => { // 省略
// なし
const Auth = () => { //省略
GCP と Firebase のプロジェクトについて
Firebase プロジェクトは、GCP プロジェクトに Firebase 固有の構成とサービスを加えたものです
Firebase で作成したプロジェクトは GCP 上にも表示されます。GCP でプロジェクトを削除すると Firebase のプロジェクトも消えます。GCP から Firebase Authentication で連携して新規 Firebase のプロジェクトを作成することができます。ただ、その場合は Firebase は無料の Spark ではなく従量課金の Blaze でプロジェクト作成となります。Firebase は無料の Spark のまま管理したいので、プロジェクトの作成は Firebase から行います。
この記事では GCP と Firebase のプロジェクトは連携せず別々に作成します。
GCP と Firebase プロジェクトの作成
GCP でプロジェクトを作成するか既存のプロジェクトがあるならそれを使います。プロジェクトの用意ができたら Cloud Firestore を作成します。Firebase にも Firestore はありますが、GCP 内で用意すると Google Cloud functions でのアクセス時に認証の手間が減るため GCP の方で作成します。
データベースはネイティブモードを選択。ロケーションは asia-northeast1(Tokyo)にしました。データベースが作成できたらローカルの Go から Cloud Firestore にアクセスしてみます。
データベース接続と書き込み
公式の QuickStart の手順で進めます。まず、データベースへの接続権限をローカルのアプリケーションに付与するため、公式ドキュメントの手順でサービスアカウントを作成してダウンロードします。サービスアカウントの権限は公式通りに全権か、あるいは datastore のアクセス権限を付与したものにします。現状、Firestore と datastore の権限は同じようです。
サービスアカウントの json ファイルをダウンロードしたらローカルのアプリから Cloud Firestore にアクセスができます。公式では環境変数から json を読み込んでいますが今回はファイルのまま読み込みます。
package main
import (
"context"
"log"
"google.golang.org/api/option"
"cloud.google.com/go/firestore"
)
var projectID = "gcp上で確認" // GCPコンソール上部のproject名-324342 のような部分
func main() {
ctx := context.Background()
sa := option.WithCredentialsFile("ダウンロードしたjsonファイルのパス")
client, err := firestore.NewClient(ctx, projectID, sa)
if err != nil {
log.Fatalf("Failtd to create client: %v", err)
}
defer client.Close()
_, _, err = client.Collection("users").Add(ctx, map[string]interface{}{
"first": "Ada",
"last": "Lovelace",
"born": 1815,
})
if err != nil {
log.Fatalf("Failed adding alovelace: %v", err)
}
}
上記を実行して GCP の FireStore を確認、書き込みが行われていれば成功です。
次に書き込んだデータを取得するサンプルも動かします。
package main
import (
"context"
"log"
"google.golang.org/api/option"
"cloud.google.com/go/firestore"
)
var projectID = "gcp上で確認"
func main() {
ctx := context.Background()
sa := option.WithCredentialsFile("ダウンロードしたjsonファイルのパス")
client, err := firestore.NewClient(ctx, projectID, sa)
if err != nil {
log.Fatalf("Failtd to create client: %v", err)
}
defer client.Close()
iter := client.Collection("users").Documents(ctx)
for {
doc, err := iter.Next()
if err == iterator.Done {
break
}
if err != nil {
log.Fatalf("Failed to iterate: %v", err)
}
fmt.Println(doc.Data())
}
}
書き込んだ内容を取得できていれば成功です。
Google Cloud Functions からデータの取得を行う
ローカルから FireStore へのアクセスが確認できたら、次は Google Cloud Functions から同じことをします。まず、GCP コンソールで Cloud Functions を作成します。ランタイムには Go を選択。
Firestore と同じプロジェクトで作成すれば ローカルとは違いサービスアカウントの json は必要ありません。もし、サービスアカウントが必要な場合(例えば Firebase の FireStore と GCP の Cloud Functions で行っている時)はサービスアカウントの json をどこかに設置して読み込むか、Firebase のサービスアカウントを GCP のサービスアカウントに登録して読み込みます。
上記のコードから少し変更しているため注意してください。コード中の projectID は環境変数から取得しています。
コードは次です。
// Package p contains an HTTP Cloud Function.
package p
import (
"context"
"encoding/json"
"fmt"
"net/http"
"google.golang.org/api/iterator"
"cloud.google.com/go/firestore"
)
func GetUsers(w http.ResponseWriter, r *http.Request) {
projectID := os.Getenv("PROJECT_ID")
ctx := context.Background()
client, err := firestore.NewClient(ctx, projectID)
if err != nil {
fmt.Fprint(w,err)
return
}
defer client.Close()
iter := client.Collection("users").Documents(ctx)
var result []interface{}
for {
doc, err := iter.Next()
if err == iterator.Done {
break
}
if err != nil {
fmt.Fprint(w,err)
return
}
result = append(result, doc.Data())
}
w.Header().Set("Content-Type", "application/json")
res, err := json.Marshal(result)
if err != nil {
fmt.Fprint(w, err)
return
}
w.Write(res)
}
deploy が成功したらテスト タブから関数を呼び出し、データが出力されていれば成功です。
Firebase プロジェクトと Authentication の準備
上記で作成した function は認証がかかっていないので誰でもアクセスが可能です。一般公開する API なら問題ありませんが、プライベートに使用したい場合は認証を追加します。
認証システムには Firebase の Authentications という仕組みを使います。ユーザー登録や管理だけでなく、メール、電話、各種 SNS 系でのログインや、登録時のメール送信などなど、非常に高性能なログイン機能を手軽に使うことができます。
注意
Firebase Authentication のような機能が GCP にもあるかもしれません。今回は見つけられなかったので Firebase を使用しています。
ユーザーの追加
Firebase プロジェクトを作成したら、左メニューの Authentication からユーザーを追加します。ログイン方法タブからメールとパスワードを有効にします。
ユーザーが追加できたら適当なウェブページから認証を行ってみます。
ユーザーの認証
ユーザーの認証には firebase のライブラリを使用します。ウェブサイトの場合は Firebase の設定を JavaScript に書き、firebase のライブラリを通してあれこれします。
Firebase のプロジェクトトップ画面から+アプリを追加ボタンを押し、ウェブを選択します。適当なニックネームをつけて手順を進めると、firebaseConfig が取得できます。
const firebaseConfig = {
apiKey: "",
authDomain: "",
databaseURL: "",
projectId: "",
storageBucket: "",
messagingSenderId: "",
appId: ""
};
次に、CDN か npm から firebase のライブラリを読み込んで認証を行います。React だとこんな感じで idtoken が確認できます。ここで取得した idtoken は認証をかけたあとに通過できるか確認するときに使います。
import React, { useState } from "react";
import * as firebase from "firebase/app";
import "firebase/auth";
// Firebaseから取得した設定をそのまま貼り付ける
// ↓は""ですが実際には値が入っています
const firebaseConfig = {
apiKey: "",
authDomain: "",
databaseURL: "",
projectId: "",
storageBucket: "",
messagingSenderId: "",
appId: ""
};
firebase.initializeApp(firebaseConfig);
const Auth: React.FC = () => {
// firebaseに登録したユーザーの情報
const email = "[email protected]";
const password = "pass";
firebase
.auth()
.signInWithEmailAndPassword(email, password)
.then(response => {
if (response.user) {
response.user.getIdToken().then(idtoken => {
console.log(idtoken);
});
}
})
.catch(error => {
console.log(error);
});
return <div />;
};
export default Auth;
上記コンポネントを動かして console にトークンが出力されたら認証できています。
Cloud Functions の関数に認証をかける
Firebase での idtoken 発行を確認できたたら、次は Google Cloud Functions で Firebase の token 認証を実装します。認証のテスト用に新規の function を作成します。環境変数に上記の JavaScript で入手した idToken を設定しています。
Firebase Authentication は GCP のサービスではないので Google Cloud Functions からアクセスするには Firebase のサービスアカウント情報が必要です。下記コードはサービスアカウント情報を追記しないと動きません。その部分は次のセクションで説明します。
// Package p contains an HTTP Cloud Function.
package p
import (
"context"
"os"
"fmt"
"net/http"
firebase "firebase.google.com/go"
)
func auth(w http.ResponseWriter, r *http.Request) {
idToken := os.Getenv("TOKEN")
ctx := context.Background()
conf := &firebase.Config{
ServiceAccountID: "",
ProjectID: "",
}
app, err := firebase.NewApp(context.Background(), conf)
if err != nil {
fmt.Fprint(w,err)
return
}
authClient, err := app.Auth(context.Background())
if err != nil {
fmt.Fprint(w,err)
return
}
token, err := authClient.VerifyIDToken(ctx, idToken)
if err != nil {
fmt.Fprint(w,err)
return
}
fmt.Fprint(w, token)
}
func AuthCheck(w http.ResponseWriter, r *http.Request) {
auth(w,r)
}
Firebase Authentication への認証
※GCP と Firebase を連携している場合はこの手順は不要かもしれません。
Firebase に Google Cloud Functions からアクセスするには Firebase のサービスアカウントが必要です。サービスアカウントは json ファイルで入手でき、Firebase の設定 => サービスアカウント => Firebase Admin SDK => 新しい秘密鍵の生成 という手順でダウンロードできます。
Firebase や GCP のサービスにアプリケーションからアクセスする時は、サービスアカウントの json ファイルをアプリケーションに組み込むか、どこかに保存して読み取る必要があります。ただ、GCP のサービスの場合は他にも方法があります。今回わたしが試して成功したのは以下3つの方法です。
- Google Cloud Functions から firebase のサービスアカウント名を指定してアクセス
- Cloud Storage に json をアップロードしてアクセス
- 環境変数に json の中身を貼り付けてアクセス (公式非推奨)
3 の方法は簡単ですが公式非推奨なので紹介しません。1 の方法をお勧めしますが、プロジェクトの構成上難しい場合は 2 の方法を試すと良いと思います。
1.サービスアカウント名を指定してアクセス
Firebase からダウンロードしたサービスアカウントの json ファイルを開き、下記 2 つの情報を取得します。
- client_email // firebase-adminsdk~~@~~のような形
- project_id // firebase のプロジェクト id
上で紹介した認証 API コード部分の conf に上記2つの情報を追記します
conf := &firebase.Config{
ServiceAccountID: client_email,
ProjectID: project_id,
}
これで Firebase Authentications の情報にアクセスして、上記の API に認証をかけるが実行できるようになります。サービスアカウントの json ファイルをアップロードする必要がないためおすすめの方法です。
2.Cloud Storage に json をアップロードして読み取る
※Cloud Storage の理解が甘いため、Cloud Storage にサービスアカウントをアップロードすることがセキュリティ的に大丈夫か自信がありません
Firebase からダウンロードしたサービスアカウントの json ファイルを cloud storage に非公開でアップロードして、Google Cloud Functions から読み取ります。
公開アクセスが非公開になっているかを確認します。作成したバケットの名前と、json のファイル名を Google Cloud Functions から使用します。上記の認証用コードの下記部分を書き換えます。
conf := &firebase.Config{
ServiceAccountID: "",
ProjectID: "",
}
app, err := firebase.NewApp(context.Background(), conf)
if err != nil {
fmt.Fprint(w,err)
return
}
この部分を削除して以下のように Cloud Storage からのファイル読み取りに変更します。
bucketName := "" // 作成したcloud storageのbucket名
fileName := "" // アップロードしたファイル名
client, err := storage.NewClient(ctx)
if err != nil {
fmt.Fprint(w,err)
return
}
bucket := client.Bucket(bucketName)
rc, err := bucket.Object(fileName).NewReader(ctx)
if err != nil {
fmt.Fprint(w,err)
return
}
defer rc.Close()
data, err := ioutil.ReadAll(rc)
if err != nil {
fmt.Fprint(w,err)
return
}
sa := option.WithCredentialsJSON([]byte(data))
app, err := firebase.NewApp(context.Background(), nil, sa)
if err != nil {
fmt.Fprint(w,err)
return
}
読み取りが成功すれば Firebase の認証情報に接続できるはずです。
トークンを Authorization Header から受け取る
現状の認証テストコードは下記です。1 の方法を使いサービスアカウント名から Firebase Admin SDK を初期化しています。
// Package p contains an HTTP Cloud Function.
package p
import (
"context"
"fmt"
"net/http"
"strings"
firebase "firebase.google.com/go"
)
func auth(w http.ResponseWriter, r *http.Request) {
idToken := os.Getenv("TOKEN")
ctx := context.Background()
// 実際にはサービスアカウント情報を入れています
conf := &firebase.Config{
ServiceAccountID: "",
ProjectID: "",
}
app, err := firebase.NewApp(context.Background(), conf)
if err != nil {
fmt.Fprint(w,err)
return
}
authClient, err := app.Auth(context.Background())
if err != nil {
fmt.Fprint(w,err)
return
}
token, err := authClient.VerifyIDToken(ctx, idToken)
if err != nil {
fmt.Fprint(w,err)
return
}
fmt.Fprint(w, token)
}
func AuthCheck(w http.ResponseWriter, r *http.Request) {
auth(w,r)
}
idToken は暫定で環境変数から取得していますが、実際にはウェブあるいはアプリからのアクセス時に Authorization Header から取得します。idToken 取得部分を Authorization Header から取得するように変更します。
// idToken := os.Getenv("TOKEN")
idToken := r.Header.Get("Authorization")
splitToken := strings.Split(reqToken, "Bearer")
if len(splitToken) != 2 {
fmt.Print(w, "token err")
return
}
idToken = strings.TrimSpace(splitToken[1])
JWT token(Firebase Authentication から取得する idToken)はアクセス側からは下記の形で投げるのがおそらく一般的かと思います。
Bearer $token
Bearer の部分と空白を除いて token を受け取ります。違う方法で設定している場合は取得できるように変更してください
ウェブ側から Authorization Header を設定して token を投げてみる
上記コードのテストは Postman など API テスト用のアプリを使って確認できます。この記事では token 取得テストをした React のコンポネントがあるので、そこから token を投げて認証を確認します。axios を追加し、get の request 時に Authorization Header を設定します。
localhost からアクセスする場合は、Cloud Functions の方に CORS の設定が必要です。
func auth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept,Authorization")
// 省略
アクセス元、Authorization Header の許可を追加します。次にウェブ側のコードに get 処理を追加します。
import React, { useState, useEffect } from "react";
import * as firebase from "firebase/app";
import "firebase/auth";
import axios from "axios";
const firebaseConfig = {
// 省略
};
firebase.initializeApp(firebaseConfig);
// Google Cloud Functionsで作成した関数のURL
const apiUrl = ;
const Auth: React.FC = () => {
const [token, setToken] = useState("");
const submit = () => {
if (token) {
const config = {
headers: {
Authorization: `Bearer ${token}`
}
};
axios
.get(apiUrl, config)
.then(response => {
console.log(response);
})
.catch(error => {
console.log(error);
});
}
};
useEffect(() => {
// Firebaseに登録したユーザーの情報を書く
const email = ;
const password = ;
firebase
.auth()
.signInWithEmailAndPassword(email, password)
.then(response => {
if (response.user) {
response.user.getIdToken().then(idtoken => {
setToken(idtoken);
console.log(idtoken);
});
}
})
.catch(error => {
console.log(error);
});
}, []);
return <button onClick={submit}>Auth Check</button>;
};
export default Auth;
console に token が表示されたあと、Auth Check ボタンを押して data が返答されたら成功です。
user を追加する関数を作成する
ここまでで以下の機能を実装しました。
- Firestore データベースへの接続
- Firestore への書き込み
- Firebase のメール認証
- Firebase の token を Cloud Functions 側で認証する
- ウェブ側から token を投げて認証を確認
これらを組み合わせて Firestore に user を追加する Function を新規作成します。処理の順序はここまでの記事の通りで
- ウェブサイトから Firebase ライブラリを通じて token を取得
- Authorization Header に token をセットして Cloud Functions にデータを POST
- Cloud Functions で token の認証、認証が通ればデータを受け取り Firestore に書き込み
まずはウェブ側を準備。
ウェブ側
さきほどの React コンポネントを form に作り変えます。通常、認証用トークンは local か session の storage に入れます。また、フォームの入力値には validation が必要です。下記のコードでは色々と省略していて、暫定で API の動作を確認したいコードとなります。
import React, { useState, useEffect } from "react";
import * as firebase from "firebase/app";
import "firebase/auth";
import axios from "axios";
const firebaseConfig = {
// 省略
};
firebase.initializeApp(firebaseConfig);
const apiUrl = ; // これから作成するuser追加用のCloud FunctionsのURL
const Auth: React.FC = () => {
const [first, setFirst] = useState("");
const [last, setLast] = useState("");
const [born, setBorn] = useState();
const [token, setToken] = useState("");
useEffect(() => {
const email = ;
const password = ;
firebase
.auth()
.signInWithEmailAndPassword(email, password)
.then(response => {
if (response.user) {
response.user.getIdToken().then(idtoken => {
setToken(idtoken);
console.log(idtoken);
});
}
})
.catch(error => {
console.log(error);
});
}, []);
const handleFirstChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setFirst(event.target.value);
};
const handleLastChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setLast(event.target.value);
};
const handleBornChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setBorn(event.target.value);
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
if (token) {
const config = {
headers: {
Authorization: `Bearer ${token}`
}
};
let params = new URLSearchParams();
params.append("first", first);
params.append("last", last);
params.append("born", born);
axios
.post(
apiUrl,
params,
config
)
.then(response => {
console.log(response);
})
.catch(error => {
console.log(error);
});
}
event.preventDefault();
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="firstInput">First Name:</label>
<input
id="firstInput"
type="text"
name="first"
onChange={handleFirstChange}
/>
<label htmlFor="lastInput">Last Name:</label>
<input
id="lastInput"
type="text"
name="last"
onChange={handleLastChange}
/>
<label htmlFor="bornInput">Born:</label>
<input
id="bornInput"
type="number"
name="born"
onChange={handleBornChange}
/>
<button type="submit">Add User</button>
</form>
);
};
export default Auth;
Google Cloud Functions 側
これまでに作成した Auth とデータベース接続と書き込み処理を使います。また、POST された値は r.FormValue で受け取ります。
r.FormValue("first")
長くなりますが下記がコード全文です
// Package p contains an HTTP Cloud Function.
package p
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"cloud.google.com/go/firestore"
firebase "firebase.google.com/go"
)
type User struct {
First string `firestore: "first"`
Last string `firestore: "last"`
Born int `firestore:"born"`
}
func auth(w http.ResponseWriter, r *http.Request) bool{
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept,Authorization")
idToken := r.Header.Get("Authorization")
splitToken := strings.Split(idToken, "Bearer")
if len(splitToken) != 2 {
return false
}
idToken = strings.TrimSpace(splitToken[1])
ctx := context.Background()
// 設定が必要
conf := &firebase.Config{
ServiceAccountID: ,
ProjectID: ,
}
app, err := firebase.NewApp(context.Background(), conf)
if err != nil {
return false
}
authClient, err := app.Auth(context.Background())
if err != nil {
return false
}
_, err = authClient.VerifyIDToken(ctx, idToken)
if err != nil {
return false
}
return true
}
func AddUser(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
authCheck := auth(w, r)
if authCheck != true {
res := map[string]interface{} {
"status": "ng",
"message": "認証エラー",
}
json.NewEncoder(w).Encode(res)
return
}
first := r.FormValue("first")
last := r.FormValue("last")
born, err := strconv.Atoi(r.FormValue("born"))
if err != nil || first == "" || last == "" {
res := map[string]interface{} {
"status": "ng",
"message": "入力値エラー",
}
json.NewEncoder(w).Encode(res)
return
}
user := User{
First : first,
Last : last,
Born : born,
}
projectID := os.Getenv("PROJECT_ID")
ctx := context.Background()
client, err := firestore.NewClient(ctx, projectID)
if err != nil {
res := map[string]interface{} {
"status": "ng",
"message": "書き込みに失敗",
}
json.NewEncoder(w).Encode(res)
return
}
defer client.Close()
_, _, err = client.Collection("users").Add(ctx, user)
if err != nil {
res := map[string]interface{} {
"status": "ng",
"message": "書き込みに失敗",
}
json.NewEncoder(w).Encode(res)
return
}
res := map[string]interface{} {
"status": "ok",
"message": "書き込み成功"
}
json.NewEncoder(w).Encode(res)
return
}
ウェブ側 + Google Cloud Functions 側
ウェブ側のコンポネントを動かして値を入力して Google Cloud Functions で作成した URL に POST します。認証など全て成功すれば Firestore に書き込みが行われます。
これで実装は終了です。
全体を通しての感想
まだまだ不十分なコードだとは思いますが、Firebase Authentication と Google Cloud Functions を初めて使う状態からスタートして、2 日程度で認証付き API を動かすところまでできました。GCP と Firebase の公式ドキュメントの親切さと、サービスの質の高さには驚きます。
Google Cloud Functions は今回のように直接叩く API だけでなく、他のイベントに連動して動かす機能があります。Slack や Firebase のあれこれ、github と連携して動かすのはとても面白そうで、想像以上に柔軟に使えそうです。
今回は Google Cloud Functions のインラインエディタでコード書いていたので、その部分はとても非効率でした。ローカル開発できるはずなので開発環境を整備するのが次の課題です。
より込み入ったバックエンド開発なら GAE など使った方が開発しやすいと思います。この方法だと API 増えてくると管理するのが大変でしょうし、認証機能が今回の方法だと確実にコピペ実装になります。その点で認証が必要な API に関しては直接叩くより、何かと連携して動かし、外からたたけないようにするのが正解なのかもしれません。
使い始めなので?が多い状態ではあるものの、Google Cloud Functions を触ってみて感じた魅力は、他のシステムを汚さず、実験しやすく、削除しやすく実装できることです。色々使ってみてより良い方法がわかればこの記事も更新します。