読者です 読者をやめる 読者になる 読者になる

letsspeak's diary

世界を大いに盛り上げるためのletsspeakの日記。

FuelPHPでopauthを使って色んなログインに対応してみた

FuelPHPでopauthを使って色んなログインに対応してみました。
ログインのパターンは

1.通常のusernameとpasswordのログイン
2.Twitterのoauthログイン
3.Facebookのoauthログイン

です。
TwitterFacebookのログインについてはfuel-opauthを使っていますが、こちらの参考ブログ記事にも記載されている通りnamespaceの設定に不具合があるためgithubでforkしたものをsubmodule化しています。

本家 https://github.com/andreoav/fuel-opauth本家
修正版 https://github.com/letsspeak/fuel-opauth

今回作成したり変更したファイルは一通りgistにもアップロードしています。
不具合はいつも通り触りながら修正していこうかと思っていますが、問題点などありましたらお気軽にご連絡ください。

gist
https://gist.github.com/letsspeak/5229245

Twitter/Facebookのアプリケーション登録

参考ブログの通り進める。

fuel-opauthのsubmodule化

git submodule add https://github.com/letsspeak/fuel-opauth.git fuel/app/packages/opauth/
TIPS

githubからhttps://でsubmodule化した場合、内容の変更が発生したときのpush時に下記修正が必要なので注意。

fuel/packages/opauth/.git/config
- url = https://github.com/letsspeak/fuel-opauth
+ url = ssh://git@github.com/letsspeak/fuel-opauth.git

opauth.phpの設定

ソルトとTwitter/Facebookの開発者用のキーを登録する。

fuel/app/config/opauth.php
<?php
namespace Opauth;
return array(
    'path' => '/auth/login/',
    'callback_url'  => '/auth/callback/',
    'security_salt' => 'ランダムな文字列を生成して登録する',
    'Strategy' => array(
      'Facebook' => array(
        'app_id' => 'Facebookで取得したアプリID / APIキー',
        'app_secret' => 'Facebookで取得したアプリのシークレットキー'
      ),

      'Twitter' => array(
        'key' => 'Consumer key',
        'secret' => 'Consumer secret'
      ),  
    ),     
);

config.php

fuel/app/config/config.php
  'always_load' = array(
    'packages' => array(
      'opauth',
    ),
  ),

User/TwitterUser/FacebookUserを作成

oil g model user name:string password:string nickname:string email:string last_login:int
oil g model twitteruser uid:string token:string secret:string user_id:int
oil g model facebookuser uid:string token:string expires:int user_id:int
マイグレーションの変更点

ストレージエンジンは全てInnoDBに変更。
Userで一元管理することを想定してUser->last_login以外はすべて'null'=>trueに設定。
モデルクラス名をModel_TwitterUserに変更したいのでテーブル名をtwitter_usersに変更。
モデルクラス名をModel_FacebookUserに変更したいのでテーブル名をfacebook_usersに変更。

モデルの変更点

モデルクラス名をそれぞれModel_TwitterUser、Model_FacebookUserに変更。
Model_User、Model_TwitterUser、Model_FacebookUserのバリデーション追加。

authコントローラーを作成

通常ログイン時は引数無しで /auth/login/にPOSTする。
Twitterログイン時は/auth/login/twitter/を叩く。
Facebookログイン時は/auth/login/facebook/を叩く。

fuel/app/classes/controller/auth.php
<?php
class Controller_Auth extends Controller
{
    private $_config = null;
    private $_salt_length = null;
    private $_iteration_count = null;

    public function before()
    {
      if(!isset($this->_config))
      {
          $this->_config = Config::load('opauth', 'opauth');
      }

      $this->_salt_length = 32;
      $this->_iteration_count = 10;
    }

    // auth/login/*
    public function action_login($_provider = null, $method = null)
    {
      // 引数無し時は通常ログイン
      if (is_null($_provider)) return $this->normal_login();

      // http://domainname/auth/login/twitter/oauth_callback?denied=signature
      if ($method === 'oauth_callback') {
        if (Input::get('denied')){
          return $this->login_failed();
        }
      }

      if(array_key_exists(Inflector::humanize($_provider), Arr::get($this->_config, 'Strategy')))
      {
        $_oauth = new Opauth($this->_config, true);
      }
      else
      {
        return $this->login_failed();
      }
    }

    // 通常ログイン
    public function normal_login()
    {
      $username = Input::post('username');
      $password = Input::post('password');

     // ユーザー名とパスワード空欄時はログインフォームを表示する
      if (is_null($username) and is_null($password)) {
        return Response::forge(View::forge('auth/form'));
      }
      
      // 認証
      $query = Model_User::query()->where('name', $username);
      if ($query->count() === 0){
        // 認証エラー
        $this->login_failed();
      }

      // パスワードのハッシュ化
      $user = $query->get_one();
      $salt = substr($user->password, 0, $this->_salt_length);
      $enc_password = $salt.$password;
      for ($i = 0; $i < $this->_iteration_count; $i++)
      {
        $enc_password = sha1($enc_password);
      }
      
      if ($user->password === $salt.$enc_password){
        // 認証成功
        return $this->login_succeeded($user->id);
      }else{
        // 認証エラー
        $this->login_failed();
      }
    }

    public function action_signup()
    {
      $username = Input::post('username');
      $password = Input::post('password');

      // ユーザー名とパスワード空欄時はサインアップフォームを表示する
      if (is_null($username) and is_null($password)) {
        return Response::forge(View::forge('auth/signup'));
      }

      // サインアップ処理

      // パスワードのハッシュ化
      $salt = substr(md5(uniqid(rand(), true)), 0, $this->_salt_length);
      $enc_password = $salt.$password;
      for ($i = 0; $i < $this->_iteration_count; $i++)
      {
        $enc_password = sha1($enc_password);
      }

      // バリデーション
      $val = Model_User::validate('create');
      $input = array(
        'name' => $username,
        'password' => $salt.$enc_password,
        'last_login' => \Date::time()->get_timestamp(),
      );
    
      if ($val->run($input))
      {
        // バリデーション成功時
        $user = Model_User::forge($input);
        if ($user and $user->save())
        {
          // サインアップ成功時
          return $this->login_succeeded($user->id);
        }
        else
        {
          // サインアップ失敗時
          return $this->login_failed();
        }
      }
      else
      {
        // バリデーション失敗時
        $data['errors'] = $val->error();
        return Response::forge(View::forge('auth/signup', $data));
      }

    }

    public function action_test1()
    {
      return Response::forge(View::forge('auth/test'));
    }

    public function action_test2()
    {
      return Response::forge(View::forge('auth/test'));
    }

    public function action_test3()
    {
      return Response::forge(View::forge('auth/test'));
    }

    // Twitter / Facebook ログイン成功/失敗時に呼ばれる
    public function action_callback()
    {
      $_opauth = new Opauth($this->_config, false);

      switch($_opauth->env['callback_transport'])
      {
        case 'session':
          session_start();
          $response = $_SESSION['opauth'];
          unset($_SESSION['opauth']);
        break;            
      }

      if (array_key_exists('error', $response))
      {
        return $this->login_failed();
//            echo '<strong style="color: red;">Authentication error: </strong> Opauth returns error auth response.'."<br>\n";
      }
      else
      {
        if (empty($response['auth']) || empty($response['timestamp']) || empty($response['signature']) || empty($response['auth']['provider']) || empty($response['auth']['uid']))
        {
          return $this->login_failed();
//          echo '<strong style="color: red;">Invalid auth response: </strong>Missing key auth response components.'."<br>\n";
        }
        elseif (!$_opauth->validate(sha1(print_r($response['auth'], true)), $response['timestamp'], $response['signature'], $reason))
        {
          return $this->login_failed();
//          echo '<strong style="color: red;">Invalid auth response: </strong>'.$reason.".<br>\n";
        }
        else
        {
          // Twitter / Facebook ログイン成功
          return $this->opauth_login($response);
        }
      }
    }

    public function opauth_login($response = null)
    {
       $provider = $response['auth']['provider'];
       if ($provider === 'Twitter') return $this->twitter_login($response);
       if ($provider === 'Facebook') return $this->facebook_login($response);
    }

   public function twitter_login($response = null)
    {
      $uid = (string) $response['auth']['uid'];
      $query = Model_TwitterUser::query()->where('uid', $uid);
      if ($query->count() == 0)
      {
        // TwitterUser未登録の場合はサインアップ
        return $this->twitter_signup($response);
      }

      // TwitterUser登録済みの場合はログイン
      $twitter_user = $query->get_one();
      return $this->login_succeeded($twitter_user->user_id);
    }

    public function facebook_login($response = null)
    {
      $uid = $response['auth']['uid'];
      $query = Model_FacebookUser::query()->where('uid', $uid);
      if ($query->count() == 0)
      {
        // FacebookUser未登録の場合はサインアップ
        return $this->facebook_signup($response);
      }

      // FacebookUser登録済みの場合はログイン
      $facebook_user = $query->get_one();
      return $this->login_succeeded($facebook_user->user_id);
   }

    public function twitter_signup($response = null)
    {
      // バリデーション
      $val = Model_TwitterUser::validate('create');
      $input = array(
        'uid' => (string) $response['auth']['uid'],
        'token' => $response['auth']['credentials']['token'],
        'secret' => $response['auth']['credentials']['secret'],
      );
    
      if ($val->run($input))
      {
        // バリデーション成功時
        $user = Model_User::forge(array(
          'nickname' => $response['auth']['info']['nickname'],
          'last_login' => \Date::time()->get_timestamp(),
        ));
        $twitter_user = Model_TwitterUser::forge($input);

        if ($user and $twitter_user)
        {
          // ユーザー生成成功
          try
          {
            \DB::start_transaction();
            if ($user->save() === false)
            {
              // User保存失敗
              throw new \Exception('user save failed.');
            }
              
            $twitter_user->user_id = $user->id;
            if ($twitter_user->save() === false)
            {
              // TwitterUser保存失敗
              throw new \Exception('twitter_user save failed.');
            }

            // UserとTwitterUserの保存成功
            \DB::commit_transaction();
            return $this->login_succeeded($user->id);
          }
          catch (\Exception $e)
          {
            \DB::rollback_transaction();
            return $this->login_failed();
          }

        }
        else
        {
          // ユーザー生成失敗
          return $this->login_failed();
        }

      }
      else
      {
        // バリデーション失敗時
        return $this->login_failed();
      }
    }

    public function facebook_signup($response = null)
    {
      // バリデーション
      $val = Model_FacebookUser::validate('create');
      $expires = strtotime($response['auth']['credentials']['expires']);
      $input = array(
        'uid' => (string) $response['auth']['uid'],
        'token' => $response['auth']['credentials']['token'],
        'expires' => $expires,
      );
    
      if ($val->run($input))
      {
        // バリデーション成功時
        $user = Model_User::forge(array(
          'nickname' => $response['auth']['info']['name'],
          'last_login' => \Date::time()->get_timestamp(),
        ));
        $facebook_user = Model_FacebookUser::forge($input);

        if ($user and $facebook_user)
        {
          // ユーザー生成成功
          try
          {
            \DB::start_transaction();
            if ($user->save() === false)
            {
              // User保存失敗
              throw new \Exception('user save failed.');
            }
              
            $facebook_user->user_id = $user->id;
            if ($facebook_user->save() === false)
            {
              // FacebookUser保存失敗
              throw new \Exception('facebook_user save failed.');
            }

            // UserとFacebookUserの保存成功
            \DB::commit_transaction();
            return $this->login_succeeded($user->id);
          }
          catch (\Exception $e)
          {
            \DB::rollback_transaction();
            return $this->login_failed();
          }

        }
        else
        {
          // ユーザー生成失敗
          return $this->login_failed();
        }

      }
      else
      {
        // バリデーション失敗時
        return $this->login_failed();
      }
    }

    public function login_succeeded($user_id)
    {
      Session::set('user_id', $user_id);
      Response::redirect('auth/test1');
    }

    public function login_failed()
    {
      return Response::redirect('auth/test1');
    }
 
    public function action_logout()
    {
      Session::delete('user_id');
      Response::redirect('auth/test1');
    }
}

懸念点など

・とりあえず作っただけなので全然脆弱な気がする。
XSRF対策をしていない。
・ぜんぶlogin_failed()に飛ばしているけどいいの?
・特定のページでログインを経由して特定のページに復帰するようなことは考慮されていない。
・パスワードはいちおうハッシュ化してみた。
・Usersでの一括管理に意味があるのか...