HaskellでGoogle OAuth2の認証等の実験

個人的なプロジェクトとして、ログイン機能を実装したWebアプリを作成したいと考えています。 独自のユーザ認証にするという方法もありますが、今回はGoogleのOAuth2を使う方法を検討しました。

HaskellのWebフレームワークであるSpockを使用しました。 また、OAuth2はwreqを使って、自分でHTTPリクエストを発行することで実装しました。

また、Webアプリ自体はポート8888でSSLを使用せずに待ち受けるようになっています。 SSL化をするためにnginxをインストールして、/etc/nginx/conf.d/test.confとして以下を作成します。

server {
   listen 443;
   server_name test.com;
   
   ssl on;
   ssl_certificate /etc/letsencrypt/live/test.com/fullchain.pem;
   ssl_sertificate_key /etc/letsencrypt/live/test.com/privkey.pem;

   ssl_session_timeout 15m;

   ssl_protocols SSLv2 SSLv3 TLSv1;
   ssl_ciphers HIGH:!aNULL:!MD5;
   ssl_prefer_server_ciphers on;

   proxy_set_header Host $host;
   proxy_set_header X-Real-IP $remote_addr;
   proxy_set_header X-Forwarded-Host $host;
   proxy_set_header X-Forwarded-Server $host;
   proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

   location / {
     proxy_pass http://localhost:8888/;
     proxy_redirect default;
   }
}

これでnginxをSSL対応リバースプロキシとして動作させ、同一マシン上で動くHaskell WebアプリにHTTPリクエストを転送することができます。

プログラムはモジュールに分割せず、全部を1ファイルにまとめて書いてしまいましたが、次のようになりました。

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables,FlexibleContexts #-}
module Main where

import Web.Spock (SpockM, get, root, param, getState, runQuery, text, redirect, (<//>), spock, runSpock, var)
import Web.Spock.Config
import Web.Spock.SessionActions
import Database.PostgreSQL.Simple
import Data.Pool
import Database.PostgreSQL.Simple.FromRow
import qualified Network.Wreq as W
import Network.Wreq (FormParam(..))
import Control.Lens
import qualified Control.Exception as E
import qualified Network.HTTP.Client as H
import Data.Aeson.Lens (_String, _Integer, key)
import Data.Aeson.Types (Value)
import Data.Map.Strict (Map, (!?), insert, empty, (!))
import Control.Monad.Trans
import Data.IORef
import Data.Monoid
import qualified Data.ByteString.Char8 as B
import Data.ByteString.Lazy (ByteString)
import qualified Data.Text as T
import qualified System.Random as R

type Sess = Map T.Text T.Text
data AppState = DummyAppState (IORef Int)
type Email = String
type ErrorMsg = String
type Resp = W.Response (Map String Value)

redirect_uri = "https://(GoogleからリダイレクトされるURL)/google_oauth_callback" :: T.Text
client_id = "(Googleから発行されるClient ID)" :: T.Text
client_secret = "(Googleから発行されるClient Secret)" :: T.Text

auth_uri = "https://accounts.google.com/o/oauth2/v2/auth" :: T.Text
exchange_uri = "https://www.googleapis.com/oauth2/v4/token" :: String
api_uri = "https://www.googleapis.com/oauth2/v2/userinfo" :: String
scope = "https://www.googleapis.com/auth/userinfo.email email" :: T.Text

db_name = "(データベース名)"
db_user = "(データベースユーザ名)"
db_pwd = "(データベースパスワード)"

main :: IO ()
main = 
    do ref <- newIORef 0
       pool <- createPool (connect defaultConnectInfo
                                     { connectDatabase = db_name,
                                       connectUser = db_user,
                                       connectPassword = db_pwd })
                             close 1 10 10
       spockCfg <- defaultSpockCfg empty (PCPool pool) (DummyAppState ref)
       runSpock 8888 (spock spockCfg app)

-- Here "id_token" contains JWT, which includes email address (See https://developers.google.com/identity/protocols/OpenIDConnect)
debugExchangeResult :: W.Response ByteString -> IO ()
debugExchangeResult r = do
    res <- W.asJSON r :: IO Resp
    putStrLn "======================"
    putStrLn $ show $ r ^. W.responseBody
    putStrLn $ dbgMsg res "access_token"
    putStrLn $ dbgMsg res "token_type"
    putStrLn $ dbgMsg res "expires_in"
    putStrLn $ dbgMsg res "refresh_token"
    putStrLn "======================"
    where dbgMsg res str = str ++ " : " ++ show ((res ^. W.responseBody) !? str)

getEmail :: String -> IO (Either ErrorMsg Email)
getEmail auth_code = do
    putStrLn ("authorization code : " ++ auth_code)
    r <- W.post exchange_uri
                      ["code"          := auth_code,
                       "client_id"     := client_id,
                       "client_secret" := client_secret,
                       "redirect_uri"  := redirect_uri,
                       "grant_type"    := ("authorization_code" :: T.Text)]
    let status_code = r ^. W.responseStatus . W.statusCode
    if status_code == 200 then
      do
        let access_token = show $ r ^. W.responseBody . key "access_token" . _String
        let opts = W.defaults & W.header "Authorization" .~ [B.pack $ "Bearer " ++ access_token]
        debugExchangeResult r
        r2 <- W.getWith opts api_uri
        putStrLn $ show (r2 ^? W.responseBody)
        let email = show (r2 ^. W.responseBody . key "email" . _String)
        case email of
          "" -> return $ Left ""
          _ -> return $ Right email
    else
      return $ Left "failed to exchange token"

handle_callback = do
 (Just code :: Maybe String) <- param "code"
 (Just state :: Maybe String) <- param "state"
 m <- readSession
 if (m ! "state" /= T.pack state) then
    text "state invalid"
 else
    (liftIO $ getEmail code `E.catch` handler) >>= handle_result
      where handle_result (Left msg) = text ("error " <> T.pack msg)
            handle_result (Right mail) = text ("Logged in as: " <> T.pack mail)
            handler e@(H.HttpExceptionRequest req cont) =
                         do liftIO $ putStrLn $ show cont
                            return $ Left $ show cont

handle_hello name = do
 (DummyAppState ref) <- getState
 [Only xs] <- runQuery $ \conn -> query_ conn "select 1+1;" :: IO [Only Int]
 liftIO $ putStrLn $ show xs
 num <- liftIO $ atomicModifyIORef' ref $ \i -> (i+1, i+1)
 m <- readSession
 writeSession $ insert "name" name m
 text ("Hello " <> name <> ", number " <> T.pack (show num))

handle_login = do
 g <- liftIO $ R.newStdGen
 let state = take 30 (R.randomRs ('a', 'z') g)
 liftIO $ putStrLn state
 sessionRegenerateId
 m <- readSession
 writeSession $ insert "state" (T.pack state) m
 redirect (T.concat [auth_uri, "?client_id=", client_id,
                               "&redirect_uri=", redirect_uri,
                               "&state=", T.pack state,
                               "&scope=", scope, "&access_type=online&response_type=code"])
 
app :: SpockM Connection Sess AppState ()
app =
    do get root $
            do m <- readSession
               let name = m !? "name"
               case name of
                   Nothing -> text "Hello World"
                   Just n -> text ("Hello World, " <> n)
       get ("hello" <//> var) handle_hello
       get "login" handle_login
       get "google_oauth_callback" handle_callback

上記コードでは、OAuth2を用いてログインしたユーザのメールアドレスを取得しています。アクセストークン取得時にJWT(JSON Web Token)がid_tokenに発行されるので、それをデコードして、中のデータから取り出しても良いはずですが、もう一度APIを呼び出すことで、その結果からアドレスを取り出しています。

また、データベースやセッション管理の検証のためのコードも追加されています。実装が正しいかあまり自信はありませんが、CSRF対策ということで、認証する際のstateパラメタに、セッションに結びついたランダム文字列を渡し、コールバックで戻されるstateパラメタの値と、セッションに保存しておいた文字列が等しいことを検証しています。

セッションが保持する情報は、文字列から文字列への連想配列(Map)としています。

package.yamlのdependenciesは次のように記述しています。

dependencies:
- base >= 4.7 && < 5
- Spock == 0.13.0.0
- Spock-core == 0.13.0.0
- reroute == 0.5.0.0
- mtl >= 2.2
- text >= 1.2.3
- postgresql-simple >= 0.5.4
- resource-pool >= 0.2.3
- wreq >= 0.5.3
- lens >= 4.16
- lens-aeson >= 1.0.2
- bytestring >= 0.9.1
- aeson >= 1.2.1.0
- containers >= 0.5.11
- random >= 1.1
- http-client >= 0.5.14

stack.yamlのresolverはlts-12.25とし、extra-depsは次のように記述しています。

extra-deps:
- Spock-0.13.0.0
- Spock-core-0.13.0.0
- reroute-0.5.0.0

これで、所望のWebアプリを作成するための技術的な方式の検討ができました。あとはひたすらページを作成し、データベースとのグルーロジックを追加して行けば、完成まで持って行けそうです。 基本的にフロントエンドに動的要素は必要ではないと考えていますが、もし必要ならVue.jsあたりを使おうかと思います。

comments powered by Disqus