From b73c2527622d2de4c74ad97bcd710f2028ad5668 Mon Sep 17 00:00:00 2001 From: eikek Date: Sun, 5 Sep 2021 16:29:42 +0200 Subject: [PATCH 01/12] 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. --- build.sbt | 23 ++- .../scala/docspell/oidc/AccessToken.scala | 63 ++++++ .../main/scala/docspell/oidc/CodeFlow.scala | 179 +++++++++++++++++ .../scala/docspell/oidc/CodeFlowConfig.scala | 45 +++++ .../scala/docspell/oidc/CodeFlowRoutes.scala | 95 +++++++++ .../src/main/scala/docspell/oidc/Jwt.scala | 22 +++ .../main/scala/docspell/oidc/OnUserInfo.scala | 68 +++++++ .../scala/docspell/oidc/OpenidConnect.scala | 16 ++ .../scala/docspell/oidc/ProviderConfig.scala | 39 ++++ .../scala/docspell/oidc/SignatureAlgo.scala | 185 ++++++++++++++++++ .../scala/docspell/oidc/UserInfoDecoder.scala | 43 ++++ .../src/main/resources/reference.conf | 38 ++++ .../scala/docspell/restserver/Config.scala | 7 +- .../docspell/restserver/ConfigFile.scala | 4 + .../docspell/restserver/RestServer.scala | 18 +- .../docspell/restserver/auth/OpenId.scala | 57 ++++++ project/Dependencies.scala | 5 + 17 files changed, 902 insertions(+), 5 deletions(-) create mode 100644 modules/oidc/src/main/scala/docspell/oidc/AccessToken.scala create mode 100644 modules/oidc/src/main/scala/docspell/oidc/CodeFlow.scala create mode 100644 modules/oidc/src/main/scala/docspell/oidc/CodeFlowConfig.scala create mode 100644 modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala create mode 100644 modules/oidc/src/main/scala/docspell/oidc/Jwt.scala create mode 100644 modules/oidc/src/main/scala/docspell/oidc/OnUserInfo.scala create mode 100644 modules/oidc/src/main/scala/docspell/oidc/OpenidConnect.scala create mode 100644 modules/oidc/src/main/scala/docspell/oidc/ProviderConfig.scala create mode 100644 modules/oidc/src/main/scala/docspell/oidc/SignatureAlgo.scala create mode 100644 modules/oidc/src/main/scala/docspell/oidc/UserInfoDecoder.scala create mode 100644 modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala diff --git a/build.sbt b/build.sbt index 505dd0c5..52291cfb 100644 --- a/build.sbt +++ b/build.sbt @@ -502,6 +502,24 @@ val backend = project ) .dependsOn(store, joexapi, ftsclient, totp) +val oidc = project + .in(file("modules/oidc")) + .disablePlugins(RevolverPlugin) + .settings(sharedSettings) + .settings(testSettingsMUnit) + .settings( + name := "docspell-oidc", + libraryDependencies ++= + Dependencies.loggingApi ++ + Dependencies.fs2 ++ + Dependencies.http4sClient ++ + Dependencies.http4sCirce ++ + Dependencies.http4sDsl ++ + Dependencies.circe ++ + Dependencies.jwtScala + ) + .dependsOn(common) + val webapp = project .in(file("modules/webapp")) .disablePlugins(RevolverPlugin) @@ -615,7 +633,7 @@ val restserver = project } } ) - .dependsOn(restapi, joexapi, backend, webapp, ftssolr) + .dependsOn(restapi, joexapi, backend, webapp, ftssolr, oidc) // --- Website Documentation @@ -695,7 +713,8 @@ val root = project restserver, query.jvm, query.js, - totp + totp, + oidc ) // --- Helpers diff --git a/modules/oidc/src/main/scala/docspell/oidc/AccessToken.scala b/modules/oidc/src/main/scala/docspell/oidc/AccessToken.scala new file mode 100644 index 00000000..175ee07e --- /dev/null +++ b/modules/oidc/src/main/scala/docspell/oidc/AccessToken.scala @@ -0,0 +1,63 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.oidc + +import io.circe.Decoder +import scodec.bits.ByteVector + +/** The response from an authorization server to the "token request". The redirect request + * contains an authorization code that is used to request tokens at the authorization + * server. The server then responds with this structure. + * + * @param accessToken + * the jwt encoded access token + * @param tokenType + * the token type, is always 'Bearer' + * @param expiresIn + * when it expires (in seconds from unix epoch) + * @param refreshToken + * optional refresh token + * @param refreshExpiresIn + * optional expiry time for the refresh token (in seconds from unix epoch) + * @param sessionState + * an optional session state + * @param scope + * the scope as requested. this must be present for OpenId Connect, but not necessarily + * for OAuth2 + */ +final case class AccessToken( + accessToken: String, + tokenType: String, + expiresIn: Option[Long], + refreshToken: Option[String], + refreshExpiresIn: Option[Long], + sessionState: Option[String], + scope: Option[String] +) { + + /** Decodes the `accessToken` as a JWT and validates it given the key and expected + * signature algorithm. + */ + def decodeToken(key: ByteVector, algo: SignatureAlgo): Either[String, Jwt] = + SignatureAlgo.decoder(key, algo)(accessToken).left.map(_.getMessage) +} + +object AccessToken { + + implicit val decoder: Decoder[AccessToken] = + Decoder.instance { c => + for { + atoken <- c.get[String]("access_token") + ttype <- c.get[String]("token_type") + expire <- c.get[Option[Long]]("expires_in") + rtoken <- c.get[Option[String]]("refresh_token") + rexpire <- c.get[Option[Long]]("refresh_expires_in") + sstate <- c.get[Option[String]]("session_state") + scope <- c.get[Option[String]]("scope") + } yield AccessToken(atoken, ttype, expire, rtoken, rexpire, sstate, scope) + } +} diff --git a/modules/oidc/src/main/scala/docspell/oidc/CodeFlow.scala b/modules/oidc/src/main/scala/docspell/oidc/CodeFlow.scala new file mode 100644 index 00000000..6865ca39 --- /dev/null +++ b/modules/oidc/src/main/scala/docspell/oidc/CodeFlow.scala @@ -0,0 +1,179 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.oidc + +import cats.data.OptionT +import cats.effect._ +import cats.implicits._ + +import docspell.common._ + +import io.circe.Json +import org.http4s.Method._ +import org.http4s._ +import org.http4s.circe.CirceEntityCodec._ +import org.http4s.client.Client +import org.http4s.client.dsl.Http4sClientDsl +import org.http4s.client.middleware.RequestLogger +import org.http4s.client.middleware.ResponseLogger +import org.http4s.headers.Accept +import org.http4s.headers.Authorization +import org.log4s.getLogger + +/** https://openid.net/specs/openid-connect-core-1_0.html (OIDC) + * https://openid.net/specs/openid-connect-basic-1_0.html#TokenRequest (OIDC) + * https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4 (OAuth2) + * https://datatracker.ietf.org/doc/html/rfc7519 (JWT) + */ +object CodeFlow { + private[this] val log4sLogger = getLogger + + def apply[F[_]: Async, A]( + client: Client[F], + cfg: ProviderConfig, + redirectUri: String + )( + code: String + ): OptionT[F, Json] = { + val logger = Logger.log4s[F](log4sLogger) + val dsl = new Http4sClientDsl[F] {} + val c = logRequests[F](logResponses[F](client)) + + for { + _ <- OptionT.liftF( + logger.debug( + s"Obtaining access_token for provider ${cfg.providerId.id} and code $code" + ) + ) + token <- fetchAccessToken[F](c, dsl, cfg, redirectUri, code) + _ <- OptionT.liftF( + logger.debug( + s"Obtaining user-info for provider ${cfg.providerId.id} and token $token" + ) + ) + user <- cfg.userUrl match { + case Some(url) if cfg.signKey.isEmpty => + fetchFromUserEndpoint[F](c, dsl, url, token) + case _ if cfg.signKey.nonEmpty => + token.decodeToken(cfg.signKey, cfg.sigAlgo) match { + case Right(jwt) => + OptionT.pure[F](jwt.claims) + case Left(err) => + OptionT + .liftF(logger.error(s"Error verifying jwt access token: $err")) + .flatMap(_ => OptionT.none[F, Json]) + } + case _ => + OptionT + .liftF( + logger.error( + s"No signature specified and no user endpoint url. Cannot obtain user info from access token!" + ) + ) + .flatMap(_ => OptionT.none[F, Json]) + } + } yield user + } + + /** Using the code that was given by the authentication providers redirect request, get + * the access token. It returns the raw response only json-decoded into a data + * structure. If something fails, it is logged ant None is returned + * + * See https://openid.net/specs/openid-connect-basic-1_0.html#TokenRequest + */ + def fetchAccessToken[F[_]: Async]( + c: Client[F], + dsl: Http4sClientDsl[F], + cfg: ProviderConfig, + redirectUri: String, + code: String + ): OptionT[F, AccessToken] = { + import dsl._ + val logger = Logger.log4s[F](log4sLogger) + + val req = POST( + UrlForm( + "client_id" -> cfg.clientId, + "client_secret" -> cfg.clientSecret, + "code" -> code, + "grant_type" -> "authorization_code", + "redirect_uri" -> redirectUri + ), + Uri.unsafeFromString(cfg.tokenUrl.asString), + Accept(MediaType.application.json) + ) + + OptionT(c.run(req).use { + case Status.Successful(r) => + for { + token <- r.attemptAs[AccessToken].value + _ <- token match { + case Right(t) => + logger.debug(s"Got token response: $t") + case Left(err) => + logger.error(err)(s"Error decoding access token: ${err.getMessage}") + } + } yield token.toOption + case r => + logger + .error(s"Error obtaining access token '${r.status.code}' / ${r.as[String]}") + .map(_ => None) + }) + } + + /** Fetches user info by using a request against the userinfo endpoint. */ + def fetchFromUserEndpoint[F[_]: Async]( + c: Client[F], + dsl: Http4sClientDsl[F], + endpointUrl: LenientUri, + token: AccessToken + ): OptionT[F, Json] = { + import dsl._ + val logger = Logger.log4s[F](log4sLogger) + + val req = GET( + Uri.unsafeFromString(endpointUrl.asString), + Authorization(Credentials.Token(AuthScheme.Bearer, token.accessToken)), + Accept(MediaType.application.json) + ) + + val resp: F[Option[Json]] = c.run(req).use { + case Status.Successful(r) => + for { + json <- r.attemptAs[Json].value + _ <- json match { + case Right(j) => + logger.trace(s"Got user info: ${j.noSpaces}") + case Left(err) => + logger.error(err)(s"Error decoding user info response into json!") + } + } yield json.toOption + case r => + r.as[String] + .flatMap(err => + logger.error(s"Cannot obtain user info: ${r.status.code} / $err") + ) + .map(_ => None) + } + OptionT(resp) + } + + private def logRequests[F[_]: Async](c: Client[F]): Client[F] = + RequestLogger( + logHeaders = true, + logBody = true, + logAction = Some((msg: String) => Logger.log4s(log4sLogger).trace(msg)) + )(c) + + private def logResponses[F[_]: Async](c: Client[F]): Client[F] = + ResponseLogger( + logHeaders = true, + logBody = true, + logAction = Some((msg: String) => Logger.log4s(log4sLogger).trace(msg)) + )(c) + +} diff --git a/modules/oidc/src/main/scala/docspell/oidc/CodeFlowConfig.scala b/modules/oidc/src/main/scala/docspell/oidc/CodeFlowConfig.scala new file mode 100644 index 00000000..37460a43 --- /dev/null +++ b/modules/oidc/src/main/scala/docspell/oidc/CodeFlowConfig.scala @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.oidc + +import docspell.common._ + +import org.http4s.Request + +trait CodeFlowConfig[F[_]] { + + /** Return the URL to the path where the `CodeFlowRoutes` are mounted. This is used to + * construct the redirect url. + */ + def getEndpointUrl(req: Request[F]): LenientUri + + /** Multiple authentication providers are supported, each has its own id. For a given + * id, return the config to use. + */ + def findProvider(id: Ident): Option[ProviderConfig] + +} + +object CodeFlowConfig { + + def apply[F[_]]( + url: Request[F] => LenientUri, + provider: Ident => Option[ProviderConfig] + ): CodeFlowConfig[F] = + new CodeFlowConfig[F] { + def getEndpointUrl(req: Request[F]): LenientUri = url(req) + def findProvider(id: Ident): Option[ProviderConfig] = provider(id) + } + + private[oidc] def resumeUri[F[_]]( + req: Request[F], + prov: ProviderConfig, + cfg: CodeFlowConfig[F] + ): LenientUri = + cfg.getEndpointUrl(req) / prov.providerId.id / "resume" + +} diff --git a/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala b/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala new file mode 100644 index 00000000..0f3cfda5 --- /dev/null +++ b/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala @@ -0,0 +1,95 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.oidc + +import cats.data.OptionT +import cats.effect._ +import cats.implicits._ + +import docspell.common._ + +import org.http4s.HttpRoutes +import org.http4s._ +import org.http4s.client.Client +import org.http4s.dsl.Http4sDsl +import org.http4s.headers.Location +import org.log4s.getLogger + +object CodeFlowRoutes { + private[this] val log4sLogger = getLogger + + def apply[F[_]: Async, A]( + onUserInfo: OnUserInfo[F], + config: CodeFlowConfig[F], + client: Client[F] + ): HttpRoutes[F] = { + val dsl: Http4sDsl[F] = new Http4sDsl[F] {} + import dsl._ + val logger = Logger.log4s[F](log4sLogger) + HttpRoutes.of[F] { + case req @ GET -> Root / Ident(id) => + config.findProvider(id) match { + case Some(cfg) => + val uri = cfg.authorizeUrl + .withQuery("client_id", cfg.clientId) + .withQuery("scope", cfg.scope) + .withQuery( + "redirect_uri", + CodeFlowConfig.resumeUri(req, cfg, config).asString + ) + .withQuery("response_type", "code") + logger.debug( + s"Redirecting to OAuth provider ${cfg.providerId.id}: ${uri.asString}" + ) + SeeOther().map(_.withHeaders(Location(Uri.unsafeFromString(uri.asString)))) + case None => + logger.debug(s"No oauth provider found with id '$id'") *> + NotFound() + } + + case req @ GET -> Root / Ident(id) / "resume" => + config.findProvider(id) match { + case None => + logger.debug(s"No oauth provider found with id '$id'") *> + NotFound() + case Some(provider) => + val codeFromReq = OptionT.fromOption[F](req.params.get("code")) + + val userInfo = for { + _ <- OptionT.liftF(logger.info(s"Resume OAuth/OIDC flow for ${id.id}")) + code <- codeFromReq + _ <- OptionT.liftF( + logger.debug( + s"Resume OAuth/OIDC flow from ${provider.providerId.id} with auth_code=$code" + ) + ) + redirectUri = CodeFlowConfig.resumeUri(req, provider, config) + u <- CodeFlow(client, provider, redirectUri.asString)(code) + } yield u + + userInfo.value.flatMap { + case t @ Some(_) => + onUserInfo.handle(req, provider, t) + case None => + val reason = req.params + .get("error") + .map { err => + val descr = + req.params.get("error_description").map(s => s" ($s)").getOrElse("") + s"$err$descr" + } + .map(err => s": $err") + .getOrElse("") + + logger.error(s"Error resuming code flow from '${id.id}'$reason") *> + onUserInfo.handle(req, provider, None) + } + } + + } + } +} diff --git a/modules/oidc/src/main/scala/docspell/oidc/Jwt.scala b/modules/oidc/src/main/scala/docspell/oidc/Jwt.scala new file mode 100644 index 00000000..60f97339 --- /dev/null +++ b/modules/oidc/src/main/scala/docspell/oidc/Jwt.scala @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.oidc + +import io.circe.{Decoder, Json} +import scodec.bits.Bases.Alphabets +import scodec.bits.ByteVector + +case class Jwt(header: Json, claims: Json, signature: ByteVector) { + + def claimsAs[A: Decoder]: Either[String, A] = + claims.as[A].left.map(_.getMessage()) +} + +object Jwt { + private[oidc] def create(t: (Json, Json, String)): Jwt = + Jwt(t._1, t._2, ByteVector.fromValidBase64(t._3, Alphabets.Base64UrlNoPad)) +} diff --git a/modules/oidc/src/main/scala/docspell/oidc/OnUserInfo.scala b/modules/oidc/src/main/scala/docspell/oidc/OnUserInfo.scala new file mode 100644 index 00000000..a8febee5 --- /dev/null +++ b/modules/oidc/src/main/scala/docspell/oidc/OnUserInfo.scala @@ -0,0 +1,68 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.oidc + +import cats.effect._ +import cats.implicits._ +import fs2.Stream + +import docspell.common.Logger + +import io.circe.Json +import org.http4s._ +import org.http4s.headers.`Content-Type` +import org.http4s.implicits._ +import org.log4s.getLogger + +/** Once the authentication flow is completed, we get "some" json structure that contains + * a claim about the user. From here it's to the user of this small library to complete + * the request. + * + * Usually the json is searched for an account name and the account is then created in + * the application, if it not already exists. The concrete response is up to the + * application, the OAuth/OpenID Connect is done (successfully) at this point. + */ +trait OnUserInfo[F[_]] { + + /** Create a response given the request and the obtained user info data. The `userInfo` + * may be retrieved from an JWT token or it is the response of querying the user-info + * endpoint, depending on the configuration provided to `CodeFlowRoutes`. In the latter + * case, the authorization server validated the token. + * + * If `userInfo` is empty, then some error occurred during the flow. The exact error + * has been logged, but it is not given here. + */ + def handle( + req: Request[F], + provider: ProviderConfig, + userInfo: Option[Json] + ): F[Response[F]] +} + +object OnUserInfo { + private[this] val log = getLogger + + def apply[F[_]]( + f: (Request[F], ProviderConfig, Option[Json]) => F[Response[F]] + ): OnUserInfo[F] = + (req: Request[F], cfg: ProviderConfig, userInfo: Option[Json]) => + f(req, cfg, userInfo) + + def logInfo[F[_]: Sync]: OnUserInfo[F] = + OnUserInfo((_, _, json) => + Logger + .log4s(log) + .info(s"Got data: ${json.map(_.spaces2)}") + .map(_ => + Response[F](Status.Ok) + .withContentType(`Content-Type`(mediaType"application/json")) + .withBodyStream( + Stream.emits(json.getOrElse(Json.obj()).spaces2.getBytes.toSeq) + ) + ) + ) +} diff --git a/modules/oidc/src/main/scala/docspell/oidc/OpenidConnect.scala b/modules/oidc/src/main/scala/docspell/oidc/OpenidConnect.scala new file mode 100644 index 00000000..22b32208 --- /dev/null +++ b/modules/oidc/src/main/scala/docspell/oidc/OpenidConnect.scala @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.oidc + +import org.http4s.HttpRoutes +import org.http4s.client.Client + +object OpenidConnect { + + def codeFlow[F[_]](client: Client[F]): HttpRoutes[F] = + ??? +} diff --git a/modules/oidc/src/main/scala/docspell/oidc/ProviderConfig.scala b/modules/oidc/src/main/scala/docspell/oidc/ProviderConfig.scala new file mode 100644 index 00000000..86f343c9 --- /dev/null +++ b/modules/oidc/src/main/scala/docspell/oidc/ProviderConfig.scala @@ -0,0 +1,39 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.oidc + +import docspell.common._ + +import scodec.bits.ByteVector + +final case class ProviderConfig( + providerId: Ident, + clientId: String, + clientSecret: String, + scope: String, + authorizeUrl: LenientUri, + tokenUrl: LenientUri, + userUrl: Option[LenientUri], + signKey: ByteVector, + sigAlgo: SignatureAlgo +) + +object ProviderConfig { + + def github(clientId: String, clientSecret: String) = + ProviderConfig( + Ident.unsafe("github"), + clientId, + clientSecret, + "profile", + LenientUri.unsafe("https://github.com/login/oauth/authorize"), + LenientUri.unsafe("https://github.com/login/oauth/access_token"), + Some(LenientUri.unsafe("https://api.github.com/user")), + ByteVector.empty, + SignatureAlgo.RS256 + ) +} diff --git a/modules/oidc/src/main/scala/docspell/oidc/SignatureAlgo.scala b/modules/oidc/src/main/scala/docspell/oidc/SignatureAlgo.scala new file mode 100644 index 00000000..661b98e3 --- /dev/null +++ b/modules/oidc/src/main/scala/docspell/oidc/SignatureAlgo.scala @@ -0,0 +1,185 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.oidc + +import java.security.spec.X509EncodedKeySpec +import java.security.{KeyFactory, PublicKey} +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec + +import cats.data.NonEmptyList +import cats.implicits._ + +import pdi.jwt.{JwtAlgorithm, JwtCirce} +import scodec.bits.ByteVector + +sealed trait SignatureAlgo { self: Product => + + def name: String = + self.productPrefix +} + +object SignatureAlgo { + + case object RS256 extends SignatureAlgo + case object RS384 extends SignatureAlgo + case object RS512 extends SignatureAlgo + + case object ES256 extends SignatureAlgo + case object ES384 extends SignatureAlgo + case object ES512 extends SignatureAlgo + case object Ed25519 extends SignatureAlgo + + case object HMD5 extends SignatureAlgo + case object HS224 extends SignatureAlgo + case object HS256 extends SignatureAlgo + case object HS384 extends SignatureAlgo + case object HS512 extends SignatureAlgo + + val all: NonEmptyList[SignatureAlgo] = + NonEmptyList.of( + RS256, + RS384, + RS512, + ES256, + ES384, + ES512, + Ed25519, + HMD5, + HS224, + HS256, + HS384, + HS512 + ) + + def fromString(str: String): Either[String, SignatureAlgo] = + str.toUpperCase() match { + case "RS256" => Right(RS256) + case "RS384" => Right(RS384) + case "RS512" => Right(RS512) + case "ES256" => Right(ES256) + case "ES384" => Right(ES384) + case "ES512" => Right(ES512) + case "ED25519" => Right(Ed25519) + case "HMD5" => Right(HMD5) + case "HS224" => Right(HS224) + case "HS256" => Right(HS256) + case "HS384" => Right(HS384) + case "HS512" => Right(HS512) + case _ => Left(s"Unknown signature algo: $str") + } + + def unsafeFromString(str: String): SignatureAlgo = + fromString(str).fold(sys.error, identity) + + private[oidc] def decoder( + sigKey: ByteVector, + algo: SignatureAlgo + ): String => Either[Throwable, Jwt] = { token => + algo match { + case RS256 => + for { + pubKey <- createPublicKey(sigKey, "RSA") + decoded <- JwtCirce + .decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.RS256)) + .toEither + } yield Jwt.create(decoded) + + case RS384 => + for { + pubKey <- createPublicKey(sigKey, "RSA") + decoded <- JwtCirce + .decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.RS384)) + .toEither + } yield Jwt.create(decoded) + + case RS512 => + for { + pubKey <- createPublicKey(sigKey, "RSA") + decoded <- JwtCirce + .decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.RS512)) + .toEither + } yield Jwt.create(decoded) + + case ES256 => + for { + pubKey <- createPublicKey(sigKey, "EC") + decoded <- JwtCirce + .decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.ES256)) + .toEither + } yield Jwt.create(decoded) + case ES384 => + for { + pubKey <- createPublicKey(sigKey, "EC") + decoded <- JwtCirce + .decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.ES384)) + .toEither + } yield Jwt.create(decoded) + case ES512 => + for { + pubKey <- createPublicKey(sigKey, "EC") + decoded <- JwtCirce + .decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.ES512)) + .toEither + } yield Jwt.create(decoded) + + case Ed25519 => + for { + pubKey <- createPublicKey(sigKey, "EdDSA") + decoded <- JwtCirce + .decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.Ed25519)) + .toEither + } yield Jwt.create(decoded) + + case HMD5 => + for { + key <- createSecretKey(sigKey, JwtAlgorithm.HMD5.fullName) + decoded <- JwtCirce.decodeJsonAll(token, key, Seq(JwtAlgorithm.HMD5)).toEither + } yield Jwt.create(decoded) + + case HS224 => + for { + key <- createSecretKey(sigKey, JwtAlgorithm.HS224.fullName) + decoded <- JwtCirce.decodeJsonAll(token, key, Seq(JwtAlgorithm.HS224)).toEither + } yield Jwt.create(decoded) + + case HS256 => + for { + key <- createSecretKey(sigKey, JwtAlgorithm.HS256.fullName) + decoded <- JwtCirce.decodeJsonAll(token, key, Seq(JwtAlgorithm.HS256)).toEither + } yield Jwt.create(decoded) + + case HS384 => + for { + key <- createSecretKey(sigKey, JwtAlgorithm.HS384.fullName) + decoded <- JwtCirce.decodeJsonAll(token, key, Seq(JwtAlgorithm.HS384)).toEither + } yield Jwt.create(decoded) + + case HS512 => + for { + key <- createSecretKey(sigKey, JwtAlgorithm.HS512.fullName) + decoded <- JwtCirce.decodeJsonAll(token, key, Seq(JwtAlgorithm.HS512)).toEither + } yield Jwt.create(decoded) + } + } + + private def createSecretKey( + key: ByteVector, + keyAlgo: String + ): Either[Throwable, SecretKey] = + Either.catchNonFatal(new SecretKeySpec(key.toArray, keyAlgo)) + + private def createPublicKey( + key: ByteVector, + keyAlgo: String + ): Either[Throwable, PublicKey] = + Either.catchNonFatal { + val spec = new X509EncodedKeySpec(key.toArray) + KeyFactory.getInstance(keyAlgo).generatePublic(spec) + } + +} diff --git a/modules/oidc/src/main/scala/docspell/oidc/UserInfoDecoder.scala b/modules/oidc/src/main/scala/docspell/oidc/UserInfoDecoder.scala new file mode 100644 index 00000000..f9bce25a --- /dev/null +++ b/modules/oidc/src/main/scala/docspell/oidc/UserInfoDecoder.scala @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.oidc + +import docspell.common.Ident + +import io.circe.{Decoder, DecodingFailure} + +/** Helpers for implementing `OnUserInfo`. */ +object UserInfoDecoder { + + /** Find the value for `preferred_username` standard claim (see + * https://openid.net/specs/openid-connect-basic-1_0.html#StandardClaims). + */ + def preferredUsername: Decoder[Ident] = + findSomeId("preferred_username") + + /** Looks recursively in the JSON for the first attribute with name `key` and returns + * its value (expecting an Ident). + */ + def findSomeId(key: String): Decoder[Ident] = + Decoder.instance { cursor => + cursor.value + .findAllByKey(key) + .find(_.isString) + .flatMap(_.asString) + .toRight(s"No value found in JSON for key '$key'") + .flatMap(normalizeUid) + .left + .map(msg => DecodingFailure(msg, Nil)) + } + + private def normalizeUid(uid: String): Either[String, Ident] = + Ident(uid.filter(Ident.chars.contains)) + .flatMap(id => + if (id.nonEmpty) Right(id) else Left(s"Id '$uid' empty after normalizing!'") + ) + +} diff --git a/modules/restserver/src/main/resources/reference.conf b/modules/restserver/src/main/resources/reference.conf index 52cf2991..8020ee1b 100644 --- a/modules/restserver/src/main/resources/reference.conf +++ b/modules/restserver/src/main/resources/reference.conf @@ -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 = "", + 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 diff --git a/modules/restserver/src/main/scala/docspell/restserver/Config.scala b/modules/restserver/src/main/scala/docspell/restserver/Config.scala index d1fc8c0f..8ab744c7 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/Config.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/Config.scala @@ -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) + } diff --git a/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala b/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala index 6225ff64..4025bf50 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala @@ -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)) } } diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 39f63b40..0f4710eb 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -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), diff --git a/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala b/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala new file mode 100644 index 00000000..e7c6dae2 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala @@ -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) + // } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 5adf1e88..f081e815 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -23,6 +23,7 @@ object Dependencies { val Icu4jVersion = "69.1" val javaOtpVersion = "0.3.0" val JsoupVersion = "1.14.2" + val JwtScalaVersion = "9.0.1" val KindProjectorVersion = "0.10.3" val KittensVersion = "2.3.2" val LevigoJbig2Version = "2.0" @@ -48,6 +49,10 @@ object Dependencies { val JQueryVersion = "3.5.1" val ViewerJSVersion = "0.5.9" + val jwtScala = Seq( + "com.github.jwt-scala" %% "jwt-circe" % JwtScalaVersion + ) + val scodecBits = Seq( "org.scodec" %% "scodec-bits" % ScodecBitsVersion ) From aef56233a5adbdcb0961eee60be97d9c7fbf37b2 Mon Sep 17 00:00:00 2001 From: eikek Date: Sun, 5 Sep 2021 17:08:52 +0200 Subject: [PATCH 02/12] Add a new column to distinguish local from external users --- build.sbt | 6 ++++ .../scala/docspell/backend/auth/Login.scala | 12 ++++--- .../docspell/backend/ops/OCollective.scala | 13 +++++-- .../docspell/backend/signup/OSignup.scala | 1 + .../scala/docspell/common/AccountSource.scala | 35 +++++++++++++++++++ .../src/main/resources/docspell-openapi.yml | 7 ++++ .../restserver/conv/Conversions.scala | 5 +++ .../restserver/routes/UserRoutes.scala | 6 ++++ .../db/migration/h2/V1.26.2__openid.sql | 8 +++++ .../db/migration/mariadb/V1.26.2__openid.sql | 8 +++++ .../migration/postgresql/V1.26.2__openid.sql | 8 +++++ .../docspell/store/impl/DoobieMeta.scala | 3 ++ .../scala/docspell/store/queries/QLogin.scala | 5 +-- .../scala/docspell/store/records/RUser.scala | 7 ++-- .../webapp/src/main/elm/Comp/UserTable.elm | 4 +++ .../src/main/elm/Messages/Comp/UserTable.elm | 3 ++ 16 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 modules/common/src/main/scala/docspell/common/AccountSource.scala create mode 100644 modules/store/src/main/resources/db/migration/h2/V1.26.2__openid.sql create mode 100644 modules/store/src/main/resources/db/migration/mariadb/V1.26.2__openid.sql create mode 100644 modules/store/src/main/resources/db/migration/postgresql/V1.26.2__openid.sql diff --git a/build.sbt b/build.sbt index 52291cfb..7856c5db 100644 --- a/build.sbt +++ b/build.sbt @@ -254,6 +254,12 @@ val openapiScalaSettings = Seq( field => field .copy(typeDef = TypeDef("LenientUri", Imports("docspell.common.LenientUri"))) + case "accountsource" => + field => + field + .copy(typeDef = + TypeDef("AccountSource", Imports("docspell.common.AccountSource")) + ) }) ) diff --git a/modules/backend/src/main/scala/docspell/backend/auth/Login.scala b/modules/backend/src/main/scala/docspell/backend/auth/Login.scala index 721c362c..bfca90e4 100644 --- a/modules/backend/src/main/scala/docspell/backend/auth/Login.scala +++ b/modules/backend/src/main/scala/docspell/backend/auth/Login.scala @@ -194,7 +194,7 @@ object Login { logF.info(s"Account lookup via remember me: $data") ) res <- OptionT.liftF( - if (checkNoPassword(data)) + if (checkNoPassword(data, AccountSource.all.toList.toSet)) logF.info("RememberMe auth successful") *> okResult(data.account) else logF.warn("RememberMe auth not successful") *> Result.invalidAuth.pure[F] @@ -260,13 +260,17 @@ object Login { private def check(given: String)(data: QLogin.Data): Boolean = { val passOk = BCrypt.checkpw(given, data.password.pass) - checkNoPassword(data) && passOk + checkNoPassword(data, Set(AccountSource.Local)) && passOk } - private def checkNoPassword(data: QLogin.Data): Boolean = { + def checkNoPassword( + data: QLogin.Data, + expectedSources: Set[AccountSource] + ): Boolean = { val collOk = data.collectiveState == CollectiveState.Active || data.collectiveState == CollectiveState.ReadOnly - val userOk = data.userState == UserState.Active + val userOk = + data.userState == UserState.Active && expectedSources.contains(data.source) collOk && userOk } }) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala index 057e0dd7..4f1b1787 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala @@ -9,7 +9,6 @@ package docspell.backend.ops import cats.effect.{Async, Resource} import cats.implicits._ import fs2.Stream - import docspell.backend.JobFactory import docspell.backend.PasswordCrypt import docspell.backend.ops.OCollective._ @@ -20,7 +19,6 @@ import docspell.store.queue.JobQueue import docspell.store.records._ import docspell.store.usertask.{UserTask, UserTaskScope, UserTaskStore} import docspell.store.{AddResult, Store} - import com.github.eikek.calev._ trait OCollective[F[_]] { @@ -95,9 +93,11 @@ object OCollective { object PassResetResult { case class Success(newPw: Password) extends PassResetResult case object NotFound extends PassResetResult + case object UserNotLocal extends PassResetResult def success(np: Password): PassResetResult = Success(np) def notFound: PassResetResult = NotFound + def userNotLocal: PassResetResult = UserNotLocal } sealed trait PassChangeResult @@ -105,12 +105,14 @@ object OCollective { case object UserNotFound extends PassChangeResult case object PasswordMismatch extends PassChangeResult case object UpdateFailed extends PassChangeResult + case object UserNotLocal extends PassChangeResult case object Success extends PassChangeResult def userNotFound: PassChangeResult = UserNotFound def passwordMismatch: PassChangeResult = PasswordMismatch def success: PassChangeResult = Success def updateFailed: PassChangeResult = UpdateFailed + def userNotLocal: PassChangeResult = UserNotLocal } case class RegisterData( @@ -245,11 +247,14 @@ object OCollective { def resetPassword(accountId: AccountId): F[PassResetResult] = for { newPass <- Password.generate[F] + optUser <- store.transact(RUser.findByAccount(accountId)) n <- store.transact( RUser.updatePassword(accountId, PasswordCrypt.crypt(newPass)) ) res = - if (n <= 0) PassResetResult.notFound + if (optUser.exists(_.source != AccountSource.Local)) + PassResetResult.userNotLocal + else if (n <= 0) PassResetResult.notFound else PassResetResult.success(newPass) } yield res @@ -270,6 +275,8 @@ object OCollective { res = check match { case Some(true) => if (n.getOrElse(0) > 0) PassChangeResult.success + else if (optUser.exists(_.source != AccountSource.Local)) + PassChangeResult.userNotLocal else PassChangeResult.updateFailed case Some(false) => PassChangeResult.passwordMismatch diff --git a/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala b/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala index aae3ffe4..6c1add2c 100644 --- a/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala +++ b/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala @@ -109,6 +109,7 @@ object OSignup { data.collName, PasswordCrypt.crypt(data.password), UserState.Active, + AccountSource.Local, None, 0, None, diff --git a/modules/common/src/main/scala/docspell/common/AccountSource.scala b/modules/common/src/main/scala/docspell/common/AccountSource.scala new file mode 100644 index 00000000..f2efecb7 --- /dev/null +++ b/modules/common/src/main/scala/docspell/common/AccountSource.scala @@ -0,0 +1,35 @@ +package docspell.common + +import cats.data.NonEmptyList +import io.circe.{Decoder, Encoder} + +sealed trait AccountSource { self: Product => + + def name: String = + self.productPrefix.toLowerCase +} + +object AccountSource { + + case object Local extends AccountSource + case object OpenId extends AccountSource + + val all: NonEmptyList[AccountSource] = + NonEmptyList.of(Local, OpenId) + + def fromString(str: String): Either[String, AccountSource] = + str.toLowerCase match { + case "local" => Right(Local) + case "openid" => Right(OpenId) + case _ => Left(s"Invalid account source: $str") + } + + def unsafeFromString(str: String): AccountSource = + fromString(str).fold(sys.error, identity) + + implicit val jsonDecoder: Decoder[AccountSource] = + Decoder.decodeString.emap(fromString) + + implicit val jsonEncoder: Encoder[AccountSource] = + Encoder.encodeString.contramap(_.name) +} diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 3e444072..c6523a95 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -5405,6 +5405,7 @@ components: - id - login - state + - source - loginCount - created properties: @@ -5420,6 +5421,12 @@ components: enum: - active - disabled + source: + type: string + format: accountsource + enum: + - local + - openid password: type: string format: password diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index 6a112c32..5e30c33e 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -522,6 +522,7 @@ trait Conversions { ru.uid, ru.login, ru.state, + ru.source, None, ru.email, ru.lastLogin, @@ -537,6 +538,7 @@ trait Conversions { cid, u.password.getOrElse(Password.empty), u.state, + u.source, u.email, 0, None, @@ -551,6 +553,7 @@ trait Conversions { cid, u.password.getOrElse(Password.empty), u.state, + u.source, u.email, u.loginCount, u.lastLogin, @@ -706,6 +709,8 @@ trait Conversions { case PassChangeResult.PasswordMismatch => BasicResult(false, "The current password is incorrect.") case PassChangeResult.UserNotFound => BasicResult(false, "User not found.") + case PassChangeResult.UserNotLocal => + BasicResult(false, "User is not local, passwords are managed externally.") } def basicResult(e: Either[Throwable, _], successMsg: String): BasicResult = diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala index 02aaedd6..756829b1 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/UserRoutes.scala @@ -86,6 +86,12 @@ object UserRoutes { Password(""), "Password update failed. User not found." ) + case OCollective.PassResetResult.UserNotLocal => + ResetPasswordResult( + false, + Password(""), + "Password update failed. User is not local, passwords are managed externally." + ) }) } yield resp } diff --git a/modules/store/src/main/resources/db/migration/h2/V1.26.2__openid.sql b/modules/store/src/main/resources/db/migration/h2/V1.26.2__openid.sql new file mode 100644 index 00000000..7247a86d --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/V1.26.2__openid.sql @@ -0,0 +1,8 @@ +ALTER TABLE "user_" +ADD COLUMN "account_source" varchar(254); + +UPDATE "user_" +SET "account_source" = 'local'; + +ALTER TABLE "user_" +ALTER COLUMN "account_source" SET NOT NULL; diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.26.2__openid.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.26.2__openid.sql new file mode 100644 index 00000000..06750f8f --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.26.2__openid.sql @@ -0,0 +1,8 @@ +ALTER TABLE `user_` +ADD COLUMN (`account_source` varchar(254)); + +UPDATE `user_` +SET `account_source` = 'local'; + +ALTER TABLE `user_` +MODIFY `account_source` varchar(254) NOT NULL; diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.26.2__openid.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.26.2__openid.sql new file mode 100644 index 00000000..7247a86d --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.26.2__openid.sql @@ -0,0 +1,8 @@ +ALTER TABLE "user_" +ADD COLUMN "account_source" varchar(254); + +UPDATE "user_" +SET "account_source" = 'local'; + +ALTER TABLE "user_" +ALTER COLUMN "account_source" SET NOT NULL; diff --git a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala index 37ea1c71..3b45e00c 100644 --- a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala +++ b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala @@ -35,6 +35,9 @@ trait DoobieMeta extends EmilDoobieMeta { e.apply(a).noSpaces ) + implicit val metaAccountSource: Meta[AccountSource] = + Meta[String].imap(AccountSource.unsafeFromString)(_.name) + implicit val metaDuration: Meta[Duration] = Meta[Long].imap(Duration.millis)(_.millis) diff --git a/modules/store/src/main/scala/docspell/store/queries/QLogin.scala b/modules/store/src/main/scala/docspell/store/queries/QLogin.scala index c77610d2..8908ae56 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QLogin.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QLogin.scala @@ -24,7 +24,8 @@ object QLogin { account: AccountId, password: Password, collectiveState: CollectiveState, - userState: UserState + userState: UserState, + source: AccountSource ) def findUser(acc: AccountId): ConnectionIO[Option[Data]] = { @@ -32,7 +33,7 @@ object QLogin { val coll = RCollective.as("c") val sql = Select( - select(user.cid, user.login, user.password, coll.state, user.state), + select(user.cid, user.login, user.password, coll.state, user.state, user.source), from(user).innerJoin(coll, user.cid === coll.id), user.login === acc.user && user.cid === acc.collective ).build diff --git a/modules/store/src/main/scala/docspell/store/records/RUser.scala b/modules/store/src/main/scala/docspell/store/records/RUser.scala index 615ac254..586d29b4 100644 --- a/modules/store/src/main/scala/docspell/store/records/RUser.scala +++ b/modules/store/src/main/scala/docspell/store/records/RUser.scala @@ -21,6 +21,7 @@ case class RUser( cid: Ident, password: Password, state: UserState, + source: AccountSource, email: Option[String], loginCount: Int, lastLogin: Option[Timestamp], @@ -36,6 +37,7 @@ object RUser { val cid = Column[Ident]("cid", this) val password = Column[Password]("password", this) val state = Column[UserState]("state", this) + val source = Column[AccountSource]("account_source", this) val email = Column[String]("email", this) val loginCount = Column[Int]("logincount", this) val lastLogin = Column[Timestamp]("lastlogin", this) @@ -48,6 +50,7 @@ object RUser { cid, password, state, + source, email, loginCount, lastLogin, @@ -65,7 +68,7 @@ object RUser { DML.insert( t, t.all, - fr"${v.uid},${v.login},${v.cid},${v.password},${v.state},${v.email},${v.loginCount},${v.lastLogin},${v.created}" + fr"${v.uid},${v.login},${v.cid},${v.password},${v.state},${v.source},${v.email},${v.loginCount},${v.lastLogin},${v.created}" ) } @@ -134,7 +137,7 @@ object RUser { val t = Table(None) DML.update( t, - t.cid === accountId.collective && t.login === accountId.user, + t.cid === accountId.collective && t.login === accountId.user && t.source === AccountSource.Local, DML.set(t.password.setTo(hashedPass)) ) } diff --git a/modules/webapp/src/main/elm/Comp/UserTable.elm b/modules/webapp/src/main/elm/Comp/UserTable.elm index 169d294f..58c872fd 100644 --- a/modules/webapp/src/main/elm/Comp/UserTable.elm +++ b/modules/webapp/src/main/elm/Comp/UserTable.elm @@ -66,6 +66,7 @@ view2 texts model = [ th [ class "w-px whitespace-nowrap" ] [] , th [ class "text-left" ] [ text texts.login ] , th [ class "text-center" ] [ text texts.state ] + , th [ class "text-center" ] [ text texts.source ] , th [ class "hidden md:table-cell text-left" ] [ text texts.email ] , th [ class "hidden md:table-cell text-center" ] [ text texts.logins ] , th [ class "hidden sm:table-cell text-center" ] [ text texts.lastLogin ] @@ -92,6 +93,9 @@ renderUserLine2 texts model user = , td [ class "text-center" ] [ text user.state ] + , td [ class "text-center" ] + [ text user.source + ] , td [ class "hidden md:table-cell text-left" ] [ Maybe.withDefault "" user.email |> text ] diff --git a/modules/webapp/src/main/elm/Messages/Comp/UserTable.elm b/modules/webapp/src/main/elm/Messages/Comp/UserTable.elm index 059b7181..b0edf6f7 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/UserTable.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/UserTable.elm @@ -20,6 +20,7 @@ type alias Texts = { basics : Messages.Basics.Texts , login : String , state : String + , source : String , email : String , logins : String , lastLogin : String @@ -32,6 +33,7 @@ gb = { basics = Messages.Basics.gb , login = "Login" , state = "State" + , source = "Type" , email = "E-Mail" , logins = "Logins" , lastLogin = "Last Login" @@ -44,6 +46,7 @@ de = { basics = Messages.Basics.de , login = "Benutzername" , state = "Status" + , source = "Typ" , email = "E-Mail" , logins = "Anmeldungen" , lastLogin = "Letzte Anmeldung" From aa099a340e7237c8b2f1f96d7fd3cbda70409796 Mon Sep 17 00:00:00 2001 From: eikek Date: Sun, 5 Sep 2021 17:32:32 +0200 Subject: [PATCH 03/12] Remove unused code --- .../docspell/backend/ops/OCollective.scala | 22 ------------------- .../docspell/backend/signup/OSignup.scala | 1 - .../backend/signup/RegisterData.scala | 9 ++++++++ .../restserver/routes/RegisterRoutes.scala | 3 +-- 4 files changed, 10 insertions(+), 25 deletions(-) create mode 100644 modules/backend/src/main/scala/docspell/backend/signup/RegisterData.scala diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala index 4f1b1787..0efc7225 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala @@ -115,28 +115,6 @@ object OCollective { def userNotLocal: PassChangeResult = UserNotLocal } - case class RegisterData( - collName: Ident, - login: Ident, - password: Password, - invite: Option[Ident] - ) - - sealed trait RegisterResult { - def toEither: Either[Throwable, Unit] - } - object RegisterResult { - case object Success extends RegisterResult { - val toEither = Right(()) - } - case class CollectiveExists(id: Ident) extends RegisterResult { - val toEither = Left(new Exception()) - } - case class Error(ex: Throwable) extends RegisterResult { - val toEither = Left(ex) - } - } - def apply[F[_]: Async]( store: Store[F], uts: UserTaskStore[F], diff --git a/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala b/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala index 6c1add2c..89a10866 100644 --- a/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala +++ b/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala @@ -10,7 +10,6 @@ import cats.effect.{Async, Resource} import cats.implicits._ import docspell.backend.PasswordCrypt -import docspell.backend.ops.OCollective.RegisterData import docspell.common._ import docspell.common.syntax.all._ import docspell.store.records.{RCollective, RInvitation, RUser} diff --git a/modules/backend/src/main/scala/docspell/backend/signup/RegisterData.scala b/modules/backend/src/main/scala/docspell/backend/signup/RegisterData.scala new file mode 100644 index 00000000..3ce905be --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/signup/RegisterData.scala @@ -0,0 +1,9 @@ +package docspell.backend.signup +import docspell.common._ + +case class RegisterData( + collName: Ident, + login: Ident, + password: Password, + invite: Option[Ident] +) diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/RegisterRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/RegisterRoutes.scala index 834b61c3..d3139479 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/RegisterRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/RegisterRoutes.scala @@ -10,8 +10,7 @@ import cats.effect._ import cats.implicits._ import docspell.backend.BackendApp -import docspell.backend.ops.OCollective.RegisterData -import docspell.backend.signup.{NewInviteResult, SignupResult} +import docspell.backend.signup.{NewInviteResult, RegisterData, SignupResult} import docspell.restapi.model._ import docspell.restserver.Config import docspell.restserver.http4s.ResponseGenerator From 7edb96a2973796f765f448215b11bfce6552783a Mon Sep 17 00:00:00 2001 From: eikek Date: Sun, 5 Sep 2021 19:11:42 +0200 Subject: [PATCH 04/12] Register external accounts This creates the account if it doesn't exist yet; otherwise it's a noop. Only valid for non-local accounts. --- .../backend/signup/ExternalAccount.scala | 13 ++++ .../docspell/backend/signup/OSignup.scala | 77 ++++++++++++------- .../docspell/store/records/RCollective.scala | 3 + .../scala/docspell/store/records/RUser.scala | 22 ++++++ 4 files changed, 87 insertions(+), 28 deletions(-) create mode 100644 modules/backend/src/main/scala/docspell/backend/signup/ExternalAccount.scala diff --git a/modules/backend/src/main/scala/docspell/backend/signup/ExternalAccount.scala b/modules/backend/src/main/scala/docspell/backend/signup/ExternalAccount.scala new file mode 100644 index 00000000..2923a57f --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/signup/ExternalAccount.scala @@ -0,0 +1,13 @@ +package docspell.backend.signup + +import docspell.common._ + +final case class ExternalAccount( + collName: Ident, + login: Ident, + source: AccountSource +) { + + def toAccountId: AccountId = + AccountId(collName, login) +} diff --git a/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala b/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala index 89a10866..15a9de16 100644 --- a/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala +++ b/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala @@ -8,13 +8,11 @@ package docspell.backend.signup import cats.effect.{Async, Resource} import cats.implicits._ - import docspell.backend.PasswordCrypt import docspell.common._ import docspell.common.syntax.all._ import docspell.store.records.{RCollective, RInvitation, RUser} import docspell.store.{AddResult, Store} - import doobie.free.connection.ConnectionIO import org.log4s.getLogger @@ -22,6 +20,9 @@ trait OSignup[F[_]] { def register(cfg: Config)(data: RegisterData): F[SignupResult] + /** Creates the given account if it doesn't exist. */ + def setupExternal(cfg: Config)(data: ExternalAccount): F[SignupResult] + def newInvite(cfg: Config)(password: Password): F[NewInviteResult] } @@ -76,6 +77,31 @@ object OSignup { } } + def setupExternal(cfg: Config)(data: ExternalAccount): F[SignupResult] = + cfg.mode match { + case Config.Mode.Closed => + SignupResult.signupClosed.pure[F] + case _ => + if (data.source == AccountSource.Local) + SignupResult.failure(new Exception("Account source must not be LOCAL!")).pure[F] + else for { + recs <- makeRecords(data.collName, data.login, Password(""), data.source) + cres <- store.add(RCollective.insert(recs._1), RCollective.existsById(data.collName)) + ures <- store.add(RUser.insert(recs._2), RUser.exists(data.login)) + res = cres match { + case AddResult.Failure(ex) => + SignupResult.failure(ex) + case _ => + ures match { + case AddResult.Failure(ex) => + SignupResult.failure(ex) + case _ => + SignupResult.success + } + } + } yield res + } + private def retryInvite(res: SignupResult): Boolean = res match { case SignupResult.CollectiveExists => @@ -91,31 +117,6 @@ object OSignup { } private def addUser(data: RegisterData): F[AddResult] = { - def toRecords: F[(RCollective, RUser)] = - for { - id2 <- Ident.randomId[F] - now <- Timestamp.current[F] - c = RCollective( - data.collName, - CollectiveState.Active, - Language.German, - true, - now - ) - u = RUser( - id2, - data.login, - data.collName, - PasswordCrypt.crypt(data.password), - UserState.Active, - AccountSource.Local, - None, - 0, - None, - now - ) - } yield (c, u) - def insert(coll: RCollective, user: RUser): ConnectionIO[Int] = for { n1 <- RCollective.insert(coll) @@ -127,9 +128,29 @@ object OSignup { val msg = s"The collective '${data.collName}' already exists." for { - cu <- toRecords + cu <- makeRecords(data.collName, data.login, data.password, AccountSource.Local) save <- store.add(insert(cu._1, cu._2), collectiveExists) } yield save.fold(identity, _.withMsg(msg), identity) } + + private def makeRecords( + collName: Ident, + login: Ident, + password: Password, + source: AccountSource + ): F[(RCollective, RUser)] = + for { + id2 <- Ident.randomId[F] + now <- Timestamp.current[F] + c = RCollective.makeDefault(collName, now) + u = RUser.makeDefault( + id2, + login, + collName, + PasswordCrypt.crypt(password), + source, + now + ) + } yield (c, u) }) } diff --git a/modules/store/src/main/scala/docspell/store/records/RCollective.scala b/modules/store/src/main/scala/docspell/store/records/RCollective.scala index 43b7e382..5d3fc97c 100644 --- a/modules/store/src/main/scala/docspell/store/records/RCollective.scala +++ b/modules/store/src/main/scala/docspell/store/records/RCollective.scala @@ -37,6 +37,9 @@ object RCollective { val all = NonEmptyList.of[Column[_]](id, state, language, integration, created) } + def makeDefault(collName: Ident, created: Timestamp): RCollective = + RCollective(collName, CollectiveState.Active, Language.German, true, created) + val T = Table(None) def as(alias: String): Table = Table(Some(alias)) diff --git a/modules/store/src/main/scala/docspell/store/records/RUser.scala b/modules/store/src/main/scala/docspell/store/records/RUser.scala index 586d29b4..57b23ad8 100644 --- a/modules/store/src/main/scala/docspell/store/records/RUser.scala +++ b/modules/store/src/main/scala/docspell/store/records/RUser.scala @@ -29,6 +29,28 @@ case class RUser( ) {} object RUser { + + def makeDefault( + id: Ident, + login: Ident, + collName: Ident, + password: Password, + source: AccountSource, + created: Timestamp + ): RUser = + RUser( + id, + login, + collName, + password, + UserState.Active, + source, + None, + 0, + None, + created + ) + final case class Table(alias: Option[String]) extends TableDef { val tableName = "user_" From f8362329a9666c989daf38a35db24dfb2106b98f Mon Sep 17 00:00:00 2001 From: eikek Date: Sun, 5 Sep 2021 21:39:09 +0200 Subject: [PATCH 05/12] 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. --- .../scala/docspell/backend/auth/Login.scala | 45 ++-- .../docspell/backend/ops/OCollective.scala | 2 + .../backend/signup/ExternalAccount.scala | 12 + .../docspell/backend/signup/OSignup.scala | 42 ++-- .../backend/signup/RegisterData.scala | 6 + .../scala/docspell/common/AccountSource.scala | 7 + .../scala/docspell/oidc/CodeFlowRoutes.scala | 15 +- .../scala/docspell/oidc/UserInfoDecoder.scala | 13 +- .../src/main/resources/reference.conf | 78 ++++++- .../scala/docspell/restserver/Config.scala | 13 +- .../docspell/restserver/ConfigFile.scala | 4 + .../docspell/restserver/RestServer.scala | 1 + .../docspell/restserver/auth/OpenId.scala | 219 +++++++++++++++--- 13 files changed, 382 insertions(+), 75 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/auth/Login.scala b/modules/backend/src/main/scala/docspell/backend/auth/Login.scala index bfca90e4..501c1040 100644 --- a/modules/backend/src/main/scala/docspell/backend/auth/Login.scala +++ b/modules/backend/src/main/scala/docspell/backend/auth/Login.scala @@ -23,6 +23,8 @@ import scodec.bits.ByteVector trait Login[F[_]] { + def loginExternal(config: Config)(accountId: AccountId): F[Result] + def loginSession(config: Config)(sessionKey: String): F[Result] def loginUserPass(config: Config)(up: UserPass): F[Result] @@ -93,6 +95,16 @@ object Login { private val logF = Logger.log4s(logger) + def loginExternal(config: Config)(accountId: AccountId): F[Result] = + for { + data <- store.transact(QLogin.findUser(accountId)) + _ <- logF.trace(s"Account lookup: $data") + res <- + if (data.exists(checkNoPassword(_, Set(AccountSource.OpenId)))) + doLogin(config, accountId, false) + else Result.invalidAuth.pure[F] + } yield res + def loginSession(config: Config)(sessionKey: String): F[Result] = AuthToken.fromString(sessionKey) match { case Right(at) => @@ -110,24 +122,11 @@ object Login { def loginUserPass(config: Config)(up: UserPass): F[Result] = AccountId.parse(up.user) match { case Right(acc) => - val okResult = - for { - require2FA <- store.transact(RTotp.isEnabled(acc)) - _ <- - if (require2FA) ().pure[F] - else store.transact(RUser.updateLogin(acc)) - token <- AuthToken.user(acc, require2FA, config.serverSecret) - rem <- OptionT - .whenF(!require2FA && up.rememberMe && config.rememberMe.enabled)( - insertRememberToken(store, acc, config) - ) - .value - } yield Result.ok(token, rem) for { data <- store.transact(QLogin.findUser(acc)) _ <- Sync[F].delay(logger.trace(s"Account lookup: $data")) res <- - if (data.exists(check(up.pass))) okResult + if (data.exists(check(up.pass))) doLogin(config, acc, up.rememberMe) else Result.invalidAuth.pure[F] } yield res case Left(_) => @@ -247,6 +246,24 @@ object Login { 0.pure[F] } + private def doLogin( + config: Config, + acc: AccountId, + rememberMe: Boolean + ): F[Result] = + for { + require2FA <- store.transact(RTotp.isEnabled(acc)) + _ <- + if (require2FA) ().pure[F] + else store.transact(RUser.updateLogin(acc)) + token <- AuthToken.user(acc, require2FA, config.serverSecret) + rem <- OptionT + .whenF(!require2FA && rememberMe && config.rememberMe.enabled)( + insertRememberToken(store, acc, config) + ) + .value + } yield Result.ok(token, rem) + private def insertRememberToken( store: Store[F], acc: AccountId, diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala index 0efc7225..3e93217b 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala @@ -9,6 +9,7 @@ package docspell.backend.ops import cats.effect.{Async, Resource} import cats.implicits._ import fs2.Stream + import docspell.backend.JobFactory import docspell.backend.PasswordCrypt import docspell.backend.ops.OCollective._ @@ -19,6 +20,7 @@ import docspell.store.queue.JobQueue import docspell.store.records._ import docspell.store.usertask.{UserTask, UserTaskScope, UserTaskStore} import docspell.store.{AddResult, Store} + import com.github.eikek.calev._ trait OCollective[F[_]] { diff --git a/modules/backend/src/main/scala/docspell/backend/signup/ExternalAccount.scala b/modules/backend/src/main/scala/docspell/backend/signup/ExternalAccount.scala index 2923a57f..e6ddc01a 100644 --- a/modules/backend/src/main/scala/docspell/backend/signup/ExternalAccount.scala +++ b/modules/backend/src/main/scala/docspell/backend/signup/ExternalAccount.scala @@ -1,3 +1,9 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + package docspell.backend.signup import docspell.common._ @@ -11,3 +17,9 @@ final case class ExternalAccount( def toAccountId: AccountId = AccountId(collName, login) } + +object ExternalAccount { + def apply(accountId: AccountId): ExternalAccount = + ExternalAccount(accountId.collective, accountId.user, AccountSource.OpenId) + +} diff --git a/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala b/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala index 15a9de16..381be35e 100644 --- a/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala +++ b/modules/backend/src/main/scala/docspell/backend/signup/OSignup.scala @@ -8,11 +8,13 @@ package docspell.backend.signup import cats.effect.{Async, Resource} import cats.implicits._ + import docspell.backend.PasswordCrypt import docspell.common._ import docspell.common.syntax.all._ import docspell.store.records.{RCollective, RInvitation, RUser} import docspell.store.{AddResult, Store} + import doobie.free.connection.ConnectionIO import org.log4s.getLogger @@ -83,23 +85,29 @@ object OSignup { SignupResult.signupClosed.pure[F] case _ => if (data.source == AccountSource.Local) - SignupResult.failure(new Exception("Account source must not be LOCAL!")).pure[F] - else for { - recs <- makeRecords(data.collName, data.login, Password(""), data.source) - cres <- store.add(RCollective.insert(recs._1), RCollective.existsById(data.collName)) - ures <- store.add(RUser.insert(recs._2), RUser.exists(data.login)) - res = cres match { - case AddResult.Failure(ex) => - SignupResult.failure(ex) - case _ => - ures match { - case AddResult.Failure(ex) => - SignupResult.failure(ex) - case _ => - SignupResult.success - } - } - } yield res + SignupResult + .failure(new Exception("Account source must not be LOCAL!")) + .pure[F] + else + for { + recs <- makeRecords(data.collName, data.login, Password(""), data.source) + cres <- store.add( + RCollective.insert(recs._1), + RCollective.existsById(data.collName) + ) + ures <- store.add(RUser.insert(recs._2), RUser.exists(data.login)) + res = cres match { + case AddResult.Failure(ex) => + SignupResult.failure(ex) + case _ => + ures match { + case AddResult.Failure(ex) => + SignupResult.failure(ex) + case _ => + SignupResult.success + } + } + } yield res } private def retryInvite(res: SignupResult): Boolean = diff --git a/modules/backend/src/main/scala/docspell/backend/signup/RegisterData.scala b/modules/backend/src/main/scala/docspell/backend/signup/RegisterData.scala index 3ce905be..94bb3aeb 100644 --- a/modules/backend/src/main/scala/docspell/backend/signup/RegisterData.scala +++ b/modules/backend/src/main/scala/docspell/backend/signup/RegisterData.scala @@ -1,3 +1,9 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + package docspell.backend.signup import docspell.common._ diff --git a/modules/common/src/main/scala/docspell/common/AccountSource.scala b/modules/common/src/main/scala/docspell/common/AccountSource.scala index f2efecb7..71beef0b 100644 --- a/modules/common/src/main/scala/docspell/common/AccountSource.scala +++ b/modules/common/src/main/scala/docspell/common/AccountSource.scala @@ -1,6 +1,13 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + package docspell.common import cats.data.NonEmptyList + import io.circe.{Decoder, Encoder} sealed trait AccountSource { self: Product => diff --git a/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala b/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala index 0f3cfda5..e9a43532 100644 --- a/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala +++ b/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala @@ -6,12 +6,10 @@ package docspell.oidc -import cats.data.OptionT +import cats.data.{Kleisli, OptionT} import cats.effect._ import cats.implicits._ - import docspell.common._ - import org.http4s.HttpRoutes import org.http4s._ import org.http4s.client.Client @@ -22,7 +20,16 @@ import org.log4s.getLogger object CodeFlowRoutes { private[this] val log4sLogger = getLogger - def apply[F[_]: Async, A]( + def apply[F[_]: Async]( + enabled: Boolean, + onUserInfo: OnUserInfo[F], + config: CodeFlowConfig[F], + client: Client[F] + ): HttpRoutes[F] = + if (enabled) route[F](onUserInfo, config, client) + else Kleisli(_ => OptionT.pure(Response.notFound[F])) + + def route[F[_]: Async]( onUserInfo: OnUserInfo[F], config: CodeFlowConfig[F], client: Client[F] diff --git a/modules/oidc/src/main/scala/docspell/oidc/UserInfoDecoder.scala b/modules/oidc/src/main/scala/docspell/oidc/UserInfoDecoder.scala index f9bce25a..f6837788 100644 --- a/modules/oidc/src/main/scala/docspell/oidc/UserInfoDecoder.scala +++ b/modules/oidc/src/main/scala/docspell/oidc/UserInfoDecoder.scala @@ -20,21 +20,26 @@ object UserInfoDecoder { findSomeId("preferred_username") /** Looks recursively in the JSON for the first attribute with name `key` and returns - * its value (expecting an Ident). + * its value. */ - def findSomeId(key: String): Decoder[Ident] = + def findSomeString(key: String): Decoder[String] = Decoder.instance { cursor => cursor.value .findAllByKey(key) .find(_.isString) .flatMap(_.asString) .toRight(s"No value found in JSON for key '$key'") - .flatMap(normalizeUid) .left .map(msg => DecodingFailure(msg, Nil)) } - private def normalizeUid(uid: String): Either[String, Ident] = + /** Looks recursively in the JSON for the first attribute with name `key` and returns + * its value (expecting an Ident). + */ + def findSomeId(key: String): Decoder[Ident] = + findSomeString(key).emap(normalizeUid) + + def normalizeUid(uid: String): Either[String, Ident] = Ident(uid.filter(Ident.chars.contains)) .flatMap(id => if (id.nonEmpty) Right(id) else Left(s"Id '$uid' empty after normalizing!'") diff --git a/modules/restserver/src/main/resources/reference.conf b/modules/restserver/src/main/resources/reference.conf index 8020ee1b..26bd0f7f 100644 --- a/modules/restserver/src/main/resources/reference.conf +++ b/modules/restserver/src/main/resources/reference.conf @@ -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 = "", @@ -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" } ] diff --git a/modules/restserver/src/main/scala/docspell/restserver/Config.scala b/modules/restserver/src/main/scala/docspell/restserver/Config.scala index 8ab744c7..6d8d291d 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/Config.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/Config.scala @@ -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 + ) } diff --git a/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala b/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala index 4025bf50..8818e92a 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala @@ -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)) } } diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 0f4710eb..0d867304 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -110,6 +110,7 @@ object RestServer { ): HttpRoutes[F] = Router( "auth/oauth" -> CodeFlowRoutes( + cfg.openIdEnabled, OpenId.handle[F](restApp.backend, cfg), OpenId.codeFlowConfig(cfg), client diff --git a/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala b/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala index e7c6dae2..38111c82 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala @@ -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 + } + } } From 984dda9da060d6a76d0663c73538160f867e17f3 Mon Sep 17 00:00:00 2001 From: eikek Date: Sun, 5 Sep 2021 23:43:07 +0200 Subject: [PATCH 06/12] Add OpenID support to webapp --- .../docspell/restserver/webapp/Flags.scala | 23 ++++++++++--- modules/webapp/src/main/elm/Api.elm | 6 ++++ modules/webapp/src/main/elm/App/Data.elm | 9 +++++- modules/webapp/src/main/elm/App/Update.elm | 32 +++++++++++-------- modules/webapp/src/main/elm/Data/Flags.elm | 7 ++++ .../src/main/elm/Messages/Comp/HttpError.elm | 6 ++++ .../src/main/elm/Messages/Page/Login.elm | 3 ++ modules/webapp/src/main/elm/Page.elm | 28 ++++++++++------ .../webapp/src/main/elm/Page/Login/Data.elm | 16 ++++++++++ .../webapp/src/main/elm/Page/Login/Update.elm | 4 +-- .../webapp/src/main/elm/Page/Login/View2.elm | 32 +++++++++++++++++++ .../src/main/elm/Page/Register/Update.elm | 2 +- .../src/main/elm/Page/Register/View2.elm | 2 +- 13 files changed, 138 insertions(+), 32 deletions(-) diff --git a/modules/restserver/src/main/scala/docspell/restserver/webapp/Flags.scala b/modules/restserver/src/main/scala/docspell/restserver/webapp/Flags.scala index 4964909d..a26ce488 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/webapp/Flags.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/webapp/Flags.scala @@ -7,9 +7,8 @@ package docspell.restserver.webapp import docspell.backend.signup.{Config => SignupConfig} -import docspell.common.LenientUri +import docspell.common.{Ident, LenientUri} import docspell.restserver.{BuildInfo, Config} - import io.circe._ import io.circe.generic.semiauto._ import yamusca.implicits._ @@ -25,7 +24,8 @@ case class Flags( maxPageSize: Int, maxNoteLength: Int, showClassificationSettings: Boolean, - uiVersion: Int + uiVersion: Int, + openIdAuth: List[Flags.OpenIdAuth] ) object Flags { @@ -40,9 +40,20 @@ object Flags { cfg.maxItemPageSize, cfg.maxNoteLength, cfg.showClassificationSettings, - uiVersion + uiVersion, + cfg.openid.filter(_.enabled).map(c => OpenIdAuth(c.provider.providerId, c.display)) ) + final case class OpenIdAuth(provider: Ident, name: String) + + object OpenIdAuth { + implicit val jsonDecoder: Decoder[OpenIdAuth] = + deriveDecoder[OpenIdAuth] + + implicit val jsonEncoder: Encoder[OpenIdAuth] = + deriveEncoder[OpenIdAuth] + } + private def getBaseUrl(cfg: Config): String = if (cfg.baseUrl.isLocal) cfg.baseUrl.rootPathToEmpty.path.asString else cfg.baseUrl.rootPathToEmpty.asString @@ -50,6 +61,10 @@ object Flags { implicit val jsonEncoder: Encoder[Flags] = deriveEncoder[Flags] + implicit def yamuscaIdentConverter: ValueConverter[Ident] = + ValueConverter.of(id => Value.fromString(id.id)) + implicit def yamuscaOpenIdAuthConverter: ValueConverter[OpenIdAuth] = + ValueConverter.deriveConverter[OpenIdAuth] implicit def yamuscaSignupModeConverter: ValueConverter[SignupConfig.Mode] = ValueConverter.of(m => Value.fromString(m.name)) implicit def yamuscaUriConverter: ValueConverter[LenientUri] = diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index c9486ec4..e9588df1 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -87,6 +87,7 @@ module Api exposing , mergeItems , moveAttachmentBefore , newInvite + , openIdAuthLink , postCustomField , postEquipment , postNewUser @@ -935,6 +936,11 @@ newInvite flags req receive = --- Login +openIdAuthLink : Flags -> String -> String +openIdAuthLink flags provider = + flags.config.baseUrl ++ "/api/v1/open/auth/openid/" ++ provider + + login : Flags -> UserPass -> (Result Http.Error AuthResult -> msg) -> Cmd msg login flags up receive = Http.post diff --git a/modules/webapp/src/main/elm/App/Data.elm b/modules/webapp/src/main/elm/App/Data.elm index fe44da24..f60b02a2 100644 --- a/modules/webapp/src/main/elm/App/Data.elm +++ b/modules/webapp/src/main/elm/App/Data.elm @@ -82,6 +82,12 @@ init key url flags_ settings = ( csm, csc ) = Page.CollectiveSettings.Data.init flags + ( loginm, loginc ) = + Page.Login.Data.init flags + (Page.loginPageReferrer page + |> Tuple.second + ) + homeViewMode = if settings.searchMenuVisible then Page.Home.Data.SearchView @@ -94,7 +100,7 @@ init key url flags_ settings = , page = page , version = Api.Model.VersionInfo.empty , homeModel = Page.Home.Data.init flags homeViewMode - , loginModel = Page.Login.Data.emptyModel + , loginModel = loginm , manageDataModel = mdm , collSettingsModel = csm , userSettingsModel = um @@ -116,6 +122,7 @@ init key url flags_ settings = [ Cmd.map UserSettingsMsg uc , Cmd.map ManageDataMsg mdc , Cmd.map CollSettingsMsg csc + , Cmd.map LoginMsg loginc ] ) diff --git a/modules/webapp/src/main/elm/App/Update.elm b/modules/webapp/src/main/elm/App/Update.elm index b5381433..280cb990 100644 --- a/modules/webapp/src/main/elm/App/Update.elm +++ b/modules/webapp/src/main/elm/App/Update.elm @@ -158,7 +158,7 @@ updateWithSub msg model = LogoutResp _ -> ( { model | loginModel = Page.Login.Data.emptyModel } - , Page.goto (LoginPage Nothing) + , Page.goto (LoginPage ( Nothing, False )) , Sub.none ) @@ -216,20 +216,24 @@ updateWithSub msg model = NavRequest req -> case req of Internal url -> - let - isCurrent = - Page.fromUrl url - |> Maybe.map (\p -> p == model.page) - |> Maybe.withDefault True - in - ( model - , if isCurrent then - Cmd.none + if String.startsWith "/app" url.path then + let + isCurrent = + Page.fromUrl url + |> Maybe.map (\p -> p == model.page) + |> Maybe.withDefault True + in + ( model + , if isCurrent then + Cmd.none - else - Nav.pushUrl model.key (Url.toString url) - , Sub.none - ) + else + Nav.pushUrl model.key (Url.toString url) + , Sub.none + ) + + else + ( model, Nav.load <| Url.toString url, Sub.none ) External url -> ( model diff --git a/modules/webapp/src/main/elm/Data/Flags.elm b/modules/webapp/src/main/elm/Data/Flags.elm index df841b51..ddcf4f73 100644 --- a/modules/webapp/src/main/elm/Data/Flags.elm +++ b/modules/webapp/src/main/elm/Data/Flags.elm @@ -17,6 +17,12 @@ module Data.Flags exposing import Api.Model.AuthResult exposing (AuthResult) +type alias OpenIdAuth = + { provider : String + , name : String + } + + type alias Config = { appName : String , baseUrl : String @@ -27,6 +33,7 @@ type alias Config = , maxPageSize : Int , maxNoteLength : Int , showClassificationSettings : Bool + , openIdAuth : List OpenIdAuth } diff --git a/modules/webapp/src/main/elm/Messages/Comp/HttpError.elm b/modules/webapp/src/main/elm/Messages/Comp/HttpError.elm index 99cbaa7d..3209de14 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/HttpError.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/HttpError.elm @@ -26,6 +26,7 @@ gb err = , invalidInput = "Invalid input when processing the request." , notFound = "The requested resource doesn't exist." , invalidBody = \str -> "There was an error decoding the response: " ++ str + , accessDenied = "Access denied" } in errorToString texts err @@ -44,6 +45,7 @@ de err = , invalidInput = "Die Daten im Request waren ungültig." , notFound = "Die angegebene Ressource wurde nicht gefunden." , invalidBody = \str -> "Es gab einen Fehler beim Dekodieren der Antwort: " ++ str + , accessDenied = "Zugriff verweigert" } in errorToString texts err @@ -61,6 +63,7 @@ type alias Texts = , invalidInput : String , notFound : String , invalidBody : String -> String + , accessDenied : String } @@ -90,6 +93,9 @@ errorToString texts error = if sc == 404 then texts.notFound + else if sc == 403 then + texts.accessDenied + else if sc >= 400 && sc < 500 then texts.invalidInput diff --git a/modules/webapp/src/main/elm/Messages/Page/Login.elm b/modules/webapp/src/main/elm/Messages/Page/Login.elm index 10f8c4bf..1b7c606a 100644 --- a/modules/webapp/src/main/elm/Messages/Page/Login.elm +++ b/modules/webapp/src/main/elm/Messages/Page/Login.elm @@ -29,6 +29,7 @@ type alias Texts = , noAccount : String , signupLink : String , otpCode : String + , or : String } @@ -47,6 +48,7 @@ gb = , noAccount = "No account?" , signupLink = "Sign up!" , otpCode = "Authentication code" + , or = "Or" } @@ -65,4 +67,5 @@ de = , noAccount = "Kein Konto?" , signupLink = "Hier registrieren!" , otpCode = "Authentifizierungscode" + , or = "Oder" } diff --git a/modules/webapp/src/main/elm/Page.elm b/modules/webapp/src/main/elm/Page.elm index d6a4afa4..a25fa305 100644 --- a/modules/webapp/src/main/elm/Page.elm +++ b/modules/webapp/src/main/elm/Page.elm @@ -33,7 +33,7 @@ import Util.Maybe type Page = HomePage - | LoginPage (Maybe Page) + | LoginPage ( Maybe Page, Bool ) | ManageDataPage | CollectiveSettingPage | UserSettingPage @@ -99,10 +99,10 @@ loginPage : Page -> Page loginPage p = case p of LoginPage _ -> - LoginPage Nothing + LoginPage ( Nothing, False ) _ -> - LoginPage (Just p) + LoginPage ( Just p, False ) pageName : Page -> String @@ -144,14 +144,14 @@ pageName page = "Item" -loginPageReferrer : Page -> Maybe Page +loginPageReferrer : Page -> ( Maybe Page, Bool ) loginPageReferrer page = case page of - LoginPage r -> - r + LoginPage ( r, flag ) -> + ( r, flag ) _ -> - Nothing + ( Nothing, False ) uploadId : Page -> Maybe String @@ -170,7 +170,7 @@ pageToString page = HomePage -> "/app/home" - LoginPage referer -> + LoginPage ( referer, _ ) -> case referer of Just (LoginPage _) -> "/app/login" @@ -253,7 +253,7 @@ parser = , s pathPrefix s "home" ] ) - , Parser.map LoginPage (s pathPrefix s "login" pageQuery) + , Parser.map LoginPage (s pathPrefix s "login" loginPageParser) , Parser.map ManageDataPage (s pathPrefix s "managedata") , Parser.map CollectiveSettingPage (s pathPrefix s "csettings") , Parser.map UserSettingPage (s pathPrefix s "usettings") @@ -280,6 +280,16 @@ fromString str = fromUrl url +loginPageOAuthQuery : Query.Parser Bool +loginPageOAuthQuery = + Query.map Util.Maybe.nonEmpty (Query.string "openid") + + +loginPageParser : Query.Parser ( Maybe Page, Bool ) +loginPageParser = + Query.map2 Tuple.pair pageQuery loginPageOAuthQuery + + pageQuery : Query.Parser (Maybe Page) pageQuery = let diff --git a/modules/webapp/src/main/elm/Page/Login/Data.elm b/modules/webapp/src/main/elm/Page/Login/Data.elm index 33bd3913..c3a17d2e 100644 --- a/modules/webapp/src/main/elm/Page/Login/Data.elm +++ b/modules/webapp/src/main/elm/Page/Login/Data.elm @@ -11,9 +11,12 @@ module Page.Login.Data exposing , Model , Msg(..) , emptyModel + , init ) +import Api import Api.Model.AuthResult exposing (AuthResult) +import Data.Flags exposing (Flags) import Http import Page exposing (Page(..)) @@ -51,6 +54,19 @@ emptyModel = } +init : Flags -> Bool -> ( Model, Cmd Msg ) +init flags oauth = + let + cmd = + if oauth then + Api.loginSession flags AuthResp + + else + Cmd.none + in + ( emptyModel, cmd ) + + type Msg = SetUsername String | SetPassword String diff --git a/modules/webapp/src/main/elm/Page/Login/Update.elm b/modules/webapp/src/main/elm/Page/Login/Update.elm index 6f3c82e6..31e1375f 100644 --- a/modules/webapp/src/main/elm/Page/Login/Update.elm +++ b/modules/webapp/src/main/elm/Page/Login/Update.elm @@ -15,8 +15,8 @@ import Page.Login.Data exposing (..) import Ports -update : Maybe Page -> Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe AuthResult ) -update referrer flags msg model = +update : ( Maybe Page, Bool ) -> Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe AuthResult ) +update ( referrer, oauth ) flags msg model = case msg of SetUsername str -> ( { model | username = str }, Cmd.none, Nothing ) diff --git a/modules/webapp/src/main/elm/Page/Login/View2.elm b/modules/webapp/src/main/elm/Page/Login/View2.elm index c5e06a2b..ddbecacf 100644 --- a/modules/webapp/src/main/elm/Page/Login/View2.elm +++ b/modules/webapp/src/main/elm/Page/Login/View2.elm @@ -7,8 +7,10 @@ module Page.Login.View2 exposing (viewContent, viewSidebar) +import Api import Api.Model.AuthResult exposing (AuthResult) import Api.Model.VersionInfo exposing (VersionInfo) +import Comp.Basic as B import Data.Flags exposing (Flags) import Data.UiSettings exposing (UiSettings) import Html exposing (..) @@ -53,6 +55,7 @@ viewContent texts flags versionInfo _ model = StepLogin -> loginForm texts flags model + , openIdLinks texts flags ] , a [ class "inline-flex items-center mt-4 text-xs opacity-50 hover:opacity-90" @@ -72,6 +75,35 @@ viewContent texts flags versionInfo _ model = ] +openIdLinks : Texts -> Flags -> Html Msg +openIdLinks texts flags = + let + renderLink prov = + a + [ href (Api.openIdAuthLink flags prov.provider) + , class S.link + ] + [ i [ class "fab fa-openid mr-1" ] [] + , text prov.name + ] + in + case flags.config.openIdAuth of + [] -> + span [ class "hidden" ] [] + + provs -> + div [ class "mt-3" ] + [ B.horizontalDivider + { label = texts.or + , topCss = "w-2/3 mb-4 hidden md:inline-flex w-full" + , labelCss = "px-4 bg-gray-200 bg-opacity-50" + , lineColor = "bg-gray-300 dark:bg-bluegray-600" + } + , div [ class "flex flex-row space-x-4 items-center justify-center" ] + (List.map renderLink provs) + ] + + otpForm : Texts -> Flags -> Model -> AuthResult -> Html Msg otpForm texts flags model acc = Html.form diff --git a/modules/webapp/src/main/elm/Page/Register/Update.elm b/modules/webapp/src/main/elm/Page/Register/Update.elm index 8eb22439..f0d72784 100644 --- a/modules/webapp/src/main/elm/Page/Register/Update.elm +++ b/modules/webapp/src/main/elm/Page/Register/Update.elm @@ -97,7 +97,7 @@ update flags msg model = cmd = if r.success then - Page.goto (LoginPage Nothing) + Page.goto (LoginPage ( Nothing, False )) else Cmd.none diff --git a/modules/webapp/src/main/elm/Page/Register/View2.elm b/modules/webapp/src/main/elm/Page/Register/View2.elm index 2942c0c0..867f451b 100644 --- a/modules/webapp/src/main/elm/Page/Register/View2.elm +++ b/modules/webapp/src/main/elm/Page/Register/View2.elm @@ -232,7 +232,7 @@ viewContent texts flags _ model = [ text texts.alreadySignedUp ] , a - [ Page.href (LoginPage Nothing) + [ Page.href (LoginPage ( Nothing, False )) , class ("ml-2" ++ S.link) ] [ i [ class "fa fa-user-plus mr-1" ] [] From 8158e36d4062b7441057fe07466a225e4dee6c3c Mon Sep 17 00:00:00 2001 From: eikek Date: Sun, 5 Sep 2021 23:43:25 +0200 Subject: [PATCH 07/12] Improve logging and rename oauth->openid --- .../main/scala/docspell/oidc/CodeFlow.scala | 8 +- .../scala/docspell/oidc/CodeFlowRoutes.scala | 14 +-- .../src/main/resources/reference.conf | 95 +++++++++++-------- .../scala/docspell/restserver/Config.scala | 3 +- .../docspell/restserver/RestServer.scala | 2 +- .../docspell/restserver/auth/OpenId.scala | 15 ++- 6 files changed, 82 insertions(+), 55 deletions(-) diff --git a/modules/oidc/src/main/scala/docspell/oidc/CodeFlow.scala b/modules/oidc/src/main/scala/docspell/oidc/CodeFlow.scala index 6865ca39..a949eb42 100644 --- a/modules/oidc/src/main/scala/docspell/oidc/CodeFlow.scala +++ b/modules/oidc/src/main/scala/docspell/oidc/CodeFlow.scala @@ -45,13 +45,13 @@ object CodeFlow { for { _ <- OptionT.liftF( - logger.debug( + logger.trace( s"Obtaining access_token for provider ${cfg.providerId.id} and code $code" ) ) token <- fetchAccessToken[F](c, dsl, cfg, redirectUri, code) _ <- OptionT.liftF( - logger.debug( + logger.trace( s"Obtaining user-info for provider ${cfg.providerId.id} and token $token" ) ) @@ -70,7 +70,7 @@ object CodeFlow { case _ => OptionT .liftF( - logger.error( + logger.warn( s"No signature specified and no user endpoint url. Cannot obtain user info from access token!" ) ) @@ -113,7 +113,7 @@ object CodeFlow { token <- r.attemptAs[AccessToken].value _ <- token match { case Right(t) => - logger.debug(s"Got token response: $t") + logger.trace(s"Got token response: $t") case Left(err) => logger.error(err)(s"Error decoding access token: ${err.getMessage}") } diff --git a/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala b/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala index e9a43532..ca0e51b5 100644 --- a/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala +++ b/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala @@ -50,18 +50,18 @@ object CodeFlowRoutes { ) .withQuery("response_type", "code") logger.debug( - s"Redirecting to OAuth provider ${cfg.providerId.id}: ${uri.asString}" - ) - SeeOther().map(_.withHeaders(Location(Uri.unsafeFromString(uri.asString)))) + s"Redirecting to OAuth/OIDC provider ${cfg.providerId.id}: ${uri.asString}" + ) *> + SeeOther().map(_.withHeaders(Location(Uri.unsafeFromString(uri.asString)))) case None => - logger.debug(s"No oauth provider found with id '$id'") *> + logger.debug(s"No OAuth/OIDC provider found with id '$id'") *> NotFound() } case req @ GET -> Root / Ident(id) / "resume" => config.findProvider(id) match { case None => - logger.debug(s"No oauth provider found with id '$id'") *> + logger.debug(s"No OAuth/OIDC provider found with id '$id'") *> NotFound() case Some(provider) => val codeFromReq = OptionT.fromOption[F](req.params.get("code")) @@ -70,7 +70,7 @@ object CodeFlowRoutes { _ <- OptionT.liftF(logger.info(s"Resume OAuth/OIDC flow for ${id.id}")) code <- codeFromReq _ <- OptionT.liftF( - logger.debug( + logger.trace( s"Resume OAuth/OIDC flow from ${provider.providerId.id} with auth_code=$code" ) ) @@ -92,7 +92,7 @@ object CodeFlowRoutes { .map(err => s": $err") .getOrElse("") - logger.error(s"Error resuming code flow from '${id.id}'$reason") *> + logger.warn(s"Error resuming code flow from '${id.id}'$reason") *> onUserInfo.handle(req, provider, None) } } diff --git a/modules/restserver/src/main/resources/reference.conf b/modules/restserver/src/main/resources/reference.conf index 26bd0f7f..be13766a 100644 --- a/modules/restserver/src/main/resources/reference.conf +++ b/modules/restserver/src/main/resources/reference.conf @@ -61,53 +61,67 @@ docspell.server { } } - # Configures OpenID Connect or OAuth2 authentication. Only the - # "Authorization Code Flow" is supported. + # Configures OpenID Connect (OIDC) or OAuth2 authentication. Only + # the "Authorization Code Flow" is supported. # - # Multiple authentication providers are supported. Each is + # Multiple authentication providers can be defined. 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. + # details necessary to authenticate agains an external OIDC or OAuth + # provider. This requires at least two URLs for OIDC and three for + # OAuth2. The `user-url` is only required for OIDC, if the account + # data is to be retrieved from the user-info endpoint and not from + # the JWT token. The access token is then used to authenticate at + # the provider to obtain user info. Thus, it doesn't need to be + # validated here 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. This would save + # another request. If the `sign-key` is left empty, the `user-url` + # is used and must be specified. If the `sign-key` is _not_ empty, + # the response from the authentication provider is validated using + # this key. # # 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. + # account. For this a username and collective name is required. The + # username is defined by the `user-key` setting. 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. + # There are the following ways to specify how to retrieve the full + # account id depending on the value of `collective-key`: # - # 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 `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 `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. + # - 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:ds-account", it + # works the same as `lookup:` only that the value is interpreted + # as the full account name of form `collective/login`. The + # `user-key` value is ignored in this case. + # + # If these values cannot be obtained from the response, docspell + # fails the authentication by denying access. It is then assumed + # that the successfully authenticated user has not enough + # permissions to access docspell. # # Below are examples for OpenID Connect (keycloak) and OAuth2 # (github). openid = [ { enabled = false, + # The name to render on the login link/button. + display = "Keycloak" + # This illustrates to use a custom keycloak setup as the - # authentication provider. For details, please refer to its - # documentation. + # authentication provider. For details, please refer to the + # keycloak documentation. The settings here assume a certain + # configuration at keycloak. # # Keycloak can be configured to return the collective name for # each user in the access token. It may also be configured to @@ -120,7 +134,7 @@ docspell.server { provider-id = "keycloak", client-id = "docspell", client-secret = "example-secret-439e-bf06-911e4cdd56a6", - scope = "docspell", + scope = "docspell", # scope is required for OIDC 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. @@ -136,22 +150,27 @@ docspell.server { }, { enabled = false, + # The name to render on the login link/button. + display = "Github" + # 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. + # Github doesn't have full OpenIdConnect, but supports the + # OAuth2 code flow (which is very similar). It mainly means, + # that there is no standardized token to validate and get + # information from. So the user-url must be used in this case. provider = { provider-id = "github", client-id = "", client-secret = "", - scope = "", + scope = "", # scope is not needed for github 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 = "" + sign-key = "" # this must be set empty sig-algo = "RS256" #unused but must be set to something }, diff --git a/modules/restserver/src/main/scala/docspell/restserver/Config.scala b/modules/restserver/src/main/scala/docspell/restserver/Config.scala index 6d8d291d..c0f7afe2 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/Config.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/Config.scala @@ -78,7 +78,8 @@ object Config { object FullTextSearch {} final case class OpenIdConfig( - enabled: Boolean, + enabled: Boolean, + display: String, collectiveKey: OpenId.UserInfo.Extractor, userKey: String, provider: ProviderConfig diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 0d867304..38e43c9c 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -109,7 +109,7 @@ object RestServer { restApp: RestApp[F] ): HttpRoutes[F] = Router( - "auth/oauth" -> CodeFlowRoutes( + "auth/openid" -> CodeFlowRoutes( cfg.openIdEnabled, OpenId.handle[F](restApp.backend, cfg), OpenId.codeFlowConfig(cfg), diff --git a/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala b/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala index 38111c82..ae762755 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala @@ -31,7 +31,7 @@ object OpenId { CodeFlowConfig( req => ClientRequestInfo - .getBaseUrl(config, req) / "api" / "v1" / "open" / "auth" / "oauth", + .getBaseUrl(config, req) / "api" / "v1" / "open" / "auth" / "openid", id => config.openid.filter(_.enabled).find(_.provider.providerId == id).map(_.provider) ) @@ -42,7 +42,7 @@ object OpenId { import dsl._ val logger = Logger.log4s(log) val baseUrl = ClientRequestInfo.getBaseUrl(config, req) - val uri = baseUrl.withQuery("oauth", "1") / "app" / "login" + val uri = baseUrl.withQuery("openid", "1") / "app" / "login" val location = Location(Uri.unsafeFromString(uri.asString)) val cfg = config.openid .find(_.provider.providerId == provider.providerId) @@ -54,7 +54,7 @@ object OpenId { extractColl match { case ExtractResult.Failure(message) => - logger.error(s"Error retrieving user data: $message") *> + logger.warn(s"Can't retrieve user data using collective-key=${cfg.collectiveKey.asString}: $message") *> TemporaryRedirect(location) case ExtractResult.Account(accountId) => @@ -63,7 +63,7 @@ object OpenId { case ExtractResult.Identifier(coll) => Extractor.Lookup(cfg.userKey).find(userJson) match { case ExtractResult.Failure(message) => - logger.error(s"Error retrieving user data: $message") *> + logger.warn(s"Can't retrieve user data using user-key=${cfg.userKey}: $message") *> TemporaryRedirect(location) case ExtractResult.Identifier(name) => @@ -158,6 +158,7 @@ object OpenId { sealed trait Extractor { def find(json: Json): ExtractResult + def asString: String } object Extractor { final case class Fixed(value: String) extends Extractor { @@ -165,6 +166,8 @@ object OpenId { UserInfoDecoder .normalizeUid(value) .fold(err => ExtractResult.Failure(err), ExtractResult.Identifier) + + val asString = s"fixed:$value" } final case class Lookup(value: String) extends Extractor { @@ -176,6 +179,8 @@ object OpenId { err => ExtractResult.Failure(err.getMessage()), ExtractResult.Identifier ) + + val asString = s"lookup:$value" } final case class AccountLookup(value: String) extends Extractor { @@ -185,6 +190,8 @@ object OpenId { .emap(AccountId.parse) .decodeJson(json) .fold(df => ExtractResult.Failure(df.getMessage()), ExtractResult.Account) + + def asString = s"account:$value" } def fromString(str: String): Either[String, Extractor] = From 468ba90158a6c1ab02c6202203741a51285e59f1 Mon Sep 17 00:00:00 2001 From: eikek Date: Mon, 6 Sep 2021 01:07:31 +0200 Subject: [PATCH 08/12] Fix OTP authentication for external accounts --- .../scala/docspell/oidc/CodeFlowRoutes.scala | 2 + .../scala/docspell/restserver/Config.scala | 4 +- .../docspell/restserver/auth/OpenId.scala | 18 +++++-- .../docspell/restserver/webapp/Flags.scala | 1 + modules/webapp/src/main/elm/App/Data.elm | 5 +- modules/webapp/src/main/elm/App/Update.elm | 2 +- modules/webapp/src/main/elm/Page.elm | 50 +++++++++++++------ .../webapp/src/main/elm/Page/Login/Data.elm | 12 ++--- .../webapp/src/main/elm/Page/Login/Update.elm | 37 ++++++++++---- .../webapp/src/main/elm/Page/Login/View2.elm | 6 +-- .../src/main/elm/Page/Register/Update.elm | 2 +- .../src/main/elm/Page/Register/View2.elm | 2 +- 12 files changed, 95 insertions(+), 46 deletions(-) diff --git a/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala b/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala index ca0e51b5..931fc8c1 100644 --- a/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala +++ b/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala @@ -9,7 +9,9 @@ package docspell.oidc import cats.data.{Kleisli, OptionT} import cats.effect._ import cats.implicits._ + import docspell.common._ + import org.http4s.HttpRoutes import org.http4s._ import org.http4s.client.Client diff --git a/modules/restserver/src/main/scala/docspell/restserver/Config.scala b/modules/restserver/src/main/scala/docspell/restserver/Config.scala index c0f7afe2..039f1848 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/Config.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/Config.scala @@ -78,8 +78,8 @@ object Config { object FullTextSearch {} final case class OpenIdConfig( - enabled: Boolean, - display: String, + enabled: Boolean, + display: String, collectiveKey: OpenId.UserInfo.Extractor, userKey: String, provider: ProviderConfig diff --git a/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala b/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala index ae762755..76129fa8 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala @@ -54,7 +54,9 @@ object OpenId { extractColl match { case ExtractResult.Failure(message) => - logger.warn(s"Can't retrieve user data using collective-key=${cfg.collectiveKey.asString}: $message") *> + logger.warn( + s"Can't retrieve user data using collective-key=${cfg.collectiveKey.asString}: $message" + ) *> TemporaryRedirect(location) case ExtractResult.Account(accountId) => @@ -63,7 +65,9 @@ object OpenId { case ExtractResult.Identifier(coll) => Extractor.Lookup(cfg.userKey).find(userJson) match { case ExtractResult.Failure(message) => - logger.warn(s"Can't retrieve user data using user-key=${cfg.userKey}: $message") *> + logger.warn( + s"Can't retrieve user data using user-key=${cfg.userKey}: $message" + ) *> TemporaryRedirect(location) case ExtractResult.Identifier(name) => @@ -144,7 +148,15 @@ object OpenId { login <- backend.login.loginExternal(config.auth)(accountId) resp <- login match { case Login.Result.Ok(session, _) => - TemporaryRedirect(location) + val loc = + if (session.requireSecondFactor) + location.copy(uri = + location.uri + .withQueryParam("openid", "2") + .withQueryParam("auth", session.asString) + ) + else location + TemporaryRedirect(loc) .map(_.addCookie(CookieData(session).asCookie(baseUrl))) case failed => diff --git a/modules/restserver/src/main/scala/docspell/restserver/webapp/Flags.scala b/modules/restserver/src/main/scala/docspell/restserver/webapp/Flags.scala index a26ce488..1f159926 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/webapp/Flags.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/webapp/Flags.scala @@ -9,6 +9,7 @@ package docspell.restserver.webapp import docspell.backend.signup.{Config => SignupConfig} import docspell.common.{Ident, LenientUri} import docspell.restserver.{BuildInfo, Config} + import io.circe._ import io.circe.generic.semiauto._ import yamusca.implicits._ diff --git a/modules/webapp/src/main/elm/App/Data.elm b/modules/webapp/src/main/elm/App/Data.elm index f60b02a2..6bb45b65 100644 --- a/modules/webapp/src/main/elm/App/Data.elm +++ b/modules/webapp/src/main/elm/App/Data.elm @@ -83,10 +83,7 @@ init key url flags_ settings = Page.CollectiveSettings.Data.init flags ( loginm, loginc ) = - Page.Login.Data.init flags - (Page.loginPageReferrer page - |> Tuple.second - ) + Page.Login.Data.init flags (Page.loginPageReferrer page) homeViewMode = if settings.searchMenuVisible then diff --git a/modules/webapp/src/main/elm/App/Update.elm b/modules/webapp/src/main/elm/App/Update.elm index 280cb990..e0cce73b 100644 --- a/modules/webapp/src/main/elm/App/Update.elm +++ b/modules/webapp/src/main/elm/App/Update.elm @@ -158,7 +158,7 @@ updateWithSub msg model = LogoutResp _ -> ( { model | loginModel = Page.Login.Data.emptyModel } - , Page.goto (LoginPage ( Nothing, False )) + , Page.goto (LoginPage Page.emptyLoginData) , Sub.none ) diff --git a/modules/webapp/src/main/elm/Page.elm b/modules/webapp/src/main/elm/Page.elm index a25fa305..c5f78960 100644 --- a/modules/webapp/src/main/elm/Page.elm +++ b/modules/webapp/src/main/elm/Page.elm @@ -6,7 +6,9 @@ module Page exposing - ( Page(..) + ( LoginData + , Page(..) + , emptyLoginData , fromUrl , goto , hasSidebar @@ -31,9 +33,24 @@ import Url.Parser.Query as Query import Util.Maybe +type alias LoginData = + { referrer : Maybe Page + , session : Maybe String + , openid : Int + } + + +emptyLoginData : LoginData +emptyLoginData = + { referrer = Nothing + , session = Nothing + , openid = 0 + } + + type Page = HomePage - | LoginPage ( Maybe Page, Bool ) + | LoginPage LoginData | ManageDataPage | CollectiveSettingPage | UserSettingPage @@ -99,10 +116,10 @@ loginPage : Page -> Page loginPage p = case p of LoginPage _ -> - LoginPage ( Nothing, False ) + LoginPage emptyLoginData _ -> - LoginPage ( Just p, False ) + LoginPage { emptyLoginData | referrer = Just p } pageName : Page -> String @@ -144,14 +161,14 @@ pageName page = "Item" -loginPageReferrer : Page -> ( Maybe Page, Bool ) +loginPageReferrer : Page -> LoginData loginPageReferrer page = case page of - LoginPage ( r, flag ) -> - ( r, flag ) + LoginPage data -> + data _ -> - ( Nothing, False ) + emptyLoginData uploadId : Page -> Maybe String @@ -170,8 +187,8 @@ pageToString page = HomePage -> "/app/home" - LoginPage ( referer, _ ) -> - case referer of + LoginPage data -> + case data.referrer of Just (LoginPage _) -> "/app/login" @@ -280,14 +297,19 @@ fromString str = fromUrl url -loginPageOAuthQuery : Query.Parser Bool +loginPageOAuthQuery : Query.Parser Int loginPageOAuthQuery = - Query.map Util.Maybe.nonEmpty (Query.string "openid") + Query.map (Maybe.withDefault 0) (Query.int "openid") -loginPageParser : Query.Parser ( Maybe Page, Bool ) +loginPageSessionQuery : Query.Parser (Maybe String) +loginPageSessionQuery = + Query.string "auth" + + +loginPageParser : Query.Parser LoginData loginPageParser = - Query.map2 Tuple.pair pageQuery loginPageOAuthQuery + Query.map3 LoginData pageQuery loginPageSessionQuery loginPageOAuthQuery pageQuery : Query.Parser (Maybe Page) diff --git a/modules/webapp/src/main/elm/Page/Login/Data.elm b/modules/webapp/src/main/elm/Page/Login/Data.elm index c3a17d2e..6628fec2 100644 --- a/modules/webapp/src/main/elm/Page/Login/Data.elm +++ b/modules/webapp/src/main/elm/Page/Login/Data.elm @@ -18,7 +18,7 @@ import Api import Api.Model.AuthResult exposing (AuthResult) import Data.Flags exposing (Flags) import Http -import Page exposing (Page(..)) +import Page exposing (LoginData, Page(..)) type alias Model = @@ -40,7 +40,7 @@ type FormState type AuthStep = StepLogin - | StepOtp AuthResult + | StepOtp String emptyModel : Model @@ -54,11 +54,11 @@ emptyModel = } -init : Flags -> Bool -> ( Model, Cmd Msg ) -init flags oauth = +init : Flags -> LoginData -> ( Model, Cmd Msg ) +init flags ld = let cmd = - if oauth then + if ld.openid > 0 then Api.loginSession flags AuthResp else @@ -74,4 +74,4 @@ type Msg | Authenticate | AuthResp (Result Http.Error AuthResult) | SetOtp String - | AuthOtp AuthResult + | AuthOtp String diff --git a/modules/webapp/src/main/elm/Page/Login/Update.elm b/modules/webapp/src/main/elm/Page/Login/Update.elm index 31e1375f..3afa07cb 100644 --- a/modules/webapp/src/main/elm/Page/Login/Update.elm +++ b/modules/webapp/src/main/elm/Page/Login/Update.elm @@ -10,13 +10,13 @@ module Page.Login.Update exposing (update) import Api import Api.Model.AuthResult exposing (AuthResult) import Data.Flags exposing (Flags) -import Page exposing (Page(..)) +import Page exposing (LoginData, Page(..)) import Page.Login.Data exposing (..) import Ports -update : ( Maybe Page, Bool ) -> Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe AuthResult ) -update ( referrer, oauth ) flags msg model = +update : LoginData -> Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe AuthResult ) +update loginData flags msg model = case msg of SetUsername str -> ( { model | username = str }, Cmd.none, Nothing ) @@ -40,11 +40,11 @@ update ( referrer, oauth ) flags msg model = in ( model, Api.login flags userPass AuthResp, Nothing ) - AuthOtp acc -> + AuthOtp token -> let sf = { rememberMe = model.rememberMe - , token = Maybe.withDefault "" acc.token + , token = token , otp = model.otp } in @@ -53,7 +53,7 @@ update ( referrer, oauth ) flags msg model = AuthResp (Ok lr) -> let gotoRef = - Maybe.withDefault HomePage referrer |> Page.goto + Maybe.withDefault HomePage loginData.referrer |> Page.goto in if lr.success && not lr.requireSecondFactor then ( { model | formState = AuthSuccess lr, password = "" } @@ -62,7 +62,11 @@ update ( referrer, oauth ) flags msg model = ) else if lr.success && lr.requireSecondFactor then - ( { model | formState = FormInitial, authStep = StepOtp lr, password = "" } + ( { model + | formState = FormInitial + , authStep = StepOtp <| Maybe.withDefault "" lr.token + , password = "" + } , Cmd.none , Nothing ) @@ -77,11 +81,22 @@ update ( referrer, oauth ) flags msg model = let empty = Api.Model.AuthResult.empty + + session = + Maybe.withDefault "" loginData.session in - ( { model | password = "", formState = HttpError err } - , Ports.removeAccount () - , Just empty - ) + -- A value of 2 indicates that TOTP is required + if loginData.openid == 2 then + ( { model | formState = FormInitial, authStep = StepOtp session, password = "" } + , Cmd.none + , Nothing + ) + + else + ( { model | password = "", formState = HttpError err } + , Ports.removeAccount () + , Just empty + ) setAccount : AuthResult -> Cmd msg diff --git a/modules/webapp/src/main/elm/Page/Login/View2.elm b/modules/webapp/src/main/elm/Page/Login/View2.elm index ddbecacf..79182722 100644 --- a/modules/webapp/src/main/elm/Page/Login/View2.elm +++ b/modules/webapp/src/main/elm/Page/Login/View2.elm @@ -104,11 +104,11 @@ openIdLinks texts flags = ] -otpForm : Texts -> Flags -> Model -> AuthResult -> Html Msg -otpForm texts flags model acc = +otpForm : Texts -> Flags -> Model -> String -> Html Msg +otpForm texts flags model token = Html.form [ action "#" - , onSubmit (AuthOtp acc) + , onSubmit (AuthOtp token) , autocomplete False ] [ div [ class "flex flex-col mt-6" ] diff --git a/modules/webapp/src/main/elm/Page/Register/Update.elm b/modules/webapp/src/main/elm/Page/Register/Update.elm index f0d72784..2479ce2c 100644 --- a/modules/webapp/src/main/elm/Page/Register/Update.elm +++ b/modules/webapp/src/main/elm/Page/Register/Update.elm @@ -97,7 +97,7 @@ update flags msg model = cmd = if r.success then - Page.goto (LoginPage ( Nothing, False )) + Page.goto (LoginPage Page.emptyLoginData) else Cmd.none diff --git a/modules/webapp/src/main/elm/Page/Register/View2.elm b/modules/webapp/src/main/elm/Page/Register/View2.elm index 867f451b..e7dd97f5 100644 --- a/modules/webapp/src/main/elm/Page/Register/View2.elm +++ b/modules/webapp/src/main/elm/Page/Register/View2.elm @@ -232,7 +232,7 @@ viewContent texts flags _ model = [ text texts.alreadySignedUp ] , a - [ Page.href (LoginPage ( Nothing, False )) + [ Page.href (LoginPage Page.emptyLoginData) , class ("ml-2" ++ S.link) ] [ i [ class "fa fa-user-plus mr-1" ] [] From 28fdeb5a9331a5f89b81904942692993f1c4181a Mon Sep 17 00:00:00 2001 From: eikek Date: Mon, 6 Sep 2021 11:40:47 +0200 Subject: [PATCH 09/12] Fixing http redirect status codes --- .../scala/docspell/oidc/CodeFlowRoutes.scala | 2 +- .../scala/docspell/restserver/auth/OpenId.scala | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala b/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala index 931fc8c1..52b46331 100644 --- a/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala +++ b/modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala @@ -54,7 +54,7 @@ object CodeFlowRoutes { logger.debug( s"Redirecting to OAuth/OIDC provider ${cfg.providerId.id}: ${uri.asString}" ) *> - SeeOther().map(_.withHeaders(Location(Uri.unsafeFromString(uri.asString)))) + Found(Location(Uri.unsafeFromString(uri.asString))) case None => logger.debug(s"No OAuth/OIDC provider found with id '$id'") *> NotFound() diff --git a/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala b/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala index 76129fa8..f9348534 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/auth/OpenId.scala @@ -57,7 +57,7 @@ object OpenId { logger.warn( s"Can't retrieve user data using collective-key=${cfg.collectiveKey.asString}: $message" ) *> - TemporaryRedirect(location) + SeeOther(location) case ExtractResult.Account(accountId) => signUpAndLogin[F](backend)(config, accountId, location, baseUrl) @@ -68,7 +68,7 @@ object OpenId { logger.warn( s"Can't retrieve user data using user-key=${cfg.userKey}: $message" ) *> - TemporaryRedirect(location) + SeeOther(location) case ExtractResult.Identifier(name) => signUpAndLogin[F](backend)( @@ -112,23 +112,23 @@ object OpenId { res <- setup match { case SignupResult.Failure(ex) => logger.error(ex)(s"Error when creating external account!") *> - TemporaryRedirect(location) + SeeOther(location) case SignupResult.SignupClosed => logger.error(s"External accounts don't work when signup is closed!") *> - TemporaryRedirect(location) + SeeOther(location) case SignupResult.CollectiveExists => logger.error( s"Error when creating external accounts! Collective exists error reported. This is a bug!" ) *> - TemporaryRedirect(location) + SeeOther(location) case SignupResult.InvalidInvitationKey => logger.error( s"Error when creating external accounts! Invalid invitation key reported. This is a bug!" ) *> - TemporaryRedirect(location) + SeeOther(location) case SignupResult.Success => loginAndVerify(backend, cfg)(accountId, location, baseUrl) @@ -156,12 +156,12 @@ object OpenId { .withQueryParam("auth", session.asString) ) else location - TemporaryRedirect(loc) + SeeOther(loc) .map(_.addCookie(CookieData(session).asCookie(baseUrl))) case failed => Logger.log4s(log).error(s"External login failed: $failed") *> - TemporaryRedirect(location) + SeeOther(location) } } yield resp } From 4237caa755dd4bf1e2c9d02993ab42bb2bd55716 Mon Sep 17 00:00:00 2001 From: eikek Date: Mon, 6 Sep 2021 11:41:40 +0200 Subject: [PATCH 10/12] Add some documentation for OIDC --- .../src/main/resources/docspell-openapi.yml | 53 ++++++++++++ .../src/main/resources/reference.conf | 2 +- website/site/content/docs/api/intro.md | 8 ++ website/site/content/docs/configure/_index.md | 85 +++++++++++++++++++ website/site/content/docs/features/_index.md | 7 +- 5 files changed, 152 insertions(+), 3 deletions(-) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index c6523a95..b6d5112c 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -42,6 +42,7 @@ paths: application/json: schema: $ref: "#/components/schemas/VersionInfo" + /open/auth/login: post: operationId: "open-auth-login" @@ -93,6 +94,51 @@ paths: application/json: schema: $ref: "#/components/schemas/AuthResult" + /open/auth/openid/{providerId}: + get: + operationId: "open-auth-openid" + tags: [ Authentication ] + summary: Authenticates via OIDC at the external provider given by its id + description: | + Initiates the ["Authorization Code + Flow"](https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth) + as described in the OpenID Connect specification. This only is + enabled, if an external provider has been configured correctly + in the config file. + + This will redirect to the external provider to authenticate + the user. Once authenticated, the user is redirected back to + the `/resume` endpoint. + parameters: + - $ref: "#/components/parameters/providerId" + responses: + 302: + description: Found. Redirect to external authentication provider + 200: + description: Not used, is only here because openid requires it + /open/auth/openid/{providerId}/resume: + get: + operationId: "open-auth-openid-resume" + tags: [ Authentication ] + summary: The callback URL for the authentication provider + description: | + This URL is used to redirect the user back to the application + by the authentication provider after login is completed. + + This will then try to find (or create) the account at docspell + using information about the user provided by the + authentication provider. If the required information cannot be + found, the user cannot be logged into the application. + + If the process completed successfully, this endpoint redirects + into the web application which will take over from here. + parameters: + - $ref: "#/components/parameters/providerId" + responses: + 303: + description: See Other. Redirect to the webapp + 200: + description: Not used, is only here because openid requires it /open/checkfile/{id}/{checksum}: get: @@ -6269,3 +6315,10 @@ components: some identifier for a client application schema: type: string + providerId: + name: providerId + in: path + required: true + schema: + type: string + format: ident diff --git a/modules/restserver/src/main/resources/reference.conf b/modules/restserver/src/main/resources/reference.conf index be13766a..2929f68d 100644 --- a/modules/restserver/src/main/resources/reference.conf +++ b/modules/restserver/src/main/resources/reference.conf @@ -134,7 +134,7 @@ docspell.server { provider-id = "keycloak", client-id = "docspell", client-secret = "example-secret-439e-bf06-911e4cdd56a6", - scope = "docspell", # scope is required for OIDC + scope = "profile", # scope is required for OIDC 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. diff --git a/website/site/content/docs/api/intro.md b/website/site/content/docs/api/intro.md index c6359ab0..cb232f53 100644 --- a/website/site/content/docs/api/intro.md +++ b/website/site/content/docs/api/intro.md @@ -44,6 +44,14 @@ must be `docspell_auth` and a custom header must be named The admin route (see below) `/admin/user/resetPassword` can be used to reset a password of a user. +### OpenID Connect + +Docspell can be configured to be a relying party for OpenID Connect. +Please see [the config +section](@/docs/configure/_index.md#openid-connect-oauth2) for +details. + + ## Admin There are some endpoints available for adminstration tasks, for diff --git a/website/site/content/docs/configure/_index.md b/website/site/content/docs/configure/_index.md index 4a8f14ea..9651f81a 100644 --- a/website/site/content/docs/configure/_index.md +++ b/website/site/content/docs/configure/_index.md @@ -342,6 +342,91 @@ The `session-valid` determines how long a token is valid. This can be just some minutes, the web application obtains new ones periodically. So a rather short time is recommended. +### OpenID Connect / OAuth2 + +You can integrate Docspell into your SSO solution via [OpenID +Connect](https://openid.net/connect/) (OIDC). This requires to set up +an OpenID Provider (OP) somewhere and to configure Docspell +accordingly to act as the relying party. + +You can define multiple OPs to use. For some examples, please see the +default configuration file [below](#rest-server). + +The configuration of a provider highly depends on how it is setup. +Here is an example for a setup using +[keycloak](https://www.keycloak.org): + +``` conf +provider = { + provider-id = "keycloak", + client-id = "docspell", + client-secret = "example-secret-439e-bf06-911e4cdd56a6", + scope = "profile", # scope is required for OIDC + 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" +} +``` + +The `provider-id` is some identifier that is used in the URL to +distinguish between possibly multiple providers. The `client-id` and +`client-secret` define the two parameters required for a "confidential +client". The different URLs are best explained at the [keycloak +docs](https://www.keycloak.org/docs/latest/server_admin/#_oidc-endpoints). +They are available for all OPs in some way. The `user-url` is not +required, if the access token is already containing the necessary +data. If not, then docspell performs another request to the +`user-url`, which must be the user-info endpoint, to obtain the +required user data. + +If the data is taken from the token directly and not via a request to +the user-info endpoint, then the token must be validated using the +given `sign-key` and `sig-algo`. These two values are then required to +specify! However, if the user-info endpoint should be used, then leave +the `sign-key` empty and specify the correct url in `user-url`. When +specifying the `sign-key` use a prefix of `b64:` if it is Base64 +encoded or `hex:` if it is hex encoded. Otherwise the unicode bytes +are used, which is most probably not wanted for this setting. + +Once the user is authenticated, docspell tries to setup an account and +does some checks. For this it must get to the username and collective +name somehow. How it does this, can be specified by the `user-key` and +`collective-key` settings: + +``` conf +# 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" +``` + +The `user-key` is some string that is used to search the JSON response +from the OP for an object with that key. The search happens +recursively, so the field can be in a nested object. The found value +is used as the user name. Keycloak transmits the `preferred_username` +when asking for the `profile` scope. This can be used as the user +name. + +The collective name can be obtained by different ways. For example, +you can instruct your OP (like keycloak) to provide a collective name +in the token and/or user-info responses. If you do this, then use the +`lookup:` prefix as in the example above. This instructs docspell to +search for a value the same way as the `user-key`. You can also set a +fixed collective, using `fixed:` prefix; in this case all users are in +the same collective! A third option is to prefix it with `account:` - +then the value that is looked up is interpreted as the full account +name, like `collective/user` and the `user-key` setting is ignored. If +you want to put each user in its own collective, you can just use the +same value as in `user-key`, only prefixed with `lookup:`. In the +example it would be `lookup:preferred_username`. + +If you find that these methods do not suffice for your case, please +open an issue. + ## File Processing diff --git a/website/site/content/docs/features/_index.md b/website/site/content/docs/features/_index.md index c1a6c1d0..029647c6 100644 --- a/website/site/content/docs/features/_index.md +++ b/website/site/content/docs/features/_index.md @@ -31,8 +31,11 @@ description = "A list of features and limitations." jobs, set priorities - Everything available via a [documented](https://www.openapis.org/) [REST Api](@/docs/api/_index.md); allows to [generate - clients](https://openapi-generator.tech/docs/generators) for - (almost) any language + clients](https://openapi-generator.tech/docs/generators) for many + languages +- [OpenID Connect](@/docs/configure/_index.md#openid-connect-oauth2) + support allows Docspell to integrate into your SSO setup, for + example with keycloak. - mobile-friendly Web-UI with dark and light theme - [Create anonymous “upload-urls”](@/docs/webapp/uploading.md#anonymous-upload) to From 11de82402eb61c6e721f66887b322cf495ef973c Mon Sep 17 00:00:00 2001 From: eikek Date: Mon, 6 Sep 2021 13:49:59 +0200 Subject: [PATCH 11/12] Add cross checks for the server config --- .../docspell/restserver/ConfigFile.scala | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala b/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala index 8818e92a..c838ca07 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/ConfigFile.scala @@ -6,9 +6,13 @@ package docspell.restserver +import cats.Semigroup +import cats.data.{Validated, ValidatedNec} +import cats.implicits._ + import docspell.backend.signup.{Config => SignupConfig} import docspell.common.config.Implicits._ -import docspell.oidc.SignatureAlgo +import docspell.oidc.{ProviderConfig, SignatureAlgo} import docspell.restserver.auth.OpenId import pureconfig._ @@ -18,7 +22,7 @@ object ConfigFile { import Implicits._ def loadConfig: Config = - ConfigSource.default.at("docspell.server").loadOrThrow[Config] + Validate(ConfigSource.default.at("docspell.server").loadOrThrow[Config]) object Implicits { implicit val signupModeReader: ConfigReader[SignupConfig.Mode] = @@ -30,4 +34,59 @@ object ConfigFile { implicit val openIdExtractorReader: ConfigReader[OpenId.UserInfo.Extractor] = ConfigReader[String].emap(reason(OpenId.UserInfo.Extractor.fromString)) } + + object Validate { + + implicit val firstConfigSemigroup: Semigroup[Config] = + Semigroup.first + + def apply(config: Config): Config = + all(config).foldLeft(valid(config))(_.combine(_)) match { + case Validated.Valid(cfg) => cfg + case Validated.Invalid(errs) => + val msg = errs.toList.mkString("- ", "\n- ", "\n") + throw sys.error(s"\n\n$msg") + } + + def all(cfg: Config) = List( + duplicateOpenIdProvider(cfg), + signKeyVsUserUrl(cfg) + ) + + private def valid(cfg: Config): ValidatedNec[String, Config] = + Validated.validNec(cfg) + + def duplicateOpenIdProvider(cfg: Config): ValidatedNec[String, Config] = { + val dupes = + cfg.openid + .filter(_.enabled) + .groupBy(_.provider.providerId) + .filter(_._2.size > 1) + .map(_._1.id) + .toList + + val dupesStr = dupes.mkString(", ") + if (dupes.isEmpty) valid(cfg) + else Validated.invalidNec(s"There is a duplicate openId provider: $dupesStr") + } + + def signKeyVsUserUrl(cfg: Config): ValidatedNec[String, Config] = { + def checkProvider(p: ProviderConfig): ValidatedNec[String, Config] = + if (p.signKey.isEmpty && p.userUrl.isEmpty) + Validated.invalidNec( + s"Either user-url or sign-key must be set for provider ${p.providerId.id}" + ) + else if (p.signKey.nonEmpty && p.scope.isEmpty) + Validated.invalidNec( + s"A scope is missing for OIDC auth at provider ${p.providerId.id}" + ) + else Validated.valid(cfg) + + cfg.openid + .filter(_.enabled) + .map(_.provider) + .map(checkProvider) + .foldLeft(valid(cfg))(_.combine(_)) + } + } } From cc9e3a31d80fd71ea06d070e13444ea2ed8a1efb Mon Sep 17 00:00:00 2001 From: eikek Date: Mon, 6 Sep 2021 14:25:10 +0200 Subject: [PATCH 12/12] Add openid to the nixos module --- nix/configuration-test.nix | 13 +++++ nix/module-server.nix | 101 +++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/nix/configuration-test.nix b/nix/configuration-test.nix index 17a82e9b..94236770 100644 --- a/nix/configuration-test.nix +++ b/nix/configuration-test.nix @@ -48,6 +48,19 @@ in header-value = "test123"; }; }; + openid = [ + { enabled = true; + display = "Local"; + provider = { + provider-id = "local"; + client-id = "cid1"; + client-secret = "csecret-1"; + authorize-url = "http:auth"; + token-url = "http:token"; + sign-key = "b64:uiaeuae"; + }; + } + ]; inherit full-text-search; }; diff --git a/nix/module-server.nix b/nix/module-server.nix index edaf3819..ca7eb013 100644 --- a/nix/module-server.nix +++ b/nix/module-server.nix @@ -61,6 +61,23 @@ let valid = "30 days"; }; }; + openid = { + enabled = false; + display = ""; + provider = { + provider-id = null; + client-id = null; + client-secret = null; + scope = "profile"; + authorize-url = null; + token-url = null; + user-url = ""; + sign-key = ""; + sig-algo = "RS256"; + }; + user-key = "preferred_username"; + collective-key = "lookup:preferred_username"; + }; backend = { mail-debug = false; jdbc = { @@ -226,6 +243,90 @@ in { description = "Authentication"; }; + openid = mkOption { + type = types.listOf (types.submodule { + options = { + enabled = mkOption { + type = types.bool; + default = defaults.openid.enabled; + description = "Whether to use these settings."; + }; + display = mkOption { + type = types.str; + default = defaults.openid.display; + example = "via Keycloak"; + description = "The name for the button on the login page."; + }; + user-key = mkOption { + type = types.str; + default = defaults.openid.user-key; + description = "The key to retrieve the username"; + }; + collective-key = mkOption { + type = types.str; + default = defaults.openid.collective-key; + description = "How to retrieve the collective name."; + }; + provider = mkOption { + type = (types.submodule { + options = { + provider-id = mkOption { + type = types.str; + default = defaults.openid.provider.provider-id; + example = "keycloak"; + description = "The id of the provider, used in the URL and to distinguish other providers."; + }; + client-id = mkOption { + type = types.str; + default = defaults.openid.provider.client-id; + description = "The client-id as registered at the OP."; + }; + client-secret = mkOption { + type = types.str; + default = defaults.openid.provider.client-secret; + description = "The client-secret as registered at the OP."; + }; + scope = mkOption { + type = types.str; + default = defaults.openid.provider.scope; + description = "A scope to define what data to return from OP"; + }; + authorize-url = mkOption { + type = types.str; + default = defaults.openid.provider.authorize-url; + description = "The URL used to authenticate the user"; + }; + token-url = mkOption { + type = types.str; + default = defaults.openid.provider.token-url; + description = "The URL used to retrieve the token."; + }; + user-url = mkOption { + type = types.str; + default = defaults.openid.provider.user-url; + description = "The URL to the user-info endpoint."; + }; + sign-key = mkOption { + type = types.str; + default = defaults.openid.provider.sign-key; + description = "The key for verifying the jwt signature."; + }; + sig-algo = mkOption { + type = types.str; + default = defaults.openid.provider.sig-algo; + description = "The expected algorithm used to sign the token."; + }; + }; + }); + default = defaults.openid.provider; + description = "The config for an OpenID Connect provider."; + }; + }; + }); + default = []; + description = "A list of OIDC provider configurations."; + }; + integration-endpoint = mkOption { type = types.submodule({ options = {