From: fr33domlover <fr33domlo...@rel4tion.org> --- 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 Dev@lists.snowdrift.coop https://lists.snowdrift.coop/mailman/listinfo/dev