diff --git a/config/settings.yml b/config/settings.yml index 602c9c0e2..28858440b 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Wolfgang Witt +# SPDX-FileCopyrightText: 2022-2024 Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Wolfgang Witt ,David Mosbach # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -131,6 +131,8 @@ database: auto-db-migrate: '_env:AUTO_DB_MIGRATE:true' +single-sign-on: "_env:OIDC_SSO:true" + ldap: - host: "_env:LDAPHOST:" tls: "_env:LDAPTLS:" diff --git a/messages/uniworx/categories/authorization/de-de-formal.msg b/messages/uniworx/categories/authorization/de-de-formal.msg index f9a26de23..667051a51 100644 --- a/messages/uniworx/categories/authorization/de-de-formal.msg +++ b/messages/uniworx/categories/authorization/de-de-formal.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Winnie Ros +# SPDX-FileCopyrightText: 2022-2024 Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Winnie Ros ,David Mosbach # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -139,3 +139,6 @@ FormHoneypotNamePlaceholder: Name FormHoneypotComment: Kommentar FormHoneypotCommentPlaceholder: Kommentar FormHoneypotFilled: Bitte füllen Sie keines der verstecken Felder aus + +Logout: Abmeldung +SingleSignOut: Abmeldung bei Azure diff --git a/messages/uniworx/categories/authorization/en-eu.msg b/messages/uniworx/categories/authorization/en-eu.msg index b539efbf1..f31413299 100644 --- a/messages/uniworx/categories/authorization/en-eu.msg +++ b/messages/uniworx/categories/authorization/en-eu.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Winnie Ros +# SPDX-FileCopyrightText: 2022-2024 Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Winnie Ros ,David Mosbach # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -140,3 +140,6 @@ FormHoneypotNamePlaceholder !ident-ok: Name FormHoneypotComment: Comment FormHoneypotCommentPlaceholder: Comment FormHoneypotFilled: Please do not fill in any of the hidden fields + +Logout: Logout +SingleSignOut: Azure logout diff --git a/messages/uniworx/utils/navigation/menu/de-de-formal.msg b/messages/uniworx/utils/navigation/menu/de-de-formal.msg index 78e095b6d..77e7baa57 100644 --- a/messages/uniworx/utils/navigation/menu/de-de-formal.msg +++ b/messages/uniworx/utils/navigation/menu/de-de-formal.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Steffen Jost ,Winnie Ros +# SPDX-FileCopyrightText: 2022-2024 Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Steffen Jost ,Winnie Ros ,David Mosbach # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -24,6 +24,7 @@ MenuPayments: Zahlungsbedingungen MenuInstance: Instanz-Identifikation MenuHealth: Instanz-Zustand MenuHelp: Hilfe +MenuAccount: Konto MenuProfile: Anpassen MenuLogin !ident-ok: Login MenuLogout !ident-ok: Logout diff --git a/messages/uniworx/utils/navigation/menu/en-eu.msg b/messages/uniworx/utils/navigation/menu/en-eu.msg index bb085c38e..f4d48aa93 100644 --- a/messages/uniworx/utils/navigation/menu/en-eu.msg +++ b/messages/uniworx/utils/navigation/menu/en-eu.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022 Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Winnie Ros +# SPDX-FileCopyrightText: 2022-2024 Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Winnie Ros ,David Mosbach # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -24,6 +24,7 @@ MenuPayments: Payment Terms MenuInstance: Instance identification MenuHealth: Instance health MenuHelp: Support +MenuAccount: Account MenuProfile: Settings MenuLogin: Login MenuLogout: Logout diff --git a/routes b/routes index 2376c33af..13f6914f9 100644 --- a/routes +++ b/routes @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-2023 Sarah Vaupel , Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Wolfgang Witt +-- SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel , Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Wolfgang Witt ,David Mosbach -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -46,6 +46,9 @@ /static StaticR EmbeddedStatic appStatic !free /auth AuthR Auth getAuth !free +/logout SOutR GET !free +/logout/ssout SSOutR GET !free -- single sign-out (OIDC) + /metrics MetricsR GET !free -- verify if this can be free /err ErrorR GET !free diff --git a/shell.nix b/shell.nix index 8c3f8b97e..a5ca0056c 100644 --- a/shell.nix +++ b/shell.nix @@ -9,7 +9,8 @@ let haskellPackages = pkgs.haskellPackages; - oauth2Flake = (builtins.getFlake "git+https://gitlab.uniworx.de/mosbach/oauth2-mock-server/?rev=d47908b4f7883b4b485abf1ee06645495ccdc7b3&ref=user-queries").packages.x86_64-linux; + oauth2Flake = (builtins.getFlake "git+https://gitlab.uniworx.de/mosbach/oauth2-mock-server/?rev=7b995e6cffa963a24eb5d0373b2d29089533284f&ref=main").packages.x86_64-linux; + oauth2MockServer = oauth2Flake.default; mkOauth2DB = oauth2Flake.mkOauth2DB; diff --git a/src/Application.hs b/src/Application.hs index 08fef42ee..215f4631d 100644 --- a/src/Application.hs +++ b/src/Application.hs @@ -164,6 +164,7 @@ import Handler.PrintCenter import Handler.ApiDocs import Handler.Swagger import Handler.Firm +import Handler.SingleSignOut import ServantApi () -- YesodSubDispatch instances import Servant.API diff --git a/src/Auth/OAuth2.hs b/src/Auth/OAuth2.hs index fab04ca16..5ed9921e2 100644 --- a/src/Auth/OAuth2.hs +++ b/src/Auth/OAuth2.hs @@ -8,15 +8,16 @@ module Auth.OAuth2 ( AzureUserException(..) , azurePluginName , oauth2MockServer -, mockPluginName +, mockPluginName , queryOAuth2User , UserDataException +, singleSignOut ) where import Data.Maybe (fromJust) import Data.Text -import Import.NoFoundation hiding (unpack) +import Import.NoFoundation hiding (pack, unpack) import Network.HTTP.Simple (httpJSONEither, getResponseBody, JSONException) @@ -36,9 +37,9 @@ instance Exception AzureUserException azurePluginName :: Text azurePluginName = "azureadv2" ----------------------------------------- ----- OAuth2 development auth plugin ---- ----------------------------------------- +----------------------------------------------- +---- OAuth2 + OIDC development auth plugin ---- +----------------------------------------------- mockPluginName :: Text mockPluginName = "dev-oauth2-mock" @@ -53,7 +54,11 @@ oauth2MockServer port = let oa = OAuth2 { oauth2ClientId = "42" , oauth2ClientSecret = Just "shhh" - , oauth2AuthorizeEndpoint = (fromString $ mockServerURL <> "/auth") `withQuery` [scopeParam " " ["ID", "Profile"]] + , oauth2AuthorizeEndpoint = (fromString $ mockServerURL <> "/auth") + `withQuery` [ scopeParam " " ["openid", "profile", "email", "offline_access"] -- TODO read scopes from config + , ("response_type", "code id_token") + , ("nonce", "Foo") -- TODO generate meaningful value + ] , oauth2TokenEndpoint = fromString $ mockServerURL <> "/token" , oauth2RedirectUri = Nothing } @@ -94,7 +99,8 @@ queryOAuth2User userID = runExceptT $ do setSessionJson SessionOAuth2Token (Just $ accessToken newTokens, refreshToken newTokens) eResult <- lift $ getResponseBody <$> httpJSONEither @m @j (req { secure = secure - , requestHeaders = [("Authorization", encodeUtf8 . ("Bearer " <>) . atoken $ accessToken newTokens)] }) + , requestHeaders = [("Authorization", encodeUtf8 . ("Bearer " <>) . atoken $ accessToken newTokens)] + }) case eResult of Left x -> throwE $ UserDataJSONException x Right x -> return x @@ -130,8 +136,8 @@ refreshOAuth2Token (_, rToken) url secure body' <- if secure then do clientID <- liftIO $ fromJust <$> lookupEnv "CLIENT_ID" clientSecret <- liftIO $ fromJust <$> lookupEnv "CLIENT_SECRET" - return $ body ++ [("client_id", fromString clientID), ("client_secret", fromString clientSecret), ("scope", "openid profile")] - else return $ ("scope", "ID Profile") : body + return $ body ++ [("client_id", fromString clientID), ("client_secret", fromString clientSecret), ("scope", "openid profile offline_access")] -- TODO read from config + else return $ ("scope", "openid profile offline_access") : body -- TODO read from config $logErrorS "\27[31mAdmin Handler\27[0m" $ tshow (requestBody $ urlEncodedBody body' req{ secure = secure }) eResult <- lift $ getResponseBody <$> httpJSONEither @m @OAuth2Token (urlEncodedBody body' req{ secure = secure }) case eResult of @@ -142,3 +148,25 @@ refreshOAuth2Token (_, rToken) url secure instance Show RequestBody where show (RequestBodyLBS x) = show x show _ = error ":(" + + + ----------------------- +---- Single Sign-Out ---- + ----------------------- + +singleSignOut :: forall a m. (MonadHandler m) + => Maybe Text -- ^ redirect uri + -> m a +singleSignOut mRedirect = do +# ifdef DEVELOPMENT + port <- liftIO $ fromJust <$> lookupEnv "OAUTH2_SERVER_PORT" + let base = "http://localhost:" <> pack port <> "/logout" +# else + let base = "" -- TODO find out fraport oidc end_session_endpoint +# endif + endpoint = case mRedirect of + Just r -> base <> "?post_logout_redirect_uri=" <> r + Nothing -> base + $logErrorS "\n\27[31mSSO\27[0m" endpoint + redirect endpoint + diff --git a/src/Foundation/Instances.hs b/src/Foundation/Instances.hs index 20d10b2de..8321894f8 100644 --- a/src/Foundation/Instances.hs +++ b/src/Foundation/Instances.hs @@ -11,11 +11,14 @@ module Foundation.Instances , unsafeHandler ) where +import qualified Prelude as P + import Import.NoFoundation import qualified Data.Text as Text import Data.List (inits) +import Yesod.Auth.OAuth2 import qualified Yesod.Core.Unsafe as Unsafe import qualified Yesod.Auth.Message as Auth @@ -23,6 +26,7 @@ import Utils.Form import Auth.LDAP import Auth.PWHash import Auth.Dummy +import Auth.OAuth2 import qualified Foundation.Yesod.Session as UniWorX import qualified Foundation.Yesod.Middleware as UniWorX @@ -42,6 +46,8 @@ import Foundation.DB import Network.Wai.Parse (lbsBackEnd) +import System.Environment (lookupEnv) + import UnliftIO.Pool (withResource) import qualified Control.Monad.State.Class as State @@ -128,14 +134,23 @@ instance YesodAuth UniWorX where -- Where to send a user after logout logoutDest _ = NewsR -- Override the above two destinations when a Referer: header is present - redirectToReferer _ = True + redirectToReferer _ = False loginHandler = do + plugins <- getsYesod authPlugins + AppSettings{..} <- getsYesod appSettings' + + when appSingleSignOn $ do + let plugin = P.head $ P.filter ((`elem` [mockPluginName, azurePluginName]) . apName) plugins + pieces = case oauth2Url (apName plugin) of + PluginR _ p -> p + _ -> error "Unexpected OAuth2 AuthRoute" + void $ apDispatch plugin "GET" pieces + toParent <- getRouteToParent liftHandler . defaultLayout $ do - plugins <- getsYesod authPlugins $logDebugS "Auth" $ "Enabled plugins: " <> Text.intercalate ", " (map apName plugins) - + mPort <- liftIO $ lookupEnv "OAUTH2_SERVER_PORT" setTitleI MsgLoginTitle $(widgetFile "login") @@ -159,6 +174,11 @@ instance YesodAuth UniWorX where addMessage Success . toHtml $ mr Auth.NowLoggedIn + -- onLogout = do + -- AppSettings{..} <- getsYesod appSettings' + -- when appSingleSignOn $ singleSignOut @UniWorX Nothing + + onErrorHtml dest msg = do addMessage Error $ toHtml msg redirect dest diff --git a/src/Foundation/Navigation.hs b/src/Foundation/Navigation.hs index bf486ed22..a52cdbfb6 100644 --- a/src/Foundation/Navigation.hs +++ b/src/Foundation/Navigation.hs @@ -73,6 +73,8 @@ breadcrumb :: ( BearerAuthSite UniWorX => Route UniWorX -> m Breadcrumb breadcrumb (AuthR _) = i18nCrumb MsgMenuLogin $ Just NewsR +breadcrumb SOutR = i18nCrumb MsgLogout Nothing +breadcrumb SSOutR = i18nCrumb MsgSingleSignOut Nothing breadcrumb (StaticR _) = i18nCrumb MsgBreadcrumbStatic Nothing breadcrumb (WellKnownR _) = i18nCrumb MsgBreadcrumbWellKnown Nothing breadcrumb MetricsR = i18nCrumb MsgBreadcrumbMetrics Nothing @@ -541,42 +543,37 @@ defaultLinks :: ( MonadHandler m , BearerAuthSite UniWorX ) => m [Nav] defaultLinks = fmap catMaybes . mapM runMaybeT $ -- Define the menu items of the header. - [ return NavHeader + [ return NavHeaderContainer { navHeaderRole = NavHeaderSecondary - , navIcon = IconMenuLogout - , navLink = NavLink - { navLabel = MsgMenuLogout - , navRoute = AuthR LogoutR - , navAccess' = NavAccessHandler $ is _Just <$> maybeAuthId - , navType = NavTypeLink { navModal = False } - , navQuick' = mempty - , navForceActive = False - } - } - , return NavHeader - { navHeaderRole = NavHeaderSecondary - , navIcon = IconMenuLogin - , navLink = NavLink - { navLabel = MsgMenuLogin - , navRoute = AuthR LoginR - , navAccess' = NavAccessHandler $ is _Nothing <$> maybeAuthId - , navType = NavTypeLink { navModal = True } - , navQuick' = mempty - , navForceActive = False - } - } - , return NavHeader - { navHeaderRole = NavHeaderSecondary - , navIcon = IconMenuProfile - , navLink = NavLink - { navLabel = MsgMenuProfile - , navRoute = ProfileR - , navAccess' = NavAccessHandler $ is _Just <$> maybeAuthId - , navType = NavTypeLink { navModal = False } - , navQuick' = mempty - , navForceActive = False - } - } + , navLabel = SomeMessage MsgMenuAccount + , navIcon = IconMenuAccount + , navChildren = + [ NavLink + { navLabel = MsgMenuLogout + , navRoute = SSOutR -- AuthR LogoutR + , navAccess' = NavAccessHandler $ is _Just <$> maybeAuthId + , navType = NavTypeLink { navModal = False } + , navQuick' = mempty + , navForceActive = False + } + , NavLink + { navLabel = MsgMenuLogin + , navRoute = AuthR LoginR + , navAccess' = NavAccessHandler $ is _Nothing <$> maybeAuthId + , navType = NavTypeLink { navModal = False } + , navQuick' = mempty + , navForceActive = False + } + , NavLink + { navLabel = MsgMenuProfile + , navRoute = ProfileR + , navAccess' = NavAccessHandler $ is _Just <$> maybeAuthId + , navType = NavTypeLink { navModal = False } + , navQuick' = mempty + , navForceActive = False + } + ] + } , do mCurrentRoute <- getCurrentRoute diff --git a/src/Handler/SingleSignOut.hs b/src/Handler/SingleSignOut.hs new file mode 100644 index 000000000..8b89a19d0 --- /dev/null +++ b/src/Handler/SingleSignOut.hs @@ -0,0 +1,31 @@ +-- SPDX-FileCopyrightText: 2024 David Mosbach +-- +-- SPDX-License-Identifier: AGPL-3.0-or-later + +module Handler.SingleSignOut + ( getSOutR + , getSSOutR + ) where + +import Import +import Auth.OAuth2 (singleSignOut) +import qualified Network.Wai as W + + +getSOutR :: Handler Html +getSOutR = do + $logErrorS "\27[31mSOut\27[0m" "Redirect to LogoutR" + redirect $ AuthR LogoutR + +getSSOutR :: Handler Html +getSSOutR = do + app <- getYesod + let redir = intercalate "/" . fst . renderRoute $ SOutR + root = case approot of + ApprootRequest f -> f app W.defaultRequest + _ -> error "approt implementation changed" + url = decodeUtf8 . urlEncode True . encodeUtf8 $ root <> "/" <> redir + AppSettings{..} <- getsYesod appSettings' + $logErrorS "\27[31mSSOut\27[0m" "Redirect to auth server" + if appSingleSignOn then singleSignOut (Just url) else redirect (AuthR LogoutR) + diff --git a/src/Settings.hs b/src/Settings.hs index e3fcc6105..5dadb7646 100644 --- a/src/Settings.hs +++ b/src/Settings.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022 Gregor Kleen ,Sarah Vaupel ,Steffen Jost +-- SPDX-FileCopyrightText: 2022-2024 Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,David Mosbach -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -96,6 +96,8 @@ data AppSettings = AppSettings , appDatabaseConf :: PostgresConf -- ^ Configuration settings for accessing the database. , appAutoDbMigrate :: Bool + , appSingleSignOn :: Bool + -- ^ Enable OIDC single sign-on , appLdapConf :: Maybe (PointedList LdapConf) -- ^ Configuration settings for CSV export/import to LMS (= Learn Management System) , appLmsConf :: LmsConf @@ -627,6 +629,7 @@ instance FromJSON AppSettings where appWebpackEntrypoints <- o .: "webpack-manifest" appDatabaseConf <- o .: "database" appAutoDbMigrate <- o .: "auto-db-migrate" + appSingleSignOn <- o .: "single-sign-on" let nonEmptyHost LdapConf{..} = case ldapHost of Ldap.Tls host _ -> not $ null host Ldap.Plain host -> not $ null host diff --git a/src/Utils/Icon.hs b/src/Utils/Icon.hs index 07804c015..1f72ea042 100644 --- a/src/Utils/Icon.hs +++ b/src/Utils/Icon.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-23 Gregor Kleen ,Sarah Vaupel ,Sarah Vaupel ,Steffen Jost ,Wolfgang Witt ,Steffen Jost +-- SPDX-FileCopyrightText: 2022-24 Gregor Kleen ,Sarah Vaupel ,Sarah Vaupel ,Steffen Jost ,Wolfgang Witt ,Steffen Jost ,David Mosbach -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -81,6 +81,7 @@ data Icon | IconNavContainerClose | IconPageActionChildrenClose | IconMenuNews | IconMenuHelp + | IconMenuAccount | IconMenuProfile | IconMenuLogin | IconMenuLogout | IconBreadcrumbsHome @@ -173,6 +174,7 @@ iconText = \case IconPageActionChildrenClose -> "chevron-up" IconMenuNews -> "megaphone" IconMenuHelp -> "question" + IconMenuAccount -> "user" IconMenuProfile -> "cogs" IconMenuLogin -> "sign-in-alt" IconMenuLogout -> "sign-out-alt" diff --git a/templates/login.hamlet b/templates/login.hamlet index bb3ee704e..e5dc2706f 100644 --- a/templates/login.hamlet +++ b/templates/login.hamlet @@ -1,6 +1,6 @@ $newline never -$# SPDX-FileCopyrightText: 2022-2024 Gregor Kleen ,David Mosbach +$# SPDX-FileCopyrightText: 2022-2024 Gregor Kleen , David Mosbach $# $# SPDX-License-Identifier: AGPL-3.0-or-later @@ -26,3 +26,8 @@ $forall AuthPlugin{apName, apLogin} <- plugins

_{MsgDummyLoginTitle} ^{apLogin toParent} +$maybe port <- mPort +
+

SSO Dev Test + Test login via single sign-on +