rails-session 0.1.1.0 → 0.1.2.0
raw patch · 4 files changed
+202/−4 lines, 4 filesdep +base16-bytestringdep +containersdep +semigroupsdep ~bytestringPVP ok
version bump matches the API change (PVP)
Dependencies added: base16-bytestring, containers, semigroups
Dependency ranges changed: bytestring
API changes (from Hackage documentation)
+ Web.Rails3.Session: Cookie :: ByteString -> Cookie
+ Web.Rails3.Session: Secret :: ByteString -> Secret
+ Web.Rails3.Session: decode :: Secret -> Cookie -> Maybe RubyObject
+ Web.Rails3.Session: decodeEither :: Secret -> Cookie -> Either String RubyObject
+ Web.Rails3.Session: lookupUserIds :: (Num a) => RubyObject -> Maybe (NonEmpty a)
+ Web.Rails3.Session: newtype Cookie
+ Web.Rails3.Session: newtype Secret
Files
- CHANGELOG.md +4/−0
- rails-session.cabal +8/−1
- src/Web/Rails3/Session.hs +141/−0
- test/Spec.hs +49/−3
CHANGELOG.md view
@@ -1,3 +1,7 @@+## 0.1.2.0++- Add Rails3 support.+ ## 0.1.1.0 - Export DecryptedData type.
rails-session.cabal view
@@ -1,5 +1,5 @@ name: rails-session-version: 0.1.1.0+version: 0.1.2.0 synopsis: Decrypt Ruby on Rails sessions in Haskell description: Please see README.md homepage: http://github.com/iconnect/rails-session#readme@@ -20,6 +20,7 @@ library hs-source-dirs: src exposed-modules: Web.Rails.Session+ , Web.Rails3.Session build-depends: base >= 4.7 && < 5 , base-compat >= 0.8.2 , base64-bytestring >= 1.0.0.1@@ -30,6 +31,11 @@ , ruby-marshal >= 0.1.1 , string-conv >= 0.1 , vector >= 0.10.12.3+ , bytestring+ , base16-bytestring+ , containers+ if impl(ghc < 8.0)+ build-depends: semigroups default-language: Haskell2010 test-suite specs@@ -40,6 +46,7 @@ build-depends: base , bytestring , filepath+ , semigroups , rails-session , ruby-marshal , tasty
+ src/Web/Rails3/Session.hs view
@@ -0,0 +1,141 @@+{-# 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 = maybeToEither+ "[Rails3 Cookie] Illegal checksum in cookie. Wasn't able to extract a valid HMAC checksum out of it."+ (Hash.digestFromByteString $ fst $ (B16.decode hexChecksum))++ 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
test/Spec.hs view
@@ -11,14 +11,61 @@ import Test.Tasty (defaultMain, testGroup) import Test.Tasty.Hspec (describe, it, shouldBe, shouldSatisfy, testSpec, Spec) import Web.Rails.Session+import qualified Web.Rails3.Session as R3+import qualified Data.List.NonEmpty as NE -- SPECS main :: IO () main = do rails4 <- testSpec "Web.Rails.Session: Rails4" $ specsFor Rails4- defaultMain (testGroup "All the Specs" [rails4])+ rails3 <- testSpec "Web.Rails3.Session: Rails4" specsForRails3+ defaultMain (testGroup "All the Specs" [rails4, rails3]) +specsForRails3 :: Spec+specsForRails3 = do+ let cookie = R3.Cookie $ unsafePerformIO $ BS.readFile "test/Rails3"+ secret_ = R3.Secret "test secret token for testing cookies"+ sessionIdVal_ = "3e0d672d2d594d02c8f015388ae380a8"+ csrfTokenVal_ = "fKui2MZV3u5jhGBIgvvrOi7lo0mD/Jge3e0LOAS19Vg="+ userIdVal_ = 107 :: Int+ wardenContents_ = RArray (fromList [RArray (fromList [RFixnum userIdVal_]), RIVar (RString "$2a$10$hDu7wHneNw4A.6Wg61R4vO",UTF_8)])+ cookieContents_ = RHash (fromList+ [ (RIVar (RString "session_id",UTF_8), RIVar (RString sessionIdVal_,UTF_8))+ , (RIVar (RString "warden.user.user.key",UTF_8), wardenContents_)+ , (RIVar (RString "_csrf_token",US_ASCII),RIVar (RString csrfTokenVal_,US_ASCII))+ ])+++ describe "all tests" $ do+ it "should be a Right(..)" $ do+ let result = R3.decodeEither secret_ cookie+ result `shouldSatisfy` isRight++ it "should be a fully-formed Ruby object" $ do+ case R3.decodeEither secret_ cookie of+ Left e -> error $ "decodeEither failed:" ++ (show e)+ Right result -> do+ result `shouldBe` cookieContents_++ it "should look up the '_csrf_token'" $ do+ case R3.decodeEither secret_ cookie of+ Left e -> error $ "decodeEither failed:" ++ (show e)+ Right result ->+ csrfToken result `shouldBe` Just csrfTokenVal_++ it "should look up the 'session_id'" $ do+ case R3.decodeEither secret_ cookie of+ Left e -> error $ "decodeEither failed:" ++ (show e)+ Right result ->+ sessionId result `shouldBe` Just sessionIdVal_++ it "should look up the warden user_id(s)" $ do+ case R3.decodeEither secret_ cookie of+ Left e -> error $ "decodeEither failed:" ++ (show e)+ Right result ->+ R3.lookupUserIds result `shouldBe` Just (userIdVal_ NE.:| [] :: NE.NonEmpty Int)+ specsFor :: Rails -> Spec specsFor rails = do let cookie = unsafeReadCookie rails@@ -33,7 +80,6 @@ Left _ -> error "decode failed" Right result -> do result `shouldBe` rubySession- describe "decrypt" $ do it "should be a Right(..)" $ do let result = decrypt Nothing secret cookie@@ -90,7 +136,7 @@ -- UTIL -data Rails = Rails4 deriving (Show)+data Rails = Rails4 | Rails3 deriving (Show) unsafeReadCookie :: Rails -> Cookie unsafeReadCookie rails = unsafePerformIO $