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(