From: fr33domlover <[email protected]>
---
website/Snowdrift.cabal | 8 ++++
website/config/routes | 2 +
website/config/settings.yml | 3 ++
website/src/Application.hs | 1 +
website/src/Discourse.hs | 86 ++++++++++++++++++++++++++++++++++++++++
website/src/Handler/Discourse.hs | 49 +++++++++++++++++++++++
website/src/Settings.hs | 2 +
7 files changed, 151 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..8eca05d 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
@@ -70,10 +72,12 @@ library
, crowdmatch
-- Other
, aeson >= 0.6 && < 0.12
+ , base64-bytestring
, blaze-html
, bytestring >= 0.9 && < 0.11
, classy-prelude >= 0.10.2
, classy-prelude-yesod >= 0.10.2
+ , cryptonite
, data-default
, errors
, esqueleto
@@ -82,8 +86,10 @@ library
, formattable
, hjsmin >= 0.1
, http-client
+ , http-types
, lens
, libravatar
+ , memory
, mime-mail
, monad-logger >= 0.3 && < 0.4
, nonce
@@ -99,6 +105,7 @@ library
, text >= 0.11 && < 2.0
, time
, titlecase
+ , transformers
, unordered-containers
, wai
, wai-extra >= 3.0 && < 3.1
@@ -122,6 +129,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..61ff751 100644
--- a/website/config/settings.yml
+++ b/website/config/settings.yml
@@ -44,3 +44,6 @@ 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-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..83106bb
--- /dev/null
+++ b/website/src/Discourse.hs
@@ -0,0 +1,86 @@
+module Discourse where
+
+import Prelude
+
+import Control.Monad.Trans.Except
+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, decodeUtf8')
+import Network.HTTP.Types.URI (renderSimpleQuery, parseSimpleQuery)
+
+import qualified Data.ByteString as B (drop)
+import qualified Data.ByteString.Base64 as B64 (decodeLenient)
+
+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 and the return URL from the payload by decoding from Base64
+-- and extracting the parameter values.
+--
+-- We use lenient decoding here because Discourse doesn't seem to add the
+-- necessary padding for strictly by-the-spec Base64 encoding.
+parsePayload :: ByteString -> Either String (ByteString, Text)
+parsePayload b = runExcept $ do
+ let params = parseSimpleQuery $ B64.decodeLenient b
+ nonce <- maybe (throwE "Nonce is missing") return $ lookup "nonce" params
+ burl <- maybe (throwE "URL is missing") return $
+ lookup "return_sso_url" params
+ let mapLeft f (Left x) = Left $ f x
+ mapLeft _ (Right x) = Right x
+ url <- except $ mapLeft show $ decodeUtf8' burl
+ return (nonce, url)
+
+-- | 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..4d69a68
--- /dev/null
+++ b/website/src/Handler/Discourse.hs
@@ -0,0 +1,49 @@
+module Handler.Discourse (getDiscourseR) where
+
+import Import
+import Control.Lens
+import Control.Monad.Trans.Except
+import Avatar
+import Discourse
+
+getDiscourseR :: Handler Html
+getDiscourseR = do
+ result <- runExceptT $ do
+ -- Extract payload param
+ mpayload <- lift $ fmap encodeUtf8 <$> lookupGetParam "sso"
+ payload <- maybe (throwE "No payload") return mpayload
+ -- Extract sig param
+ msig <- lift $ fmap encodeUtf8 <$> lookupGetParam "sig"
+ sig <- maybe (throwE "No sig") return msig
+ -- Get SSO secret from settings
+ secret <- lift $ getsYesod $ appDiscourseSsoSecret . appSettings
+ -- Verify signature
+ unless (validateSig secret payload sig) $ throwE "Signature is invalid"
+ -- Extract nonce and return URL from payload
+ (nonce, baseUrl) <- case parsePayload payload of
+ Left err -> throwE $ "Payload invalid: " <> pack err
+ Right p -> return p
+ -- 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
+ let params = [("sso", uinfoPayload), ("sig", uinfoSig)]
+ return $ baseUrl <> decodeUtf8 (renderSimpleQuery True params)
+ 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..dd6f270 100644
--- a/website/src/Settings.hs
+++ b/website/src/Settings.hs
@@ -51,6 +51,7 @@ data AppSettings = AppSettings
-- ^ Whether to send emails
, appStripeSecretKey :: StripeKey
, appStripePublishableKey :: StripeKey
+ , appDiscourseSsoSecret :: ByteString
}
instance FromJSON AppSettings where
@@ -76,6 +77,7 @@ instance FromJSON AppSettings where
appSendMail <- o .:? "send-email" .!= not
runningDevelopment
appStripePublishableKey <- StripeKey . encodeUtf8 <$> o .:
"stripe-publishable-key"
appStripeSecretKey <- StripeKey . encodeUtf8 <$> o .:
"stripe-secret-key"
+ appDiscourseSsoSecret <- encodeUtf8 <$> o .: "discourse-sso-secret"
return AppSettings {..}
--
1.9.1
_______________________________________________
Dev mailing list
[email protected]
https://lists.snowdrift.coop/mailman/listinfo/dev