diff --git a/config/settings.yml b/config/settings.yml index 36a0094e3..787a584c3 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel , Gregor Kleen , Sarah Vaupel , Steffen Jost , Wolfgang Witt +# SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel , David Mosbach , Gregor Kleen , Sarah Vaupel , Steffen Jost , Wolfgang Witt # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -126,10 +126,9 @@ database: database: "_env:PGDATABASE:uniworx" poolsize: "_env:PGPOOLSIZE:990" -auto-db-migrate: '_env:AUTO_DB_MIGRATE:true' +auto-db-migrate: "_env:AUTO_DB_MIGRATE:true" # External sources used for user authentication and userdata lookups -# TODO: add SSO option for user-auth config user-auth: # mode: single-source protocol: azureadv2 @@ -150,6 +149,8 @@ user-auth: # timeout: "_env:LDAPTIMEOUT:5" # search-timeout: "_env:LDAPSEARCHTIME:5" +single-sign-on: "_env:OIDC_SSO:true" + # TODO: generalize for arbitrary auth protocols # TODO: maybe use separate pools for external databases? ldap-pool: diff --git a/messages/uniworx/categories/authorization/de-de-formal.msg b/messages/uniworx/categories/authorization/de-de-formal.msg index 48648198c..5204bddb7 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-2024 Sarah Vaupel , Gregor Kleen , Sarah Vaupel , Steffen Jost , Winnie Ros +# SPDX-FileCopyrightText: 2022-2024 David Mosbach , Sarah Vaupel , Gregor Kleen , Sarah Vaupel , Steffen Jost , Winnie Ros # # 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 47735ffd8..713afeec3 100644 --- a/messages/uniworx/categories/authorization/en-eu.msg +++ b/messages/uniworx/categories/authorization/en-eu.msg @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel , Gregor Kleen , Sarah Vaupel , Steffen Jost , Winnie Ros +# SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel , David Mosbach , Gregor Kleen , Sarah Vaupel , Steffen Jost , Winnie Ros # # 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 8bcdf9ec9..19282706e 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-2024 Sarah Vaupel , Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Steffen Jost ,Winnie Ros +# SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel , David Mosbach , Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Steffen Jost ,Winnie Ros # # 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 1b59f781a..c091491b5 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-2024 Sarah Vaupel , Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Winnie Ros +# SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel , David Mosbach , Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,Winnie Ros # # 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 ec953250d..bc88f82e2 100644 --- a/routes +++ b/routes @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-2024 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 76d56defd..7a53057e7 100644 --- a/src/Application.hs +++ b/src/Application.hs @@ -163,6 +163,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 8d217cbf3..272129052 100644 --- a/src/Auth/OAuth2.hs +++ b/src/Auth/OAuth2.hs @@ -13,13 +13,14 @@ module Auth.OAuth2 , azureMockServer , queryOAuth2User , refreshOAuth2Token + , singleSignOut ) where -- import qualified Data.CaseInsensitive as CI 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) @@ -102,9 +103,9 @@ azureUserPreferredLanguage = "preferredLanguage" -- = runMaybeT . catchIfMaybeT (is _AzureUserNoResult) $ azureUser conf (Creds apAzure (CI.original userIdent) []) ----------------------------------------- ----- OAuth2 development auth plugin ---- ----------------------------------------- +----------------------------------------------- +---- OAuth2 + OIDC development auth plugin ---- +----------------------------------------------- apAzureMock :: Text apAzureMock = "uniworx_dev" @@ -119,7 +120,11 @@ azureMockServer 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 } @@ -165,7 +170,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 @@ -204,8 +210,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 $ scopeParam " " ["ID","Profile"] : body + return $ body ++ [("client_id", fromString clientID), ("client_secret", fromString clientSecret), scopeParam " " ["openid","profile"," offline_access"]] -- TODO read from config + else return $ scopeParam " " ["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 @@ -216,3 +222,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 f2a87dd9a..2bef8bec5 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 @@ -24,6 +27,7 @@ import Auth.OAuth2 (apAzure, apAzureMock) 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 @@ -43,6 +47,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 @@ -129,14 +135,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") @@ -158,6 +173,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 8f3f58467..42eb412ca 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 @@ -540,42 +542,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 238d21791..650ed85f4 100644 --- a/src/Settings.hs +++ b/src/Settings.hs @@ -1,4 +1,4 @@ --- SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel , Gregor Kleen , Sarah Vaupel , Steffen Jost +-- SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel , Gregor Kleen ,Sarah Vaupel ,Steffen Jost ,David Mosbach -- -- SPDX-License-Identifier: AGPL-3.0-or-later @@ -452,7 +452,14 @@ data AppSettings = AppSettings , appDatabaseConf :: PostgresConf -- ^ Configuration settings for accessing the database. , appAutoDbMigrate :: Bool +<<<<<<< HEAD , appUserAuthConf :: UserAuthConf -- TODO: add SSO option for user-auth config +======= + , appSingleSignOn :: Bool + -- ^ Enable OIDC single sign-on + , appLdapConf :: Maybe (PointedList LdapConf) + -- ^ Configuration settings for CSV export/import to LMS (= Learn Management System) +>>>>>>> 139-single-sign-on-sso-routing-anpassen , appLmsConf :: LmsConf -- ^ Configuration settings for CSV export/import to LMS (= Learn Management System) -- TODO, TODISCUSS: reimplement as user-auth source? , appAvsConf :: Maybe AvsConf @@ -624,6 +631,7 @@ instance FromJSON AppSettings where appWebpackEntrypoints <- o .: "webpack-manifest" appDatabaseConf <- o .: "database" appAutoDbMigrate <- o .: "auto-db-migrate" +<<<<<<< HEAD -- TODO: reintroduce non-emptyness check for ldap hosts -- let nonEmptyHost (UserDbLdap LdapConf{..}) = case ldapHost of -- Ldap.Tls host _ -> not $ null host @@ -632,6 +640,13 @@ instance FromJSON AppSettings where appUserAuthConf <- o .: "user-auth" -- P.fromList . mapMaybe (assertM nonEmptyHost) <$> o .:? "user-database" .!= [] appLdapPoolConf <- o .:? "ldap-pool" +======= + appSingleSignOn <- o .: "single-sign-on" + let nonEmptyHost LdapConf{..} = case ldapHost of + Ldap.Tls host _ -> not $ null host + Ldap.Plain host -> not $ null host + appLdapConf <- P.fromList . mapMaybe (assertM nonEmptyHost) <$> o .:? "ldap" .!= [] +>>>>>>> 139-single-sign-on-sso-routing-anpassen appLmsConf <- o .: "lms-direct" appAvsConf <- assertM (not . null . avsPass) <$> o .:? "avs" appLprConf <- o .: "lpr" 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 cbb45e165..8b3cb046b 100644 --- a/templates/login.hamlet +++ b/templates/login.hamlet @@ -1,6 +1,6 @@ $newline never -$# SPDX-FileCopyrightText: 2022-2024 Sarah Vaupel , 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 +