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"