{-# LANGUAGE NoImplicitPrelude #-} {-# LANGUAGE OverloadedStrings #-} module Web.Rails3.Session ( -- * Tutorial -- $tutorial -- * Decoding decodeEither , decode -- * Utilities , lookupUserIds -- * Throw-away data-types -- $datatypes , Secret(..) , Cookie(..) ) where import Crypto.Hash as Hash import Crypto.MAC.HMAC as HMAC import Data.ByteString import Data.ByteString as BS import qualified Data.ByteString.Base16 as B16 import qualified Data.ByteString.Base64 as B64 import qualified Data.Map.Strict as Map import Data.Ruby.Marshal as Marshal hiding (decode, decodeEither) import qualified Data.Ruby.Marshal as Marshal (decodeEither) import Data.Ruby.Marshal.RubyObject import Network.HTTP.Types (urlDecode) import Data.List.NonEmpty as NE import Data.List as DL import Prelude (Either(..), (>>=), (.), (==), ($), Maybe(..), return, Num(..), Int, fromIntegral, Bool(..), fst, String, either, id, const) -- $tutorial -- -- Here's how to decode a Rail3 session/auth cookie using 'wai' & 'cookie' package. -- -- @ -- import Network.Wai (requestHeaders) -- import Web.Cookie (parseCookies) -- ... -- -- case (fmap (lookup "_yourapp_session") $ fmap parseCookies $ lookup "Cookie" $ requestHeaders waiRequest) of -- -- -- no active Rails session -- Left _ -> ... -- -- Right c -> case (decodeEither (Secret "yourSessionSecret") (Cookie c)) of -- -- -- something went wrong in decoding the cookie. You should log "e" for debugging! -- Left e -> ... -- -- Right obj -> case (lookupUserIds obj) of -- -- -- we have a Rails session-cookie, but the user has not signed-in -- Nothing -> ... -- -- -- signed-in user. This /may/ contain muliple userIds depending up on how you have configured Devise\/Warden in your Rails app. -- Just userIds -> ... -- @ -- $datatypes -- -- These data-types exist only as a way to semantically differentiate between -- various ByteString arguments when they are passed to functions. This is required -- only because Haskell doesn't have proper keywords-arguments. newtype Secret = Secret ByteString newtype Cookie = Cookie ByteString maybeToEither :: a -> Maybe b -> Either a b maybeToEither _ (Just b) = Right b maybeToEither a Nothing = Left a -- | Decode a cookie encoded by Rails3. Please read the documentation of -- 'decodeEither' for more details, and consider using 'decodeEither' instead of -- 'decode' decode :: Secret -> Cookie -> Maybe RubyObject decode s c = either (const Nothing) Just $ decodeEither s c -- | Decode a cookie encoded by Rails3. You can find the @Secret@ in a file -- called @config\/initializers\/secret_token.rb@ in your Rail3 app. -- -- __Note:__ `decodeMaybe` has not been added on purpose. When cookie decoding -- fails, you would really want to know why. Please consider logging `Left` -- values returned by this function in your log, to save yourself some debugging -- time later. decodeEither :: Secret -> Cookie -> Either String RubyObject decodeEither (Secret cookieSecret) (Cookie x) = extractChecksum >>= compareChecksum >>= Marshal.decodeEither where extractChecksum :: Either String (Digest SHA1) extractChecksum = do decoded <- B16.decode hexChecksum maybeToEither "[Rails3 Cookie] Illegal checksum in cookie. Wasn't able to extract a valid HMAC checksum out of it." (Hash.digestFromByteString decoded) compareChecksum :: Digest SHA1 -> Either String ByteString compareChecksum checksum = if (computedChecksum == checksum) then (Right $ B64.decodeLenient b64) else (Left "[Rails3 Cookie] Checksum doesn't match") computedChecksum :: Digest SHA1 computedChecksum = HMAC.hmacGetDigest (HMAC.hmac cookieSecret b64 :: HMAC SHA1) (b64, hexChecksum) = let (a, b) = (breakSubstring delimiter $ urlDecode False x) in (a, BS.drop (BS.length delimiter) b) delimiter = "--" -- NOTE: Please refer to -- http://blog.bigbinary.com/2013/03/19/cookies-on-rails.html to understand how -- a Rails3 cookie is encoded (NOT encyrpted). Encryption of session cookies -- only began in Rails4. Rails3 marshals a RubyObject and base64 encodes it to -- store it as a cookie. To ensure that it cannot be tamped with, it also adds -- an HMAC computed with the help of a secret key/value/token. safeHead :: [a] -> Maybe a safeHead [] = Nothing safeHead (x:_) = Just x lookupKey :: (Rubyable a) => (BS.ByteString, RubyStringEncoding) -> RubyObject -> Maybe a lookupKey key robj = (fromRuby robj :: Maybe (Map.Map (BS.ByteString, RubyStringEncoding) RubyObject)) >>= Map.lookup key >>= fromRuby -- | Lookup the Warden\/Devise UserIds from a decoded cookie. __Please note,__ a -- cookie may contain multiple UserIds, because it /seems/ that it is possible -- to be logged-in as multiple users simultaneously, if you define [multiple -- user -- models](https://github.com/plataformatec/devise/wiki/How-to-Setup-Multiple-Devise-User-Models) -- (the underlying data-structure allows it, as well). lookupUserIds :: (Num a) => RubyObject -> Maybe (NonEmpty a) lookupUserIds robj = lookupKey ("warden.user.user.key", UTF_8) robj >>= (\x -> fromRuby x :: Maybe [RubyObject]) -- [[int, int int], "random string"] >>= safeHead >>= (\x -> fromRuby x :: Maybe [Int]) -- [int, int, int] >>= (\xs -> return $ DL.map fromIntegral xs) >>= NE.nonEmpty