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

@ -11,10 +11,13 @@ import docspell.common.Password
import org.mindrot.jbcrypt.BCrypt
object PasswordCrypt {
// BCrypt requires non-empty strings
def crypt(pass: Password): Password =
Password(BCrypt.hashpw(pass.pass, BCrypt.gensalt()))
if (pass.isEmpty) sys.error("Empty password given to hash")
else Password(BCrypt.hashpw(pass.pass, BCrypt.gensalt()))
def check(plain: Password, hashed: Password): Boolean =
BCrypt.checkpw(plain.pass, hashed.pass)
if (plain.isEmpty || hashed.isEmpty) false
else BCrypt.checkpw(plain.pass, hashed.pass)
}

View File

@ -6,10 +6,11 @@
package docspell.backend.auth
import cats.data.{EitherT, OptionT}
import cats.data.{EitherT, NonEmptyList, OptionT}
import cats.effect._
import cats.implicits._
import docspell.backend.PasswordCrypt
import docspell.backend.auth.Login._
import docspell.common._
import docspell.store.Store
@ -17,7 +18,6 @@ import docspell.store.queries.QLogin
import docspell.store.records._
import docspell.totp.{OnetimePassword, Totp}
import org.mindrot.jbcrypt.BCrypt
import scodec.bits.ByteVector
trait Login[F[_]] {
@ -43,9 +43,32 @@ object Login {
case class Config(
serverSecret: ByteVector,
sessionValid: Duration,
rememberMe: RememberMe
rememberMe: RememberMe,
onAccountSourceConflict: OnAccountSourceConflict
)
sealed trait OnAccountSourceConflict {
def name: String
}
object OnAccountSourceConflict {
case object Fail extends OnAccountSourceConflict {
val name = "fail"
}
case object Convert extends OnAccountSourceConflict {
val name = "convert"
}
val all: NonEmptyList[OnAccountSourceConflict] =
NonEmptyList.of(Fail, Convert)
def fromString(str: String): Either[String, OnAccountSourceConflict] =
all
.find(_.name.equalsIgnoreCase(str))
.toRight(
s"Invalid on-account-source-conflict value: $str. Use one of ${all.toList.mkString(", ")}"
)
}
case class RememberMe(enabled: Boolean, valid: Duration) {
val disabled = !enabled
}
@ -106,7 +129,25 @@ object Login {
case Some(d) if checkNoPassword(d, Set(AccountSource.OpenId)) =>
doLogin(config, d.account, false)
case Some(d) if checkNoPassword(d, Set(AccountSource.Local)) =>
Result.invalidAccountSource(accountId).pure[F]
config.onAccountSourceConflict match {
case OnAccountSourceConflict.Fail =>
Result.invalidAccountSource(accountId).pure[F]
case OnAccountSourceConflict.Convert =>
for {
_ <- logF.debug(
s"Converting account ${d.account.asString} from Local to OpenId!"
)
_ <- store
.transact(
RUser.updateSource(
d.account.userId,
d.account.collectiveId,
AccountSource.OpenId
)
)
res <- doLogin(config, d.account, false)
} yield res
}
case _ =>
Result.invalidAuth.pure[F]
}
@ -133,8 +174,31 @@ object Login {
data <- store.transact(QLogin.findUser(acc))
_ <- logF.trace(s"Account lookup: $data")
res <- data match {
case Some(d) if check(up.pass)(d) =>
case Some(d) if check(up.pass)(d, Set(AccountSource.Local)) =>
doLogin(config, d.account, up.rememberMe)
case Some(d) if check(up.pass)(d, Set(AccountSource.OpenId)) =>
config.onAccountSourceConflict match {
case OnAccountSourceConflict.Fail =>
logF.info(
s"Fail authentication because of account source mismatch (local vs openid)."
) *>
Result.invalidAccountSource(d.account.asAccountId).pure[F]
case OnAccountSourceConflict.Convert =>
for {
_ <- logF.debug(
s"Converting account ${d.account.asString} from OpenId to Local!"
)
_ <- store
.transact(
RUser.updateSource(
d.account.userId,
d.account.collectiveId,
AccountSource.Local
)
)
res <- doLogin(config, d.account, up.rememberMe)
} yield res
}
case _ =>
Result.invalidAuth.pure[F]
}
@ -285,9 +349,11 @@ object Login {
token <- RememberToken.user(rme.id, config.serverSecret)
} yield token
private def check(givenPass: String)(data: QLogin.Data): Boolean = {
val passOk = BCrypt.checkpw(givenPass, data.password.pass)
checkNoPassword(data, Set(AccountSource.Local)) && passOk
private def check(
givenPass: String
)(data: QLogin.Data, expectedSources: Set[AccountSource]): Boolean = {
val passOk = PasswordCrypt.check(Password(givenPass), data.password)
checkNoPassword(data, expectedSources) && passOk
}
def checkNoPassword(

View File

@ -55,10 +55,14 @@ trait OCollective[F[_]] {
collectiveId: CollectiveId,
userId: Ident,
current: Password,
newPass: Password
newPass: Password,
expectedSources: Set[AccountSource]
): F[PassChangeResult]
def resetPassword(accountId: AccountId): F[PassResetResult]
def resetPassword(
accountId: AccountId,
expectedSources: Set[AccountSource]
): F[PassResetResult]
def getContacts(
collective: CollectiveId,
@ -114,11 +118,11 @@ object OCollective {
object PassResetResult {
case class Success(newPw: Password) extends PassResetResult
case object NotFound extends PassResetResult
case object UserNotLocal extends PassResetResult
case class InvalidSource(source: AccountSource) extends PassResetResult
def success(np: Password): PassResetResult = Success(np)
def notFound: PassResetResult = NotFound
def userNotLocal: PassResetResult = UserNotLocal
def invalidSource(source: AccountSource): PassResetResult = InvalidSource(source)
}
sealed trait PassChangeResult
@ -126,14 +130,14 @@ object OCollective {
case object UserNotFound extends PassChangeResult
case object PasswordMismatch extends PassChangeResult
case object UpdateFailed extends PassChangeResult
case object UserNotLocal extends PassChangeResult
case class InvalidSource(source: AccountSource) 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
def invalidSource(source: AccountSource): PassChangeResult = InvalidSource(source)
}
def apply[F[_]: Async](
@ -280,7 +284,10 @@ object OCollective {
def tagCloud(collective: CollectiveId): F[List[TagCount]] =
store.transact(QCollective.tagCloud(collective))
def resetPassword(accountId: AccountId): F[PassResetResult] =
def resetPassword(
accountId: AccountId,
expectedSources: Set[AccountSource]
): F[PassResetResult] =
(for {
user <- OptionT(store.transact(RUser.findByAccount(accountId)))
newPass <- OptionT.liftF(Password.generate[F])
@ -289,8 +296,8 @@ object OCollective {
RUser.updatePassword(user.cid, user.uid, PasswordCrypt.crypt(newPass))
)
res <-
if (user.source != AccountSource.Local)
OptionT.pure[F](PassResetResult.userNotLocal)
if (!expectedSources.contains(user.source))
OptionT.pure[F](PassResetResult.invalidSource(user.source))
else OptionT.liftF(doUpdate.as(PassResetResult.success(newPass)))
} yield res).getOrElse(PassResetResult.notFound)
@ -298,31 +305,31 @@ object OCollective {
collectiveId: CollectiveId,
userId: Ident,
current: Password,
newPass: Password
newPass: Password,
expectedSources: Set[AccountSource]
): F[PassChangeResult] = {
val q = for {
optUser <- RUser.findById(userId, collectiveId.some)
check = optUser.map(_.password).map(p => PasswordCrypt.check(current, p))
n <-
check
.filter(identity)
.traverse(_ =>
RUser.updatePassword(collectiveId, userId, PasswordCrypt.crypt(newPass))
user <- OptionT(store.transact(RUser.findById(userId, collectiveId.some)))
check = user.password.isEmpty || PasswordCrypt.check(current, user.password)
res <-
if (check && expectedSources.contains(user.source))
OptionT.liftF(
store
.transact(
RUser
.updatePassword(collectiveId, userId, PasswordCrypt.crypt(newPass))
)
.map {
case 0 => PassChangeResult.updateFailed
case _ => PassChangeResult.success
}
)
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
case None =>
PassChangeResult.userNotFound
}
else if (check && !expectedSources.contains(user.source))
OptionT.some[F](PassChangeResult.invalidSource(user.source))
else OptionT.some[F](PassChangeResult.passwordMismatch)
} yield res
store.transact(q)
q.getOrElse(PassChangeResult.userNotFound)
}
def getContacts(

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
}
}

View File

@ -204,11 +204,20 @@ object RUser {
val t = Table(None)
DML.update(
t,
t.cid === collId && t.uid === userId && t.source === AccountSource.Local,
t.cid === collId && t.uid === userId,
DML.set(t.password.setTo(hashedPass))
)
}
def updateSource(
userId: Ident,
collId: CollectiveId,
source: AccountSource
): ConnectionIO[Int] = {
val t = Table(None)
DML.update(t, t.uid === userId && t.cid === collId, DML.set(t.source.setTo(source)))
}
def delete(user: Ident, coll: CollectiveId): ConnectionIO[Int] = {
val t = Table(None)
DML.delete(t, t.cid === coll && t.login === user)