diff --git a/modules/backend/src/main/scala/sharry/backend/auth/AuthConfig.scala b/modules/backend/src/main/scala/sharry/backend/auth/AuthConfig.scala index 6d6df2c5..520246c4 100644 --- a/modules/backend/src/main/scala/sharry/backend/auth/AuthConfig.scala +++ b/modules/backend/src/main/scala/sharry/backend/auth/AuthConfig.scala @@ -10,6 +10,7 @@ case class AuthConfig( http: AuthConfig.Http, httpBasic: AuthConfig.HttpBasic, command: AuthConfig.Command, + proxy: AuthConfig.Proxy, internal: AuthConfig.Internal, oauth: Seq[AuthConfig.OAuth] ) { @@ -17,12 +18,29 @@ case class AuthConfig( def isOAuthOnly: Boolean = fixed.disabled && http.disabled && httpBasic.disabled && command.disabled && - internal.disabled && oauth.nonEmpty + internal.disabled && proxy.disabled && oauth.exists(_.enabled) + def isProxyAuthOnly: Boolean = + fixed.disabled && http.disabled && + httpBasic.disabled && command.disabled && + internal.disabled && !oauth.exists(_.enabled) && proxy.enabled + + def isAutoLogin: Boolean = + fixed.disabled && http.disabled && + httpBasic.disabled && command.disabled && + internal.disabled && (oauth.exists(_.enabled) || proxy.enabled) } object AuthConfig { + final case class Proxy( + enabled: Boolean, + userHeader: String, + emailHeader: Option[String] + ) { + def disabled = !enabled + } + case class Fixed(enabled: Boolean, user: Ident, password: Password, order: Int) { def disabled = !enabled } diff --git a/modules/backend/src/test/scala/sharry/backend/auth/LoginModuleTest.scala b/modules/backend/src/test/scala/sharry/backend/auth/LoginModuleTest.scala index 6637b667..2829b2df 100644 --- a/modules/backend/src/test/scala/sharry/backend/auth/LoginModuleTest.scala +++ b/modules/backend/src/test/scala/sharry/backend/auth/LoginModuleTest.scala @@ -26,6 +26,7 @@ class LoginModuleTest extends FunSuite { AuthConfig.Http(true, LenientUri.unsafe("http://test.com"), "GET", "", "", 2), AuthConfig.HttpBasic(true, LenientUri.unsafe("http://test.com"), "GET", 3), AuthConfig.Command(true, Seq.empty, 0, 4), + AuthConfig.Proxy(false, "", None), AuthConfig.Internal(true, 5), Seq.empty ) diff --git a/modules/microsite/docs/doc/configure.md b/modules/microsite/docs/doc/configure.md index 3ad3d022..6b63344d 100644 --- a/modules/microsite/docs/doc/configure.md +++ b/modules/microsite/docs/doc/configure.md @@ -449,6 +449,23 @@ The internal login module simply authenticates against the sharry database. If it is disabled, you should disable signup, too, because those user won't be authenticated. +#### Proxy + +The `proxy` option allows automatically authenticate users by trusting +specific request headers. The configured headers of the login request +to `open/auth/proxy` are read and a user account is created if +missing. Be aware that sharry blindly trusts these headers. + +``` +proxy { + enabled = false + user-header = "X-Valid-User" + email-header = "X-User-Email" +} +``` + +The webapp automatically logs the user in, if the auth configuration +only consists of proxy auth and nothing else. #### OAuth diff --git a/modules/restapi/src/main/resources/sharry-openapi.yml b/modules/restapi/src/main/resources/sharry-openapi.yml index a02fb21c..dfdf5481 100644 --- a/modules/restapi/src/main/resources/sharry-openapi.yml +++ b/modules/restapi/src/main/resources/sharry-openapi.yml @@ -151,6 +151,20 @@ paths: description: OK '403': description: Forbidden + /open/auth/proxy: + post: + operationId: "open-auth-proxy" + tags: [ Authentication ] + summary: Authenticate via request headers + description: | + When using an authenticating proxy in front, this login route + can be used to rely on trusted request headers to perform + login. + responses: + '200': + description: Ok + '403': + description: Forbidden /sec/auth/session: post: operationId: "sec-auth-session" @@ -1923,6 +1937,9 @@ components: - defaultValidity - initialTheme - oauthAutoRedirect + - proxyAuthEnabled + - proxyOnly + - hideLoginForm properties: appName: type: string @@ -1990,6 +2007,12 @@ components: format: theme oauthAutoRedirect: type: boolean + proxyAuthEnabled: + type: boolean + proxyOnly: + type: boolean + hideLoginForm: + type: boolean OAuthItem: description: | Information about a configured OAuth provider. diff --git a/modules/restserver/src/main/resources/reference.conf b/modules/restserver/src/main/resources/reference.conf index 4fc0a42a..4763a904 100644 --- a/modules/restserver/src/main/resources/reference.conf +++ b/modules/restserver/src/main/resources/reference.conf @@ -107,10 +107,11 @@ sharry.restserver { # The inital ui theme to use. Can be either 'light' or 'dark'. initial-theme = "light" - # When only OAuth is configured and only a single provider, then - # the weapp automatically redirects to its authentication page - # skipping the sharry login page. This will also disable the - # logout button, since sharry is not in charge anyways. + # When only OAuth (or only Proxy Auth) is configured and only a + # single provider, then the weapp automatically redirects to its + # authentication page skipping the sharry login page. This will + # also disable the logout button, since sharry is not in charge + # anyways. oauth-auto-redirect = true # A custom html snippet that is rendered into the html head @@ -265,8 +266,8 @@ sharry.restserver { id = "aad" name = "Azure AD" icon = "fab fa-microsoft" - scope = "" - authorize-url = "https://login.microsoftonline.com//oauth2/v2.0/authorize?scope=openid" + scope = "openid" + authorize-url = "https://login.microsoftonline.com//oauth2/v2.0/authorize" token-url = "https://login.microsoftonline.com//oauth2/v2.0/token" user-url = "https://graph.microsoft.com/oidc/userinfo" user-id-key = "email" @@ -275,6 +276,16 @@ sharry.restserver { client-secret = "" } ] + + # Allows to inspect the request headers for finding already + # authorized user name/email. If enabled and during login the + # request contains these headers, they will be used to + # automatically create accounts. + proxy { + enabled = false + user-header = "X-Valid-User" + email-header = "X-User-Email" + } } # The database connection. diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/InfoRoutes.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/InfoRoutes.scala index 2993791d..fcc717f9 100644 --- a/modules/restserver/src/main/scala/sharry/restserver/routes/InfoRoutes.scala +++ b/modules/restserver/src/main/scala/sharry/restserver/routes/InfoRoutes.scala @@ -76,7 +76,10 @@ object InfoRoutes { cfg.aliasMemberEnabled, cfg.webapp.defaultValidity, cfg.webapp.initialTheme, - cfg.webapp.oauthAutoRedirect + cfg.webapp.oauthAutoRedirect, + cfg.backend.auth.proxy.enabled, + cfg.backend.auth.isProxyAuthOnly, + cfg.backend.auth.isAutoLogin ) } diff --git a/modules/restserver/src/main/scala/sharry/restserver/routes/LoginRoutes.scala b/modules/restserver/src/main/scala/sharry/restserver/routes/LoginRoutes.scala index 21026d2e..6caa8fd9 100644 --- a/modules/restserver/src/main/scala/sharry/restserver/routes/LoginRoutes.scala +++ b/modules/restserver/src/main/scala/sharry/restserver/routes/LoginRoutes.scala @@ -20,6 +20,7 @@ import org.http4s.circe.CirceEntityEncoder._ import org.http4s.client.Client import org.http4s.dsl.Http4sDsl import org.http4s.headers.Location +import org.typelevel.ci.CIString object LoginRoutes { @@ -42,6 +43,51 @@ object LoginRoutes { resp <- makeResponse(dsl, cfg, req, res, up.account) } yield resp + case req @ POST -> Root / "proxy" => + val unameOpt = + req.headers + .get(CIString(cfg.backend.auth.proxy.userHeader)) + .map(_.head.value) + .filter(_ => cfg.backend.auth.proxy.enabled) + + val email = cfg.backend.auth.proxy.emailHeader + .map(CIString.apply) + .flatMap(req.headers.get(_).map(_.head.value)) + + def doLogin(userId: Ident) = for { + newAcc <- NewAccount.create(userId, AccountSource.Extern, email = email) + token <- finalizeLogin(cfg, S)(newAcc) + resp <- makeResponse(dsl, cfg, req, LoginResult.ok(token), userId.id) + } yield resp + + for { + _ <- + if (cfg.backend.auth.proxy.disabled) + logger.info("Proxy authentication is disabled in the config!") + else logger.debug(s"Use proxy authentication: user=$unameOpt, email=$email") + + resp <- unameOpt.map(Ident.apply) match { + case None => + makeResponse( + dsl, + cfg, + req, + LoginResult.invalidAuth, + unameOpt.getOrElse("") + ) + case Some(Left(err)) => + logger.error(s"Error reading username from header: $err") >> + makeResponse( + dsl, + cfg, + req, + LoginResult.invalidAuth, + unameOpt.getOrElse("") + ) + case Some(Right(userId)) => doLogin(userId) + } + } yield resp + case req @ GET -> Root / "oauth" / id => findOAuthProvider(cfg, id) match { case Some(p) => @@ -94,12 +140,7 @@ object LoginRoutes { email = u.email ) ) - acc <- OptionT.liftF(S.account.createIfMissing(newAcc)) - accId = acc.accountId(None) - _ <- OptionT.liftF(S.account.updateLoginStats(accId)) - token <- OptionT.liftF( - AuthToken.user[F](accId, cfg.backend.auth.serverSecret) - ) + token <- OptionT.liftF(finalizeLogin(cfg, S)(newAcc)) } yield token val uri = getBaseUrl(cfg, req).withQuery("oauth", "1") / "app" / "login" @@ -113,6 +154,16 @@ object LoginRoutes { } } + private def finalizeLogin[F[_]: Async](cfg: Config, S: BackendApp[F])( + newAcc: NewAccount + ) = + for { + acc <- S.account.createIfMissing(newAcc) + accId = acc.accountId(None) + _ <- S.account.updateLoginStats(accId) + token <- AuthToken.user[F](accId, cfg.backend.auth.serverSecret) + } yield token + private def redirectUri[F[_]]( cfg: Config, req: Request[F], diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 1ea48dbc..e9a88768 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -23,6 +23,7 @@ module Api exposing , listAliasMember , loadAccount , login + , loginProxy , loginSession , logout , modifyAccount @@ -537,6 +538,15 @@ login flags up receive = } +loginProxy : Flags -> (Result Http.Error AuthResult -> msg) -> Cmd msg +loginProxy flags receive = + Http.post + { url = flags.config.baseUrl ++ "/api/v2/open/auth/proxy" + , body = Http.emptyBody + , expect = Http.expectJson receive Api.Model.AuthResult.decoder + } + + logout : Flags -> (Result Http.Error () -> msg) -> Cmd msg logout flags receive = Http2.authPost diff --git a/modules/webapp/src/main/elm/App/View.elm b/modules/webapp/src/main/elm/App/View.elm index 91b0aca2..fbcd1209 100644 --- a/modules/webapp/src/main/elm/App/View.elm +++ b/modules/webapp/src/main/elm/App/View.elm @@ -265,13 +265,13 @@ userMenu2 texts model acc = , a [ href "#" , class dropdownItem - , classList [ ( "disabled", Data.Flags.isOAuthAutoRedirect model.flags ) ] - , if Data.Flags.isOAuthAutoRedirect model.flags then + , classList [ ( "disabled", Data.Flags.isAutoRedirect model.flags ) ] + , if Data.Flags.isAutoRedirect model.flags then class "" else onClick Logout - , if Data.Flags.isOAuthAutoRedirect model.flags then + , if Data.Flags.isAutoRedirect model.flags then title texts.app.logoutOAuth else diff --git a/modules/webapp/src/main/elm/Data/Flags.elm b/modules/webapp/src/main/elm/Data/Flags.elm index 8e278186..9471f14c 100644 --- a/modules/webapp/src/main/elm/Data/Flags.elm +++ b/modules/webapp/src/main/elm/Data/Flags.elm @@ -22,6 +22,16 @@ isOAuthAutoRedirect flags = flags.config.oauthAutoRedirect && flags.config.oauthOnly +isProxyAutoRedirect : Flags -> Bool +isProxyAutoRedirect flags = + flags.config.oauthAutoRedirect && flags.config.proxyOnly + + +isAutoRedirect : Flags -> Bool +isAutoRedirect flags = + isOAuthAutoRedirect flags || isProxyAutoRedirect flags + + getToken : Flags -> Maybe String getToken flags = flags.account diff --git a/modules/webapp/src/main/elm/Page/Login/Data.elm b/modules/webapp/src/main/elm/Page/Login/Data.elm index a66a5738..c6ce9029 100644 --- a/modules/webapp/src/main/elm/Page/Login/Data.elm +++ b/modules/webapp/src/main/elm/Page/Login/Data.elm @@ -26,6 +26,7 @@ type Msg = SetUsername String | SetPassword String | Authenticate + | AuthenticateProxy | AuthResp (Result Http.Error AuthResult) | Init | LangChooseMsg Comp.LanguageChoose.Msg diff --git a/modules/webapp/src/main/elm/Page/Login/Update.elm b/modules/webapp/src/main/elm/Page/Login/Update.elm index cc1a3268..09bdf2f1 100644 --- a/modules/webapp/src/main/elm/Page/Login/Update.elm +++ b/modules/webapp/src/main/elm/Page/Login/Update.elm @@ -31,6 +31,9 @@ update ( referrer, oauth ) flags key msg model = _ -> ( model, Cmd.none, Nothing ) + else if not oauth && Data.Flags.isProxyAutoRedirect flags && flags.account == Nothing then + ( model, Api.loginProxy flags AuthResp, Nothing ) + else ( model, Cmd.none, Nothing ) @@ -43,6 +46,9 @@ update ( referrer, oauth ) flags key msg model = Authenticate -> ( model, Api.login flags (UserPass model.username model.password) AuthResp, Nothing ) + AuthenticateProxy -> + ( model, Api.loginProxy flags AuthResp, Nothing ) + AuthResp (Ok lr) -> if lr.success then loginSuccess referrer flags lr model diff --git a/modules/webapp/src/main/elm/Page/Login/View.elm b/modules/webapp/src/main/elm/Page/Login/View.elm index fc217a0c..3539a35b 100644 --- a/modules/webapp/src/main/elm/Page/Login/View.elm +++ b/modules/webapp/src/main/elm/Page/Login/View.elm @@ -7,7 +7,7 @@ import Data.Flags exposing (Flags) import Data.UiTheme exposing (UiTheme) import Html exposing (..) import Html.Attributes exposing (..) -import Html.Events exposing (onInput, onSubmit) +import Html.Events exposing (onClick, onInput, onSubmit) import Markdown import Messages.LoginPage exposing (Texts) import Page exposing (Page(..)) @@ -39,7 +39,7 @@ view texts flags currentTheme model = , onSubmit Authenticate , autocomplete False , classList - [ ( "hidden invisible", flags.config.oauthOnly ) ] + [ ( "hidden invisible", flags.config.hideLoginForm ) ] ] [ div [ class "flex flex-col mt-6" ] [ label @@ -101,6 +101,7 @@ view texts flags currentTheme model = else renderOAuthButtons texts flags model + , renderProxyAuthButton texts , resultMessage texts model , renderLangAndSignup texts flags ] @@ -189,6 +190,25 @@ renderOAuthButton texts flags item = ] +renderProxyAuthButton : Texts -> Html Msg +renderProxyAuthButton texts = + div + [ class "flex flex-row space-x-2 flex-wrap items-center justify-center" + ] + [ a + [ class S.primaryBasicButton + , class "mt-1" + , href "#" + , onClick AuthenticateProxy + ] + [ i [ class "fa fa-right-to-bracket" ] [] + , span [ class "ml-2" ] + [ text texts.loginButton + ] + ] + ] + + resultMessage : Texts -> Model -> Html Msg resultMessage texts model = case model.result of