Compare commits

..

2 Commits
main ... debug

Author SHA1 Message Date
Sarah Vaupel
440c7fe2a8 fix debug prints 2024-03-06 01:50:22 +01:00
Sarah Vaupel
c3fcf3703c add debug print of codeExpiration 2024-03-05 17:12:47 +01:00
11 changed files with 185 additions and 813 deletions

View File

@ -9,7 +9,6 @@ module Main (main) where
import UniWorX import UniWorX
import Server import Server
import SSO (CustomRoutes, customRoutes)
import Control.Applicative ((<|>)) import Control.Applicative ((<|>))
import Database.Persist (Entity(..)) import Database.Persist (Entity(..))
import System.Environment (lookupEnv) import System.Environment (lookupEnv)
@ -21,7 +20,7 @@ main = do
port <- determinePort port <- determinePort
putStrLn $ "Try: http://localhost:" ++ show port ++ "/auth?scope=ID%20Profile&client_id=42&response_type=code&redirect_uri=http:%2F%2Flocalhost:0000%2F" putStrLn $ "Try: http://localhost:" ++ show port ++ "/auth?scope=ID%20Profile&client_id=42&response_type=code&redirect_uri=http:%2F%2Flocalhost:0000%2F"
initDB initDB
runMockServerWithRoutes @(Entity User) @(M.Map T.Text T.Text) @CustomRoutes port customRoutes runMockServer @(Entity User) @(M.Map T.Text T.Text) port
where where
determinePort :: IO Int determinePort :: IO Int
determinePort = do determinePort = do

View File

@ -1,102 +0,0 @@
-- SPDX-FileCopyrightText: 2024 UniWorX Systems
-- SPDX-FileContributor: David Mosbach <david.mosbach@uniworx.de>
--
-- SPDX-License-Identifier: AGPL-3.0-or-later
{-# Language DataKinds, TypeOperators, OverloadedStrings, LambdaCase, TypeApplications, QuasiQuotes #-}
module SSO (CustomRoutes, customRoutes) where
import Prelude hiding (head)
import UniWorX
import Server
import User
import LoginForm
import Control.Monad.IO.Class (liftIO)
import Data.Map (Map, empty)
import Data.Maybe (fromMaybe)
import Data.String (IsString(..))
import Data.Text (Text, splitOn)
import Data.Text.Encoding (encodeUtf8)
import Data.Text.Encoding.Base64
import qualified Data.String.Interpolate as I
import Database.Persist (Entity(..))
import Servant
import Servant.API
import Text.Blaze.Html5
import qualified Text.Blaze.Html5.Attributes as A
import Web.Cookie (parseCookiesText)
type CustomRoutes = Login :<|> SSOTest
customRoutes = login :<|> routes
type Login = "login"
:> QueryParam' [Strict, Required] "redirect" Text
:> Header' [Strict, Required] "Authorization" Text
:> Verb 'GET 303 '[HTML] (Headers '[ Header "Set-Cookie" Text
, Header "Location" Text
] Html)
login :: AuthServer (Entity User) Login
login redirect creds = addHeader (authCookie <> "=\"" <> creds <> "\"") . addHeader redirect <$> do
liftIO . putStrLn $ "\nREDIRECT: " ++ show redirect
(liftIO . getUser $ Just creds) >>= \case
Just user -> return mempty
Nothing -> throwError err500 { errBody = "Unknown user" }
successMsg :: Html
successMsg = do
head $ do
meta ! A.charset "UTF-8"
meta ! A.name "viewport" ! A.content "width=device-width, initial-scale=1.0"
title "Success"
body $ do
h1 "OIDC SSO Test"
p "Login successful."
getUser :: Maybe Text -> IO (Maybe (Entity User))
getUser (Just creds) = do
putStrLn $ "\nCREDS: " ++ (show $ decodeBase64Lenient creds)
let [username, password] = splitOn ":" $ decodeBase64Lenient creds
lookupUser @(Entity User) @(Map Text Text) $ UserQuery (Just username) (Just password) Nothing
getUser Nothing = return Nothing
type SSOTest = "test-sso"
:> QueryParam "redirect" String
:> Header "Cookie" Text
:> Get '[HTML] Html
routes :: AuthServer (Entity User) SSOTest
routes redirect mCookies = do
(liftIO $ getUser mCreds) >>= \case
Just user -> return $ ssoLink redirect
Nothing -> return $ loginPage route empty
where
mCreds = mCookies >>= lookup authCookie . parseCookiesText . encodeUtf8 >>= \c -> if c == "\"\"" then Nothing else Just c
route = "../login?redirect=-" -- TODO hacky "../login?redirect=..%2Ftest-sso%3Fredirect%3D" <> fromMaybe "" redirect
ssoLink :: Maybe String -> Html
ssoLink redirect = docTypeHtml $ head' >> body'
where
t = "OIDC SSO Test"
head' = head $ do
meta ! A.charset "UTF-8"
meta ! A.name "viewport" ! A.content "width=device-width, initial-scale=1.0"
title t
body' = body $ do
h1 t
case redirect of
Just r -> a ! A.href (fromString r) $ "Go to FraDrive"
Nothing -> b "Redirect link is missing."

View File

@ -31,7 +31,6 @@ import Control.Monad.Reader (ReaderT)
import Conduit (ResourceT) import Conduit (ResourceT)
import Data.Map (Map(..)) import Data.Map (Map(..))
import Data.Maybe (fromJust)
import Data.String (IsString(..)) import Data.String (IsString(..))
import Data.Text (Text(..)) import Data.Text (Text(..))
import Data.Yaml (decodeFileThrow, FromJSON(..), Value(..), (.:), (.:?)) import Data.Yaml (decodeFileThrow, FromJSON(..), Value(..), (.:), (.:?))
@ -52,7 +51,7 @@ share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
email Text email Text
matricNumber Text Maybe matricNumber Text Maybe
title Text Maybe title Text Maybe
gender Text Maybe sex Text Maybe
birthday Text Maybe birthday Text Maybe
telephone Text Maybe telephone Text Maybe
mobile Text Maybe mobile Text Maybe
@ -69,7 +68,7 @@ instance FromJSON User where
<*> o .: "userEmail" <*> o .: "userEmail"
<*> o .:? "userMatrikelnummer" <*> o .:? "userMatrikelnummer"
<*> o .:? "userTitle" <*> o .:? "userTitle"
<*> o .:? "userGender" <*> o .:? "userSex"
<*> o .:? "userBirthday" <*> o .:? "userBirthday"
<*> o .:? "userTelephone" <*> o .:? "userTelephone"
<*> o .:? "userMobile" <*> o .:? "userMobile"
@ -108,49 +107,28 @@ initDB = do
instance UserData (Entity User) (Map Text Text) where instance UserData (Entity User) (Map Text Text) where
type UserID (Entity User) = Key User data Scope (Entity User) = ID | Profile deriving (Show, Read, Eq)
data CustomScope (Entity User) = UWX deriving (Read, Show, Eq) readScope = read
userScope (Entity _ User{..}) (Left OpenID) = M.singleton "id" userEmail showScope = show
userScope (Entity _ User{..}) (Left Profile) = M.fromList $ catM userScope (Entity _ User{..}) ID = M.singleton "id" userEmail
[ ("name", Just $ userFirstName <> " " <> userSurname) userScope (Entity _ User{..}) Profile = M.fromList [(key, val) | (key, Just val) <-
, ("given_name", Just userFirstName) [ ("firstName", Just userFirstName)
, ("family_name", Just userSurname) , ("surname", Just userSurname)
, ("middle_name", Nothing) , ("email", Just userEmail)
, ("nickname", Nothing) , ("matriculationNumber", userMatricNumber)
, ("preferred_username", Nothing)
, ("profile", Nothing)
, ("picture", Nothing)
, ("website", Nothing)
, ("gender", userGender)
, ("birthdate", userBirthday)
, ("zoneinfo", Nothing)
, ("locale", Nothing)
, ("updated_at", Nothing)
]
userScope (Entity _ User{..}) (Left Email) = M.fromList [("email", userEmail), ("email_verified", userEmail)]
userScope (Entity _ User{..}) (Left Address) = case userPostAddress of
Just address -> M.singleton "address" address
Nothing -> M.empty
userScope (Entity _ User{..}) (Left Phone) = M.fromList $ catM [("phone_number", userMobile), ("phone_number_verified", userTelephone)]
userScope (Entity _ User{..}) (Right UWX) = M.fromList $ catM
[ ("matriculationNumber", userMatricNumber)
, ("title", userTitle) , ("title", userTitle)
, ("sex", userSex)
, ("birthday", userBirthday)
, ("telephone", userTelephone)
, ("mobile", userMobile)
, ("companyPersonalNumber", userCompPersNumber) , ("companyPersonalNumber", userCompPersNumber)
, ("companyDepartment", userCompDepartment) , ("companyDepartment", userCompDepartment)
] , ("postAddress", userPostAddress)
userScope (Entity _ User{..}) _ = M.empty ]]
lookupUser UserQuery{..} = runDB $ do lookupUser email _ = runDB $ do
let filters = map fst $ catM [(UserEmail ==. fromJust email, email)] user <- selectList [UserEmail ==. email] []
keyFilter = case key of
Just k -> \(Entity x _) -> (T.pack $ show x) == k
Nothing -> \_ -> True
user <- filter keyFilter <$> selectList filters []
case user of case user of
[entity] -> return $ Just entity [entity] -> return $ Just entity
[] -> return Nothing [] -> return Nothing
_ -> error "Oauth2 Mock Server: Ambiguous User." _ -> error "Oauth2 Mock Server: Ambiguous User."
userID (Entity x _) = x
catM :: [(a, Maybe b)] -> [(a, b)]
catM l = [ (x,y) | (x, Just y) <- l ]

View File

@ -41,12 +41,11 @@
LD_LIBRARY_PATH=${libPath} LD_LIBRARY_PATH=${libPath}
mkdir -p $HOME/.stack mkdir -p $HOME/.stack
stack build --verbose stack build --verbose
rm -rf $HOME/.stack
''; '';
installPhase = '' installPhase = ''
mkdir -p $out/bin mkdir -p $out/bin
mv .stack-work/install/${system}/*/*/bin/${name}-exe $out/bin/${name} mv .stack-work/install/${system}/*/*/bin/${name}-exe $out/bin/${name}
rm -rf .stack-work echo "moved"
''; '';
}; };
mkDB = builtins.readFile ./mkDB.sh; mkDB = builtins.readFile ./mkDB.sh;
@ -78,7 +77,6 @@
${mkDB} ${mkDB}
zsh zsh
${killDB} ${killDB}
exit
''; '';
}; };
}; };

View File

@ -37,8 +37,6 @@ library
, blaze-html , blaze-html
, bytestring , bytestring
, containers , containers
, cookie
, ghc
, http-api-data , http-api-data
, http-client , http-client
, http-media , http-media
@ -58,7 +56,6 @@ library
executable oauth2-mock-server-exe executable oauth2-mock-server-exe
main-is: Main.hs main-is: Main.hs
other-modules: other-modules:
SSO
UniWorX UniWorX
Paths_oauth2_mock_server Paths_oauth2_mock_server
autogen-modules: autogen-modules:
@ -74,8 +71,6 @@ executable oauth2-mock-server-exe
, bytestring , bytestring
, conduit , conduit
, containers , containers
, cookie
, ghc
, http-api-data , http-api-data
, http-client , http-client
, http-media , http-media
@ -116,8 +111,6 @@ test-suite oauth2-mock-server-test
, blaze-html , blaze-html
, bytestring , bytestring
, containers , containers
, cookie
, ghc
, http-api-data , http-api-data
, http-client , http-client
, http-media , http-media

View File

@ -25,7 +25,6 @@ description: Please see the README # on GitHub at <https://github.com/gi
dependencies: dependencies:
- base >= 4.7 && < 5 - base >= 4.7 && < 5
- ghc
- servant - servant
- servant-server - servant-server
- servant-client - servant-client
@ -45,7 +44,6 @@ dependencies:
- blaze-html - blaze-html
- http-media - http-media
- string-interpolate - string-interpolate
- cookie
ghc-options: ghc-options:
- -Wall - -Wall
@ -78,9 +76,6 @@ executables:
- conduit - conduit
- mtl - mtl
- yaml - yaml
- servant
- servant-server
- blaze-html
tests: tests:
oauth2-mock-server-test: oauth2-mock-server-test:

View File

@ -3,13 +3,12 @@
-- --
-- SPDX-License-Identifier: AGPL-3.0-or-later -- SPDX-License-Identifier: AGPL-3.0-or-later
{-# LANGUAGE OverloadedRecordDot, OverloadedStrings, ScopedTypeVariables, TypeApplications, LambdaCase, DeriveGeneric, AllowAmbiguousTypes #-} {-# LANGUAGE OverloadedRecordDot, OverloadedStrings, ScopedTypeVariables, TypeApplications, LambdaCase, RecordWildCards #-}
module AuthCode module AuthCode
( State(..) ( State(..)
, AuthState , AuthState
, AuthRequest(..) , AuthRequest(..)
, TokenParams(..)
, JWT(..) , JWT(..)
, JWTWrapper(..) , JWTWrapper(..)
, genUnencryptedCode , genUnencryptedCode
@ -19,23 +18,18 @@ module AuthCode
, renewToken , renewToken
) where ) where
import Prelude hiding (exp)
import User import User
import Data.Aeson import Data.Aeson
import Data.Bool (bool)
import Data.ByteString (ByteString (..), fromStrict, toStrict) import Data.ByteString (ByteString (..), fromStrict, toStrict)
import Data.Either (fromRight) import Data.Either (fromRight)
import Data.List ((\\)) import Data.Map.Strict (Map, assocs)
import Data.Map.Strict (Map) import Data.Maybe (isJust, fromMaybe, fromJust)
import Data.Maybe (isJust, fromMaybe, fromJust, catMaybes)
import Data.Time.Calendar
import Data.Time.Clock import Data.Time.Clock
import Data.Text (pack, Text, stripPrefix) import Data.Text (pack, replace, Text, stripPrefix)
import Data.Text.Encoding (decodeUtf8, encodeUtf8) import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import Data.Text.Encoding.Base64.URL import Data.Text.Encoding.Base64
import Data.UUID hiding (null) import Data.UUID
import Data.UUID.V4 import Data.UUID.V4
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
@ -46,22 +40,13 @@ import Control.Concurrent.STM.TVar
import Control.Monad (void, (>=>)) import Control.Monad (void, (>=>))
import Control.Monad.STM import Control.Monad.STM
import GHC.Generics
import Jose.Jwa import Jose.Jwa
import Jose.Jwe import Jose.Jwe
import Jose.Jwk (Jwk(..)) import Jose.Jwk (Jwk(..))
import Jose.Jwt hiding (decode, encode) import Jose.Jwt hiding (decode, encode)
import qualified Jose.Jws as Jws
import Servant.API (FromHttpApiData(..)) import Servant.API (FromHttpApiData(..))
import System.Environment (getEnv)
--------------
---- Tokens ----
--------------
data JWT = JWT data JWT = JWT
{ issuer :: Text { issuer :: Text
@ -75,41 +60,25 @@ instance ToJSON JWT where
instance FromJSON JWT where instance FromJSON JWT where
parseJSON (Object o) = JWT <$> o .: "iss" <*> o .: "exp" <*> o .: "jti" parseJSON (Object o) = JWT <$> o .: "iss" <*> o .: "exp" <*> o .: "jti"
data IDToken = IDT
{ iss :: Text
, sub :: Text
, aud :: [Text]
, exp :: NominalDiffTime
, iat :: NominalDiffTime
, auth_time :: Maybe NominalDiffTime
, nonce :: Maybe Text
} deriving (Generic, Show)
instance ToJSON IDToken
instance FromJSON IDToken
data JWTWrapper = JWTW data JWTWrapper = JWTW
{ acessToken :: String { acessToken :: String
, expiresIn :: NominalDiffTime , expiresIn :: NominalDiffTime
, refreshToken :: Maybe String , refreshToken :: Maybe String
, idToken :: Maybe String
} deriving (Show) } deriving (Show)
instance ToJSON JWTWrapper where instance ToJSON JWTWrapper where
toJSON (JWTW a e r i) = object toJSON (JWTW a e r) = object
[ "access_token" .= a [ "access_token" .= a
, "token_type" .= ("JWT" :: Text) , "token_type" .= ("JWT" :: Text)
, "expires_in" .= fromEnum e , "expires_in" .= fromEnum e
, "refresh_token" .= r , "refresh_token" .= r ]
, "id_token" .= i ]
instance FromJSON JWTWrapper where instance FromJSON JWTWrapper where
parseJSON (Object o) = JWTW parseJSON (Object o) = JWTW
<$> o .: "access_token" <$> o .: "access_token"
<*> o .: "expires_in" <*> o .: "expires_in"
<*> o .:? "refresh_token" <*> o .:? "refresh_token"
<*> o .:? "id_token"
instance FromHttpApiData JWTWrapper where instance FromHttpApiData JWTWrapper where
parseHeader bs = case decode (fromStrict bs) of parseHeader bs = case decode (fromStrict bs) of
@ -117,33 +86,26 @@ instance FromHttpApiData JWTWrapper where
Nothing -> Left "Invalid JWT wrapper" Nothing -> Left "Invalid JWT wrapper"
-------------
---- State ----
-------------
data AuthRequest user = AuthRequest data AuthRequest user = AuthRequest
{ client :: String { client :: String
, codeExpiration :: NominalDiffTime , codeExpiration :: NominalDiffTime
, user :: user , user :: user
, scopes :: [Scope' user] , scopes :: [Scope user]
, rNonce :: Maybe Text
} }
type TokenParams user = (user, [Scope' user], Maybe Text) instance Show user => Show (AuthRequest user) where
show AuthRequest{..} = "AuthRequest{ codeExpiration = " ++ show codeExpiration ++ ", .. }"
data State user = State data State user = State
{ activeCodes :: Map Text (AuthRequest user) { activeCodes :: Map Text (AuthRequest user)
, activeTokens :: Map UUID (TokenParams user) , activeTokens :: Map UUID (user, [Scope user])
, publicKey :: Jwk , publicKey :: Jwk
, privateKey :: Jwk , privateKey :: Jwk
} }
type AuthState user = TVar (State user) type AuthState user = TVar (State user)
-----------------
---- Functions ----
-----------------
genUnencryptedCode :: AuthRequest user genUnencryptedCode :: AuthRequest user
-> String -> String
-> AuthState user -> AuthState user
@ -152,7 +114,7 @@ genUnencryptedCode req url state = do
now <- getCurrentTime now <- getCurrentTime
let let
expiresAt = req.codeExpiration `addUTCTime` now expiresAt = req.codeExpiration `addUTCTime` now
simpleCode = encodeBase64Unpadded . pack $ req.client <> url <> show now <> show expiresAt simpleCode = replace "/" "%2F" . replace "=" "%3D" . encodeBase64 . pack . filter (/= ' ') $ req.client <> url <> show now <> show expiresAt
success <- atomically . stateTVar state $ \s -> success <- atomically . stateTVar state $ \s ->
let mEntry = M.lookup simpleCode s.activeCodes let mEntry = M.lookup simpleCode s.activeCodes
in in
@ -167,89 +129,60 @@ genUnencryptedCode req url state = do
atomically . modifyTVar state $ \s -> s{ activeCodes = M.delete code s.activeCodes } atomically . modifyTVar state $ \s -> s{ activeCodes = M.delete code s.activeCodes }
verify :: Text verify :: (Show user) => Text -> Maybe String -> AuthState user -> IO (Maybe (user, [Scope user]))
-> Maybe String
-> AuthState user
-> IO (Maybe (TokenParams user))
verify code mClientID state = do verify code mClientID state = do
now <- getCurrentTime now <- getCurrentTime
(codes, tokens) <- atomically $ do
codes <- readTVar state >>= return . activeCodes
tokens <- readTVar state >>= return . activeTokens
return (codes, tokens)
print $ "activeCodes: " ++ show codes
-- print $ "activeTokens: " ++ show ((\(uuid,(usr,scps)) -> (uuid,(usr,showScope <$> scps))) <$> assocs tokens)
mData <- atomically $ do mData <- atomically $ do
result <- (readTVar >=> return . M.lookup code . activeCodes) state result <- (readTVar >=> return . M.lookup code . activeCodes) state
modifyTVar state $ \s -> s{ activeCodes = M.delete code s.activeCodes } modifyTVar state $ \s -> s{ activeCodes = M.delete code s.activeCodes }
return result return result
return $ case mData of return $ case mData of
Just (AuthRequest clientID' _ u s n) -> if (fromMaybe clientID' mClientID) == clientID' Just (AuthRequest clientID' _ u s) -> if (fromMaybe clientID' mClientID) == clientID' then Just (u, s) else Nothing
then Just (u, s, n)
else Nothing
_ -> Nothing _ -> Nothing
mkToken :: forall user userData . UserData user userData mkToken :: user -> [Scope user] -> AuthState user -> IO JWTWrapper
=> TokenParams user mkToken u scopes state = do
-> Maybe Text -- client_id pubKey <- atomically $ readTVar state >>= return . publicKey
-> AuthState user
-> IO JWTWrapper
mkToken (u, scopes, nonce) clientID state = do
(pubKey, privKey) <- atomically $ readTVar state >>= return . ((,) <$> publicKey <*> privateKey)
now <- getCurrentTime now <- getCurrentTime
uuid <- nextRandom uuid <- nextRandom
port <- pack <$> getEnv "OAUTH2_SERVER_PORT"
let let
lifetimeAT = 3600 :: NominalDiffTime -- TODO make configurable lifetimeAT = 3600 :: NominalDiffTime -- TODO make configurable
lifetimeRT = nominalDay -- TODO make configurable lifetimeRT = nominalDay -- TODO make configurable
lifetimeIT = 3600 :: NominalDiffTime -- TODO make configurable
itRefDate = UTCTime (fromGregorian 1970 1 1) 0
at = JWT "Oauth2MockServer" (lifetimeAT `addUTCTime` now) uuid at = JWT "Oauth2MockServer" (lifetimeAT `addUTCTime` now) uuid
rt = JWT "Oauth2MockServer" (lifetimeRT `addUTCTime` now) uuid rt = JWT "Oauth2MockServer" (lifetimeRT `addUTCTime` now) uuid
it = IDT
{ iss = "http://localhost:" <> port -- TODO maybe make configurable
, sub = pack . show $ userID @user @userData u
, aud = catMaybes [clientID]
, exp = (lifetimeIT `addUTCTime` now) `diffUTCTime` itRefDate
, iat = now `diffUTCTime` itRefDate
, auth_time = Just $ now `diffUTCTime` itRefDate
, nonce = nonce
}
encodedAT <- jwkEncode RSA_OAEP_256 A128GCM pubKey (Nested . Jwt . toStrict $ encode at) encodedAT <- jwkEncode RSA_OAEP_256 A128GCM pubKey (Nested . Jwt . toStrict $ encode at)
encodedRT <- jwkEncode RSA_OAEP_256 A128GCM pubKey (Nested . Jwt . toStrict $ encode rt) encodedRT <- jwkEncode RSA_OAEP_256 A128GCM pubKey (Nested . Jwt . toStrict $ encode rt)
encodedIT <- Jws.jwkEncode RS256 privKey (Nested . Jwt . toStrict $ encode it) case encodedAT >> encodedRT of
case encodedAT >> encodedRT >> encodedIT of
Right _ -> do Right _ -> do
let Jwt aToken = fromRight undefined encodedAT let Jwt aToken = fromRight undefined encodedAT
Jwt rToken = fromRight undefined encodedRT Jwt rToken = fromRight undefined encodedRT
Jwt iToken = fromRight undefined encodedIT atomically . modifyTVar state $ \s -> s { activeTokens = M.insert uuid (u, scopes) (activeTokens s) }
atomically . modifyTVar state $ \s -> s { activeTokens = M.insert uuid (u, scopes, nonce) (activeTokens s) } return $ JWTW (BS.unpack aToken) lifetimeAT (Just $ BS.unpack rToken)
return $ JWTW
{ acessToken = BS.unpack aToken
, expiresIn = lifetimeAT
, refreshToken = Just $ BS.unpack rToken
, idToken = if Left OpenID `elem` scopes then Nothing else Just $ BS.unpack iToken
}
Left e -> error $ show e Left e -> error $ show e
decodeToken :: Text -> AuthState user -> IO (Either JwtError JwtContent) decodeToken :: Text -> AuthState user -> IO (Either JwtError JwtContent)
decodeToken token state = do decodeToken token state = do
key <- atomically $ readTVar state >>= return . privateKey prKey <- atomically $ readTVar state >>= return . privateKey
jwkDecode key $ encodeUtf8 token jwkDecode prKey $ encodeUtf8 token
renewToken :: forall user userData . UserData user userData renewToken :: Text -> AuthState user -> IO (Maybe JWTWrapper)
=> Text -- ^ token renewToken t state = decodeToken t state >>= \case
-> [Scope' user]
-> Maybe Text -- ^ client_id
-> AuthState user
-> IO (Either Text JWTWrapper) -- TODO more descriptive failures
renewToken t scopes clientID state = decodeToken t state >>= \case
Right (Jwe (header, body)) -> do Right (Jwe (header, body)) -> do
let jwt = fromJust . decode @JWT $ fromStrict body let jwt = fromJust . decode @JWT $ fromStrict body
now <- getCurrentTime now <- getCurrentTime
if now >= expiration jwt then return (Left "token expired") else do if now >= expiration jwt then return Nothing else do
mUser <- atomically . stateTVar state $ \s -> mUser <- atomically . stateTVar state $ \s ->
let (key, tokens) = M.updateLookupWithKey (\_ _ -> Nothing) (jti jwt) s.activeTokens let (key, tokens) = M.updateLookupWithKey (\_ _ -> Nothing) (jti jwt) s.activeTokens
in (key, s { activeTokens = tokens }) in (key, s { activeTokens = tokens })
case mUser of case mUser of
Just (u, scopes', nonce) -> bool (pure $ Left "must not request new scopes") Just (u, scopes) -> Just <$> mkToken u scopes state
(Right <$> mkToken @user @userData (u, scopes, nonce) clientID state) Nothing -> return Nothing
(null $ scopes \\ scopes') Left _ -> return Nothing
Nothing -> return $ Left "no user associated with this token"
Left _ -> return $ Left "could not decode token"

View File

@ -5,22 +5,15 @@
{-# Language OverloadedStrings, MultiParamTypeClasses, FlexibleInstances, QuasiQuotes #-} {-# Language OverloadedStrings, MultiParamTypeClasses, FlexibleInstances, QuasiQuotes #-}
module LoginForm module LoginForm (HTML(..), Html, loginPage) where
( HTML(..)
, Html
, loginPage
, logoutPage
) where
import Prelude hiding (head) import Prelude hiding (head)
import Data.Aeson (encode)
import Data.String (IsString(..))
import Data.Text (Text, unpack)
import qualified Data.Map as M import qualified Data.Map as M
import Data.Aeson (encode)
import qualified Data.String.Interpolate as I import qualified Data.String.Interpolate as I
import Data.String (IsString(..))
import GHC.Data.Maybe (whenIsJust) import Data.Text (Text)
import Network.HTTP.Media ((//), (/:)) import Network.HTTP.Media ((//), (/:))
@ -39,8 +32,8 @@ instance Accept HTML where
instance MimeRender HTML Html where instance MimeRender HTML Html where
mimeRender _ = renderHtml mimeRender _ = renderHtml
loginPage :: String -> M.Map Text Text -> Html loginPage :: M.Map Text Text -> Html
loginPage uri headers = docTypeHtml $ head' >> body' loginPage headers = docTypeHtml $ head' >> body'
where where
headers' = encode headers headers' = encode headers
formID = "loginForm" :: String formID = "loginForm" :: String
@ -65,7 +58,7 @@ loginPage uri headers = docTypeHtml $ head' >> body'
headers.append('Authorization', btoa(creds)); headers.append('Authorization', btoa(creds));
//alert(creds); //alert(creds);
e.preventDefault(); e.preventDefault();
fetch('#{uri}', { fetch('../code', {
method: 'GET', method: 'GET',
headers: headers headers: headers
}) })
@ -73,22 +66,4 @@ loginPage uri headers = docTypeHtml $ head' >> body'
.then(url => window.location.replace(url.substring(1, url.length - 1))); .then(url => window.location.replace(url.substring(1, url.length - 1)));
// Response.redirect(url); // Response.redirect(url);
}; };
|] |]
logoutPage :: Maybe Text -> Html
logoutPage mUri = docTypeHtml $ head' >> body'
where
head' = head $ do
meta ! A.charset "UTF-8"
meta ! A.name "viewport" ! A.content "width=device-width, initial-scale=1.0"
title "UniWorX Oauth2 Mock Server"
body' = body $ do
h1 "UniWorX Oauth2 Mock Server"
p "Logout successful."
whenIsJust mUri $ \uri -> do
a ! A.href (fromString $ unpack uri) $ "Continue"
script $
[I.i|
setTimeout(_ => window.location.replace('#{uri}'), 2000); |]

View File

@ -12,20 +12,13 @@
, RecordWildCards , RecordWildCards
, AllowAmbiguousTypes , AllowAmbiguousTypes
, LambdaCase , LambdaCase
, FlexibleContexts
, KindSignatures
#-} #-}
module Server module Server
( insecureOAuthMock {-( insecureOAuthMock'
, runMockServer , runMockServer
, runMockServerWithRoutes -- , runMockServer'
, HTML )-} where
, Html
, AuthServer
, AuthHandler
, authCookie
) where
import AuthCode import AuthCode
import LoginForm import LoginForm
@ -36,19 +29,18 @@ import Control.Concurrent
import Control.Concurrent.STM (atomically) import Control.Concurrent.STM (atomically)
import Control.Concurrent.STM.TVar (newTVarIO, readTVar, modifyTVar) import Control.Concurrent.STM.TVar (newTVarIO, readTVar, modifyTVar)
import Control.Exception (bracket) import Control.Exception (bracket)
import Control.Monad (unless, (>=>), foldM, void) import Control.Monad (unless, (>=>))
import Control.Monad.IO.Class import Control.Monad.IO.Class
import Control.Monad.Trans.Error (Error(..)) import Control.Monad.Trans.Error (Error(..))
import Control.Monad.Trans.Reader import Control.Monad.Trans.Reader
import Data.Aeson import Data.Aeson
import Data.ByteString (fromStrict, toStrict, ByteString) import Data.ByteString (fromStrict)
import Data.List (find, elemIndex) import Data.List (find, elemIndex)
import Data.Maybe (fromMaybe, fromJust, isJust, isNothing) import Data.Maybe (fromMaybe, fromJust, isJust, isNothing)
import Data.String (IsString (..)) import Data.String (IsString (..))
import Data.Text hiding (elem, find, head, length, map, null, splitAt, tail, words) import Data.Text hiding (elem, find, head, length, map, null, splitAt, tail, words)
import qualified Data.Text as T import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8, decodeUtf8)
import Data.Text.Encoding.Base64 import Data.Text.Encoding.Base64
import Data.Time.Clock (NominalDiffTime (..), nominalDay, UTCTime(..), getCurrentTime, addUTCTime) import Data.Time.Clock (NominalDiffTime (..), nominalDay, UTCTime(..), getCurrentTime, addUTCTime)
@ -56,31 +48,24 @@ import qualified Data.Map.Strict as Map
import GHC.Read (readPrec, lexP) import GHC.Read (readPrec, lexP)
import Jose.Jwk (generateRsaKeyPair, KeyUse(..), KeyId) import Jose.Jwk (generateRsaKeyPair, KeyUse(Enc), KeyId)
import Jose.Jwt hiding (decode, encode) import Jose.Jwt hiding (decode, encode)
import Network.HTTP.Client (newManager, defaultManagerSettings, httpLbs, parseRequest) import Network.HTTP.Client (newManager, defaultManagerSettings)
import Network.Wai.Handler.Warp import Network.Wai.Handler.Warp
import Servant import Servant
import Servant.Client hiding (client) import Servant.Client
import Servant.API import Servant.API
import System.Environment (getEnv)
import Text.ParserCombinators.ReadPrec (look, pfail) import Text.ParserCombinators.ReadPrec (look, pfail)
import Text.Read (readMaybe)
import qualified Text.Read.Lex as Lex import qualified Text.Read.Lex as Lex
import Web.Cookie (parseCookiesText) import Web.Internal.FormUrlEncoded (FromForm(..), parseUnique, parseMaybe, Form(..))
import Web.Internal.FormUrlEncoded (FromForm(..), parseUnique, parseMaybe, Form(..), urlEncodeParams)
authCookie :: Text
authCookie = "oa2_auth_cookie"
data AuthClient = Client data AuthClient = Client
{ ident :: Text { ident :: Text
, secret :: Text , secret :: Text
@ -89,11 +74,10 @@ data AuthClient = Client
trustedClients :: [AuthClient] -- TODO move to db trustedClients :: [AuthClient] -- TODO move to db
trustedClients = [Client "42" "shhh"] trustedClients = [Client "42" "shhh"]
data ResponseType = Code -- ^ authorisation code flow data ResponseType = Code -- ^ authorisation code grant
| Token -- ^ implicit flow via access token | Token -- ^ implicit grant via access token
| IDToken -- ^ implicit flow via access token & ID token | IDToken -- ^ implicit grant via access token & ID token
deriving (Eq, Show) deriving (Eq, Show)
instance Read ResponseType where instance Read ResponseType where
readPrec = do readPrec = do
Lex.Ident str <- lexP Lex.Ident str <- lexP
@ -113,32 +97,33 @@ type QClient = String
type QResType = String type QResType = String
type QRedirect = Text type QRedirect = Text
type QState = Text type QState = Text
type QNonce = Text
type QAuth = Text type QAuth = Text
type QCookie = Text
type QPrompt = Text
type QParam = QueryParam' [Required, Strict] type QParam = QueryParam' [Required, Strict]
-- type Oauth2Params = QParam "scope" QScope
-- :> QParam "client_id" QClient
-- :> QParam "response_type" QResType
-- :> QParam "redirect_uri" QRedirect
-- :> QueryParam "state" QState
-- type ProtectedAuth user = BasicAuth "login" user :> "auth" :> Auth -- Prompts for username & password
-- type QuickAuth = "qauth" :> Auth -- Prompts for username only
type Auth = "auth" type Auth = "auth"
:> QParam "scope" QScope :> QParam "scope" QScope
:> QParam "client_id" QClient :> QParam "client_id" QClient
:> QParam "response_type" QResType :> QParam "response_type" QResType
:> QParam "redirect_uri" QRedirect :> QParam "redirect_uri" QRedirect
:> QueryParam "state" QState :> QueryParam "state" QState
:> QueryParam "nonce" QNonce :> Get '[HTML] Html -- login
:> QueryParam "prompt" QPrompt
:> Header "Cookie" QCookie
:> Get '[HTML] Html -- ^ login
type AuthCode = "code" type AuthCode = "code"
:> HeaderR "Authorization" QAuth --TODO store in cookie instead of passing as headers :> HeaderR "Authorization" QAuth
:> HeaderR "OA2_Scope" QScope :> HeaderR "OA2_Scope" QScope
:> HeaderR "OA2_Client_ID" QClient :> HeaderR "OA2_Client_ID" QClient
:> HeaderR "OA2_Redirect_URI" QRedirect :> HeaderR "OA2_Redirect_URI" QRedirect
:> Header "OA2_State" QState :> Header "OA2_State" QState
:> Header "OA2_Nonce" QNonce :> Get '[JSON] Text -- returns auth code
:> Get '[JSON] (Headers '[Header "Set-Cookie" Text] Text) -- ^ returns auth code
type AuthHandler user = ReaderT (AuthState user) Handler type AuthHandler user = ReaderT (AuthState user) Handler
@ -148,83 +133,59 @@ toHandler :: forall user userData a . UserData user userData => AuthState user -
toHandler s h = runReaderT h s toHandler s h = runReaderT h s
loginServer :: forall user userData . UserData user userData => AuthServer user Auth loginServer :: forall user userData . UserData user userData => AuthServer user Auth
loginServer = decideLogin loginServer = handleAuth
where where
decideLogin scopes client responseType url mState mNonce mPrompt mCookies handleAuth :: QScope
| Nothing <- responseType' = throwError err401 { errBody = "Unsupported response type" } -> QClient
| not validOIDC = throwError err401 { errBody = "For OIDC, the 'openid' scope and the 'id_token' response type must be given" } -> QResType
| Just "none" <- mPrompt = handleSSO -> QRedirect
| Just "login" <- mPrompt = handleLogin -> Maybe QState
| Nothing <- mPrompt = if isJust mCreds then handleSSO else handleLogin -> AuthHandler user Html
| otherwise = throwError err401 { errBody = "Prompt not supported" } handleAuth scopes client responseType url mState = do
where let
responseType' = foldM (\acc x -> readMaybe @ResponseType x >>= return . (: acc)) [] $ words responseType responseType' = read @ResponseType responseType
mCreds = mCookies >>= lookup authCookie . parseCookiesText . encodeUtf8 >>= \c -> if c == "\"\"" then Nothing else Just c headers = Map.fromList @Text @Text
validOIDC :: Bool [ ("OA2_Scope", pack scopes)
validOIDC = let scopes' = map (read @(Scope' user)) $ words scopes , ("OA2_Client_ID", pack client)
in (Left OpenID `elem` scopes') == (IDToken `elem` fromJust responseType') , ("OA2_Redirect_URI", url)]
-- | Retrieve user id from cookie headers' = if isJust mState then Map.insert "OA2_State" (fromJust mState) headers else headers
handleSSO :: AuthHandler user Html unless (responseType' == Code) $ throwError err500 { errBody = "Unsupported response type" }
handleSSO = do -- TODO check openid scope return $ loginPage headers'
liftIO $ putStrLn "login via SSO..."
liftIO . putStrLn $ "creds: " ++ show mCreds
unless (isJust mCreds) $ throwError err500 { errBody = "Missing oauth2 cookie" }
url' <- handleCreds @user @userData (fromJust mCreds) scopes client url mState mNonce
liftIO $ putStrLn "SSO successful"
throwError err303 { errHeaders = [("Location", encodeUtf8 url')]}
-- | Html login form
handleLogin :: AuthHandler user Html
handleLogin = do
unless (Code `elem` fromJust responseType') $ throwError err401 { errBody = "response type 'code' missing" }
let headers = Map.fromList @Text @Text $
[ ("OA2_Scope", pack scopes)
, ("OA2_Client_ID", pack client)
, ("OA2_Redirect_URI", url)
] ++ [(x,y) | (x, Just y) <-
[ ("OA2_State", mState)
, ("OA2_Nonce", mNonce)
]]
return $ loginPage "../code" headers
codeServer :: forall user userData . UserData user userData => AuthServer user AuthCode codeServer :: forall user userData . UserData user userData => AuthServer user AuthCode
codeServer creds scopes client url mState mNonce = addHeader (authCookie <> "=\"" <> creds <> "\"") <$> codeServer = handleCreds
handleCreds @user @userData creds scopes client url mState mNonce where
handleCreds :: QAuth
handleCreds :: forall user userData . UserData user userData -> QScope
=> QAuth -> QClient
-> QScope -> QRedirect
-> QClient -> Maybe QState
-> QRedirect -> AuthHandler user Text
-> Maybe QState handleCreds creds scopes client url mState = do
-> Maybe QNonce unless (isJust $ find (\c -> ident c == pack client) trustedClients) . -- TODO fetch trusted clients from db | TODO also check if the redirect url really belongs to the client
-> AuthHandler user Text throwError $ err404 { errBody = "Not a trusted client."}
handleCreds creds scopes client url mState mNonce = do let
unless (isJust $ find (\c -> ident c == pack client) trustedClients) . -- TODO fetch trusted clients from db | TODO also check if the redirect url really belongs to the client scopes' = map (readScope @user @userData) $ words scopes
throwError $ err404 { errBody = "Not a trusted client."} [userName, password] = splitOn ":" $ decodeBase64Lenient creds
let scopes' = map (read @(Scope' user)) $ words scopes liftIO $ print userName
[userName, password] = splitOn ":" $ decodeBase64Lenient creds mUser <- liftIO $ lookupUser @user @userData userName (Just password)
liftIO . putStrLn $ "\acreds: " <> show userName unless (isJust mUser) $ throwError err500 { errBody = "Unknown user."}
mUser <- liftIO $ lookupUser @user @userData (UserQuery (Just userName) (Just password) Nothing) let u = fromJust mUser
unless (isJust mUser) $ throwError err500 { errBody = "Unknown user."} mAuthCode <- asks (genUnencryptedCode (AuthRequest client 600 u scopes') (unpack url)) >>= liftIO
let u = fromJust mUser liftIO $ print mAuthCode
mAuthCode <- asks (genUnencryptedCode (AuthRequest client 600 u scopes' mNonce) (unpack url)) >>= liftIO liftIO . putStrLn $ "user: " ++ show u ++ " | scopes: " ++ show (map (showScope @user @userData) scopes')
liftIO $ print mAuthCode redirect $ addParams url mAuthCode mState
liftIO . putStrLn $ "user: " ++ show u ++ " | scopes: " ++ show (map show scopes') redirect :: Maybe Text -> AuthHandler user Text
redirect $ addParams url mAuthCode mState redirect (Just url) = liftIO (print url) >> return url --throwError err303 { errHeaders = [("Location", url)]}
where redirect Nothing = throwError err500 { errBody = "Could not generate authorisation code."}
redirect :: Maybe Text -> AuthHandler user Text addParams :: Text -> Maybe Text -> Maybe Text -> Maybe Text
redirect (Just url) = liftIO (print url) >> return url --throwError err303 { errHeaders = [("Location", url)]} addParams url Nothing _ = Nothing
redirect Nothing = throwError err500 { errBody = "Could not generate authorisation code."} addParams url (Just code) mState =
addParams :: Text -> Maybe Text -> Maybe Text -> Maybe Text let urlParts = splitOn "?" url
addParams url Nothing _ = Nothing (pre, post) = if length urlParts == 2 then (urlParts !! 0, urlParts !! 1) else (head urlParts, "")
addParams url (Just code) mState = rState = case mState of {Just s -> "&state=" <> (replace "/" "%2F" $ replace "=" "%3D" s); Nothing -> ""}
let urlParts = splitOn "?" url post' = if not (T.null post) then "&" <> T.tail post else post
(pre, post) = if length urlParts == 2 then (urlParts !! 0, urlParts !! 1) else (head urlParts, "") in Just $ pre <> "?code=" <> code <> post' <> rState
rState = case mState of {Just s -> "&" <> (decodeUtf8 . toStrict $ urlEncodeParams [("state", s)]) ; Nothing -> ""}
post' = if not (T.null post) then "&" <> T.tail post else post
in Just $ pre <> "?code=" <> code <> post' <> rState
@ -241,7 +202,6 @@ data ClientData = ClientData --TODO support other flows
, clientID :: Maybe String , clientID :: Maybe String
, clientSecret :: Maybe String , clientSecret :: Maybe String
, redirect :: Maybe String , redirect :: Maybe String
, scopeSubset :: Maybe QScope
} deriving Show } deriving Show
data AuthFlow = AuthFlow data AuthFlow = AuthFlow
@ -252,12 +212,11 @@ instance FromHttpApiData AuthFlow where
instance FromForm ClientData where instance FromForm ClientData where
fromForm f = ClientData fromForm f = ClientData
<$> ((Left . ACode <$> parseUnique "code" f) <|> (Right . RToken <$> parseUnique "refresh_token" f)) <$> ((Left . ACode <$> parseUnique "code" f) <|> (parseMaybe @String "scope" f *> (Right . RToken <$> parseUnique "refresh_token" f)))
<*> parseUnique "grant_type" f <*> parseUnique "grant_type" f
<*> parseMaybe "client_id" f <*> parseMaybe "client_id" f
<*> parseMaybe "client_secret" f <*> parseMaybe "client_secret" f
<*> parseMaybe "redirect_uri" f <*> parseMaybe "redirect_uri" f
<*> parseMaybe "scope" f
instance Error Text where instance Error Text where
strMsg = pack strMsg = pack
@ -277,25 +236,23 @@ tokenEndpoint = provideToken
unless (isNothing (clientID client >> clientSecret client) unless (isNothing (clientID client >> clientSecret client)
|| Client (pack . fromJust $ clientID client) (pack . fromJust $ clientSecret client) `elem` trustedClients) . || Client (pack . fromJust $ clientID client) (pack . fromJust $ clientSecret client) `elem` trustedClients) .
throwError $ err500 { errBody = "Invalid client" } throwError $ err500 { errBody = "Invalid client" }
let cid = pack <$> clientID client
case authID client of case authID client of
Left (ACode authCode) -> do Left (ACode authCode) -> do
unless (grantType client == "authorization_code") . throwError $ err500 { errBody = "Invalid grant_type" } unless (grantType client == "authorization_code") . throwError $ err500 { errBody = "Invalid grant_type" }
mUser <- asks (verify (pack authCode) (clientID client)) >>= liftIO -- TODO verify redirect url here mUser <- asks (verify (pack authCode) (clientID client)) >>= liftIO -- TODO verify redirect url here
unless (isJust mUser) . throwError $ err500 { errBody = "Invalid authorisation code" } unless (isJust mUser) . throwError $ err500 { errBody = "Invalid authorisation code" }
-- return JWT {token = "", tokenType = "JWT", expiration = 0.25 * nominalDay} -- return JWT {token = "", tokenType = "JWT", expiration = 0.25 * nominalDay}
token <- asks (mkToken @user @userData (fromJust mUser) cid) >>= liftIO let (user, scopes) = fromJust mUser
token <- asks (mkToken @user user scopes) >>= liftIO
liftIO . putStrLn $ "token: " ++ show token liftIO . putStrLn $ "token: " ++ show token
return token return token
Right (RToken jwtw) -> do Right (RToken jwtw) -> do
let scopes' = (map (read @(Scope' user)) . words) <$> scopeSubset client
liftIO . putStrLn $ "\aSCOPES: " ++ show scopes'
unless (grantType client == "refresh_token") . throwError $ err500 { errBody = "Invalid grant_type" } unless (grantType client == "refresh_token") . throwError $ err500 { errBody = "Invalid grant_type" }
liftIO $ putStrLn "... checking refresh token" liftIO $ putStrLn "... checking refresh token"
eToken <- asks (renewToken @user @userData jwtw (fromMaybe [] scopes') cid) >>= liftIO mToken <- asks (renewToken @user jwtw) >>= liftIO
case eToken of case mToken of
Right token -> liftIO (putStrLn $ "refreshed token: " ++ show token) >> return token Just token -> liftIO (putStrLn $ "refreshed token: " ++ show token) >> return token
Left err -> throwError $ err500 { errBody = fromStrict $ encodeUtf8 err } Nothing -> throwError $ err500 { errBody = "Invalid refresh token" }
---------------------- ----------------------
@ -328,7 +285,7 @@ instance ToJSON result => ToJSON (QueryResult result) where
toJSON (QRight x) = toJSON x toJSON (QRight x) = toJSON x
verifyToken :: forall user userData . UserData user userData => Text -> AuthHandler user (Maybe (TokenParams user)) verifyToken :: forall user userData . UserData user userData => Text -> AuthHandler user (Maybe (user, [Scope user]))
verifyToken jwtw = do verifyToken jwtw = do
let mToken = stripPrefix "Bearer " jwtw let mToken = stripPrefix "Bearer " jwtw
unless (isJust mToken) . throwError $ err500 { errBody = "Invalid token format" } unless (isJust mToken) . throwError $ err500 { errBody = "Invalid token format" }
@ -348,7 +305,7 @@ userEndpoint = handleUserData
where where
handleUserData :: Text -> AuthHandler user (QueryResult userData) handleUserData :: Text -> AuthHandler user (QueryResult userData)
handleUserData jwtw = verifyToken @user @userData jwtw >>= \case handleUserData jwtw = verifyToken @user @userData jwtw >>= \case
Just (u, scopes, _) -> return . QRight . mconcat $ map (userScope @user @userData u) scopes Just (u, scopes) -> return . QRight . mconcat $ map (userScope @user @userData u) scopes
Nothing -> return . QLeft $ QError "UnknownToken" Nothing -> return . QLeft $ QError "UnknownToken"
@ -356,79 +313,13 @@ userListEndpoint :: forall user userData . UserData user userData => AuthServer
userListEndpoint = handleUserData userListEndpoint = handleUserData
where where
handleUserData :: Text -> Text -> AuthHandler user (QueryResult [userData]) handleUserData :: Text -> Text -> AuthHandler user (QueryResult [userData])
handleUserData jwtw userID = verifyToken @user @userData jwtw >>= \case -- TODO check if this user is allowed to query other users handleUserData jwtw userID = verifyToken @user @userData jwtw >>= \case -- TODO check if this user is allowed query other users
Nothing -> return . QLeft $ QError "UnknownToken" Nothing -> return . QLeft $ QError "UnknownToken"
Just (_, scopes, _) -> liftIO $ lookupUser @user @userData (UserQuery (Just userID) Nothing Nothing) >>= \case Just admin -> liftIO $ lookupUser @user @userData userID Nothing >>= \case
Just u -> return $ QRight [mconcat $ map (userScope @user @userData u) scopes] -- TODO support queries that fit for multiple users Just u -> return $ QRight [mconcat $ map (userScope @user @userData u) (snd admin)] -- TODO support queries that fit for multiple users
Nothing -> return . QLeft $ QError "UserDoesNotExist" Nothing -> return . QLeft $ QError "UserDoesNotExist"
--------------
---- Logout ----
--------------
{-type Redirect (cs :: [*]) (hs :: [*]) a = Verb 'GET 303 cs (Headers (Header "Location" Text : hs) a)
type CookieLogout = "clogout"
:> QParam "redirect" QRedirect
:> Redirect '[HTML] '[] Text
clogoutEndpoint :: forall user userData . UserData user userData => AuthServer user CookieLogout
clogoutEndpoint uri = do
return $ addHeader uri ""-}
type Logout = "logout"
:> QueryParam "post_logout_redirect_uri" QRedirect
:> HeaderR "Cookie" QCookie
:> Get '[HTML] (Headers '[Header "Set-Cookie" Text] Html)
{- :> Redirect '[HTML] '[Header "Set-Cookie" Text] NoContent
:<|> "logout"
:> HeaderR "Cookie" QCookie
:> Get '[HTML] (Headers '[Header "Set-Cookie" Text] Html) -}
logoutEndpoint :: forall user userData . UserData user userData => AuthServer user Logout
logoutEndpoint = logout -- rLogout :<|> logout
where
logout :: Maybe QRedirect
-> QCookie
-> AuthHandler user (Headers '[Header "Set-Cookie" Text] Html)
logout mRedir cookie = do
let mCreds = lookup authCookie . parseCookiesText $ encodeUtf8 cookie
unless (isJust mCreds) $ throwError err401 { errBody = "Missing auth cookie" }
liftIO . putStrLn $ "\nLOGOUT\n "
return . addHeader (authCookie <> "=\"\"") $ logoutPage mRedir
{- checkCookie :: QCookie -> AuthHandler user ()
checkCookie cookie = do
let mCreds = lookup authCookie . parseCookiesText $ encodeUtf8 cookie
unless (isJust mCreds) $ throwError err401 { errBody = "Missing auth cookie" }
return ()
rLogout :: QRedirect
-> QCookie
-> AuthHandler user (Headers '[ Header "Location" Text
, Header "Set-Cookie" Text
] NoContent)
rLogout uri cookie = do
liftIO . putStrLn $ "\nLOGOUT with uri " <> show uri <> "\n"
checkCookie cookie
let param = decodeUtf8 . toStrict $ urlEncodeParams [("redirect", uri)]
uri' = "../clogout?" <> param
addHeader uri' . addHeader (authCookie <> "=\"\"") <$> return NoContent
logout :: QCookie
-> AuthHandler user (Headers '[Header "Set-Cookie" Text] Html)
logout cookie = do
liftIO . putStrLn $ "\nLOGOUT\n "
checkCookie cookie
return . addHeader (authCookie <> "=\"\"")$ logoutPage Nothing
-- liftIO $ do
-- port <- getEnv "OAUTH2_SERVER_PORT"
-- manager <- newManager defaultManagerSettings
-- req <- parseRequest $ "GET http://localhost:" ++ port ++ "/clogout" -- TODO get root
-- void $ httpLbs req manager -}
------------------- -------------------
---- Server Main ---- ---- Server Main ----
------------------- -------------------
@ -438,8 +329,6 @@ type Routing user userData = Auth
:<|> Token :<|> Token
:<|> Me userData :<|> Me userData
:<|> UserList userData :<|> UserList userData
-- :<|> CookieLogout
:<|> Logout
routing :: forall user userData . UserData user userData => AuthServer user (Routing user userData) routing :: forall user userData . UserData user userData => AuthServer user (Routing user userData)
routing = loginServer @user @userData routing = loginServer @user @userData
@ -447,45 +336,44 @@ routing = loginServer @user @userData
:<|> tokenEndpoint @user @userData :<|> tokenEndpoint @user @userData
:<|> userEndpoint @user @userData :<|> userEndpoint @user @userData
:<|> userListEndpoint @user @userData :<|> userListEndpoint @user @userData
-- :<|> clogoutEndpoint @user @userData
:<|> logoutEndpoint @user @userData
insecureOAuthMock :: forall user userData routes . -- insecureOAuthMock :: Application
(UserData user userData, HasServer routes '[]) -- insecureOAuthMock = authAPI `serve` exampleAuthServer
=> AuthState user
-> AuthServer user routes insecureOAuthMock' :: forall user userData . UserData user userData => AuthState user -> Application
-> Application insecureOAuthMock' s = serve authAPI $ hoistServer authAPI (toHandler @user @userData s) (routing @user @userData)
insecureOAuthMock s r = serve authAPI $ hoistServer authAPI (toHandler @user @userData s) (routing @user @userData :<|> r)
where where
authAPI = Proxy @(Routing user userData :<|> routes) authAPI = Proxy @(Routing user userData)
runMockServerWithRoutes :: forall user userData routes . -- authenticate :: [User] -> BasicAuthCheck User
(UserData user userData, HasServer routes '[]) -- authenticate users = BasicAuthCheck $ \authData -> do
=> Int -- let
-> AuthServer user routes -- (uEmail, uPass) = (,) <$> (decodeUtf8 . basicAuthUsername) <*> (decodeUtf8 . basicAuthPassword) $ authData
-> IO () -- case (find (\u -> email u == uEmail) users) of
runMockServerWithRoutes port server = do -- Nothing -> return NoSuchUser
-- Just u -> return $ if uPass == password u then Authorized u else BadPassword
-- frontend :: BasicAuthData -> ClientM (Map.Map Text Text)
-- frontend ba = client authAPI ba "[ID]" "42" "code" ""
runMockServer :: forall user userData . UserData user userData => Int -> IO ()
runMockServer port = do
state <- mkState @user @userData state <- mkState @user @userData
run port $ insecureOAuthMock @user @userData @routes state server run port $ insecureOAuthMock' @user @userData state
runMockServer :: forall user userData . UserData user userData
=> Int
-> IO ()
runMockServer port = runMockServerWithRoutes @user @userData @EmptyAPI port emptyServer
-- runMockServer' :: Int -> IO () -- runMockServer' :: Int -> IO ()
-- runMockServer' port = do -- runMockServer' port = do
-- mgr <- newManager defaultManagerSettings -- mgr <- newManager defaultManagerSettings
-- state <- mkState -- state <- mkState
-- bracket (forkIO . run port $ insecureOAuthMock testUsers state) killThread $ \_ -> -- bracket (forkIO . run port $ insecureOAuthMock' testUsers state) killThread $ \_ ->
-- runClientM (frontend $ BasicAuthData "foo@bar.com" "0000") (mkClientEnv mgr (BaseUrl Http "localhost" port "")) -- runClientM (frontend $ BasicAuthData "foo@bar.com" "0000") (mkClientEnv mgr (BaseUrl Http "localhost" port ""))
-- >>= print -- >>= print
mkState :: forall user userData . UserData user userData => IO (AuthState user) mkState :: forall user userData . UserData user userData => IO (AuthState user)
mkState = do mkState = do
(publicKey, privateKey) <- generateRsaKeyPair 256 (KeyId "Oauth2MockJWT") Enc Nothing (publicKey, privateKey) <- generateRsaKeyPair 256 (KeyId "Oauth2MockKey") Enc Nothing
let let
activeCodes = Map.empty activeCodes = Map.empty
activeTokens = Map.empty activeTokens = Map.empty

View File

@ -3,75 +3,21 @@
-- --
-- SPDX-License-Identifier: AGPL-3.0-or-later -- SPDX-License-Identifier: AGPL-3.0-or-later
{-# LANGUAGE OverloadedStrings, RecordWildCards, TypeFamilies, TypeApplications, MultiParamTypeClasses, FlexibleInstances, FlexibleContexts, AllowAmbiguousTypes, RankNTypes, OverlappingInstances, ScopedTypeVariables #-} {-# LANGUAGE OverloadedStrings, RecordWildCards, TypeFamilies, MultiParamTypeClasses, FlexibleInstances, AllowAmbiguousTypes #-}
module User module User ( UserData(..) ) where
( UserData(..)
, Scope(..)
, Scope'(..)
, UserQuery(..)
) where
import Control.Applicative ((<|>))
import Data.Aeson import Data.Aeson
import Data.Char (toUpper, toLower)
import Data.Map.Strict import Data.Map.Strict
import Data.Maybe import Data.Maybe
import Data.Text hiding (head, tail, toUpper, toLower) import Data.Text
import GHC.Read (readPrec, lexP)
import Text.ParserCombinators.ReadPrec (look, pfail)
import qualified Text.Read.Lex as Lex
type UserName = Text type UserName = Text
type Password = Text type Password = Text
-- | OIDC scope class (Eq u, Show u, ToJSON a, Monoid a) => UserData u a where -- TODO Show maybe not necessary
data Scope = OpenID data Scope u
| Profile readScope :: String -> Scope u
| Email showScope :: Scope u -> String
| Address userScope :: u -> Scope u -> a
| Phone lookupUser :: UserName -> Maybe Password -> IO (Maybe u)
| OfflineAccess
deriving (Show, Eq)
instance Read Scope where
readPrec = do
Lex.Ident str <- lexP
Lex.EOF <- lexP
return $ case str of
"openid" -> OpenID
"profile" -> Profile
"email" -> Email
"address" -> Address
"phone" -> Phone
"offline_access" -> OfflineAccess
type Scope' user = Either Scope (CustomScope user)
instance forall user . Read (CustomScope user) => Read (Scope' user) where
readPrec = (Left <$> readPrec @Scope) <|> (Right <$> readPrec @(CustomScope user))
data UserQuery = UserQuery
{ email :: Maybe Text
, password :: Maybe Text
, key :: Maybe Text
} deriving (Show)
class ( Eq u
, Show u -- TODO Show maybe not necessary
, Show (UserID u)
, Read (CustomScope u)
, Show (CustomScope u)
, Eq (CustomScope u)
, ToJSON a
, Monoid a
) => UserData u a where
type UserID u
data CustomScope u
userScope :: u -> Scope' u -> a
lookupUser :: UserQuery -> IO (Maybe u)
userID :: u -> UserID u

View File

@ -1,231 +0,0 @@
# SPDX-FileCopyrightText: 2024 David Mosbach <david.mosbach@uniworx.de>
#
# SPDX-License-Identifier: AGPL-3.0-or-later
special-users:
- default: &default-user
userIdent: null
userAuthentication: AuthLDAP
userLastAuthentication: null
userTokensIssuedAfter: null
userMatrikelnummer: null
userEmail: ""
userDisplayEmail: null
userDisplayName: null
userSurname: ""
userFirstName: ""
userTitle: null
userMaxFavourites: userDefaultMaxFavourites
userMaxFavouriteTerms: userDefaultMaxFavouriteTerms
userTheme: ThemeDefault
userDateTimeFormat: userDefaultDateTimeFormat
userDateFormat: userDefaultDateFormat
userTimeFormat: userDefaultTimeFormat
userDownloadFiles: userDefaultDownloadFiles
userWarningDays: userDefaultWarningDays
userLanguages: null
userCreated: now
userNotificationSettings: def
userLastLdapSynchronisation: null
userLdapPrimaryKey: null
userCsvOptions: def
userSex: null
userBirthday: null
userShowSex: userDefaultShowSex
userTelephone: null
userMobile: null
userCompanyPersonalNumber: null
userCompanyDepartment: null
userPinPassword: null
userPostAddress: null
userPostLastUpdate: null
userPrefersPostal: true
userExamOfficeGetSynced: userDefaultExamOfficeGetSynced
userExamOfficeGetLabels: userDefaultExamOfficeGetLabels
- gkleen:
<<: *default-user
userIdent: "G.Kleen@campus.lmu.de"
userLastAuthentication: now
userTokensIssuedAfter: now
userEmail: "G.Kleen@campus.lmu.de"
userDisplayEmail: "gregor.kleen@ifi.lmu.de"
userDisplayName: "Gregor Kleen"
userSurname: "Kleen"
userFirstName: "Gregor Julius Arthur"
userMaxFavourites: 6
userMaxFavouriteTerms: 1
userLanguages: ["en"]
# userCsvOptions = def { csvFormat = csvPreset # CsvPresetRFC }
userSex: SexMale
userCompanyPersonalNumber: "00000"
userPostAddress: "Büro 127 \nMathematisches Institut der Ludwig-Maximilians-Universität München \nTheresienstr. 39 \nD-80333 München"
- fhamann:
<<: *default-user
userIdent: "felix.hamann@campus.lmu.de"
userEmail: "noEmailKnown"
userDisplayEmail: "felix.hamann@campus.lmu.de"
userDisplayName: "Felix Hamann"
userSurname: "Hamann"
userFirstName: "Felix"
# userCsvOptions = def { csvFormat = csvPreset # CsvPresetExcel }
userSex: SexMale
userPinPassword: "tomatenmarmelade"
userPostAddress: "Erdbeerweg 24 \n12345 Schlumpfhausen \nTraumland"
- jost:
<<: *default-user
userIdent: "jost@tcs.ifi.lmu.de"
userAuthentication: pwSimple
userMatrikelnummer: "12345678"
userEmail: "S.Jost@Fraport.de"
userDisplayEmail: "jost@tcs.ifi.lmu.de"
userDisplayName: "Steffen Jost"
userSurname: "Jost"
userFirstName: "Steffen"
userTitle: "Dr."
userMaxFavourites: 14
userMaxFavouriteTerms: 4
userTheme: ThemeMossGreen
userSex: SexMale
# userBirthday = Just $ n_day $ 35 * (-365)
userTelephone: "+49 69 690-71706"
userMobile: "0173 69 99 646"
userCompanyPersonalNumber: "57138"
userCompanyDepartment: "AVN-AR2"
- maxMuster:
<<: *default-user
userIdent: "max@campus.lmu.de"
userLastAuthentication: now
userMatrikelnummer: "1299"
userEmail: "max@campus.lmu.de"
userDisplayEmail: "max@max.com"
userDisplayName: "Max Musterstudent"
userSurname: "Musterstudent"
userFirstName: "Max"
userMaxFavourites: 7
userTheme: ThemeAberdeenReds
userLanguages: ["de"]
userSex: SexMale
# userBirthday = Just $ n_day $ 27 * (-365)
userPrefersPostal: false
- tinaTester:
<<: *default-user
userIdent: "tester@campus.lmu.de"
userAuthentication: null
userMatrikelnummer: "999"
userEmail: "tester@campus.lmu.de"
userDisplayEmail: "tina@tester.example"
userDisplayName: "Tina Tester"
userSurname: "vön Tërrör¿"
userFirstName: "Sabrina"
userTitle: "Magister"
userMaxFavourites: 5
userTheme: ThemeAberdeenReds
userLanguages: ["sn"]
userSex: SexNotApplicable
# userBirthday = Just $ n_day 3
userCompanyPersonalNumber: "12345"
userPrefersPostal: false
- svaupel:
<<: *default-user
userIdent: "vaupel.sarah@campus.lmu.de"
userEmail: "vaupel.sarah@campus.lmu.de"
userDisplayEmail: "vaupel.sarah@campus.lmu.de"
userDisplayName: "Sarah Vaupel"
userSurname: "Vaupel"
userFirstName: "Sarah"
userMaxFavourites: 14
userMaxFavouriteTerms: 4
userTheme: ThemeMossGreen
userLanguages: null
userSex: SexFemale
userPrefersPostal: false
- sbarth:
<<: *default-user
userIdent: "Stephan.Barth@campus.lmu.de"
userEmail: "Stephan.Barth@lmu.de"
userDisplayEmail: "stephan.barth@ifi.lmu.de"
userDisplayName: "Stephan Barth"
userSurname: "Barth"
userFirstName: "Stephan"
userTheme: ThemeMossGreen
userSex: SexMale
userPrefersPostal: false
userExamOfficeGetSynced: false
userExamOfficeGetLabels: true
- _stranger1:
userIdent: "AVSID:996699"
userEmail: "E996699@fraport.de"
userDisplayEmail: ""
userDisplayName: "Stranger One"
userSurname: "One"
userFirstName: "Stranger"
userTheme: ThemeMossGreen
userSex: SexMale
userCompanyPersonalNumber: "E996699"
userCompanyDepartment: "AVN-Strange"
userPrefersPostal: false
userExamOfficeGetSynced: false
userExamOfficeGetLabels: true
- _stranger2:
userIdent: "AVSID:669966"
userEmail: "E669966@fraport.de"
userDisplayEmail: ""
userDisplayName: "Stranger Two"
userSurname: "Stranger"
userFirstName: "Two"
userTheme: ThemeMossGreen
userSex: SexMale
userCompanyPersonalNumber: "669966"
userCompanyDepartment: "AVN-Strange"
userPrefersPostal: false
userExamOfficeGetSynced: false
userExamOfficeGetLabels: true
- _stranger3:
userIdent: "AVSID:6969"
userEmail: "E6969@fraport.de"
userDisplayEmail: ""
userDisplayName: "Stranger 3 Three"
userSurname: "Three"
userFirstName: "Stranger"
userTheme: ThemeMossGreen
userSex: SexMale
userCompanyPersonalNumber: "E996699"
userCompanyDepartment: "AVN-Strange"
userPostAddress: "Kartoffelweg 12 \n666 Höllensumpf \nFreiland"
userPrefersPostal: false
userExamOfficeGetSynced: false
userExamOfficeGetLabels: true
random-users:
firstNames: [ "James", "John", "Robert", "Michael"
, "William", "David", "Mary", "Richard"
, "Joseph", "Thomas", "Charles", "Daniel"
, "Matthew", "Patricia", "Jennifer", "Linda"
, "Elizabeth", "Barbara", "Anthony", "Donald"
, "Mark", "Paul", "Steven", "Andrew"
, "Kenneth", "Joshua", "George", "Kevin"
, "Brian", "Edward", "Susan", "Ronald"
]
surnames: [ "Smith", "Johnson", "Williams", "Brown"
, "Jones", "Miller", "Davis", "Garcia"
, "Rodriguez", "Wilson", "Martinez", "Anderson"
, "Taylor", "Thomas", "Hernandez", "Moore"
, "Martin", "Jackson", "Thompson", "White"
, "Lopez", "Lee", "Gonzalez", "Harris"
, "Clark", "Lewis", "Robinson", "Walker"
, "Perez", "Hall", "Young", "Allen"
]
middlenames: [ null, "Jamesson" ]