From: fr33domlover <fr33domlo...@rel4tion.org>

---
 website/Snowdrift.cabal          |  7 ++++
 website/config/routes            |  2 ++
 website/config/settings.yml      |  4 +++
 website/src/Application.hs       |  1 +
 website/src/Discourse.hs         | 73 ++++++++++++++++++++++++++++++++++++++++
 website/src/Handler/Discourse.hs | 55 ++++++++++++++++++++++++++++++
 website/src/Settings.hs          |  4 +++
 7 files changed, 146 insertions(+)
 create mode 100644 website/src/Discourse.hs
 create mode 100644 website/src/Handler/Discourse.hs

diff --git a/website/Snowdrift.cabal b/website/Snowdrift.cabal
index 9c1664c..b5cfaa5 100644
--- a/website/Snowdrift.cabal
+++ b/website/Snowdrift.cabal
@@ -40,6 +40,7 @@ library
         Avatar
         Application
         Css
+        Discourse
         Email
         Foundation
         Import
@@ -49,6 +50,7 @@ library
         Settings.StaticFiles
         Handler
         Handler.Dashboard
+        Handler.Discourse
         Handler.PaymentInfo
         Handler.Pledge
         Handler.Project
@@ -74,6 +76,7 @@ library
         , bytestring             >= 0.9     && < 0.11
         , classy-prelude         >= 0.10.2
         , classy-prelude-yesod   >= 0.10.2
+        , cryptonite
         , data-default
         , errors
         , esqueleto
@@ -82,8 +85,10 @@ library
         , formattable
         , hjsmin                 >= 0.1
         , http-client
+        , http-types
         , lens
         , libravatar
+        , memory
         , mime-mail
         , monad-logger           >= 0.3     && < 0.4
         , nonce
@@ -99,6 +104,7 @@ library
         , text                   >= 0.11    && < 2.0
         , time
         , titlecase
+        , transformers
         , unordered-containers
         , wai
         , wai-extra              >= 3.0     && < 3.1
@@ -122,6 +128,7 @@ library
         RecordWildCards
         ScopedTypeVariables
         TemplateHaskell
+        TupleSections
         TypeFamilies
         ViewPatterns
 
diff --git a/website/config/routes b/website/config/routes
index 9e4e079..30fdef8 100644
--- a/website/config/routes
+++ b/website/config/routes
@@ -26,6 +26,8 @@
 /p/snowdrift SnowdriftProjectR GET
 /pledge/snowdrift PledgeSnowdriftR POST DELETE
 
+/discourse/sso DiscourseR GET
+
 -- ## Backward compatibility routes
 
 -- Prevents breakage of external links to the old wiki. See
diff --git a/website/config/settings.yml b/website/config/settings.yml
index 3c30c0f..e4e30bb 100644
--- a/website/config/settings.yml
+++ b/website/config/settings.yml
@@ -44,3 +44,7 @@ send-email: "_env:SD_EMAILS:false"
 # chreekat for assistance.
 stripe-secret-key: "_env:STRIPE_SECRET_KEY:"
 stripe-publishable-key: "_env:STRIPE_PUBLISHABLE_KEY:"
+
+# Discourse SSO
+discourse-url: "https://discourse.snowdrift.coop";
+discourse-sso-secret: ""
diff --git a/website/src/Application.hs b/website/src/Application.hs
index 8ccd6b2..761d3f8 100644
--- a/website/src/Application.hs
+++ b/website/src/Application.hs
@@ -35,6 +35,7 @@ import qualified Yesod.GitRev as G
 
 import Handler
 import Handler.Dashboard
+import Handler.Discourse
 import Handler.PaymentInfo
 import Handler.Pledge
 import Handler.Project
diff --git a/website/src/Discourse.hs b/website/src/Discourse.hs
new file mode 100644
index 0000000..cca0bb1
--- /dev/null
+++ b/website/src/Discourse.hs
@@ -0,0 +1,73 @@
+module Discourse where
+
+import Prelude
+
+import Crypto.Hash.Algorithms (SHA256)
+import Crypto.MAC.HMAC
+import Data.ByteArray.Encoding
+import Data.ByteString (ByteString)
+import Data.Maybe (catMaybes)
+import Data.Text (Text, pack)
+import Data.Text.Encoding (encodeUtf8)
+import Network.HTTP.Types.URI (renderSimpleQuery)
+
+import qualified Data.ByteString as B (drop)
+
+import Model
+
+-- | Information we send back to Discourse once the user logs in through our
+-- UI.
+data UserInfo = UserInfo
+    { ssoEmail     :: Text
+    , ssoId        :: UserId
+    , ssoUsername  :: Maybe Text
+    , ssoFullName  :: Maybe Text
+    , ssoAvatarUrl :: Maybe Text
+    , ssoBio       :: Maybe Text
+    }
+
+-- | Type restricted convenience wrapper that computes our HMAC.
+hmacSHA256 :: ByteString -> ByteString -> HMAC SHA256
+hmacSHA256 = hmac
+
+-- | Given secret known in advance and payload given in the query, compute the
+-- HMAC-SHA256, to which Discourse refers as the signature.
+generateSig
+    :: ByteString -- ^ Secret
+    -> ByteString -- ^ Base64 encoded payload
+    -> ByteString
+generateSig secret payload =
+    convertToBase Base16 $ hmacGetDigest $ hmacSHA256 secret payload
+
+-- | This validates the payloads's authenticity (i.e. make sure it's really our
+-- trusted local Discourse instance) by using the signature as a proof that it
+-- knows the SSO secret. This is done by verifying that the HMAC-SHA256 of the
+-- secret and the payload is identical to the signature.
+validateSig
+    :: ByteString -- ^ SSO secret, same one you specify in Discourse settings
+    -> ByteString -- ^ Base64 encoded payload sent by Discourse in the query
+    -> ByteString -- ^ Signature sent by Discourse in the query
+    -> Bool       -- ^ Whether the computed sig and one passed are identical
+validateSig secret payload signature = generateSig secret payload == signature
+
+-- | Get the nonce from the payload by decoding from Base64 and dropping the
+-- constant prefix.
+payloadToNonce :: ByteString -> Either String ByteString
+payloadToNonce = fmap (B.drop 6) . convertFromBase Base64
+
+-- | Compute Base64 encoded payload to send back to Discourse after login
+userInfoPayload
+    :: ByteString -- ^ Raw nonce string we extracted from input payload
+    -> UserInfo   -- ^ Info about the user we pass back to Discourse
+    -> ByteString
+userInfoPayload nonce uinfo =
+    let query = catMaybes $ map (\ (name, mval) -> fmap (name,) mval)
+            [ ("nonce"      , Just nonce)
+            , ("email"      , Just $ encodeUtf8 $ ssoEmail uinfo)
+            , ("external_id", Just $ encodeUtf8 $ pack $ show $ ssoId uinfo)
+            , ("username"   , fmap encodeUtf8 $ ssoUsername uinfo)
+            , ("name"       , fmap encodeUtf8 $ ssoFullName uinfo)
+            , ("avatar_url" , fmap encodeUtf8 $ ssoAvatarUrl uinfo)
+            , ("bio"        , fmap encodeUtf8 $ ssoBio uinfo)
+            ]
+    in  convertToBase Base64 $ renderSimpleQuery False query
diff --git a/website/src/Handler/Discourse.hs b/website/src/Handler/Discourse.hs
new file mode 100644
index 0000000..d95f121
--- /dev/null
+++ b/website/src/Handler/Discourse.hs
@@ -0,0 +1,55 @@
+module Handler.Discourse (getDiscourseR) where
+
+import Import
+import Control.Lens
+import Control.Monad.Trans.Except
+import Avatar
+import Discourse
+
+maybeThrow :: Monad m => e -> Maybe a -> ExceptT e m a
+maybeThrow err Nothing  = throwE err
+maybeThrow _   (Just x) = return x
+
+getDiscourseR :: Handler Html
+getDiscourseR = do
+    result <- runExceptT $ do
+        -- Extract payload param
+        mpayload <- lift $ fmap encodeUtf8 <$> lookupGetParam "sso"
+        payload <- maybeThrow "No payload" mpayload
+        -- Extract sig param
+        msig <- lift $ fmap encodeUtf8 <$> lookupGetParam "sig"
+        sig <- maybeThrow "No sig" msig
+        -- Get SSO secret from settings
+        secret <- lift $ getsYesod $ appDiscourseSsoSecret . appSettings
+        -- Verify signature
+        unless (validateSig secret payload sig) $ throwE "Signature is invalid"
+        -- Extract nonce from payload
+        nonce <- case payloadToNonce payload of
+            Left err -> throwE $ "Payload invalid: " <> pack err
+            Right n  -> return n
+        -- Perform authentication and fetch user info
+        Entity uid u <- lift requireAuth
+        avatar <-
+            lift $ getUserAvatar (StaticR img_default_avatar_png) (Just u)
+        let uinfo = UserInfo
+                { ssoEmail     = u ^. userEmail
+                , ssoId        = uid
+                -- TODO no better option right now...
+                , ssoUsername  = Nothing
+                -- TODO no better option right now...
+                , ssoFullName  = Nothing
+                , ssoAvatarUrl = Just avatar
+                -- TODO could link to Snowdrift user page
+                , ssoBio       = Nothing
+                }
+        -- Compute new payload and sig
+            uinfoPayload = userInfoPayload nonce uinfo
+            uinfoSig = generateSig secret uinfoPayload
+        -- Send them back to Discourse
+        baseUrl <- lift $ getsYesod $ appDiscourseUrl . appSettings
+        let params = [("sso", uinfoPayload), ("sig", uinfoSig)]
+            query = decodeUtf8 $ renderSimpleQuery True params
+        return $ baseUrl <> "/session/sso_login" <> query
+    case result of
+        Left err  -> invalidArgs [err]
+        Right url -> redirect url
diff --git a/website/src/Settings.hs b/website/src/Settings.hs
index 74254f7..dd7afc5 100644
--- a/website/src/Settings.hs
+++ b/website/src/Settings.hs
@@ -51,6 +51,8 @@ data AppSettings = AppSettings
     -- ^ Whether to send emails
     , appStripeSecretKey        :: StripeKey
     , appStripePublishableKey   :: StripeKey
+    , appDiscourseUrl           :: Text
+    , appDiscourseSsoSecret     :: ByteString
     }
 
 instance FromJSON AppSettings where
@@ -76,6 +78,8 @@ instance FromJSON AppSettings where
         appSendMail               <- o .:? "send-email"       .!= not 
runningDevelopment
         appStripePublishableKey   <- StripeKey . encodeUtf8 <$> o .: 
"stripe-publishable-key"
         appStripeSecretKey        <- StripeKey . encodeUtf8 <$> o .: 
"stripe-secret-key"
+        appDiscourseUrl           <- o .: "discourse-url"
+        appDiscourseSsoSecret     <- encodeUtf8 <$> o .: "discourse-sso-secret"
 
         return AppSettings {..}
 
-- 
1.9.1

_______________________________________________
Dev mailing list
Dev@lists.snowdrift.coop
https://lists.snowdrift.coop/mailman/listinfo/dev

Reply via email to