Firebase
LineLogin
FirebaseHosting
FirebaseAuth

Firebase HostingでLINEログインする

More than 1 year has passed since last update.

Overview

Firebase Hostingを使って、LINEログインさせるWEBページを作りたかったのですが、下記のサイトではLINE APIの仕様が古かったり(v1)で、順調には行かなかったので、LINEログインv2.1版として書いてみました。

Google Developers Japan: LINE ログインによる Firebase ユーザーの認証

前提条件

利用するサービスやAPIバージョン

  • Firebase Hosting (AngularFire2を利用)
  • Firebase functions (外部のLINE APIへ通信するのでSparkプラン以外)
  • LINEログインv2.1

シーケンス図

スクリーンショット 2017-12-19 18.20.47.png

ソースコード

全てのソースコードはこちらにあります。
https://github.com/kanemotos/firebase-login-sample

手順1.Firebase HostingのサイトをAngularで作成

terminal
# プロジェクト作成
mkdir firebase-login-sample
cd firebase-login-sample/
ng new line-login-app --skipInstall
cd line-login-app
npm-check-updates -u --removeRange
npm install
npm install --save typescript@2.4.2 firebase angularfire2


# ルーティング、コンポーネント、サービス、インタフェース作成
ng generate module app-routing --flat --module=app
ng generate component components/home
ng generate component components/line-auth-handler
ng generate service services/auth-state
ng generate interface interfaces/create-custom-token-response

ルーティングの設定

ポイントは、下記のように2つの画面を用意する点です。

URL Component 説明
example.com/ HomeComponent ログインボタンを置く画面
example.com/line_auth_handler LineAuthHandlerComponent LINEからリダイレクトされて戻って来る時の画面
app-routing.module.ts
const routes: Routes = [
  {path: '', component: HomeComponent, pathMatch: 'full'},
  {path: 'line_auth_handler', component: LineAuthHandlerComponent},
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
})
export class AppRoutingModule {
}

AppComponentの設定

app.module.ts
@NgModule({
  declarations: [
    AppComponent,
    HomeComponent,
    LineAuthHandlerComponent,
  ],
  imports: [
    BrowserModule,
    HttpClientModule, // MUST include this under 'imports' after BrowserModule.
    AngularFireModule.initializeApp(environment.firebase),
    AngularFireAuthModule,
    AppRoutingModule,
  ],
  providers: [],
  bootstrap: [AppComponent],
})

Home Component

Lineログインボタンが押されたあとの動作を書きます。
LINE認証が終わったあとに戻ってくる画面のURLを「redirect_uri」で指定しながら、移動します。

home.component.ts
export class HomeComponent implements OnInit {

  constructor(public afAuth: AngularFireAuth,
              private authStateService: AuthStateService) {
  }

  ngOnInit() {
  }

  loginWithLine() {
    const response_type = encodeURIComponent('code');
    const client_id = environment.line.login.channel_id;
    const redirect_uri = encodeURIComponent(window.location.href + 'line_auth_handler');
    const state = this.authStateService.getState();
    const scope = encodeURIComponent('openid profile');
    const url = `https://access.line.me/oauth2/v2.1/authorize?response_type=${response_type}&client_id=${client_id}&`
      + `redirect_uri=${redirect_uri}&state=${state}&scope=${scope}`;

    window.location.href = url;
  }

  logout() {
    this.afAuth.auth.signOut();
  }
}

LineAuthHandlerComponent

LINEからリダイレクトされて戻ってきた際の処理をします。
「code」というリクエストパラメータが渡されるので、それをFirebase Functionsで作るAPIに渡します。
APIでは、FirebaseのCustomTokenを作ってもらえるようにコーディングします。(詳細は下部参照)
なので、CustomTokenが返却されたら、これを元にsign-inすればFirebaseにログインできます。

line-auth-handler.component.ts
export class LineAuthHandlerComponent implements OnInit {

  constructor(private authStateService: AuthStateService,
              private route: ActivatedRoute,
              private router: Router,
              private http: HttpClient,
              private afAuth: AngularFireAuth) {
    this.route.queryParams.subscribe((params: Params) => {
        const responded_error = params['error'];
        const responded_error_description = params['error_description'];

        if (responded_error) {
          console.error(responded_error_description);
          this.router.navigate(['/']);
          return;
        }

        const code = params['code'];
        const state = params['state'];

        if (this.authStateService.checkState(state) === false) {
          console.error('state is wrong');
          this.router.navigate(['/']);
          return;
        }

        Promise.resolve().then(() => {
          // Request to Cloud Functions
          const url = `${environment.cloud_functions.host_name}/api/auth/line_login/web/custom_token`;
          const redirectUri = (window.location.href).replace(/\?.*$/, '');
          const body = {
            code: code,
            redirect_uri: redirectUri,
          };
          return this.http.post<CreateCustomTokenResponse>(url, body, {
            headers: new HttpHeaders()
              .set('Content-Type', 'application/json')
            ,
          }).toPromise();

        }).then((response: CreateCustomTokenResponse) => {
          return this.afAuth.auth.signInWithCustomToken(response.firebase_token);

        }).then(() => {
          console.log('signed in successfully.');
          this.router.navigate(['/']);

        }).catch((error) => {
          console.error('failed to sign in.');
          console.error(error);

        });
      },
    );
  }

  ngOnInit() {
  }
}

AuthStateService

Open IDの「state」をフロントエンドでランダム文字列(UUID)にして、リダイレクトされたフロントエンドで照合するためのサービスです。
(しかし、Open ID connect的にこれで適切なのか自信がないです。サーバサイドに保存して照合すべきなのか、必要ないのか、誰か詳しい人いたらコメントお願いします。)

auth-state.service.ts
@Injectable()
export class AuthStateService {
  constructor(private cookieService: CookieService) {
  }

  getState() {
    const state = UUID.UUID().toString();
    this.cookieService.set('auth_state', state);
    return state;
  }

  checkState(target: string): boolean {
    const state = this.cookieService.get('auth_state');
    const checkResult = (state === target);

    this.cookieService.delete('auth_state');
    return checkResult;
  }
}

環境変数の設定

こちらの手順を元に、environment.tsを設定します。

environment.ts
export const environment = {
  production: false,
  firebase: {
    apiKey: 'XXXXX',
    authDomain: 'XXXXX',
    databaseURL: 'XXXXX',
    projectId: 'XXXXX',
    storageBucket: 'XXXXX',
    messagingSenderId: 'XXXXX',
  },
  cloud_functions: {
    host_name: 'https://XXXXX.cloudfunctions.net',
  },
  line: {
    login: {
      channel_id: 'XXXXXXXXXX',
    },
  },
};
  • こちらの手順を参考に、CloudFunctionsのURLを設定しておくのを忘れないで下さい。
  • こちらの手順を元に、LINEのチャネルIDを設定しておくのを忘れないで下さい。

手順2.Firebase Functionsを作成

Firebase Functionsでは、LINEから返却される「code」を元に、LINEアクセストークンを取得し、LINEのUserIDを取得し、FirebaseのCustomTokenを発行するAPIを作成します。

# プロジェクト作成
cd firebase-login-sample/
mkdir line-login-functions
cd line-login-functions
firebase init functions
cd functions
npm-check-updates -u --removeRange
npm install
npm install --save body-parser express express-validator cookie-parser cors request request-promise

# functions config 設定
furebase use --add
firebase functions:config:set service_account="$(cat service-account.json)"
firebase functions:config:set line.login.channel_id=XXXXXXXXXX
firebase functions:config:set line.login.client_secret=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
  • こちらの手順を元にFirebaseのservice-accountを取得し、上記のコマンドでfunctions configを設定しておくのを忘れないで下さい。
  • こちらの手順を元に、LINEのチャネルIDとチャネルシークレットを取得し、上記のコマンドでfunctions configを設定しておくのを忘れないで下さい。

index.js

APIのパス設定や、サードパーティー製ライブラリの設定をします。

index.js
'use strict';

// Modules imports
const functions = require('firebase-functions');
const rp = require('request-promise');

// Firebase Setup
const admin = require('firebase-admin');
const serviceAccount = functions.config().service_account;
if (!serviceAccount) {
    throw new Error('please set SERVICE ACCOUNT before you deploy!');
} // you can remove this if statement after you set.
admin.initializeApp({
    credential: admin.credential.cert(serviceAccount),
});

const bodyParser = require('body-parser');
const express = require('express');
const expressValidator = require("express-validator");
const cookieParser = require('cookie-parser')();
const cors = require('cors')({origin: true});

const app = express();

app.use(bodyParser.json());
app.use(expressValidator([])); // this line must be immediately after any of the bodyParser middlewares!

app.use(cors);
app.use(cookieParser);

app.post('/auth/line_login/web/custom_token', require('./api/auth/line_login/createCustomTokenForWeb').handler);

// Expose the API as a function
exports.api = functions.https.onRequest(app);

createCustomTokenForWeb.js

codeを受け取ってLINEのアクセストークンを取得する処理です。
lineLoginUtilにアクセストークンを渡して、CustomTokenを発行してもらって、レスポンスします。

createCustomTokenForWeb.js
'use strict';
const functions = require('firebase-functions');
const rp = require('request-promise');

// Line Setup
if (!functions.config().line ||
    !functions.config().line.login ||
    !functions.config().line.login.channel_id ||
    !functions.config().line.login.client_secret) {
    throw new Error('please set LINE login CHANNEL_ID & CLIENT_SECRET before you deploy!');
}

const line_login_channel_id = functions.config().line.login.channel_id;
const line_login_client_secret = functions.config().line.login.client_secret;

// API code
exports.handler = ((req, res) => {
    req.checkBody('code').notEmpty();
    req.checkBody('redirect_uri').notEmpty();

    const code = req.body.code;
    const redirect_uri = req.body.redirect_uri;

    req.getValidationResult().then(result => {
        console.info('validating requested data.');

        if (!result.isEmpty()) {
            return res.status(400).json({
                error: result.array()
            });
        }
    }).then((error) => {
        if (error) {
            console.error(error);
            throw new Error('validating request Error');
        }
        console.info('validated. (result OK)');
        return Promise.resolve();

    }).then(() => {
        const options = {
            method: 'POST',
            uri: 'https://api.line.me/oauth2/v2.1/token',
            json: true,
            timeout: 10 * 1000,
            form: {
                grant_type: 'authorization_code',
                code: code,
                redirect_uri: redirect_uri,
                client_id: line_login_channel_id,
                client_secret: line_login_client_secret,
            },
        };
        return rp(options);

    }).then((response) => {
        console.log('requested token from code successfully.');
        return Promise.resolve(response);

    }).then((response) => {
        const lineAccessToken = response.access_token;

        const line_login_util = require('./lineLoginUtil');
        return line_login_util.createFirebaseCustomToken(lineAccessToken);

    }).then((firebaseCustomToken) => {
        const result = {
            firebase_token: firebaseCustomToken,
        };
        return res.status(200).json(result);

    }).catch(error => {
        console.error('Error: ', error);
        return res.status(500).json({error: error});
    });
});

lineLoginUtil.js

LINEアクセストークンを元に、LINEプロフィールAPIを呼び出します。
本記事では触れていませんが、iOS/Android用のcreateCustomTokenForClient.jsと処理を共通化するために、ファイルを分けて書いています。(詳細は、Githubのコードをご参照ください。)

lineLoginUtil.js
'use strict';
const admin = require('firebase-admin');
const rp = require('request-promise');

exports.createFirebaseCustomToken = function (lineAccessToken) {
    let firebaseUid;

    return Promise.resolve().then(() => {
        const options = {
            method: 'GET',
            url: 'https://api.line.me/v2/profile',
            json: true,
            timeout: 10 * 1000,
            headers: {
                'Authorization': `Bearer ${lineAccessToken}`,
            },
        };
        return rp(options);

    }).then((response) => {
        console.log('requested profile successfully.');
        return Promise.resolve(response);

    }).then((response) => {
        if (!response.userId) {
            return Promise.reject('No userId.');
        }

        firebaseUid = 'line:' + response.userId;
        return admin.auth().getUser(firebaseUid).then(() => {
            console.log(`user ${firebaseUid} was found.`);
        }).catch((error) => {
            if (error.code === 'auth/user-not-found') {
                const createRequest = {
                    uid: firebaseUid,
                };
                if (response.displayName) createRequest.displayName = response.displayName;
                if (response.pictureUrl) createRequest.pictureUrl = response.pictureUrl;

                return admin.auth().createUser(createRequest).then(() => {
                    console.log('created user successfully.');
                    return Promise.resolve();
                });
            } else {
                return Promise.reject(error);
            }
        });

    }).then(() => {
        const claims = {
            provider: 'LINE',
        };

        return admin.auth().setCustomUserClaims(firebaseUid, claims);

    }).then(() => {
        console.log('set custom user claims successfully.');
        return Promise.resolve();

    }).then(() => {
        return admin.auth().createCustomToken(firebaseUid);

    }).then((firebaseCustomToken) => {
        console.log('created custom token successfully.');
        return Promise.resolve(firebaseCustomToken);

    });
};

CloudFunctionsのデプロイ

terminal
cd firebase-login-sample/line-login-functions/functions/
firebase deploy --only functions:api

手順3.LINEのコールバックURLの設定

こちらの手順を元に、LINEコンソールでコールバックURLを設定して下さい。

(設定例)
http://localhost:4200/line_auth_handler
https://XXXXX.firebaseapp.com/line_auth_handler

Firebaseホスティングの起動

terminal
# ローカル起動
cd firebase-login-sample/line-login-app/
ng serve

参考

Google Developers Japan: LINE ログインによる Firebase ユーザーの認証
ウェブアプリにLINEログインを組み込む