mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-06-22 02:18:26 +00:00
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:
@ -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)
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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(
|
||||
|
Reference in New Issue
Block a user