Implement authentication via OpenIdConnect and OAuth2

The new subproject "oidc" handles all the details for working with an
OpenID Connect provider (like keycloak) or only OAuth2 - only
supporting the "Authorization Code Flow" for both variants.
This commit is contained in:
eikek
2021-09-05 16:29:42 +02:00
parent 48b35e175f
commit b73c252762
17 changed files with 902 additions and 5 deletions

View File

@ -61,6 +61,44 @@ docspell.server {
}
}
# Configures OpenID Connect or OAuth2 authentication. Only the
# "Authorization Code Flow" is supported.
#
# When using OpenID Connect, a scope is mandatory.
# TODO
#
# Below are examples for OpenID Connect (keycloak) and OAuth2
# (github).
openid =
[ { enabled = false,
provider = {
provider-id = "keycloak",
client-id = "docspell",
client-secret = "21cd4550-6328-439e-bf06-911e4cdd56a6",
scope = "docspell",
authorize-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/auth",
token-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/token",
#User URL is not used when signature key is set.
#user-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/userinfo",
sign-key = "b64:MII…ZYL09vAwLn8EAcSkCAwEAAQ==",
sig-algo = "RS512"
}
},
{ enabled = false,
provider = {
provider-id = "github",
client-id = "<your github client id>",
client-secret = "<your github client secret>",
scope = "",
authorize-url = "https://github.com/login/oauth/authorize",
token-url = "https://github.com/login/oauth/access_token",
user-url = "https://api.github.com/user",
sign-key = ""
sig-algo = "RS256" #unused but must be set to something
}
}
]
# This endpoint allows to upload files to any collective. The
# intention is that local software integrates with docspell more
# easily. Therefore the endpoint is not protected by the usual

View File

@ -10,6 +10,8 @@ import docspell.backend.auth.Login
import docspell.backend.{Config => BackendConfig}
import docspell.common._
import docspell.ftssolr.SolrConfig
import docspell.oidc.ProviderConfig
import docspell.restserver.Config.OpenIdConfig
import com.comcast.ip4s.IpAddress
@ -25,7 +27,8 @@ case class Config(
maxItemPageSize: Int,
maxNoteLength: Int,
fullTextSearch: Config.FullTextSearch,
adminEndpoint: Config.AdminEndpoint
adminEndpoint: Config.AdminEndpoint,
openid: List[OpenIdConfig]
)
object Config {
@ -70,4 +73,6 @@ object Config {
object FullTextSearch {}
final case class OpenIdConfig(enabled: Boolean, provider: ProviderConfig)
}

View File

@ -8,6 +8,7 @@ package docspell.restserver
import docspell.backend.signup.{Config => SignupConfig}
import docspell.common.config.Implicits._
import docspell.oidc.SignatureAlgo
import pureconfig._
import pureconfig.generic.auto._
@ -21,5 +22,8 @@ object ConfigFile {
object Implicits {
implicit val signupModeReader: ConfigReader[SignupConfig.Mode] =
ConfigReader[String].emap(reason(SignupConfig.Mode.fromString))
implicit val sigAlgoReader: ConfigReader[SignatureAlgo] =
ConfigReader[String].emap(reason(SignatureAlgo.fromString))
}
}

View File

@ -12,12 +12,16 @@ import fs2.Stream
import docspell.backend.auth.AuthToken
import docspell.common._
import docspell.oidc.CodeFlowRoutes
import docspell.restserver.auth.OpenId
import docspell.restserver.http4s.EnvMiddleware
import docspell.restserver.routes._
import docspell.restserver.webapp._
import org.http4s._
import org.http4s.blaze.client.BlazeClientBuilder
import org.http4s.blaze.server.BlazeServerBuilder
import org.http4s.client.Client
import org.http4s.dsl.Http4sDsl
import org.http4s.headers.Location
import org.http4s.implicits._
@ -33,9 +37,10 @@ object RestServer {
restApp <-
RestAppImpl
.create[F](cfg, pools.connectEC, pools.httpClientEC)
httpClient <- BlazeClientBuilder[F](pools.httpClientEC).resource
httpApp = Router(
"/api/info" -> routes.InfoRoutes(),
"/api/v1/open/" -> openRoutes(cfg, restApp),
"/api/v1/open/" -> openRoutes(cfg, httpClient, restApp),
"/api/v1/sec/" -> Authenticate(restApp.backend.login, cfg.auth) { token =>
securedRoutes(cfg, restApp, token)
},
@ -98,8 +103,17 @@ object RestServer {
"clientSettings" -> ClientSettingsRoutes(restApp.backend, token)
)
def openRoutes[F[_]: Async](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] =
def openRoutes[F[_]: Async](
cfg: Config,
client: Client[F],
restApp: RestApp[F]
): HttpRoutes[F] =
Router(
"auth/oauth" -> CodeFlowRoutes(
OpenId.handle[F](restApp.backend, cfg),
OpenId.codeFlowConfig(cfg),
client
),
"auth" -> LoginRoutes.login(restApp.backend.login, cfg),
"signup" -> RegisterRoutes(restApp.backend, cfg),
"upload" -> UploadRoutes.open(restApp.backend, cfg),

View File

@ -0,0 +1,57 @@
/*
* Copyright 2020 Docspell Contributors
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package docspell.restserver.auth
import cats.effect._
import docspell.backend.BackendApp
import docspell.oidc.{CodeFlowConfig, OnUserInfo}
import docspell.restserver.Config
import docspell.restserver.http4s.ClientRequestInfo
object OpenId {
def codeFlowConfig[F[_]](config: Config): CodeFlowConfig[F] =
CodeFlowConfig(
req =>
ClientRequestInfo
.getBaseUrl(config, req) / "api" / "v1" / "open" / "auth" / "oauth",
id =>
config.openid.filter(_.enabled).find(_.provider.providerId == id).map(_.provider)
)
def handle[F[_]: Async](backend: BackendApp[F], cfg: Config): OnUserInfo[F] =
OnUserInfo((req, provider, userInfo) =>
userInfo match {
case Some(userJson) =>
println(s"$backend $cfg")
OnUserInfo.logInfo[F].handle(req, provider, Some(userJson))
case None =>
OnUserInfo.logInfo[F].handle(req, provider, None)
}
)
// u <- userInfo
// newAcc <- OptionT.liftF(
// NewAccount.create(u ++ Ident.unsafe(":") ++ p.providerId, AccountSource.OAuth(p.id.id))
// )
// 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)
// )
// } yield token
//
// val uri = getBaseUrl( req).withQuery("oauth", "1") / "app" / "login"
// val location = Location(Uri.unsafeFromString(uri.asString))
// userId.value.flatMap {
// case Some(t) =>
// TemporaryRedirect(location)
// .map(_.addCookie(CookieData(t).asCookie(getBaseUrl(req))))
// case None => TemporaryRedirect(location)
// }
}