Add a new column to distinguish local from external users

This commit is contained in:
eikek 2021-09-05 17:08:52 +02:00
parent b73c252762
commit aef56233a5
16 changed files with 120 additions and 11 deletions

View File

@ -254,6 +254,12 @@ val openapiScalaSettings = Seq(
field => field =>
field field
.copy(typeDef = TypeDef("LenientUri", Imports("docspell.common.LenientUri"))) .copy(typeDef = TypeDef("LenientUri", Imports("docspell.common.LenientUri")))
case "accountsource" =>
field =>
field
.copy(typeDef =
TypeDef("AccountSource", Imports("docspell.common.AccountSource"))
)
}) })
) )

View File

@ -194,7 +194,7 @@ object Login {
logF.info(s"Account lookup via remember me: $data") logF.info(s"Account lookup via remember me: $data")
) )
res <- OptionT.liftF( res <- OptionT.liftF(
if (checkNoPassword(data)) if (checkNoPassword(data, AccountSource.all.toList.toSet))
logF.info("RememberMe auth successful") *> okResult(data.account) logF.info("RememberMe auth successful") *> okResult(data.account)
else else
logF.warn("RememberMe auth not successful") *> Result.invalidAuth.pure[F] 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 = { private def check(given: String)(data: QLogin.Data): Boolean = {
val passOk = BCrypt.checkpw(given, data.password.pass) 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 || val collOk = data.collectiveState == CollectiveState.Active ||
data.collectiveState == CollectiveState.ReadOnly data.collectiveState == CollectiveState.ReadOnly
val userOk = data.userState == UserState.Active val userOk =
data.userState == UserState.Active && expectedSources.contains(data.source)
collOk && userOk collOk && userOk
} }
}) })

View File

@ -9,7 +9,6 @@ package docspell.backend.ops
import cats.effect.{Async, Resource} import cats.effect.{Async, Resource}
import cats.implicits._ import cats.implicits._
import fs2.Stream import fs2.Stream
import docspell.backend.JobFactory import docspell.backend.JobFactory
import docspell.backend.PasswordCrypt import docspell.backend.PasswordCrypt
import docspell.backend.ops.OCollective._ import docspell.backend.ops.OCollective._
@ -20,7 +19,6 @@ import docspell.store.queue.JobQueue
import docspell.store.records._ import docspell.store.records._
import docspell.store.usertask.{UserTask, UserTaskScope, UserTaskStore} import docspell.store.usertask.{UserTask, UserTaskScope, UserTaskStore}
import docspell.store.{AddResult, Store} import docspell.store.{AddResult, Store}
import com.github.eikek.calev._ import com.github.eikek.calev._
trait OCollective[F[_]] { trait OCollective[F[_]] {
@ -95,9 +93,11 @@ object OCollective {
object PassResetResult { object PassResetResult {
case class Success(newPw: Password) extends PassResetResult case class Success(newPw: Password) extends PassResetResult
case object NotFound extends PassResetResult case object NotFound extends PassResetResult
case object UserNotLocal extends PassResetResult
def success(np: Password): PassResetResult = Success(np) def success(np: Password): PassResetResult = Success(np)
def notFound: PassResetResult = NotFound def notFound: PassResetResult = NotFound
def userNotLocal: PassResetResult = UserNotLocal
} }
sealed trait PassChangeResult sealed trait PassChangeResult
@ -105,12 +105,14 @@ object OCollective {
case object UserNotFound extends PassChangeResult case object UserNotFound extends PassChangeResult
case object PasswordMismatch extends PassChangeResult case object PasswordMismatch extends PassChangeResult
case object UpdateFailed extends PassChangeResult case object UpdateFailed extends PassChangeResult
case object UserNotLocal extends PassChangeResult
case object Success extends PassChangeResult case object Success extends PassChangeResult
def userNotFound: PassChangeResult = UserNotFound def userNotFound: PassChangeResult = UserNotFound
def passwordMismatch: PassChangeResult = PasswordMismatch def passwordMismatch: PassChangeResult = PasswordMismatch
def success: PassChangeResult = Success def success: PassChangeResult = Success
def updateFailed: PassChangeResult = UpdateFailed def updateFailed: PassChangeResult = UpdateFailed
def userNotLocal: PassChangeResult = UserNotLocal
} }
case class RegisterData( case class RegisterData(
@ -245,11 +247,14 @@ object OCollective {
def resetPassword(accountId: AccountId): F[PassResetResult] = def resetPassword(accountId: AccountId): F[PassResetResult] =
for { for {
newPass <- Password.generate[F] newPass <- Password.generate[F]
optUser <- store.transact(RUser.findByAccount(accountId))
n <- store.transact( n <- store.transact(
RUser.updatePassword(accountId, PasswordCrypt.crypt(newPass)) RUser.updatePassword(accountId, PasswordCrypt.crypt(newPass))
) )
res = res =
if (n <= 0) PassResetResult.notFound if (optUser.exists(_.source != AccountSource.Local))
PassResetResult.userNotLocal
else if (n <= 0) PassResetResult.notFound
else PassResetResult.success(newPass) else PassResetResult.success(newPass)
} yield res } yield res
@ -270,6 +275,8 @@ object OCollective {
res = check match { res = check match {
case Some(true) => case Some(true) =>
if (n.getOrElse(0) > 0) PassChangeResult.success if (n.getOrElse(0) > 0) PassChangeResult.success
else if (optUser.exists(_.source != AccountSource.Local))
PassChangeResult.userNotLocal
else PassChangeResult.updateFailed else PassChangeResult.updateFailed
case Some(false) => case Some(false) =>
PassChangeResult.passwordMismatch PassChangeResult.passwordMismatch

View File

@ -109,6 +109,7 @@ object OSignup {
data.collName, data.collName,
PasswordCrypt.crypt(data.password), PasswordCrypt.crypt(data.password),
UserState.Active, UserState.Active,
AccountSource.Local,
None, None,
0, 0,
None, None,

View File

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

View File

@ -5405,6 +5405,7 @@ components:
- id - id
- login - login
- state - state
- source
- loginCount - loginCount
- created - created
properties: properties:
@ -5420,6 +5421,12 @@ components:
enum: enum:
- active - active
- disabled - disabled
source:
type: string
format: accountsource
enum:
- local
- openid
password: password:
type: string type: string
format: password format: password

View File

@ -522,6 +522,7 @@ trait Conversions {
ru.uid, ru.uid,
ru.login, ru.login,
ru.state, ru.state,
ru.source,
None, None,
ru.email, ru.email,
ru.lastLogin, ru.lastLogin,
@ -537,6 +538,7 @@ trait Conversions {
cid, cid,
u.password.getOrElse(Password.empty), u.password.getOrElse(Password.empty),
u.state, u.state,
u.source,
u.email, u.email,
0, 0,
None, None,
@ -551,6 +553,7 @@ trait Conversions {
cid, cid,
u.password.getOrElse(Password.empty), u.password.getOrElse(Password.empty),
u.state, u.state,
u.source,
u.email, u.email,
u.loginCount, u.loginCount,
u.lastLogin, u.lastLogin,
@ -706,6 +709,8 @@ trait Conversions {
case PassChangeResult.PasswordMismatch => case PassChangeResult.PasswordMismatch =>
BasicResult(false, "The current password is incorrect.") BasicResult(false, "The current password is incorrect.")
case PassChangeResult.UserNotFound => BasicResult(false, "User not found.") 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 = def basicResult(e: Either[Throwable, _], successMsg: String): BasicResult =

View File

@ -86,6 +86,12 @@ object UserRoutes {
Password(""), Password(""),
"Password update failed. User not found." "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 } yield resp
} }

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -35,6 +35,9 @@ trait DoobieMeta extends EmilDoobieMeta {
e.apply(a).noSpaces e.apply(a).noSpaces
) )
implicit val metaAccountSource: Meta[AccountSource] =
Meta[String].imap(AccountSource.unsafeFromString)(_.name)
implicit val metaDuration: Meta[Duration] = implicit val metaDuration: Meta[Duration] =
Meta[Long].imap(Duration.millis)(_.millis) Meta[Long].imap(Duration.millis)(_.millis)

View File

@ -24,7 +24,8 @@ object QLogin {
account: AccountId, account: AccountId,
password: Password, password: Password,
collectiveState: CollectiveState, collectiveState: CollectiveState,
userState: UserState userState: UserState,
source: AccountSource
) )
def findUser(acc: AccountId): ConnectionIO[Option[Data]] = { def findUser(acc: AccountId): ConnectionIO[Option[Data]] = {
@ -32,7 +33,7 @@ object QLogin {
val coll = RCollective.as("c") val coll = RCollective.as("c")
val sql = val sql =
Select( 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), from(user).innerJoin(coll, user.cid === coll.id),
user.login === acc.user && user.cid === acc.collective user.login === acc.user && user.cid === acc.collective
).build ).build

View File

@ -21,6 +21,7 @@ case class RUser(
cid: Ident, cid: Ident,
password: Password, password: Password,
state: UserState, state: UserState,
source: AccountSource,
email: Option[String], email: Option[String],
loginCount: Int, loginCount: Int,
lastLogin: Option[Timestamp], lastLogin: Option[Timestamp],
@ -36,6 +37,7 @@ object RUser {
val cid = Column[Ident]("cid", this) val cid = Column[Ident]("cid", this)
val password = Column[Password]("password", this) val password = Column[Password]("password", this)
val state = Column[UserState]("state", this) val state = Column[UserState]("state", this)
val source = Column[AccountSource]("account_source", this)
val email = Column[String]("email", this) val email = Column[String]("email", this)
val loginCount = Column[Int]("logincount", this) val loginCount = Column[Int]("logincount", this)
val lastLogin = Column[Timestamp]("lastlogin", this) val lastLogin = Column[Timestamp]("lastlogin", this)
@ -48,6 +50,7 @@ object RUser {
cid, cid,
password, password,
state, state,
source,
email, email,
loginCount, loginCount,
lastLogin, lastLogin,
@ -65,7 +68,7 @@ object RUser {
DML.insert( DML.insert(
t, t,
t.all, 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) val t = Table(None)
DML.update( DML.update(
t, 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)) DML.set(t.password.setTo(hashedPass))
) )
} }

View File

@ -66,6 +66,7 @@ view2 texts model =
[ th [ class "w-px whitespace-nowrap" ] [] [ th [ class "w-px whitespace-nowrap" ] []
, th [ class "text-left" ] [ text texts.login ] , th [ class "text-left" ] [ text texts.login ]
, th [ class "text-center" ] [ text texts.state ] , 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-left" ] [ text texts.email ]
, th [ class "hidden md:table-cell text-center" ] [ text texts.logins ] , th [ class "hidden md:table-cell text-center" ] [ text texts.logins ]
, th [ class "hidden sm:table-cell text-center" ] [ text texts.lastLogin ] , th [ class "hidden sm:table-cell text-center" ] [ text texts.lastLogin ]
@ -92,6 +93,9 @@ renderUserLine2 texts model user =
, td [ class "text-center" ] , td [ class "text-center" ]
[ text user.state [ text user.state
] ]
, td [ class "text-center" ]
[ text user.source
]
, td [ class "hidden md:table-cell text-left" ] , td [ class "hidden md:table-cell text-left" ]
[ Maybe.withDefault "" user.email |> text [ Maybe.withDefault "" user.email |> text
] ]

View File

@ -20,6 +20,7 @@ type alias Texts =
{ basics : Messages.Basics.Texts { basics : Messages.Basics.Texts
, login : String , login : String
, state : String , state : String
, source : String
, email : String , email : String
, logins : String , logins : String
, lastLogin : String , lastLogin : String
@ -32,6 +33,7 @@ gb =
{ basics = Messages.Basics.gb { basics = Messages.Basics.gb
, login = "Login" , login = "Login"
, state = "State" , state = "State"
, source = "Type"
, email = "E-Mail" , email = "E-Mail"
, logins = "Logins" , logins = "Logins"
, lastLogin = "Last Login" , lastLogin = "Last Login"
@ -44,6 +46,7 @@ de =
{ basics = Messages.Basics.de { basics = Messages.Basics.de
, login = "Benutzername" , login = "Benutzername"
, state = "Status" , state = "Status"
, source = "Typ"
, email = "E-Mail" , email = "E-Mail"
, logins = "Anmeldungen" , logins = "Anmeldungen"
, lastLogin = "Letzte Anmeldung" , lastLogin = "Letzte Anmeldung"