53
43

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.

Ionicとバックエンドを繋げるための「CORS」と「カスタムHTTPヘッダー」再入門

53
Last updated at Posted at 2018-08-22

昨日、IonicとPHPのつなぎ方について話題にでまして、「そういえば、それ私も悩んだ!!!!」と思ったので、その周りについて簡単にまとめておきます。ここではIonicとPHPのコードで紹介していますが、フロントエンドとバックエンドをつなぐ全般で参考になると思います。

1. Ionicとは

モバイルアプリを開発するためのフレームワーク。Webアプリはもちろんのこと、そのソースコードを変換することで、iPhoneアプリ、Androidアプリもリリースすることができる。1つのソースコードで、複数プラットフォームのアプリをつくることができる「ハイブリッドアプリケーションプラットフォーム」のひとつ。
海外では多く採用されている。国内では、ニコニコ動画を運営するドワンゴや、Techfeedをはじめとした多くのスタートアップ企業で採用されている。

書籍「Ionicで作る モバイルアプリ制作入門」もあるよ!

2. CORSが当たり前になりつつあるフロントエンド

10年ほど前は、フロントエンドとバックエンドを同一ドメインでつくることが主流でした。
フロントエンド・バックエンドという言葉はあったものの、例えばMVCフレームワークを利用している場合は、フロントエンドをViewに置き、Controller/Modelにバックエンドロジックを書くという形での「フロントエンドとバックエンドの分離」であり、ドメイン自体は同一のものです。

WordPressを使ってる場合、皆さんThemeにフロントエンドファイルを置いて利用していますが、トップディレクトリにある index.php でルーティングしているだけでバックエンドを担うロジックファイルは、同一ドメインの wp-inculedes/ の中に入っています。

しかし、近年はフロントエンドとバックエンドを別ドメインでつくって、APIで通信することが多くなりました。 マイクロサービスの考え方であったり、App Shell Modelに沿ったUXを高めるプラクティスによるものです。(PHPカンファレンス関西2018「脱・なんちゃってフロントエンド」で詳しく述べています)
また、iPhoneアプリ、Androidアプリをつくる時は別ドメイン間の通信とならざるえない事情もあります。

で、この、バックエンドとフロントエンドにそれぞれ別ドメインを割り当てて、通信しあうことを「オリジン間リソース共有」(CORS)といいます。

CORSの制約

ブラウザは、Webサイトを開いた時、そこから二次的に読み込まれるリソースを同じオリジンからのみ取得します。セキュリティ(クロスサイトスクリプティング対策)のための制約でして、詳しく知りたい方はCORS (Cross-Origin Resource Sharing) ってなに?あたりをご覧いただければと思います。

で、実務的な話をしますと、この制約により、フロントエンドからバックエンドのAPIを叩きにいく時、バックエンド側は、以下のような制約を受けることになります。

  • フロントエンドのドメインを許可しないとだめ
  • preflightリクエストを許可しないとだめ
  • カスタムHTTPヘッダーは明示的に許可しないとだめ(ホワイトリスト)
  • Cookie/Sessionが使えない ※1

※1 Cookie/Sessionは withCredentials を使えば取得できるよ!!というのは重々承知ですが、ステート管理はフロントエンド側でやるべきで、フロントエンドとバックエンドで二重管理するのはアンチパターンだと思ってるので、ここでは「使えない」で紹介しています

ですので、この制約を理解した上でPHPでCORSを前提にフロントエンドと通信するためのコードを書くなら、このようになります。

function allow_cors(){
  header('Access-Control-Allow-Origin: *');
  header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
  header("Access-Control-Allow-Headers: Content-Type");
  if (isset($_SERVER['REQUEST_METHOD']) and $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
      exit;
  }
  if (isset($_SERVER['REQUEST_METHOD']) and $_SERVER['REQUEST_METHOD'] === 'POST') {
      $postdata = file_get_contents("php://input");
      $request = json_decode($postdata, true);
      if(count($request) > 0) $_POST = $request;
  }
}

2行目で、フロントエンドのドメインを許可しています。ここではワイルドカード(どのドメインからもアクセスOK)ですが、実際はちゃんとしたドメインを指定してください。
3行目で、許可するMethodを指定しています。OPTIONはpreflightリクエストで利用するため許可する必要があります。
4行目で、カスタムHTTPヘッダーを許可して、4行目でpreflightリクエストの通信を終了しています(エラー返さないならpreflightリクエストは正常に通るのでexitしてますが、これって200番レスポンス返すのが正しい方法なのかな。とりあえず後続処理が走ってエラー返さないようにしてます)。

8行目は、$_POSTを上書きしています。Ionic(Angular)からPOSTデータを送信された場合、PHPサイドでは、$_POSTではなく、php://input に格納されます。 http://php.net/manual/ja/wrappers.php.php の仕様通りなので何なのですが、ちょっと冗長ですよね・・・。

後ほど使いますので、allow_cors()という関数名をつけておくものとします。

3. Cookie/Sessionを「使わない」

まず、Cookie/Sessionを使うシーンを考えます。まぁ、もともとがユーザごとの情報を保存することなので、一番多い用途は「Auth」まわりでしょう。

フロントエンドとバックエンドを分離した世界観では、これをLocalStorage/カスタムHTTPヘッダーで乗り越えます。

ログイン実装の考え方

まず、Authのやり方を考えましょう。一番シンプルな考え方を紹介します。あくまで考え方なので、コードは読み物です。

  1. ユーザはログインする
  2. メールアドレス/パスワードをLocalStorageに格納する
  3. 通信の度に、カスタムHTTPヘッダーにメールアドレス/パスワードを乗せてAPIと通信
signin.html
...
<form #f="ngForm" class="margin-top-30" (submit)="signIn()">
  <ion-list>
    <ion-item class="item-borderd form-input">
      <ion-label stacked>メールアドレス</ion-label>
      <ion-input type="email" required [(ngModel)]="login.email" ngControl="login.email" name="login.email" placeholder="example@tipsys.jp"></ion-input>
    </ion-item>
    <ion-item class="item-borderd form-input">
      <ion-label stacked>パスワード</ion-label>
      <ion-input type="text" required [(ngModel)]="login.password" ngControl="login.password" name="password"></ion-input>
    </ion-item>
    <button ion-button round small [disabled]="!f.form.valid">ログイン</button>
  </ion-list>
</form>

よくあるログインフォームです。ログインボタンをクリックしたら、フォームに入力したメールアドレスとパスワードをそれぞれlogin.emaillogin.passwordで取得することができます。
これを、tsファイル側で処理します。

signin.ts
@Component({
  templateUrl: 'signin.html',
})
export class Signin {
  public login: {
    email: string;
    password: string;
  };

  constructor(
    public http: HttpClient
  ) {}

  signIn() {
    this.http.post('https://example.com/login', this.login).subscribe(
      data => {
        localStorage.setItem('login', JSON.stringify($this.login))
      },
      error => {
        // サインインできませんでしたという処理
      }
    )
  }
}

なお、 https://example.com/login 側はこんな感じです。 checkUser と適当にメソッドをつくっていますが、この中でDBから当該ユーザがいるかどうか参照して、true/false返してる感じです。

login.php
<?php
allow_cors(); // 上で紹介したcors関係のコードを実行

if(!$this->checkUser($_POST['email'], $_POST['password'])){
  header('HTTP/1.1 401 Unauthorized');
  return;
}
header('HTTP/1.1 200 OK');
return;

このコードにより、メールアドレス/パスワードの組み合わせがフロントエンドから送信されると、バックエンドはその組み合わせが正しかったか間違ってたかだけを応答で返します。
バックエンド側では、ユーザがログイン済みか、そうでないかのステートは保持しませんが、フロントエンド側では「正しいメールアドレスとパスワードの組み合わせ」を保持することができます。

つまり、この「正しい組み合わせを保持していること」をログイン済みユーザの条件として扱えばいいのです。

コンテンツの表示方法

ほんっと雑なチェックなので、後ほど「ちゃんと実装する」をちゃんとまとめますが、あくまで考え方です。考え方。

ログイン済みユーザしか表示してはいけないコンテンツの確認

user.ts
@Component({
  templateUrl: 'user.html',
})
export class User {
  ionViewWillEnter() {
    const login = localStorage.getItem('login');
    if(!login) {
      // 非ログインユーザ
    }
    // ログインユーザの処理
  }

  logout() {
    localStorage.removeItem('login');
  }
}

ローカルストレージに「login」の保存が行われてるかを確認しています。違ったらログインしてません。なお、ログアウトする時は、removeItem()するだけ!省エネ!

APIを叩く場合

とはいえ、当該ユーザだけに限定したコンテンツをGETしたりPOSTしたりすることも当然あると思います。APIによっては「ログイン情報をPOSTデータにのせてAPIを叩く」とかいう実装などもありますが、さすがにGETメソッドにログイン情報を載せてしまうのはあまりきれいではありませんし、GETとPOSTでログイン情報の乗せ方が異なるのも実装上手間なので、カスタムHTTPヘッダーを使います。

カスタムHTTPヘッダーとは

HTTP通信は、GETやPOSTで送信するデータ以外に、HTTPヘッダーという領域でいろいろなデータを乗せています。例えば、その通信自体がGETリクエストなのか、POSTリクエストなのかであったり、user-Agentでブラウザの種類やOSの情報を乗せていたり、Refererでどこのページからのリクエストかであったりです。
PHPの場合、以下ですべてのリクエスト情報が見れるので、一度試してみてください。

<?php
print_r($_SERVER)

で、ここにカスタムHTTPヘッダーとして、自分で値を追加することができます。先程のログイン情報をバックエンドに送るために、この領域を利用してみましょう。例えば、ユーザ情報を表示するメソッドをつくるとします。

user.ts
@Component({
  templateUrl: 'user.html',
})
export class User {
  ionViewWillEnter() {
    const login = localStorage.getItem('login');
    if(!login) {
      // 非ログインユーザ
    }
    // ログインユーザの処理
  }

  logout() {
    localStorage.removeItem('login');
  }

+  getUser() {
+   const login = JSON.parse(localStorage.getItem('login'));
+   const headers = new HttpHeaders({
+     'X-Email': login.email,
+     'X-Password': login.password,
+   });
+   this.http.get('https://example.com/user', { headers }).subscribe(
+     data => {
+       // ユーザ情報を表示
+     },
+     error => {
+       // ユーザ情報を取得できなかったという表示
+     }
+   )
+ }
}

これで、カスタムHTTPヘッダーに、X-EmailX-Passoword を載せたリクエストを行うことができました。続いて、バックエンド側を編集します。

function allow_cors(){
  header('Access-Control-Allow-Origin: *');
  header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
- header("Access-Control-Allow-Headers: Content-Type");
+ header("Access-Control-Allow-Headers: Content-Type, X-Email, X-Passoword");
  if (isset($_SERVER['REQUEST_METHOD']) and $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
      exit;
  }
  if (isset($_SERVER['REQUEST_METHOD']) and $_SERVER['REQUEST_METHOD'] === 'POST') {
      $postdata = file_get_contents("php://input");
      $request = json_decode($postdata, true);
      if(count($request) > 0) $_POST = $request;
  }
}

まず、CorsでカスタムHTTPヘッダーを明示的に許可しないといけないので、 Access-Control-Allow-Headersに、X-EmailX-Passowordを追加します。これで、PHP側でカスタムHTTPヘッダーを取得できるようになりました。例えば、ユーザ別コンテンツを返す時、このように使います。

login.php
<?php
allow_cors(); // 上で紹介したcors関係のコードを実行

if(!$user = $this->getUser($_SERVER['X-Email'], $_POST['X-Passoword'])){
  header('HTTP/1.1 401 Unauthorized');
  return;
}

header('HTTP/1.1 200 OK');
echo json_encode($user);  // 配列で返ってくるユーザ情報をjsonにして表示
return;

ちゃんと実装する

ちゃんとした実装を紹介するとコード量が半端なくなるので、以上、実装の考え方としてご紹介しました。以下はtipsとして、ちゃんとセキュリティ意識を高くして、現実的な実装をする考え方のみ紹介します。

キーペアをつくる

LocalStorageは平文保存されるので、__メールアドレスとパスワードを保存してはいけない場所です。__ですので、このメールアドレスとパスワードに変わるキーペアをバックエンド側で発行します。

例えばこんな感じのテーブル構造になります。

UserId email password
int varchar text
AutoIncrement 暗号化
UserId authKey limit
int varchar datetime

ログイン処理のリクエストがあって、正常にログインできれば、乱数でつくったauthKeyを作成・返却します。それ以降、利用期限がくるまではそのキーがカスタムHTTPヘッダーについてるのを確認して、ログイン済みユーザを識別します。

Firebase Authenticationを使う

といっても、ここまわりをガチガチに実装するのは結構手間です。ですので、私の場合はログインまわりはFirebase Authenticationをつかって行って、バックエンドと連携させています。

フロントエンドでFirebaseでログインしたユーザが

firebase.auth().currentUser.getIdToken(/* forceRefresh */ true).then(function(idToken) {
  // Send token to your backend via HTTPS
  // ...
}).catch(function(error) {
  // Handle error
});

を実行すると、当該ユーザのログイントークンを取得することができます。それをバックエンドに送信して、バックエンド側でverifyIdTokenを実行すると、ユーザ情報をFirebaseから取得することができます。

PHPで実装しているプロジェクトでは、このライブラリを利用することができます。

kreait/firebase-php
https://github.com/kreait/firebase-php

ユーザのメールアドレスを手元に保存するのもリスキーですし、パスワード流出とかを考え出すと「Auth機能は提供するけど、ユーザ情報はそもそも保存しない」というのが安全です。

Interceptorを使う

先ほど、カスタムHTTPヘッダーを載せてGETリクエストをするサンプルをお見せしましたが、ログイン済み前提のサービスで毎回それを実装するのは現実的ではありません。Ionic(Angular)には、InterceptorというHTTPリクエストをラップする仕組みがあります。それを使うと、このような感じですべての通信のHeadersにカスタムHTTPヘッダーを追加することができるようになります。

const req = request.clone({
  setHeaders: {
    'X-EXCHANGE-ID': window.localStorage.getItem('exchangeToken') || '',
  },
});

Angular 4.3で追加されたHttpClientModuleについてのメモ
あたりが詳しいです。

4. まとめ

ざっといろいろCORSと、ユーザステートをバックエンドで保存しないこと。カスタムHTTPヘッダーの使い方について書きました。私的には「Firebase Authentication使えばいいじゃん」とは思うのですが、実務上そうはいかないこともあると思いますので参考になりましたら幸いです。

それでは、また。

追記

CORSでの Access-Control-Allow-Origin 設定なのですが、「どーせアタックされる時は、curlなどでアクセスされるから設定しても気休めみたいなものだなー」と思ってたのですが、Ionic Japan UGのslack( https://t.co/K9slM8tvi8 )で「別ドメインのコピーサイトつくってアクセスしてくる人がいるので、ちゃんと設定したほうがいい」という話がでたので、やっぱりちゃんとしておいたほうがいいようです。

Cordovaの場合、Originは null で送られてくるので、本番環境の設定は

header('Access-Control-Allow-Origin: https://example.com, null');

みたいな表記になります。開発用も許可するなら、 localhost:8100 や 192.168.1.1:8100 を許可することになりますが、まぁ開発環境では、ワイルドカードのほうが取り回しいいかと思います。

以上、追記までに。

53
43
1

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
53
43

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?