Authenticate with external accounts using OIDC

After successful authentication at the provider, an account is
automatically created at docspell and the user is logged in.
This commit is contained in:
eikek
2021-09-05 21:39:09 +02:00
parent 7edb96a297
commit f8362329a9
13 changed files with 382 additions and 75 deletions

View File

@ -64,17 +64,62 @@ docspell.server {
# Configures OpenID Connect or OAuth2 authentication. Only the
# "Authorization Code Flow" is supported.
#
# When using OpenID Connect, a scope is mandatory.
# TODO
# Multiple authentication providers are supported. Each is
# configured in the array below. The `provider` block gives all
# details necessary to authenticate agains an external OpenIdConnect
# or OAuth provider. This requires at least two URLs for
# OpenIdConnect and three for OAuth2. The `user-url` is only
# required for OpenIdConnect, if the account data is to be retrieved
# from the user-info endpoint and not from the access token. This
# will use the access token to authenticate at the provider to
# obtain user info. Thus, it doesn't need to be validated and
# therefore no `sign-key` setting is needed. However, if you want to
# extract the account information from the access token, it must be
# validated here and therefore the correct signature key and
# algorithm must be provided.
#
# After successful authentication, docspell needs to create the
# account. For this a username and collective name is required.
# There are the following ways to specify how to retrieve this info
# depending on the value of `collective-key`. The `user-key` is used
# to search the JSON structure, that is obtained from the JWT token
# or the user-info endpoint, for the login name to use. It traverses
# the JSON structure recursively, until it finds an object with that
# key. The first value is used.
#
# If it starts with `fixed:`, like "fixed:collective", the name
# after the `fixed:` prefix is used as collective as is. So all
# users are in the same collective.
#
# If it starts with `lookup:`, like "lookup:collective_name", the
# value after the prefix is used to search the JSON response for an
# object with this key, just like it works with the `user-key`.
#
# If it starts with `account:`, like "account:doscpell-collective",
# it works the same as `lookup:` only that it is interpreted as the
# account name of form `collective/name`. The `user-key` value is
# ignored in this case.
#
# Below are examples for OpenID Connect (keycloak) and OAuth2
# (github).
openid =
[ { enabled = false,
# This illustrates to use a custom keycloak setup as the
# authentication provider. For details, please refer to its
# documentation.
#
# Keycloak can be configured to return the collective name for
# each user in the access token. It may also be configured to
# return it in the user info response. If it is already in the
# access token, an additional request can be omitted. Set the
# `sign-key` to an empty string then. Otherwise provide the
# algo and key from your realm settings. In this example, the
# realm is called "home".
provider = {
provider-id = "keycloak",
client-id = "docspell",
client-secret = "21cd4550-6328-439e-bf06-911e4cdd56a6",
client-secret = "example-secret-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",
@ -82,9 +127,22 @@ docspell.server {
#user-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/userinfo",
sign-key = "b64:MII…ZYL09vAwLn8EAcSkCAwEAAQ==",
sig-algo = "RS512"
}
},
# The collective of the user is given in the access token as
# property `docspell_collective`.
collective-key = "lookup:docspell_collective",
# The username to use for the docspell account
user-key = "preferred_username"
},
{ enabled = false,
# Provider settings for using github as an authentication
# provider. Note that this is only an example to illustrate
# how it works. Usually you wouldn't want to let every user on
# github in ;-).
#
# Github doesn't have full OpenIdConnect yet, but supports the
# OAuth2 code flow.
provider = {
provider-id = "github",
client-id = "<your github client id>",
@ -95,7 +153,17 @@ docspell.server {
user-url = "https://api.github.com/user",
sign-key = ""
sig-algo = "RS256" #unused but must be set to something
}
},
# If the authentication provider doesn't provide the
# collective name, simply use a fixed one. This means all
# users from this provider are in the same collective!
collective-key = "fixed:demo",
# Github provides the login name via the `login` property as
# response from the user-url. This value is used to construct
# the account in docspell.
user-key = "login"
}
]

View File

@ -12,6 +12,7 @@ import docspell.common._
import docspell.ftssolr.SolrConfig
import docspell.oidc.ProviderConfig
import docspell.restserver.Config.OpenIdConfig
import docspell.restserver.auth.OpenId
import com.comcast.ip4s.IpAddress
@ -29,7 +30,10 @@ case class Config(
fullTextSearch: Config.FullTextSearch,
adminEndpoint: Config.AdminEndpoint,
openid: List[OpenIdConfig]
)
) {
def openIdEnabled: Boolean =
openid.exists(_.enabled)
}
object Config {
@ -73,6 +77,11 @@ object Config {
object FullTextSearch {}
final case class OpenIdConfig(enabled: Boolean, provider: ProviderConfig)
final case class OpenIdConfig(
enabled: Boolean,
collectiveKey: OpenId.UserInfo.Extractor,
userKey: String,
provider: ProviderConfig
)
}

View File

@ -9,6 +9,7 @@ package docspell.restserver
import docspell.backend.signup.{Config => SignupConfig}
import docspell.common.config.Implicits._
import docspell.oidc.SignatureAlgo
import docspell.restserver.auth.OpenId
import pureconfig._
import pureconfig.generic.auto._
@ -25,5 +26,8 @@ object ConfigFile {
implicit val sigAlgoReader: ConfigReader[SignatureAlgo] =
ConfigReader[String].emap(reason(SignatureAlgo.fromString))
implicit val openIdExtractorReader: ConfigReader[OpenId.UserInfo.Extractor] =
ConfigReader[String].emap(reason(OpenId.UserInfo.Extractor.fromString))
}
}

View File

@ -110,6 +110,7 @@ object RestServer {
): HttpRoutes[F] =
Router(
"auth/oauth" -> CodeFlowRoutes(
cfg.openIdEnabled,
OpenId.handle[F](restApp.backend, cfg),
OpenId.codeFlowConfig(cfg),
client

View File

@ -7,12 +7,25 @@
package docspell.restserver.auth
import cats.effect._
import cats.implicits._
import docspell.backend.BackendApp
import docspell.oidc.{CodeFlowConfig, OnUserInfo}
import docspell.backend.auth.Login
import docspell.backend.signup.{ExternalAccount, SignupResult}
import docspell.common._
import docspell.oidc.{CodeFlowConfig, OnUserInfo, UserInfoDecoder}
import docspell.restserver.Config
import docspell.restserver.auth.OpenId.UserInfo.{ExtractResult, Extractor}
import docspell.restserver.http4s.ClientRequestInfo
import io.circe.Json
import org.http4s.dsl.Http4sDsl
import org.http4s.headers.Location
import org.http4s.{Response, Uri}
import org.log4s.getLogger
object OpenId {
private[this] val log = getLogger
def codeFlowConfig[F[_]](config: Config): CodeFlowConfig[F] =
CodeFlowConfig(
@ -23,35 +36,183 @@ object OpenId {
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) =>
def handle[F[_]: Async](backend: BackendApp[F], config: Config): OnUserInfo[F] =
OnUserInfo { (req, provider, userInfo) =>
val dsl = new Http4sDsl[F] {}
import dsl._
val logger = Logger.log4s(log)
val baseUrl = ClientRequestInfo.getBaseUrl(config, req)
val uri = baseUrl.withQuery("oauth", "1") / "app" / "login"
val location = Location(Uri.unsafeFromString(uri.asString))
val cfg = config.openid
.find(_.provider.providerId == provider.providerId)
.getOrElse(sys.error("No config found, but provider which is impossible :)"))
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)
}
)
val extractColl = cfg.collectiveKey.find(userJson)
// 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)
// }
extractColl match {
case ExtractResult.Failure(message) =>
logger.error(s"Error retrieving user data: $message") *>
TemporaryRedirect(location)
case ExtractResult.Account(accountId) =>
signUpAndLogin[F](backend)(config, accountId, location, baseUrl)
case ExtractResult.Identifier(coll) =>
Extractor.Lookup(cfg.userKey).find(userJson) match {
case ExtractResult.Failure(message) =>
logger.error(s"Error retrieving user data: $message") *>
TemporaryRedirect(location)
case ExtractResult.Identifier(name) =>
signUpAndLogin[F](backend)(
config,
AccountId(coll, name),
location,
baseUrl
)
case ExtractResult.Account(accountId) =>
signUpAndLogin[F](backend)(
config,
accountId.copy(collective = coll),
location,
baseUrl
)
}
}
case None =>
TemporaryRedirect(location)
}
}
def signUpAndLogin[F[_]: Async](
backend: BackendApp[F]
)(
cfg: Config,
accountId: AccountId,
location: Location,
baseUrl: LenientUri
): F[Response[F]] = {
val dsl = new Http4sDsl[F] {}
import dsl._
for {
setup <- backend.signup.setupExternal(cfg.backend.signup)(
ExternalAccount(accountId)
)
logger = Logger.log4s(log)
res <- setup match {
case SignupResult.Failure(ex) =>
logger.error(ex)(s"Error when creating external account!") *>
TemporaryRedirect(location)
case SignupResult.SignupClosed =>
logger.error(s"External accounts don't work when signup is closed!") *>
TemporaryRedirect(location)
case SignupResult.CollectiveExists =>
logger.error(
s"Error when creating external accounts! Collective exists error reported. This is a bug!"
) *>
TemporaryRedirect(location)
case SignupResult.InvalidInvitationKey =>
logger.error(
s"Error when creating external accounts! Invalid invitation key reported. This is a bug!"
) *>
TemporaryRedirect(location)
case SignupResult.Success =>
loginAndVerify(backend, cfg)(accountId, location, baseUrl)
}
} yield res
}
def loginAndVerify[F[_]: Async](backend: BackendApp[F], config: Config)(
accountId: AccountId,
location: Location,
baseUrl: LenientUri
): F[Response[F]] = {
val dsl = new Http4sDsl[F] {}
import dsl._
for {
login <- backend.login.loginExternal(config.auth)(accountId)
resp <- login match {
case Login.Result.Ok(session, _) =>
TemporaryRedirect(location)
.map(_.addCookie(CookieData(session).asCookie(baseUrl)))
case failed =>
Logger.log4s(log).error(s"External login failed: $failed") *>
TemporaryRedirect(location)
}
} yield resp
}
object UserInfo {
sealed trait Extractor {
def find(json: Json): ExtractResult
}
object Extractor {
final case class Fixed(value: String) extends Extractor {
def find(json: Json): ExtractResult =
UserInfoDecoder
.normalizeUid(value)
.fold(err => ExtractResult.Failure(err), ExtractResult.Identifier)
}
final case class Lookup(value: String) extends Extractor {
def find(json: Json): ExtractResult =
UserInfoDecoder
.findSomeId(value)
.decodeJson(json)
.fold(
err => ExtractResult.Failure(err.getMessage()),
ExtractResult.Identifier
)
}
final case class AccountLookup(value: String) extends Extractor {
def find(json: Json): ExtractResult =
UserInfoDecoder
.findSomeString(value)
.emap(AccountId.parse)
.decodeJson(json)
.fold(df => ExtractResult.Failure(df.getMessage()), ExtractResult.Account)
}
def fromString(str: String): Either[String, Extractor] =
str.span(_ != ':') match {
case (_, "") =>
Left(s"Invalid extractor, there is no value: $str")
case (_, value) if value == ":" =>
Left(s"Invalid extractor, there is no value: $str")
case (prefix, value) =>
prefix.toLowerCase match {
case "fixed" =>
Right(Fixed(value.drop(1)))
case "lookup" =>
Right(Lookup(value.drop(1)))
case "account" =>
Right(AccountLookup(value.drop(1)))
case _ =>
Left(s"Invalid prefix: $prefix")
}
}
}
sealed trait ExtractResult
object ExtractResult {
final case class Identifier(name: Ident) extends ExtractResult
final case class Account(accountId: AccountId) extends ExtractResult
final case class Failure(message: String) extends ExtractResult
}
}
}