Allow to authenticate with the same account from different sources

A new config allows to treat an account same independent where it was
created (openid or local).

Issue: #1827 #1781
This commit is contained in:
eikek
2022-11-04 22:33:46 +01:00
parent 14643ae4d1
commit 8ae4c9ec78
12 changed files with 219 additions and 52 deletions

View File

@ -95,6 +95,17 @@ docspell.server {
# How long the remember me cookie/token is valid.
valid = "30 days"
}
# One of: fail, convert
#
# Accounts can be local or defined at a remote provider and
# integrated via OIDC. If the same account is defined in both
# sources, docspell by default fails if a user mixes logins (e.g.
# when registering a user locally and then logging in with the
# same user via OIDC). When set to `convert` docspell treats it as
# being the same and simply updates the account to reflect the new
# account source.
on-account-source-conflict = "fail"
}
# Settings for "download as zip"

View File

@ -11,6 +11,7 @@ import java.security.SecureRandom
import cats.Monoid
import cats.effect.Async
import docspell.backend.auth.Login
import docspell.backend.signup.{Config => SignupConfig}
import docspell.config.Implicits._
import docspell.config.{ConfigFactory, FtsType, Validation}
@ -50,6 +51,10 @@ object ConfigFile {
implicit val openIdExtractorReader: ConfigReader[OpenId.UserInfo.Extractor] =
ConfigReader[String].emap(reason(OpenId.UserInfo.Extractor.fromString))
implicit val onAccountSourceConflictReader
: ConfigReader[Login.OnAccountSourceConflict] =
ConfigReader[String].emap(reason(Login.OnAccountSourceConflict.fromString))
}
def generateSecretIfEmpty: Validation[Config] =

View File

@ -81,7 +81,7 @@ final class RestAppImpl[F[_]: Async](
Router(
"fts" -> FullTextIndexRoutes.admin(config, backend),
"user/otp" -> TotpRoutes.admin(backend),
"user" -> UserRoutes.admin(backend),
"user" -> UserRoutes.admin(backend, config.auth),
"info" -> InfoRoutes.admin(config),
"attachments" -> AttachmentRoutes.admin(backend),
"files" -> FileRepositoryRoutes.admin(backend)
@ -129,7 +129,7 @@ final class RestAppImpl[F[_]: Async](
"person" -> PersonRoutes(backend, token),
"source" -> SourceRoutes(backend, token),
"user/otp" -> TotpRoutes(backend, config, token),
"user" -> UserRoutes(backend, token),
"user" -> UserRoutes(backend, config.auth, token),
"collective" -> CollectiveRoutes(backend, token),
"queue" -> JobQueueRoutes(backend, token),
"item" -> ItemRoutes(config, backend, token),

View File

@ -116,6 +116,7 @@ object RestServer {
)(
wsB: WebSocketBuilder2[F]
) = {
val logger = docspell.logging.getLogger[F]
val internal = Router(
"/" -> redirectTo("/app"),
"/internal" -> InternalHeader(internSettings.internalRouteKey) {
@ -123,6 +124,17 @@ object RestServer {
}
)
val httpApp = (internal <+> restApp.routes(wsB)).orNotFound
.mapF(
_.attempt
.flatMap { eab =>
eab.fold(
ex =>
logger.error(ex)("Processing the request resulted in an error.").as(eab),
_ => eab.pure[F]
)
}
.rethrow
)
Logger.httpApp(logHeaders = false, logBody = false)(httpApp)
}

View File

@ -699,8 +699,11 @@ 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.")
case PassChangeResult.InvalidSource(source) =>
BasicResult(
false,
s"User has invalid soure: $source. Passwords are managed elsewhere."
)
}
def basicResult(e: Either[Throwable, _], successMsg: String): BasicResult =

View File

@ -10,7 +10,8 @@ import cats.effect._
import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import docspell.backend.auth.Login.OnAccountSourceConflict
import docspell.backend.auth.{AuthToken, Login}
import docspell.backend.ops.OCollective
import docspell.common._
import docspell.restapi.model._
@ -24,7 +25,11 @@ import org.http4s.dsl.Http4sDsl
object UserRoutes {
def apply[F[_]: Async](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = {
def apply[F[_]: Async](
backend: BackendApp[F],
loginConfig: Login.Config,
user: AuthToken
): HttpRoutes[F] = {
val dsl = new Http4sDsl[F] {}
import dsl._
@ -36,7 +41,8 @@ object UserRoutes {
user.account.collectiveId,
user.account.userId,
data.currentPassword,
data.newPassword
data.newPassword,
expectedAccountSources(loginConfig)
)
resp <- Ok(basicResult(res))
} yield resp
@ -91,14 +97,20 @@ object UserRoutes {
}
}
def admin[F[_]: Async](backend: BackendApp[F]): HttpRoutes[F] = {
def admin[F[_]: Async](
backend: BackendApp[F],
loginConfig: Login.Config
): HttpRoutes[F] = {
val dsl = new Http4sDsl[F] {}
import dsl._
HttpRoutes.of { case req @ POST -> Root / "resetPassword" =>
for {
input <- req.as[ResetPassword]
result <- backend.collective.resetPassword(input.account)
result <- backend.collective.resetPassword(
input.account,
expectedAccountSources(loginConfig)
)
resp <- Ok(result match {
case OCollective.PassResetResult.Success(np) =>
ResetPasswordResult(true, np, "Password updated")
@ -108,14 +120,20 @@ object UserRoutes {
Password(""),
"Password update failed. User not found."
)
case OCollective.PassResetResult.UserNotLocal =>
case OCollective.PassResetResult.InvalidSource(source) =>
ResetPasswordResult(
false,
Password(""),
"Password update failed. User is not local, passwords are managed externally."
s"Password update failed. User has unexpected source: $source. Passwords are managed externally."
)
})
} yield resp
}
}
private def expectedAccountSources(loginConfig: Login.Config): Set[AccountSource] =
loginConfig.onAccountSourceConflict match {
case OnAccountSourceConflict.Fail => Set(AccountSource.Local)
case OnAccountSourceConflict.Convert => AccountSource.all.toList.toSet
}
}