mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-06-06 15:15:58 +00:00
commit
e943b4c60d
29
build.sbt
29
build.sbt
@ -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"))
|
||||||
|
)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -502,6 +508,24 @@ val backend = project
|
|||||||
)
|
)
|
||||||
.dependsOn(store, joexapi, ftsclient, totp)
|
.dependsOn(store, joexapi, ftsclient, totp)
|
||||||
|
|
||||||
|
val oidc = project
|
||||||
|
.in(file("modules/oidc"))
|
||||||
|
.disablePlugins(RevolverPlugin)
|
||||||
|
.settings(sharedSettings)
|
||||||
|
.settings(testSettingsMUnit)
|
||||||
|
.settings(
|
||||||
|
name := "docspell-oidc",
|
||||||
|
libraryDependencies ++=
|
||||||
|
Dependencies.loggingApi ++
|
||||||
|
Dependencies.fs2 ++
|
||||||
|
Dependencies.http4sClient ++
|
||||||
|
Dependencies.http4sCirce ++
|
||||||
|
Dependencies.http4sDsl ++
|
||||||
|
Dependencies.circe ++
|
||||||
|
Dependencies.jwtScala
|
||||||
|
)
|
||||||
|
.dependsOn(common)
|
||||||
|
|
||||||
val webapp = project
|
val webapp = project
|
||||||
.in(file("modules/webapp"))
|
.in(file("modules/webapp"))
|
||||||
.disablePlugins(RevolverPlugin)
|
.disablePlugins(RevolverPlugin)
|
||||||
@ -615,7 +639,7 @@ val restserver = project
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.dependsOn(restapi, joexapi, backend, webapp, ftssolr)
|
.dependsOn(restapi, joexapi, backend, webapp, ftssolr, oidc)
|
||||||
|
|
||||||
// --- Website Documentation
|
// --- Website Documentation
|
||||||
|
|
||||||
@ -695,7 +719,8 @@ val root = project
|
|||||||
restserver,
|
restserver,
|
||||||
query.jvm,
|
query.jvm,
|
||||||
query.js,
|
query.js,
|
||||||
totp
|
totp,
|
||||||
|
oidc
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Helpers
|
// --- Helpers
|
||||||
|
@ -23,6 +23,8 @@ import scodec.bits.ByteVector
|
|||||||
|
|
||||||
trait Login[F[_]] {
|
trait Login[F[_]] {
|
||||||
|
|
||||||
|
def loginExternal(config: Config)(accountId: AccountId): F[Result]
|
||||||
|
|
||||||
def loginSession(config: Config)(sessionKey: String): F[Result]
|
def loginSession(config: Config)(sessionKey: String): F[Result]
|
||||||
|
|
||||||
def loginUserPass(config: Config)(up: UserPass): F[Result]
|
def loginUserPass(config: Config)(up: UserPass): F[Result]
|
||||||
@ -93,6 +95,16 @@ object Login {
|
|||||||
|
|
||||||
private val logF = Logger.log4s(logger)
|
private val logF = Logger.log4s(logger)
|
||||||
|
|
||||||
|
def loginExternal(config: Config)(accountId: AccountId): F[Result] =
|
||||||
|
for {
|
||||||
|
data <- store.transact(QLogin.findUser(accountId))
|
||||||
|
_ <- logF.trace(s"Account lookup: $data")
|
||||||
|
res <-
|
||||||
|
if (data.exists(checkNoPassword(_, Set(AccountSource.OpenId))))
|
||||||
|
doLogin(config, accountId, false)
|
||||||
|
else Result.invalidAuth.pure[F]
|
||||||
|
} yield res
|
||||||
|
|
||||||
def loginSession(config: Config)(sessionKey: String): F[Result] =
|
def loginSession(config: Config)(sessionKey: String): F[Result] =
|
||||||
AuthToken.fromString(sessionKey) match {
|
AuthToken.fromString(sessionKey) match {
|
||||||
case Right(at) =>
|
case Right(at) =>
|
||||||
@ -110,24 +122,11 @@ object Login {
|
|||||||
def loginUserPass(config: Config)(up: UserPass): F[Result] =
|
def loginUserPass(config: Config)(up: UserPass): F[Result] =
|
||||||
AccountId.parse(up.user) match {
|
AccountId.parse(up.user) match {
|
||||||
case Right(acc) =>
|
case Right(acc) =>
|
||||||
val okResult =
|
|
||||||
for {
|
|
||||||
require2FA <- store.transact(RTotp.isEnabled(acc))
|
|
||||||
_ <-
|
|
||||||
if (require2FA) ().pure[F]
|
|
||||||
else store.transact(RUser.updateLogin(acc))
|
|
||||||
token <- AuthToken.user(acc, require2FA, config.serverSecret)
|
|
||||||
rem <- OptionT
|
|
||||||
.whenF(!require2FA && up.rememberMe && config.rememberMe.enabled)(
|
|
||||||
insertRememberToken(store, acc, config)
|
|
||||||
)
|
|
||||||
.value
|
|
||||||
} yield Result.ok(token, rem)
|
|
||||||
for {
|
for {
|
||||||
data <- store.transact(QLogin.findUser(acc))
|
data <- store.transact(QLogin.findUser(acc))
|
||||||
_ <- Sync[F].delay(logger.trace(s"Account lookup: $data"))
|
_ <- Sync[F].delay(logger.trace(s"Account lookup: $data"))
|
||||||
res <-
|
res <-
|
||||||
if (data.exists(check(up.pass))) okResult
|
if (data.exists(check(up.pass))) doLogin(config, acc, up.rememberMe)
|
||||||
else Result.invalidAuth.pure[F]
|
else Result.invalidAuth.pure[F]
|
||||||
} yield res
|
} yield res
|
||||||
case Left(_) =>
|
case Left(_) =>
|
||||||
@ -194,7 +193,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]
|
||||||
@ -247,6 +246,24 @@ object Login {
|
|||||||
0.pure[F]
|
0.pure[F]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private def doLogin(
|
||||||
|
config: Config,
|
||||||
|
acc: AccountId,
|
||||||
|
rememberMe: Boolean
|
||||||
|
): F[Result] =
|
||||||
|
for {
|
||||||
|
require2FA <- store.transact(RTotp.isEnabled(acc))
|
||||||
|
_ <-
|
||||||
|
if (require2FA) ().pure[F]
|
||||||
|
else store.transact(RUser.updateLogin(acc))
|
||||||
|
token <- AuthToken.user(acc, require2FA, config.serverSecret)
|
||||||
|
rem <- OptionT
|
||||||
|
.whenF(!require2FA && rememberMe && config.rememberMe.enabled)(
|
||||||
|
insertRememberToken(store, acc, config)
|
||||||
|
)
|
||||||
|
.value
|
||||||
|
} yield Result.ok(token, rem)
|
||||||
|
|
||||||
private def insertRememberToken(
|
private def insertRememberToken(
|
||||||
store: Store[F],
|
store: Store[F],
|
||||||
acc: AccountId,
|
acc: AccountId,
|
||||||
@ -260,13 +277,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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -95,9 +95,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,34 +107,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(
|
|
||||||
collName: Ident,
|
|
||||||
login: Ident,
|
|
||||||
password: Password,
|
|
||||||
invite: Option[Ident]
|
|
||||||
)
|
|
||||||
|
|
||||||
sealed trait RegisterResult {
|
|
||||||
def toEither: Either[Throwable, Unit]
|
|
||||||
}
|
|
||||||
object RegisterResult {
|
|
||||||
case object Success extends RegisterResult {
|
|
||||||
val toEither = Right(())
|
|
||||||
}
|
|
||||||
case class CollectiveExists(id: Ident) extends RegisterResult {
|
|
||||||
val toEither = Left(new Exception())
|
|
||||||
}
|
|
||||||
case class Error(ex: Throwable) extends RegisterResult {
|
|
||||||
val toEither = Left(ex)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def apply[F[_]: Async](
|
def apply[F[_]: Async](
|
||||||
@ -245,11 +227,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 +255,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
|
||||||
|
@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2020 Docspell Contributors
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package docspell.backend.signup
|
||||||
|
|
||||||
|
import docspell.common._
|
||||||
|
|
||||||
|
final case class ExternalAccount(
|
||||||
|
collName: Ident,
|
||||||
|
login: Ident,
|
||||||
|
source: AccountSource
|
||||||
|
) {
|
||||||
|
|
||||||
|
def toAccountId: AccountId =
|
||||||
|
AccountId(collName, login)
|
||||||
|
}
|
||||||
|
|
||||||
|
object ExternalAccount {
|
||||||
|
def apply(accountId: AccountId): ExternalAccount =
|
||||||
|
ExternalAccount(accountId.collective, accountId.user, AccountSource.OpenId)
|
||||||
|
|
||||||
|
}
|
@ -10,7 +10,6 @@ import cats.effect.{Async, Resource}
|
|||||||
import cats.implicits._
|
import cats.implicits._
|
||||||
|
|
||||||
import docspell.backend.PasswordCrypt
|
import docspell.backend.PasswordCrypt
|
||||||
import docspell.backend.ops.OCollective.RegisterData
|
|
||||||
import docspell.common._
|
import docspell.common._
|
||||||
import docspell.common.syntax.all._
|
import docspell.common.syntax.all._
|
||||||
import docspell.store.records.{RCollective, RInvitation, RUser}
|
import docspell.store.records.{RCollective, RInvitation, RUser}
|
||||||
@ -23,6 +22,9 @@ trait OSignup[F[_]] {
|
|||||||
|
|
||||||
def register(cfg: Config)(data: RegisterData): F[SignupResult]
|
def register(cfg: Config)(data: RegisterData): F[SignupResult]
|
||||||
|
|
||||||
|
/** Creates the given account if it doesn't exist. */
|
||||||
|
def setupExternal(cfg: Config)(data: ExternalAccount): F[SignupResult]
|
||||||
|
|
||||||
def newInvite(cfg: Config)(password: Password): F[NewInviteResult]
|
def newInvite(cfg: Config)(password: Password): F[NewInviteResult]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,6 +79,37 @@ object OSignup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def setupExternal(cfg: Config)(data: ExternalAccount): F[SignupResult] =
|
||||||
|
cfg.mode match {
|
||||||
|
case Config.Mode.Closed =>
|
||||||
|
SignupResult.signupClosed.pure[F]
|
||||||
|
case _ =>
|
||||||
|
if (data.source == AccountSource.Local)
|
||||||
|
SignupResult
|
||||||
|
.failure(new Exception("Account source must not be LOCAL!"))
|
||||||
|
.pure[F]
|
||||||
|
else
|
||||||
|
for {
|
||||||
|
recs <- makeRecords(data.collName, data.login, Password(""), data.source)
|
||||||
|
cres <- store.add(
|
||||||
|
RCollective.insert(recs._1),
|
||||||
|
RCollective.existsById(data.collName)
|
||||||
|
)
|
||||||
|
ures <- store.add(RUser.insert(recs._2), RUser.exists(data.login))
|
||||||
|
res = cres match {
|
||||||
|
case AddResult.Failure(ex) =>
|
||||||
|
SignupResult.failure(ex)
|
||||||
|
case _ =>
|
||||||
|
ures match {
|
||||||
|
case AddResult.Failure(ex) =>
|
||||||
|
SignupResult.failure(ex)
|
||||||
|
case _ =>
|
||||||
|
SignupResult.success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} yield res
|
||||||
|
}
|
||||||
|
|
||||||
private def retryInvite(res: SignupResult): Boolean =
|
private def retryInvite(res: SignupResult): Boolean =
|
||||||
res match {
|
res match {
|
||||||
case SignupResult.CollectiveExists =>
|
case SignupResult.CollectiveExists =>
|
||||||
@ -92,30 +125,6 @@ object OSignup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private def addUser(data: RegisterData): F[AddResult] = {
|
private def addUser(data: RegisterData): F[AddResult] = {
|
||||||
def toRecords: F[(RCollective, RUser)] =
|
|
||||||
for {
|
|
||||||
id2 <- Ident.randomId[F]
|
|
||||||
now <- Timestamp.current[F]
|
|
||||||
c = RCollective(
|
|
||||||
data.collName,
|
|
||||||
CollectiveState.Active,
|
|
||||||
Language.German,
|
|
||||||
true,
|
|
||||||
now
|
|
||||||
)
|
|
||||||
u = RUser(
|
|
||||||
id2,
|
|
||||||
data.login,
|
|
||||||
data.collName,
|
|
||||||
PasswordCrypt.crypt(data.password),
|
|
||||||
UserState.Active,
|
|
||||||
None,
|
|
||||||
0,
|
|
||||||
None,
|
|
||||||
now
|
|
||||||
)
|
|
||||||
} yield (c, u)
|
|
||||||
|
|
||||||
def insert(coll: RCollective, user: RUser): ConnectionIO[Int] =
|
def insert(coll: RCollective, user: RUser): ConnectionIO[Int] =
|
||||||
for {
|
for {
|
||||||
n1 <- RCollective.insert(coll)
|
n1 <- RCollective.insert(coll)
|
||||||
@ -127,9 +136,29 @@ object OSignup {
|
|||||||
|
|
||||||
val msg = s"The collective '${data.collName}' already exists."
|
val msg = s"The collective '${data.collName}' already exists."
|
||||||
for {
|
for {
|
||||||
cu <- toRecords
|
cu <- makeRecords(data.collName, data.login, data.password, AccountSource.Local)
|
||||||
save <- store.add(insert(cu._1, cu._2), collectiveExists)
|
save <- store.add(insert(cu._1, cu._2), collectiveExists)
|
||||||
} yield save.fold(identity, _.withMsg(msg), identity)
|
} yield save.fold(identity, _.withMsg(msg), identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private def makeRecords(
|
||||||
|
collName: Ident,
|
||||||
|
login: Ident,
|
||||||
|
password: Password,
|
||||||
|
source: AccountSource
|
||||||
|
): F[(RCollective, RUser)] =
|
||||||
|
for {
|
||||||
|
id2 <- Ident.randomId[F]
|
||||||
|
now <- Timestamp.current[F]
|
||||||
|
c = RCollective.makeDefault(collName, now)
|
||||||
|
u = RUser.makeDefault(
|
||||||
|
id2,
|
||||||
|
login,
|
||||||
|
collName,
|
||||||
|
PasswordCrypt.crypt(password),
|
||||||
|
source,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
} yield (c, u)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,15 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2020 Docspell Contributors
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package docspell.backend.signup
|
||||||
|
import docspell.common._
|
||||||
|
|
||||||
|
case class RegisterData(
|
||||||
|
collName: Ident,
|
||||||
|
login: Ident,
|
||||||
|
password: Password,
|
||||||
|
invite: Option[Ident]
|
||||||
|
)
|
@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2020 Docspell Contributors
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
63
modules/oidc/src/main/scala/docspell/oidc/AccessToken.scala
Normal file
63
modules/oidc/src/main/scala/docspell/oidc/AccessToken.scala
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2020 Docspell Contributors
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package docspell.oidc
|
||||||
|
|
||||||
|
import io.circe.Decoder
|
||||||
|
import scodec.bits.ByteVector
|
||||||
|
|
||||||
|
/** The response from an authorization server to the "token request". The redirect request
|
||||||
|
* contains an authorization code that is used to request tokens at the authorization
|
||||||
|
* server. The server then responds with this structure.
|
||||||
|
*
|
||||||
|
* @param accessToken
|
||||||
|
* the jwt encoded access token
|
||||||
|
* @param tokenType
|
||||||
|
* the token type, is always 'Bearer'
|
||||||
|
* @param expiresIn
|
||||||
|
* when it expires (in seconds from unix epoch)
|
||||||
|
* @param refreshToken
|
||||||
|
* optional refresh token
|
||||||
|
* @param refreshExpiresIn
|
||||||
|
* optional expiry time for the refresh token (in seconds from unix epoch)
|
||||||
|
* @param sessionState
|
||||||
|
* an optional session state
|
||||||
|
* @param scope
|
||||||
|
* the scope as requested. this must be present for OpenId Connect, but not necessarily
|
||||||
|
* for OAuth2
|
||||||
|
*/
|
||||||
|
final case class AccessToken(
|
||||||
|
accessToken: String,
|
||||||
|
tokenType: String,
|
||||||
|
expiresIn: Option[Long],
|
||||||
|
refreshToken: Option[String],
|
||||||
|
refreshExpiresIn: Option[Long],
|
||||||
|
sessionState: Option[String],
|
||||||
|
scope: Option[String]
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Decodes the `accessToken` as a JWT and validates it given the key and expected
|
||||||
|
* signature algorithm.
|
||||||
|
*/
|
||||||
|
def decodeToken(key: ByteVector, algo: SignatureAlgo): Either[String, Jwt] =
|
||||||
|
SignatureAlgo.decoder(key, algo)(accessToken).left.map(_.getMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
object AccessToken {
|
||||||
|
|
||||||
|
implicit val decoder: Decoder[AccessToken] =
|
||||||
|
Decoder.instance { c =>
|
||||||
|
for {
|
||||||
|
atoken <- c.get[String]("access_token")
|
||||||
|
ttype <- c.get[String]("token_type")
|
||||||
|
expire <- c.get[Option[Long]]("expires_in")
|
||||||
|
rtoken <- c.get[Option[String]]("refresh_token")
|
||||||
|
rexpire <- c.get[Option[Long]]("refresh_expires_in")
|
||||||
|
sstate <- c.get[Option[String]]("session_state")
|
||||||
|
scope <- c.get[Option[String]]("scope")
|
||||||
|
} yield AccessToken(atoken, ttype, expire, rtoken, rexpire, sstate, scope)
|
||||||
|
}
|
||||||
|
}
|
179
modules/oidc/src/main/scala/docspell/oidc/CodeFlow.scala
Normal file
179
modules/oidc/src/main/scala/docspell/oidc/CodeFlow.scala
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2020 Docspell Contributors
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package docspell.oidc
|
||||||
|
|
||||||
|
import cats.data.OptionT
|
||||||
|
import cats.effect._
|
||||||
|
import cats.implicits._
|
||||||
|
|
||||||
|
import docspell.common._
|
||||||
|
|
||||||
|
import io.circe.Json
|
||||||
|
import org.http4s.Method._
|
||||||
|
import org.http4s._
|
||||||
|
import org.http4s.circe.CirceEntityCodec._
|
||||||
|
import org.http4s.client.Client
|
||||||
|
import org.http4s.client.dsl.Http4sClientDsl
|
||||||
|
import org.http4s.client.middleware.RequestLogger
|
||||||
|
import org.http4s.client.middleware.ResponseLogger
|
||||||
|
import org.http4s.headers.Accept
|
||||||
|
import org.http4s.headers.Authorization
|
||||||
|
import org.log4s.getLogger
|
||||||
|
|
||||||
|
/** https://openid.net/specs/openid-connect-core-1_0.html (OIDC)
|
||||||
|
* https://openid.net/specs/openid-connect-basic-1_0.html#TokenRequest (OIDC)
|
||||||
|
* https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4 (OAuth2)
|
||||||
|
* https://datatracker.ietf.org/doc/html/rfc7519 (JWT)
|
||||||
|
*/
|
||||||
|
object CodeFlow {
|
||||||
|
private[this] val log4sLogger = getLogger
|
||||||
|
|
||||||
|
def apply[F[_]: Async, A](
|
||||||
|
client: Client[F],
|
||||||
|
cfg: ProviderConfig,
|
||||||
|
redirectUri: String
|
||||||
|
)(
|
||||||
|
code: String
|
||||||
|
): OptionT[F, Json] = {
|
||||||
|
val logger = Logger.log4s[F](log4sLogger)
|
||||||
|
val dsl = new Http4sClientDsl[F] {}
|
||||||
|
val c = logRequests[F](logResponses[F](client))
|
||||||
|
|
||||||
|
for {
|
||||||
|
_ <- OptionT.liftF(
|
||||||
|
logger.trace(
|
||||||
|
s"Obtaining access_token for provider ${cfg.providerId.id} and code $code"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
token <- fetchAccessToken[F](c, dsl, cfg, redirectUri, code)
|
||||||
|
_ <- OptionT.liftF(
|
||||||
|
logger.trace(
|
||||||
|
s"Obtaining user-info for provider ${cfg.providerId.id} and token $token"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
user <- cfg.userUrl match {
|
||||||
|
case Some(url) if cfg.signKey.isEmpty =>
|
||||||
|
fetchFromUserEndpoint[F](c, dsl, url, token)
|
||||||
|
case _ if cfg.signKey.nonEmpty =>
|
||||||
|
token.decodeToken(cfg.signKey, cfg.sigAlgo) match {
|
||||||
|
case Right(jwt) =>
|
||||||
|
OptionT.pure[F](jwt.claims)
|
||||||
|
case Left(err) =>
|
||||||
|
OptionT
|
||||||
|
.liftF(logger.error(s"Error verifying jwt access token: $err"))
|
||||||
|
.flatMap(_ => OptionT.none[F, Json])
|
||||||
|
}
|
||||||
|
case _ =>
|
||||||
|
OptionT
|
||||||
|
.liftF(
|
||||||
|
logger.warn(
|
||||||
|
s"No signature specified and no user endpoint url. Cannot obtain user info from access token!"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.flatMap(_ => OptionT.none[F, Json])
|
||||||
|
}
|
||||||
|
} yield user
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Using the code that was given by the authentication providers redirect request, get
|
||||||
|
* the access token. It returns the raw response only json-decoded into a data
|
||||||
|
* structure. If something fails, it is logged ant None is returned
|
||||||
|
*
|
||||||
|
* See https://openid.net/specs/openid-connect-basic-1_0.html#TokenRequest
|
||||||
|
*/
|
||||||
|
def fetchAccessToken[F[_]: Async](
|
||||||
|
c: Client[F],
|
||||||
|
dsl: Http4sClientDsl[F],
|
||||||
|
cfg: ProviderConfig,
|
||||||
|
redirectUri: String,
|
||||||
|
code: String
|
||||||
|
): OptionT[F, AccessToken] = {
|
||||||
|
import dsl._
|
||||||
|
val logger = Logger.log4s[F](log4sLogger)
|
||||||
|
|
||||||
|
val req = POST(
|
||||||
|
UrlForm(
|
||||||
|
"client_id" -> cfg.clientId,
|
||||||
|
"client_secret" -> cfg.clientSecret,
|
||||||
|
"code" -> code,
|
||||||
|
"grant_type" -> "authorization_code",
|
||||||
|
"redirect_uri" -> redirectUri
|
||||||
|
),
|
||||||
|
Uri.unsafeFromString(cfg.tokenUrl.asString),
|
||||||
|
Accept(MediaType.application.json)
|
||||||
|
)
|
||||||
|
|
||||||
|
OptionT(c.run(req).use {
|
||||||
|
case Status.Successful(r) =>
|
||||||
|
for {
|
||||||
|
token <- r.attemptAs[AccessToken].value
|
||||||
|
_ <- token match {
|
||||||
|
case Right(t) =>
|
||||||
|
logger.trace(s"Got token response: $t")
|
||||||
|
case Left(err) =>
|
||||||
|
logger.error(err)(s"Error decoding access token: ${err.getMessage}")
|
||||||
|
}
|
||||||
|
} yield token.toOption
|
||||||
|
case r =>
|
||||||
|
logger
|
||||||
|
.error(s"Error obtaining access token '${r.status.code}' / ${r.as[String]}")
|
||||||
|
.map(_ => None)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches user info by using a request against the userinfo endpoint. */
|
||||||
|
def fetchFromUserEndpoint[F[_]: Async](
|
||||||
|
c: Client[F],
|
||||||
|
dsl: Http4sClientDsl[F],
|
||||||
|
endpointUrl: LenientUri,
|
||||||
|
token: AccessToken
|
||||||
|
): OptionT[F, Json] = {
|
||||||
|
import dsl._
|
||||||
|
val logger = Logger.log4s[F](log4sLogger)
|
||||||
|
|
||||||
|
val req = GET(
|
||||||
|
Uri.unsafeFromString(endpointUrl.asString),
|
||||||
|
Authorization(Credentials.Token(AuthScheme.Bearer, token.accessToken)),
|
||||||
|
Accept(MediaType.application.json)
|
||||||
|
)
|
||||||
|
|
||||||
|
val resp: F[Option[Json]] = c.run(req).use {
|
||||||
|
case Status.Successful(r) =>
|
||||||
|
for {
|
||||||
|
json <- r.attemptAs[Json].value
|
||||||
|
_ <- json match {
|
||||||
|
case Right(j) =>
|
||||||
|
logger.trace(s"Got user info: ${j.noSpaces}")
|
||||||
|
case Left(err) =>
|
||||||
|
logger.error(err)(s"Error decoding user info response into json!")
|
||||||
|
}
|
||||||
|
} yield json.toOption
|
||||||
|
case r =>
|
||||||
|
r.as[String]
|
||||||
|
.flatMap(err =>
|
||||||
|
logger.error(s"Cannot obtain user info: ${r.status.code} / $err")
|
||||||
|
)
|
||||||
|
.map(_ => None)
|
||||||
|
}
|
||||||
|
OptionT(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
private def logRequests[F[_]: Async](c: Client[F]): Client[F] =
|
||||||
|
RequestLogger(
|
||||||
|
logHeaders = true,
|
||||||
|
logBody = true,
|
||||||
|
logAction = Some((msg: String) => Logger.log4s(log4sLogger).trace(msg))
|
||||||
|
)(c)
|
||||||
|
|
||||||
|
private def logResponses[F[_]: Async](c: Client[F]): Client[F] =
|
||||||
|
ResponseLogger(
|
||||||
|
logHeaders = true,
|
||||||
|
logBody = true,
|
||||||
|
logAction = Some((msg: String) => Logger.log4s(log4sLogger).trace(msg))
|
||||||
|
)(c)
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2020 Docspell Contributors
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package docspell.oidc
|
||||||
|
|
||||||
|
import docspell.common._
|
||||||
|
|
||||||
|
import org.http4s.Request
|
||||||
|
|
||||||
|
trait CodeFlowConfig[F[_]] {
|
||||||
|
|
||||||
|
/** Return the URL to the path where the `CodeFlowRoutes` are mounted. This is used to
|
||||||
|
* construct the redirect url.
|
||||||
|
*/
|
||||||
|
def getEndpointUrl(req: Request[F]): LenientUri
|
||||||
|
|
||||||
|
/** Multiple authentication providers are supported, each has its own id. For a given
|
||||||
|
* id, return the config to use.
|
||||||
|
*/
|
||||||
|
def findProvider(id: Ident): Option[ProviderConfig]
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
object CodeFlowConfig {
|
||||||
|
|
||||||
|
def apply[F[_]](
|
||||||
|
url: Request[F] => LenientUri,
|
||||||
|
provider: Ident => Option[ProviderConfig]
|
||||||
|
): CodeFlowConfig[F] =
|
||||||
|
new CodeFlowConfig[F] {
|
||||||
|
def getEndpointUrl(req: Request[F]): LenientUri = url(req)
|
||||||
|
def findProvider(id: Ident): Option[ProviderConfig] = provider(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private[oidc] def resumeUri[F[_]](
|
||||||
|
req: Request[F],
|
||||||
|
prov: ProviderConfig,
|
||||||
|
cfg: CodeFlowConfig[F]
|
||||||
|
): LenientUri =
|
||||||
|
cfg.getEndpointUrl(req) / prov.providerId.id / "resume"
|
||||||
|
|
||||||
|
}
|
104
modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala
Normal file
104
modules/oidc/src/main/scala/docspell/oidc/CodeFlowRoutes.scala
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2020 Docspell Contributors
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package docspell.oidc
|
||||||
|
|
||||||
|
import cats.data.{Kleisli, OptionT}
|
||||||
|
import cats.effect._
|
||||||
|
import cats.implicits._
|
||||||
|
|
||||||
|
import docspell.common._
|
||||||
|
|
||||||
|
import org.http4s.HttpRoutes
|
||||||
|
import org.http4s._
|
||||||
|
import org.http4s.client.Client
|
||||||
|
import org.http4s.dsl.Http4sDsl
|
||||||
|
import org.http4s.headers.Location
|
||||||
|
import org.log4s.getLogger
|
||||||
|
|
||||||
|
object CodeFlowRoutes {
|
||||||
|
private[this] val log4sLogger = getLogger
|
||||||
|
|
||||||
|
def apply[F[_]: Async](
|
||||||
|
enabled: Boolean,
|
||||||
|
onUserInfo: OnUserInfo[F],
|
||||||
|
config: CodeFlowConfig[F],
|
||||||
|
client: Client[F]
|
||||||
|
): HttpRoutes[F] =
|
||||||
|
if (enabled) route[F](onUserInfo, config, client)
|
||||||
|
else Kleisli(_ => OptionT.pure(Response.notFound[F]))
|
||||||
|
|
||||||
|
def route[F[_]: Async](
|
||||||
|
onUserInfo: OnUserInfo[F],
|
||||||
|
config: CodeFlowConfig[F],
|
||||||
|
client: Client[F]
|
||||||
|
): HttpRoutes[F] = {
|
||||||
|
val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
|
||||||
|
import dsl._
|
||||||
|
val logger = Logger.log4s[F](log4sLogger)
|
||||||
|
HttpRoutes.of[F] {
|
||||||
|
case req @ GET -> Root / Ident(id) =>
|
||||||
|
config.findProvider(id) match {
|
||||||
|
case Some(cfg) =>
|
||||||
|
val uri = cfg.authorizeUrl
|
||||||
|
.withQuery("client_id", cfg.clientId)
|
||||||
|
.withQuery("scope", cfg.scope)
|
||||||
|
.withQuery(
|
||||||
|
"redirect_uri",
|
||||||
|
CodeFlowConfig.resumeUri(req, cfg, config).asString
|
||||||
|
)
|
||||||
|
.withQuery("response_type", "code")
|
||||||
|
logger.debug(
|
||||||
|
s"Redirecting to OAuth/OIDC provider ${cfg.providerId.id}: ${uri.asString}"
|
||||||
|
) *>
|
||||||
|
Found(Location(Uri.unsafeFromString(uri.asString)))
|
||||||
|
case None =>
|
||||||
|
logger.debug(s"No OAuth/OIDC provider found with id '$id'") *>
|
||||||
|
NotFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
case req @ GET -> Root / Ident(id) / "resume" =>
|
||||||
|
config.findProvider(id) match {
|
||||||
|
case None =>
|
||||||
|
logger.debug(s"No OAuth/OIDC provider found with id '$id'") *>
|
||||||
|
NotFound()
|
||||||
|
case Some(provider) =>
|
||||||
|
val codeFromReq = OptionT.fromOption[F](req.params.get("code"))
|
||||||
|
|
||||||
|
val userInfo = for {
|
||||||
|
_ <- OptionT.liftF(logger.info(s"Resume OAuth/OIDC flow for ${id.id}"))
|
||||||
|
code <- codeFromReq
|
||||||
|
_ <- OptionT.liftF(
|
||||||
|
logger.trace(
|
||||||
|
s"Resume OAuth/OIDC flow from ${provider.providerId.id} with auth_code=$code"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
redirectUri = CodeFlowConfig.resumeUri(req, provider, config)
|
||||||
|
u <- CodeFlow(client, provider, redirectUri.asString)(code)
|
||||||
|
} yield u
|
||||||
|
|
||||||
|
userInfo.value.flatMap {
|
||||||
|
case t @ Some(_) =>
|
||||||
|
onUserInfo.handle(req, provider, t)
|
||||||
|
case None =>
|
||||||
|
val reason = req.params
|
||||||
|
.get("error")
|
||||||
|
.map { err =>
|
||||||
|
val descr =
|
||||||
|
req.params.get("error_description").map(s => s" ($s)").getOrElse("")
|
||||||
|
s"$err$descr"
|
||||||
|
}
|
||||||
|
.map(err => s": $err")
|
||||||
|
.getOrElse("")
|
||||||
|
|
||||||
|
logger.warn(s"Error resuming code flow from '${id.id}'$reason") *>
|
||||||
|
onUserInfo.handle(req, provider, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
modules/oidc/src/main/scala/docspell/oidc/Jwt.scala
Normal file
22
modules/oidc/src/main/scala/docspell/oidc/Jwt.scala
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2020 Docspell Contributors
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package docspell.oidc
|
||||||
|
|
||||||
|
import io.circe.{Decoder, Json}
|
||||||
|
import scodec.bits.Bases.Alphabets
|
||||||
|
import scodec.bits.ByteVector
|
||||||
|
|
||||||
|
case class Jwt(header: Json, claims: Json, signature: ByteVector) {
|
||||||
|
|
||||||
|
def claimsAs[A: Decoder]: Either[String, A] =
|
||||||
|
claims.as[A].left.map(_.getMessage())
|
||||||
|
}
|
||||||
|
|
||||||
|
object Jwt {
|
||||||
|
private[oidc] def create(t: (Json, Json, String)): Jwt =
|
||||||
|
Jwt(t._1, t._2, ByteVector.fromValidBase64(t._3, Alphabets.Base64UrlNoPad))
|
||||||
|
}
|
68
modules/oidc/src/main/scala/docspell/oidc/OnUserInfo.scala
Normal file
68
modules/oidc/src/main/scala/docspell/oidc/OnUserInfo.scala
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2020 Docspell Contributors
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package docspell.oidc
|
||||||
|
|
||||||
|
import cats.effect._
|
||||||
|
import cats.implicits._
|
||||||
|
import fs2.Stream
|
||||||
|
|
||||||
|
import docspell.common.Logger
|
||||||
|
|
||||||
|
import io.circe.Json
|
||||||
|
import org.http4s._
|
||||||
|
import org.http4s.headers.`Content-Type`
|
||||||
|
import org.http4s.implicits._
|
||||||
|
import org.log4s.getLogger
|
||||||
|
|
||||||
|
/** Once the authentication flow is completed, we get "some" json structure that contains
|
||||||
|
* a claim about the user. From here it's to the user of this small library to complete
|
||||||
|
* the request.
|
||||||
|
*
|
||||||
|
* Usually the json is searched for an account name and the account is then created in
|
||||||
|
* the application, if it not already exists. The concrete response is up to the
|
||||||
|
* application, the OAuth/OpenID Connect is done (successfully) at this point.
|
||||||
|
*/
|
||||||
|
trait OnUserInfo[F[_]] {
|
||||||
|
|
||||||
|
/** Create a response given the request and the obtained user info data. The `userInfo`
|
||||||
|
* may be retrieved from an JWT token or it is the response of querying the user-info
|
||||||
|
* endpoint, depending on the configuration provided to `CodeFlowRoutes`. In the latter
|
||||||
|
* case, the authorization server validated the token.
|
||||||
|
*
|
||||||
|
* If `userInfo` is empty, then some error occurred during the flow. The exact error
|
||||||
|
* has been logged, but it is not given here.
|
||||||
|
*/
|
||||||
|
def handle(
|
||||||
|
req: Request[F],
|
||||||
|
provider: ProviderConfig,
|
||||||
|
userInfo: Option[Json]
|
||||||
|
): F[Response[F]]
|
||||||
|
}
|
||||||
|
|
||||||
|
object OnUserInfo {
|
||||||
|
private[this] val log = getLogger
|
||||||
|
|
||||||
|
def apply[F[_]](
|
||||||
|
f: (Request[F], ProviderConfig, Option[Json]) => F[Response[F]]
|
||||||
|
): OnUserInfo[F] =
|
||||||
|
(req: Request[F], cfg: ProviderConfig, userInfo: Option[Json]) =>
|
||||||
|
f(req, cfg, userInfo)
|
||||||
|
|
||||||
|
def logInfo[F[_]: Sync]: OnUserInfo[F] =
|
||||||
|
OnUserInfo((_, _, json) =>
|
||||||
|
Logger
|
||||||
|
.log4s(log)
|
||||||
|
.info(s"Got data: ${json.map(_.spaces2)}")
|
||||||
|
.map(_ =>
|
||||||
|
Response[F](Status.Ok)
|
||||||
|
.withContentType(`Content-Type`(mediaType"application/json"))
|
||||||
|
.withBodyStream(
|
||||||
|
Stream.emits(json.getOrElse(Json.obj()).spaces2.getBytes.toSeq)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2020 Docspell Contributors
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package docspell.oidc
|
||||||
|
|
||||||
|
import org.http4s.HttpRoutes
|
||||||
|
import org.http4s.client.Client
|
||||||
|
|
||||||
|
object OpenidConnect {
|
||||||
|
|
||||||
|
def codeFlow[F[_]](client: Client[F]): HttpRoutes[F] =
|
||||||
|
???
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2020 Docspell Contributors
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package docspell.oidc
|
||||||
|
|
||||||
|
import docspell.common._
|
||||||
|
|
||||||
|
import scodec.bits.ByteVector
|
||||||
|
|
||||||
|
final case class ProviderConfig(
|
||||||
|
providerId: Ident,
|
||||||
|
clientId: String,
|
||||||
|
clientSecret: String,
|
||||||
|
scope: String,
|
||||||
|
authorizeUrl: LenientUri,
|
||||||
|
tokenUrl: LenientUri,
|
||||||
|
userUrl: Option[LenientUri],
|
||||||
|
signKey: ByteVector,
|
||||||
|
sigAlgo: SignatureAlgo
|
||||||
|
)
|
||||||
|
|
||||||
|
object ProviderConfig {
|
||||||
|
|
||||||
|
def github(clientId: String, clientSecret: String) =
|
||||||
|
ProviderConfig(
|
||||||
|
Ident.unsafe("github"),
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
"profile",
|
||||||
|
LenientUri.unsafe("https://github.com/login/oauth/authorize"),
|
||||||
|
LenientUri.unsafe("https://github.com/login/oauth/access_token"),
|
||||||
|
Some(LenientUri.unsafe("https://api.github.com/user")),
|
||||||
|
ByteVector.empty,
|
||||||
|
SignatureAlgo.RS256
|
||||||
|
)
|
||||||
|
}
|
185
modules/oidc/src/main/scala/docspell/oidc/SignatureAlgo.scala
Normal file
185
modules/oidc/src/main/scala/docspell/oidc/SignatureAlgo.scala
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2020 Docspell Contributors
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package docspell.oidc
|
||||||
|
|
||||||
|
import java.security.spec.X509EncodedKeySpec
|
||||||
|
import java.security.{KeyFactory, PublicKey}
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
import cats.data.NonEmptyList
|
||||||
|
import cats.implicits._
|
||||||
|
|
||||||
|
import pdi.jwt.{JwtAlgorithm, JwtCirce}
|
||||||
|
import scodec.bits.ByteVector
|
||||||
|
|
||||||
|
sealed trait SignatureAlgo { self: Product =>
|
||||||
|
|
||||||
|
def name: String =
|
||||||
|
self.productPrefix
|
||||||
|
}
|
||||||
|
|
||||||
|
object SignatureAlgo {
|
||||||
|
|
||||||
|
case object RS256 extends SignatureAlgo
|
||||||
|
case object RS384 extends SignatureAlgo
|
||||||
|
case object RS512 extends SignatureAlgo
|
||||||
|
|
||||||
|
case object ES256 extends SignatureAlgo
|
||||||
|
case object ES384 extends SignatureAlgo
|
||||||
|
case object ES512 extends SignatureAlgo
|
||||||
|
case object Ed25519 extends SignatureAlgo
|
||||||
|
|
||||||
|
case object HMD5 extends SignatureAlgo
|
||||||
|
case object HS224 extends SignatureAlgo
|
||||||
|
case object HS256 extends SignatureAlgo
|
||||||
|
case object HS384 extends SignatureAlgo
|
||||||
|
case object HS512 extends SignatureAlgo
|
||||||
|
|
||||||
|
val all: NonEmptyList[SignatureAlgo] =
|
||||||
|
NonEmptyList.of(
|
||||||
|
RS256,
|
||||||
|
RS384,
|
||||||
|
RS512,
|
||||||
|
ES256,
|
||||||
|
ES384,
|
||||||
|
ES512,
|
||||||
|
Ed25519,
|
||||||
|
HMD5,
|
||||||
|
HS224,
|
||||||
|
HS256,
|
||||||
|
HS384,
|
||||||
|
HS512
|
||||||
|
)
|
||||||
|
|
||||||
|
def fromString(str: String): Either[String, SignatureAlgo] =
|
||||||
|
str.toUpperCase() match {
|
||||||
|
case "RS256" => Right(RS256)
|
||||||
|
case "RS384" => Right(RS384)
|
||||||
|
case "RS512" => Right(RS512)
|
||||||
|
case "ES256" => Right(ES256)
|
||||||
|
case "ES384" => Right(ES384)
|
||||||
|
case "ES512" => Right(ES512)
|
||||||
|
case "ED25519" => Right(Ed25519)
|
||||||
|
case "HMD5" => Right(HMD5)
|
||||||
|
case "HS224" => Right(HS224)
|
||||||
|
case "HS256" => Right(HS256)
|
||||||
|
case "HS384" => Right(HS384)
|
||||||
|
case "HS512" => Right(HS512)
|
||||||
|
case _ => Left(s"Unknown signature algo: $str")
|
||||||
|
}
|
||||||
|
|
||||||
|
def unsafeFromString(str: String): SignatureAlgo =
|
||||||
|
fromString(str).fold(sys.error, identity)
|
||||||
|
|
||||||
|
private[oidc] def decoder(
|
||||||
|
sigKey: ByteVector,
|
||||||
|
algo: SignatureAlgo
|
||||||
|
): String => Either[Throwable, Jwt] = { token =>
|
||||||
|
algo match {
|
||||||
|
case RS256 =>
|
||||||
|
for {
|
||||||
|
pubKey <- createPublicKey(sigKey, "RSA")
|
||||||
|
decoded <- JwtCirce
|
||||||
|
.decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.RS256))
|
||||||
|
.toEither
|
||||||
|
} yield Jwt.create(decoded)
|
||||||
|
|
||||||
|
case RS384 =>
|
||||||
|
for {
|
||||||
|
pubKey <- createPublicKey(sigKey, "RSA")
|
||||||
|
decoded <- JwtCirce
|
||||||
|
.decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.RS384))
|
||||||
|
.toEither
|
||||||
|
} yield Jwt.create(decoded)
|
||||||
|
|
||||||
|
case RS512 =>
|
||||||
|
for {
|
||||||
|
pubKey <- createPublicKey(sigKey, "RSA")
|
||||||
|
decoded <- JwtCirce
|
||||||
|
.decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.RS512))
|
||||||
|
.toEither
|
||||||
|
} yield Jwt.create(decoded)
|
||||||
|
|
||||||
|
case ES256 =>
|
||||||
|
for {
|
||||||
|
pubKey <- createPublicKey(sigKey, "EC")
|
||||||
|
decoded <- JwtCirce
|
||||||
|
.decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.ES256))
|
||||||
|
.toEither
|
||||||
|
} yield Jwt.create(decoded)
|
||||||
|
case ES384 =>
|
||||||
|
for {
|
||||||
|
pubKey <- createPublicKey(sigKey, "EC")
|
||||||
|
decoded <- JwtCirce
|
||||||
|
.decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.ES384))
|
||||||
|
.toEither
|
||||||
|
} yield Jwt.create(decoded)
|
||||||
|
case ES512 =>
|
||||||
|
for {
|
||||||
|
pubKey <- createPublicKey(sigKey, "EC")
|
||||||
|
decoded <- JwtCirce
|
||||||
|
.decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.ES512))
|
||||||
|
.toEither
|
||||||
|
} yield Jwt.create(decoded)
|
||||||
|
|
||||||
|
case Ed25519 =>
|
||||||
|
for {
|
||||||
|
pubKey <- createPublicKey(sigKey, "EdDSA")
|
||||||
|
decoded <- JwtCirce
|
||||||
|
.decodeJsonAll(token, pubKey, Seq(JwtAlgorithm.Ed25519))
|
||||||
|
.toEither
|
||||||
|
} yield Jwt.create(decoded)
|
||||||
|
|
||||||
|
case HMD5 =>
|
||||||
|
for {
|
||||||
|
key <- createSecretKey(sigKey, JwtAlgorithm.HMD5.fullName)
|
||||||
|
decoded <- JwtCirce.decodeJsonAll(token, key, Seq(JwtAlgorithm.HMD5)).toEither
|
||||||
|
} yield Jwt.create(decoded)
|
||||||
|
|
||||||
|
case HS224 =>
|
||||||
|
for {
|
||||||
|
key <- createSecretKey(sigKey, JwtAlgorithm.HS224.fullName)
|
||||||
|
decoded <- JwtCirce.decodeJsonAll(token, key, Seq(JwtAlgorithm.HS224)).toEither
|
||||||
|
} yield Jwt.create(decoded)
|
||||||
|
|
||||||
|
case HS256 =>
|
||||||
|
for {
|
||||||
|
key <- createSecretKey(sigKey, JwtAlgorithm.HS256.fullName)
|
||||||
|
decoded <- JwtCirce.decodeJsonAll(token, key, Seq(JwtAlgorithm.HS256)).toEither
|
||||||
|
} yield Jwt.create(decoded)
|
||||||
|
|
||||||
|
case HS384 =>
|
||||||
|
for {
|
||||||
|
key <- createSecretKey(sigKey, JwtAlgorithm.HS384.fullName)
|
||||||
|
decoded <- JwtCirce.decodeJsonAll(token, key, Seq(JwtAlgorithm.HS384)).toEither
|
||||||
|
} yield Jwt.create(decoded)
|
||||||
|
|
||||||
|
case HS512 =>
|
||||||
|
for {
|
||||||
|
key <- createSecretKey(sigKey, JwtAlgorithm.HS512.fullName)
|
||||||
|
decoded <- JwtCirce.decodeJsonAll(token, key, Seq(JwtAlgorithm.HS512)).toEither
|
||||||
|
} yield Jwt.create(decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private def createSecretKey(
|
||||||
|
key: ByteVector,
|
||||||
|
keyAlgo: String
|
||||||
|
): Either[Throwable, SecretKey] =
|
||||||
|
Either.catchNonFatal(new SecretKeySpec(key.toArray, keyAlgo))
|
||||||
|
|
||||||
|
private def createPublicKey(
|
||||||
|
key: ByteVector,
|
||||||
|
keyAlgo: String
|
||||||
|
): Either[Throwable, PublicKey] =
|
||||||
|
Either.catchNonFatal {
|
||||||
|
val spec = new X509EncodedKeySpec(key.toArray)
|
||||||
|
KeyFactory.getInstance(keyAlgo).generatePublic(spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2020 Docspell Contributors
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package docspell.oidc
|
||||||
|
|
||||||
|
import docspell.common.Ident
|
||||||
|
|
||||||
|
import io.circe.{Decoder, DecodingFailure}
|
||||||
|
|
||||||
|
/** Helpers for implementing `OnUserInfo`. */
|
||||||
|
object UserInfoDecoder {
|
||||||
|
|
||||||
|
/** Find the value for `preferred_username` standard claim (see
|
||||||
|
* https://openid.net/specs/openid-connect-basic-1_0.html#StandardClaims).
|
||||||
|
*/
|
||||||
|
def preferredUsername: Decoder[Ident] =
|
||||||
|
findSomeId("preferred_username")
|
||||||
|
|
||||||
|
/** Looks recursively in the JSON for the first attribute with name `key` and returns
|
||||||
|
* its value.
|
||||||
|
*/
|
||||||
|
def findSomeString(key: String): Decoder[String] =
|
||||||
|
Decoder.instance { cursor =>
|
||||||
|
cursor.value
|
||||||
|
.findAllByKey(key)
|
||||||
|
.find(_.isString)
|
||||||
|
.flatMap(_.asString)
|
||||||
|
.toRight(s"No value found in JSON for key '$key'")
|
||||||
|
.left
|
||||||
|
.map(msg => DecodingFailure(msg, Nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Looks recursively in the JSON for the first attribute with name `key` and returns
|
||||||
|
* its value (expecting an Ident).
|
||||||
|
*/
|
||||||
|
def findSomeId(key: String): Decoder[Ident] =
|
||||||
|
findSomeString(key).emap(normalizeUid)
|
||||||
|
|
||||||
|
def normalizeUid(uid: String): Either[String, Ident] =
|
||||||
|
Ident(uid.filter(Ident.chars.contains))
|
||||||
|
.flatMap(id =>
|
||||||
|
if (id.nonEmpty) Right(id) else Left(s"Id '$uid' empty after normalizing!'")
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
@ -42,6 +42,7 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/VersionInfo"
|
$ref: "#/components/schemas/VersionInfo"
|
||||||
|
|
||||||
/open/auth/login:
|
/open/auth/login:
|
||||||
post:
|
post:
|
||||||
operationId: "open-auth-login"
|
operationId: "open-auth-login"
|
||||||
@ -93,6 +94,51 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/AuthResult"
|
$ref: "#/components/schemas/AuthResult"
|
||||||
|
/open/auth/openid/{providerId}:
|
||||||
|
get:
|
||||||
|
operationId: "open-auth-openid"
|
||||||
|
tags: [ Authentication ]
|
||||||
|
summary: Authenticates via OIDC at the external provider given by its id
|
||||||
|
description: |
|
||||||
|
Initiates the ["Authorization Code
|
||||||
|
Flow"](https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth)
|
||||||
|
as described in the OpenID Connect specification. This only is
|
||||||
|
enabled, if an external provider has been configured correctly
|
||||||
|
in the config file.
|
||||||
|
|
||||||
|
This will redirect to the external provider to authenticate
|
||||||
|
the user. Once authenticated, the user is redirected back to
|
||||||
|
the `/resume` endpoint.
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/providerId"
|
||||||
|
responses:
|
||||||
|
302:
|
||||||
|
description: Found. Redirect to external authentication provider
|
||||||
|
200:
|
||||||
|
description: Not used, is only here because openid requires it
|
||||||
|
/open/auth/openid/{providerId}/resume:
|
||||||
|
get:
|
||||||
|
operationId: "open-auth-openid-resume"
|
||||||
|
tags: [ Authentication ]
|
||||||
|
summary: The callback URL for the authentication provider
|
||||||
|
description: |
|
||||||
|
This URL is used to redirect the user back to the application
|
||||||
|
by the authentication provider after login is completed.
|
||||||
|
|
||||||
|
This will then try to find (or create) the account at docspell
|
||||||
|
using information about the user provided by the
|
||||||
|
authentication provider. If the required information cannot be
|
||||||
|
found, the user cannot be logged into the application.
|
||||||
|
|
||||||
|
If the process completed successfully, this endpoint redirects
|
||||||
|
into the web application which will take over from here.
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/providerId"
|
||||||
|
responses:
|
||||||
|
303:
|
||||||
|
description: See Other. Redirect to the webapp
|
||||||
|
200:
|
||||||
|
description: Not used, is only here because openid requires it
|
||||||
|
|
||||||
/open/checkfile/{id}/{checksum}:
|
/open/checkfile/{id}/{checksum}:
|
||||||
get:
|
get:
|
||||||
@ -5405,6 +5451,7 @@ components:
|
|||||||
- id
|
- id
|
||||||
- login
|
- login
|
||||||
- state
|
- state
|
||||||
|
- source
|
||||||
- loginCount
|
- loginCount
|
||||||
- created
|
- created
|
||||||
properties:
|
properties:
|
||||||
@ -5420,6 +5467,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
|
||||||
@ -6262,3 +6315,10 @@ components:
|
|||||||
some identifier for a client application
|
some identifier for a client application
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
providerId:
|
||||||
|
name: providerId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: ident
|
||||||
|
@ -61,6 +61,131 @@ docspell.server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Configures OpenID Connect (OIDC) or OAuth2 authentication. Only
|
||||||
|
# the "Authorization Code Flow" is supported.
|
||||||
|
#
|
||||||
|
# Multiple authentication providers can be defined. Each is
|
||||||
|
# configured in the array below. The `provider` block gives all
|
||||||
|
# details necessary to authenticate agains an external OIDC or OAuth
|
||||||
|
# provider. This requires at least two URLs for OIDC and three for
|
||||||
|
# OAuth2. The `user-url` is only required for OIDC, if the account
|
||||||
|
# data is to be retrieved from the user-info endpoint and not from
|
||||||
|
# the JWT token. The access token is then used to authenticate at
|
||||||
|
# the provider to obtain user info. Thus, it doesn't need to be
|
||||||
|
# validated here and therefore no `sign-key` setting is needed.
|
||||||
|
# However, if you want to extract the account information from the
|
||||||
|
# access token, it must be validated here and therefore the correct
|
||||||
|
# signature key and algorithm must be provided. This would save
|
||||||
|
# another request. If the `sign-key` is left empty, the `user-url`
|
||||||
|
# is used and must be specified. If the `sign-key` is _not_ empty,
|
||||||
|
# the response from the authentication provider is validated using
|
||||||
|
# this key.
|
||||||
|
#
|
||||||
|
# After successful authentication, docspell needs to create the
|
||||||
|
# account. For this a username and collective name is required. The
|
||||||
|
# username is defined by the `user-key` setting. The `user-key` is
|
||||||
|
# used to search the JSON structure, that is obtained from the JWT
|
||||||
|
# token or the user-info endpoint, for the login name to use. It
|
||||||
|
# traverses the JSON structure recursively, until it finds an object
|
||||||
|
# with that key. The first value is used.
|
||||||
|
#
|
||||||
|
# There are the following ways to specify how to retrieve the full
|
||||||
|
# account id depending on the value of `collective-key`:
|
||||||
|
#
|
||||||
|
# - If it starts with `fixed:`, like "fixed:collective", the name
|
||||||
|
# after the `fixed:` prefix is used as collective as is. So all
|
||||||
|
# users are in the same collective.
|
||||||
|
#
|
||||||
|
# - If it starts with `lookup:`, like "lookup:collective_name", the
|
||||||
|
# value after the prefix is used to search the JSON response for
|
||||||
|
# an object with this key, just like it works with the `user-key`.
|
||||||
|
#
|
||||||
|
# - If it starts with `account:`, like "account:ds-account", it
|
||||||
|
# works the same as `lookup:` only that the value is interpreted
|
||||||
|
# as the full account name of form `collective/login`. The
|
||||||
|
# `user-key` value is ignored in this case.
|
||||||
|
#
|
||||||
|
# If these values cannot be obtained from the response, docspell
|
||||||
|
# fails the authentication by denying access. It is then assumed
|
||||||
|
# that the successfully authenticated user has not enough
|
||||||
|
# permissions to access docspell.
|
||||||
|
#
|
||||||
|
# Below are examples for OpenID Connect (keycloak) and OAuth2
|
||||||
|
# (github).
|
||||||
|
openid =
|
||||||
|
[ { enabled = false,
|
||||||
|
|
||||||
|
# The name to render on the login link/button.
|
||||||
|
display = "Keycloak"
|
||||||
|
|
||||||
|
# This illustrates to use a custom keycloak setup as the
|
||||||
|
# authentication provider. For details, please refer to the
|
||||||
|
# keycloak documentation. The settings here assume a certain
|
||||||
|
# configuration at keycloak.
|
||||||
|
#
|
||||||
|
# Keycloak can be configured to return the collective name for
|
||||||
|
# each user in the access token. It may also be configured to
|
||||||
|
# return it in the user info response. If it is already in the
|
||||||
|
# access token, an additional request can be omitted. Set the
|
||||||
|
# `sign-key` to an empty string then. Otherwise provide the
|
||||||
|
# algo and key from your realm settings. In this example, the
|
||||||
|
# realm is called "home".
|
||||||
|
provider = {
|
||||||
|
provider-id = "keycloak",
|
||||||
|
client-id = "docspell",
|
||||||
|
client-secret = "example-secret-439e-bf06-911e4cdd56a6",
|
||||||
|
scope = "profile", # scope is required for OIDC
|
||||||
|
authorize-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/auth",
|
||||||
|
token-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/token",
|
||||||
|
#User URL is not used when signature key is set.
|
||||||
|
#user-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/userinfo",
|
||||||
|
sign-key = "b64:MII…ZYL09vAwLn8EAcSkCAwEAAQ==",
|
||||||
|
sig-algo = "RS512"
|
||||||
|
},
|
||||||
|
# The collective of the user is given in the access token as
|
||||||
|
# property `docspell_collective`.
|
||||||
|
collective-key = "lookup:docspell_collective",
|
||||||
|
# The username to use for the docspell account
|
||||||
|
user-key = "preferred_username"
|
||||||
|
},
|
||||||
|
{ enabled = false,
|
||||||
|
|
||||||
|
# The name to render on the login link/button.
|
||||||
|
display = "Github"
|
||||||
|
|
||||||
|
# Provider settings for using github as an authentication
|
||||||
|
# provider. Note that this is only an example to illustrate
|
||||||
|
# how it works. Usually you wouldn't want to let every user on
|
||||||
|
# github in ;-).
|
||||||
|
#
|
||||||
|
# Github doesn't have full OpenIdConnect, but supports the
|
||||||
|
# OAuth2 code flow (which is very similar). It mainly means,
|
||||||
|
# that there is no standardized token to validate and get
|
||||||
|
# information from. So the user-url must be used in this case.
|
||||||
|
provider = {
|
||||||
|
provider-id = "github",
|
||||||
|
client-id = "<your github client id>",
|
||||||
|
client-secret = "<your github client secret>",
|
||||||
|
scope = "", # scope is not needed for github
|
||||||
|
authorize-url = "https://github.com/login/oauth/authorize",
|
||||||
|
token-url = "https://github.com/login/oauth/access_token",
|
||||||
|
user-url = "https://api.github.com/user",
|
||||||
|
sign-key = "" # this must be set empty
|
||||||
|
sig-algo = "RS256" #unused but must be set to something
|
||||||
|
},
|
||||||
|
|
||||||
|
# If the authentication provider doesn't provide the
|
||||||
|
# collective name, simply use a fixed one. This means all
|
||||||
|
# users from this provider are in the same collective!
|
||||||
|
collective-key = "fixed:demo",
|
||||||
|
|
||||||
|
# Github provides the login name via the `login` property as
|
||||||
|
# response from the user-url. This value is used to construct
|
||||||
|
# the account in docspell.
|
||||||
|
user-key = "login"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
# This endpoint allows to upload files to any collective. The
|
# This endpoint allows to upload files to any collective. The
|
||||||
# intention is that local software integrates with docspell more
|
# intention is that local software integrates with docspell more
|
||||||
# easily. Therefore the endpoint is not protected by the usual
|
# easily. Therefore the endpoint is not protected by the usual
|
||||||
|
@ -10,6 +10,9 @@ import docspell.backend.auth.Login
|
|||||||
import docspell.backend.{Config => BackendConfig}
|
import docspell.backend.{Config => BackendConfig}
|
||||||
import docspell.common._
|
import docspell.common._
|
||||||
import docspell.ftssolr.SolrConfig
|
import docspell.ftssolr.SolrConfig
|
||||||
|
import docspell.oidc.ProviderConfig
|
||||||
|
import docspell.restserver.Config.OpenIdConfig
|
||||||
|
import docspell.restserver.auth.OpenId
|
||||||
|
|
||||||
import com.comcast.ip4s.IpAddress
|
import com.comcast.ip4s.IpAddress
|
||||||
|
|
||||||
@ -25,8 +28,12 @@ case class Config(
|
|||||||
maxItemPageSize: Int,
|
maxItemPageSize: Int,
|
||||||
maxNoteLength: Int,
|
maxNoteLength: Int,
|
||||||
fullTextSearch: Config.FullTextSearch,
|
fullTextSearch: Config.FullTextSearch,
|
||||||
adminEndpoint: Config.AdminEndpoint
|
adminEndpoint: Config.AdminEndpoint,
|
||||||
)
|
openid: List[OpenIdConfig]
|
||||||
|
) {
|
||||||
|
def openIdEnabled: Boolean =
|
||||||
|
openid.exists(_.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
object Config {
|
object Config {
|
||||||
|
|
||||||
@ -70,4 +77,12 @@ object Config {
|
|||||||
|
|
||||||
object FullTextSearch {}
|
object FullTextSearch {}
|
||||||
|
|
||||||
|
final case class OpenIdConfig(
|
||||||
|
enabled: Boolean,
|
||||||
|
display: String,
|
||||||
|
collectiveKey: OpenId.UserInfo.Extractor,
|
||||||
|
userKey: String,
|
||||||
|
provider: ProviderConfig
|
||||||
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -6,8 +6,14 @@
|
|||||||
|
|
||||||
package docspell.restserver
|
package docspell.restserver
|
||||||
|
|
||||||
|
import cats.Semigroup
|
||||||
|
import cats.data.{Validated, ValidatedNec}
|
||||||
|
import cats.implicits._
|
||||||
|
|
||||||
import docspell.backend.signup.{Config => SignupConfig}
|
import docspell.backend.signup.{Config => SignupConfig}
|
||||||
import docspell.common.config.Implicits._
|
import docspell.common.config.Implicits._
|
||||||
|
import docspell.oidc.{ProviderConfig, SignatureAlgo}
|
||||||
|
import docspell.restserver.auth.OpenId
|
||||||
|
|
||||||
import pureconfig._
|
import pureconfig._
|
||||||
import pureconfig.generic.auto._
|
import pureconfig.generic.auto._
|
||||||
@ -16,10 +22,71 @@ object ConfigFile {
|
|||||||
import Implicits._
|
import Implicits._
|
||||||
|
|
||||||
def loadConfig: Config =
|
def loadConfig: Config =
|
||||||
ConfigSource.default.at("docspell.server").loadOrThrow[Config]
|
Validate(ConfigSource.default.at("docspell.server").loadOrThrow[Config])
|
||||||
|
|
||||||
object Implicits {
|
object Implicits {
|
||||||
implicit val signupModeReader: ConfigReader[SignupConfig.Mode] =
|
implicit val signupModeReader: ConfigReader[SignupConfig.Mode] =
|
||||||
ConfigReader[String].emap(reason(SignupConfig.Mode.fromString))
|
ConfigReader[String].emap(reason(SignupConfig.Mode.fromString))
|
||||||
|
|
||||||
|
implicit val sigAlgoReader: ConfigReader[SignatureAlgo] =
|
||||||
|
ConfigReader[String].emap(reason(SignatureAlgo.fromString))
|
||||||
|
|
||||||
|
implicit val openIdExtractorReader: ConfigReader[OpenId.UserInfo.Extractor] =
|
||||||
|
ConfigReader[String].emap(reason(OpenId.UserInfo.Extractor.fromString))
|
||||||
|
}
|
||||||
|
|
||||||
|
object Validate {
|
||||||
|
|
||||||
|
implicit val firstConfigSemigroup: Semigroup[Config] =
|
||||||
|
Semigroup.first
|
||||||
|
|
||||||
|
def apply(config: Config): Config =
|
||||||
|
all(config).foldLeft(valid(config))(_.combine(_)) match {
|
||||||
|
case Validated.Valid(cfg) => cfg
|
||||||
|
case Validated.Invalid(errs) =>
|
||||||
|
val msg = errs.toList.mkString("- ", "\n- ", "\n")
|
||||||
|
throw sys.error(s"\n\n$msg")
|
||||||
|
}
|
||||||
|
|
||||||
|
def all(cfg: Config) = List(
|
||||||
|
duplicateOpenIdProvider(cfg),
|
||||||
|
signKeyVsUserUrl(cfg)
|
||||||
|
)
|
||||||
|
|
||||||
|
private def valid(cfg: Config): ValidatedNec[String, Config] =
|
||||||
|
Validated.validNec(cfg)
|
||||||
|
|
||||||
|
def duplicateOpenIdProvider(cfg: Config): ValidatedNec[String, Config] = {
|
||||||
|
val dupes =
|
||||||
|
cfg.openid
|
||||||
|
.filter(_.enabled)
|
||||||
|
.groupBy(_.provider.providerId)
|
||||||
|
.filter(_._2.size > 1)
|
||||||
|
.map(_._1.id)
|
||||||
|
.toList
|
||||||
|
|
||||||
|
val dupesStr = dupes.mkString(", ")
|
||||||
|
if (dupes.isEmpty) valid(cfg)
|
||||||
|
else Validated.invalidNec(s"There is a duplicate openId provider: $dupesStr")
|
||||||
|
}
|
||||||
|
|
||||||
|
def signKeyVsUserUrl(cfg: Config): ValidatedNec[String, Config] = {
|
||||||
|
def checkProvider(p: ProviderConfig): ValidatedNec[String, Config] =
|
||||||
|
if (p.signKey.isEmpty && p.userUrl.isEmpty)
|
||||||
|
Validated.invalidNec(
|
||||||
|
s"Either user-url or sign-key must be set for provider ${p.providerId.id}"
|
||||||
|
)
|
||||||
|
else if (p.signKey.nonEmpty && p.scope.isEmpty)
|
||||||
|
Validated.invalidNec(
|
||||||
|
s"A scope is missing for OIDC auth at provider ${p.providerId.id}"
|
||||||
|
)
|
||||||
|
else Validated.valid(cfg)
|
||||||
|
|
||||||
|
cfg.openid
|
||||||
|
.filter(_.enabled)
|
||||||
|
.map(_.provider)
|
||||||
|
.map(checkProvider)
|
||||||
|
.foldLeft(valid(cfg))(_.combine(_))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,12 +12,16 @@ import fs2.Stream
|
|||||||
|
|
||||||
import docspell.backend.auth.AuthToken
|
import docspell.backend.auth.AuthToken
|
||||||
import docspell.common._
|
import docspell.common._
|
||||||
|
import docspell.oidc.CodeFlowRoutes
|
||||||
|
import docspell.restserver.auth.OpenId
|
||||||
import docspell.restserver.http4s.EnvMiddleware
|
import docspell.restserver.http4s.EnvMiddleware
|
||||||
import docspell.restserver.routes._
|
import docspell.restserver.routes._
|
||||||
import docspell.restserver.webapp._
|
import docspell.restserver.webapp._
|
||||||
|
|
||||||
import org.http4s._
|
import org.http4s._
|
||||||
|
import org.http4s.blaze.client.BlazeClientBuilder
|
||||||
import org.http4s.blaze.server.BlazeServerBuilder
|
import org.http4s.blaze.server.BlazeServerBuilder
|
||||||
|
import org.http4s.client.Client
|
||||||
import org.http4s.dsl.Http4sDsl
|
import org.http4s.dsl.Http4sDsl
|
||||||
import org.http4s.headers.Location
|
import org.http4s.headers.Location
|
||||||
import org.http4s.implicits._
|
import org.http4s.implicits._
|
||||||
@ -33,9 +37,10 @@ object RestServer {
|
|||||||
restApp <-
|
restApp <-
|
||||||
RestAppImpl
|
RestAppImpl
|
||||||
.create[F](cfg, pools.connectEC, pools.httpClientEC)
|
.create[F](cfg, pools.connectEC, pools.httpClientEC)
|
||||||
|
httpClient <- BlazeClientBuilder[F](pools.httpClientEC).resource
|
||||||
httpApp = Router(
|
httpApp = Router(
|
||||||
"/api/info" -> routes.InfoRoutes(),
|
"/api/info" -> routes.InfoRoutes(),
|
||||||
"/api/v1/open/" -> openRoutes(cfg, restApp),
|
"/api/v1/open/" -> openRoutes(cfg, httpClient, restApp),
|
||||||
"/api/v1/sec/" -> Authenticate(restApp.backend.login, cfg.auth) { token =>
|
"/api/v1/sec/" -> Authenticate(restApp.backend.login, cfg.auth) { token =>
|
||||||
securedRoutes(cfg, restApp, token)
|
securedRoutes(cfg, restApp, token)
|
||||||
},
|
},
|
||||||
@ -98,8 +103,18 @@ object RestServer {
|
|||||||
"clientSettings" -> ClientSettingsRoutes(restApp.backend, token)
|
"clientSettings" -> ClientSettingsRoutes(restApp.backend, token)
|
||||||
)
|
)
|
||||||
|
|
||||||
def openRoutes[F[_]: Async](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] =
|
def openRoutes[F[_]: Async](
|
||||||
|
cfg: Config,
|
||||||
|
client: Client[F],
|
||||||
|
restApp: RestApp[F]
|
||||||
|
): HttpRoutes[F] =
|
||||||
Router(
|
Router(
|
||||||
|
"auth/openid" -> CodeFlowRoutes(
|
||||||
|
cfg.openIdEnabled,
|
||||||
|
OpenId.handle[F](restApp.backend, cfg),
|
||||||
|
OpenId.codeFlowConfig(cfg),
|
||||||
|
client
|
||||||
|
),
|
||||||
"auth" -> LoginRoutes.login(restApp.backend.login, cfg),
|
"auth" -> LoginRoutes.login(restApp.backend.login, cfg),
|
||||||
"signup" -> RegisterRoutes(restApp.backend, cfg),
|
"signup" -> RegisterRoutes(restApp.backend, cfg),
|
||||||
"upload" -> UploadRoutes.open(restApp.backend, cfg),
|
"upload" -> UploadRoutes.open(restApp.backend, cfg),
|
||||||
|
@ -0,0 +1,237 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2020 Docspell Contributors
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package docspell.restserver.auth
|
||||||
|
|
||||||
|
import cats.effect._
|
||||||
|
import cats.implicits._
|
||||||
|
|
||||||
|
import docspell.backend.BackendApp
|
||||||
|
import docspell.backend.auth.Login
|
||||||
|
import docspell.backend.signup.{ExternalAccount, SignupResult}
|
||||||
|
import docspell.common._
|
||||||
|
import docspell.oidc.{CodeFlowConfig, OnUserInfo, UserInfoDecoder}
|
||||||
|
import docspell.restserver.Config
|
||||||
|
import docspell.restserver.auth.OpenId.UserInfo.{ExtractResult, Extractor}
|
||||||
|
import docspell.restserver.http4s.ClientRequestInfo
|
||||||
|
|
||||||
|
import io.circe.Json
|
||||||
|
import org.http4s.dsl.Http4sDsl
|
||||||
|
import org.http4s.headers.Location
|
||||||
|
import org.http4s.{Response, Uri}
|
||||||
|
import org.log4s.getLogger
|
||||||
|
|
||||||
|
object OpenId {
|
||||||
|
private[this] val log = getLogger
|
||||||
|
|
||||||
|
def codeFlowConfig[F[_]](config: Config): CodeFlowConfig[F] =
|
||||||
|
CodeFlowConfig(
|
||||||
|
req =>
|
||||||
|
ClientRequestInfo
|
||||||
|
.getBaseUrl(config, req) / "api" / "v1" / "open" / "auth" / "openid",
|
||||||
|
id =>
|
||||||
|
config.openid.filter(_.enabled).find(_.provider.providerId == id).map(_.provider)
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle[F[_]: Async](backend: BackendApp[F], config: Config): OnUserInfo[F] =
|
||||||
|
OnUserInfo { (req, provider, userInfo) =>
|
||||||
|
val dsl = new Http4sDsl[F] {}
|
||||||
|
import dsl._
|
||||||
|
val logger = Logger.log4s(log)
|
||||||
|
val baseUrl = ClientRequestInfo.getBaseUrl(config, req)
|
||||||
|
val uri = baseUrl.withQuery("openid", "1") / "app" / "login"
|
||||||
|
val location = Location(Uri.unsafeFromString(uri.asString))
|
||||||
|
val cfg = config.openid
|
||||||
|
.find(_.provider.providerId == provider.providerId)
|
||||||
|
.getOrElse(sys.error("No config found, but provider which is impossible :)"))
|
||||||
|
|
||||||
|
userInfo match {
|
||||||
|
case Some(userJson) =>
|
||||||
|
val extractColl = cfg.collectiveKey.find(userJson)
|
||||||
|
|
||||||
|
extractColl match {
|
||||||
|
case ExtractResult.Failure(message) =>
|
||||||
|
logger.warn(
|
||||||
|
s"Can't retrieve user data using collective-key=${cfg.collectiveKey.asString}: $message"
|
||||||
|
) *>
|
||||||
|
SeeOther(location)
|
||||||
|
|
||||||
|
case ExtractResult.Account(accountId) =>
|
||||||
|
signUpAndLogin[F](backend)(config, accountId, location, baseUrl)
|
||||||
|
|
||||||
|
case ExtractResult.Identifier(coll) =>
|
||||||
|
Extractor.Lookup(cfg.userKey).find(userJson) match {
|
||||||
|
case ExtractResult.Failure(message) =>
|
||||||
|
logger.warn(
|
||||||
|
s"Can't retrieve user data using user-key=${cfg.userKey}: $message"
|
||||||
|
) *>
|
||||||
|
SeeOther(location)
|
||||||
|
|
||||||
|
case ExtractResult.Identifier(name) =>
|
||||||
|
signUpAndLogin[F](backend)(
|
||||||
|
config,
|
||||||
|
AccountId(coll, name),
|
||||||
|
location,
|
||||||
|
baseUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
case ExtractResult.Account(accountId) =>
|
||||||
|
signUpAndLogin[F](backend)(
|
||||||
|
config,
|
||||||
|
accountId.copy(collective = coll),
|
||||||
|
location,
|
||||||
|
baseUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case None =>
|
||||||
|
TemporaryRedirect(location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def signUpAndLogin[F[_]: Async](
|
||||||
|
backend: BackendApp[F]
|
||||||
|
)(
|
||||||
|
cfg: Config,
|
||||||
|
accountId: AccountId,
|
||||||
|
location: Location,
|
||||||
|
baseUrl: LenientUri
|
||||||
|
): F[Response[F]] = {
|
||||||
|
val dsl = new Http4sDsl[F] {}
|
||||||
|
import dsl._
|
||||||
|
|
||||||
|
for {
|
||||||
|
setup <- backend.signup.setupExternal(cfg.backend.signup)(
|
||||||
|
ExternalAccount(accountId)
|
||||||
|
)
|
||||||
|
logger = Logger.log4s(log)
|
||||||
|
res <- setup match {
|
||||||
|
case SignupResult.Failure(ex) =>
|
||||||
|
logger.error(ex)(s"Error when creating external account!") *>
|
||||||
|
SeeOther(location)
|
||||||
|
|
||||||
|
case SignupResult.SignupClosed =>
|
||||||
|
logger.error(s"External accounts don't work when signup is closed!") *>
|
||||||
|
SeeOther(location)
|
||||||
|
|
||||||
|
case SignupResult.CollectiveExists =>
|
||||||
|
logger.error(
|
||||||
|
s"Error when creating external accounts! Collective exists error reported. This is a bug!"
|
||||||
|
) *>
|
||||||
|
SeeOther(location)
|
||||||
|
|
||||||
|
case SignupResult.InvalidInvitationKey =>
|
||||||
|
logger.error(
|
||||||
|
s"Error when creating external accounts! Invalid invitation key reported. This is a bug!"
|
||||||
|
) *>
|
||||||
|
SeeOther(location)
|
||||||
|
|
||||||
|
case SignupResult.Success =>
|
||||||
|
loginAndVerify(backend, cfg)(accountId, location, baseUrl)
|
||||||
|
}
|
||||||
|
} yield res
|
||||||
|
}
|
||||||
|
|
||||||
|
def loginAndVerify[F[_]: Async](backend: BackendApp[F], config: Config)(
|
||||||
|
accountId: AccountId,
|
||||||
|
location: Location,
|
||||||
|
baseUrl: LenientUri
|
||||||
|
): F[Response[F]] = {
|
||||||
|
val dsl = new Http4sDsl[F] {}
|
||||||
|
import dsl._
|
||||||
|
|
||||||
|
for {
|
||||||
|
login <- backend.login.loginExternal(config.auth)(accountId)
|
||||||
|
resp <- login match {
|
||||||
|
case Login.Result.Ok(session, _) =>
|
||||||
|
val loc =
|
||||||
|
if (session.requireSecondFactor)
|
||||||
|
location.copy(uri =
|
||||||
|
location.uri
|
||||||
|
.withQueryParam("openid", "2")
|
||||||
|
.withQueryParam("auth", session.asString)
|
||||||
|
)
|
||||||
|
else location
|
||||||
|
SeeOther(loc)
|
||||||
|
.map(_.addCookie(CookieData(session).asCookie(baseUrl)))
|
||||||
|
|
||||||
|
case failed =>
|
||||||
|
Logger.log4s(log).error(s"External login failed: $failed") *>
|
||||||
|
SeeOther(location)
|
||||||
|
}
|
||||||
|
} yield resp
|
||||||
|
}
|
||||||
|
|
||||||
|
object UserInfo {
|
||||||
|
|
||||||
|
sealed trait Extractor {
|
||||||
|
def find(json: Json): ExtractResult
|
||||||
|
def asString: String
|
||||||
|
}
|
||||||
|
object Extractor {
|
||||||
|
final case class Fixed(value: String) extends Extractor {
|
||||||
|
def find(json: Json): ExtractResult =
|
||||||
|
UserInfoDecoder
|
||||||
|
.normalizeUid(value)
|
||||||
|
.fold(err => ExtractResult.Failure(err), ExtractResult.Identifier)
|
||||||
|
|
||||||
|
val asString = s"fixed:$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
final case class Lookup(value: String) extends Extractor {
|
||||||
|
def find(json: Json): ExtractResult =
|
||||||
|
UserInfoDecoder
|
||||||
|
.findSomeId(value)
|
||||||
|
.decodeJson(json)
|
||||||
|
.fold(
|
||||||
|
err => ExtractResult.Failure(err.getMessage()),
|
||||||
|
ExtractResult.Identifier
|
||||||
|
)
|
||||||
|
|
||||||
|
val asString = s"lookup:$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
final case class AccountLookup(value: String) extends Extractor {
|
||||||
|
def find(json: Json): ExtractResult =
|
||||||
|
UserInfoDecoder
|
||||||
|
.findSomeString(value)
|
||||||
|
.emap(AccountId.parse)
|
||||||
|
.decodeJson(json)
|
||||||
|
.fold(df => ExtractResult.Failure(df.getMessage()), ExtractResult.Account)
|
||||||
|
|
||||||
|
def asString = s"account:$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
def fromString(str: String): Either[String, Extractor] =
|
||||||
|
str.span(_ != ':') match {
|
||||||
|
case (_, "") =>
|
||||||
|
Left(s"Invalid extractor, there is no value: $str")
|
||||||
|
case (_, value) if value == ":" =>
|
||||||
|
Left(s"Invalid extractor, there is no value: $str")
|
||||||
|
|
||||||
|
case (prefix, value) =>
|
||||||
|
prefix.toLowerCase match {
|
||||||
|
case "fixed" =>
|
||||||
|
Right(Fixed(value.drop(1)))
|
||||||
|
case "lookup" =>
|
||||||
|
Right(Lookup(value.drop(1)))
|
||||||
|
case "account" =>
|
||||||
|
Right(AccountLookup(value.drop(1)))
|
||||||
|
case _ =>
|
||||||
|
Left(s"Invalid prefix: $prefix")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed trait ExtractResult
|
||||||
|
object ExtractResult {
|
||||||
|
final case class Identifier(name: Ident) extends ExtractResult
|
||||||
|
final case class Account(accountId: AccountId) extends ExtractResult
|
||||||
|
final case class Failure(message: String) extends ExtractResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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 =
|
||||||
|
@ -10,8 +10,7 @@ import cats.effect._
|
|||||||
import cats.implicits._
|
import cats.implicits._
|
||||||
|
|
||||||
import docspell.backend.BackendApp
|
import docspell.backend.BackendApp
|
||||||
import docspell.backend.ops.OCollective.RegisterData
|
import docspell.backend.signup.{NewInviteResult, RegisterData, SignupResult}
|
||||||
import docspell.backend.signup.{NewInviteResult, SignupResult}
|
|
||||||
import docspell.restapi.model._
|
import docspell.restapi.model._
|
||||||
import docspell.restserver.Config
|
import docspell.restserver.Config
|
||||||
import docspell.restserver.http4s.ResponseGenerator
|
import docspell.restserver.http4s.ResponseGenerator
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
package docspell.restserver.webapp
|
package docspell.restserver.webapp
|
||||||
|
|
||||||
import docspell.backend.signup.{Config => SignupConfig}
|
import docspell.backend.signup.{Config => SignupConfig}
|
||||||
import docspell.common.LenientUri
|
import docspell.common.{Ident, LenientUri}
|
||||||
import docspell.restserver.{BuildInfo, Config}
|
import docspell.restserver.{BuildInfo, Config}
|
||||||
|
|
||||||
import io.circe._
|
import io.circe._
|
||||||
@ -25,7 +25,8 @@ case class Flags(
|
|||||||
maxPageSize: Int,
|
maxPageSize: Int,
|
||||||
maxNoteLength: Int,
|
maxNoteLength: Int,
|
||||||
showClassificationSettings: Boolean,
|
showClassificationSettings: Boolean,
|
||||||
uiVersion: Int
|
uiVersion: Int,
|
||||||
|
openIdAuth: List[Flags.OpenIdAuth]
|
||||||
)
|
)
|
||||||
|
|
||||||
object Flags {
|
object Flags {
|
||||||
@ -40,9 +41,20 @@ object Flags {
|
|||||||
cfg.maxItemPageSize,
|
cfg.maxItemPageSize,
|
||||||
cfg.maxNoteLength,
|
cfg.maxNoteLength,
|
||||||
cfg.showClassificationSettings,
|
cfg.showClassificationSettings,
|
||||||
uiVersion
|
uiVersion,
|
||||||
|
cfg.openid.filter(_.enabled).map(c => OpenIdAuth(c.provider.providerId, c.display))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
final case class OpenIdAuth(provider: Ident, name: String)
|
||||||
|
|
||||||
|
object OpenIdAuth {
|
||||||
|
implicit val jsonDecoder: Decoder[OpenIdAuth] =
|
||||||
|
deriveDecoder[OpenIdAuth]
|
||||||
|
|
||||||
|
implicit val jsonEncoder: Encoder[OpenIdAuth] =
|
||||||
|
deriveEncoder[OpenIdAuth]
|
||||||
|
}
|
||||||
|
|
||||||
private def getBaseUrl(cfg: Config): String =
|
private def getBaseUrl(cfg: Config): String =
|
||||||
if (cfg.baseUrl.isLocal) cfg.baseUrl.rootPathToEmpty.path.asString
|
if (cfg.baseUrl.isLocal) cfg.baseUrl.rootPathToEmpty.path.asString
|
||||||
else cfg.baseUrl.rootPathToEmpty.asString
|
else cfg.baseUrl.rootPathToEmpty.asString
|
||||||
@ -50,6 +62,10 @@ object Flags {
|
|||||||
implicit val jsonEncoder: Encoder[Flags] =
|
implicit val jsonEncoder: Encoder[Flags] =
|
||||||
deriveEncoder[Flags]
|
deriveEncoder[Flags]
|
||||||
|
|
||||||
|
implicit def yamuscaIdentConverter: ValueConverter[Ident] =
|
||||||
|
ValueConverter.of(id => Value.fromString(id.id))
|
||||||
|
implicit def yamuscaOpenIdAuthConverter: ValueConverter[OpenIdAuth] =
|
||||||
|
ValueConverter.deriveConverter[OpenIdAuth]
|
||||||
implicit def yamuscaSignupModeConverter: ValueConverter[SignupConfig.Mode] =
|
implicit def yamuscaSignupModeConverter: ValueConverter[SignupConfig.Mode] =
|
||||||
ValueConverter.of(m => Value.fromString(m.name))
|
ValueConverter.of(m => Value.fromString(m.name))
|
||||||
implicit def yamuscaUriConverter: ValueConverter[LenientUri] =
|
implicit def yamuscaUriConverter: ValueConverter[LenientUri] =
|
||||||
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -37,6 +37,9 @@ object RCollective {
|
|||||||
val all = NonEmptyList.of[Column[_]](id, state, language, integration, created)
|
val all = NonEmptyList.of[Column[_]](id, state, language, integration, created)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def makeDefault(collName: Ident, created: Timestamp): RCollective =
|
||||||
|
RCollective(collName, CollectiveState.Active, Language.German, true, created)
|
||||||
|
|
||||||
val T = Table(None)
|
val T = Table(None)
|
||||||
def as(alias: String): Table =
|
def as(alias: String): Table =
|
||||||
Table(Some(alias))
|
Table(Some(alias))
|
||||||
|
@ -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],
|
||||||
@ -28,6 +29,28 @@ case class RUser(
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
object RUser {
|
object RUser {
|
||||||
|
|
||||||
|
def makeDefault(
|
||||||
|
id: Ident,
|
||||||
|
login: Ident,
|
||||||
|
collName: Ident,
|
||||||
|
password: Password,
|
||||||
|
source: AccountSource,
|
||||||
|
created: Timestamp
|
||||||
|
): RUser =
|
||||||
|
RUser(
|
||||||
|
id,
|
||||||
|
login,
|
||||||
|
collName,
|
||||||
|
password,
|
||||||
|
UserState.Active,
|
||||||
|
source,
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
created
|
||||||
|
)
|
||||||
|
|
||||||
final case class Table(alias: Option[String]) extends TableDef {
|
final case class Table(alias: Option[String]) extends TableDef {
|
||||||
val tableName = "user_"
|
val tableName = "user_"
|
||||||
|
|
||||||
@ -36,6 +59,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 +72,7 @@ object RUser {
|
|||||||
cid,
|
cid,
|
||||||
password,
|
password,
|
||||||
state,
|
state,
|
||||||
|
source,
|
||||||
email,
|
email,
|
||||||
loginCount,
|
loginCount,
|
||||||
lastLogin,
|
lastLogin,
|
||||||
@ -65,7 +90,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 +159,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))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -87,6 +87,7 @@ module Api exposing
|
|||||||
, mergeItems
|
, mergeItems
|
||||||
, moveAttachmentBefore
|
, moveAttachmentBefore
|
||||||
, newInvite
|
, newInvite
|
||||||
|
, openIdAuthLink
|
||||||
, postCustomField
|
, postCustomField
|
||||||
, postEquipment
|
, postEquipment
|
||||||
, postNewUser
|
, postNewUser
|
||||||
@ -935,6 +936,11 @@ newInvite flags req receive =
|
|||||||
--- Login
|
--- Login
|
||||||
|
|
||||||
|
|
||||||
|
openIdAuthLink : Flags -> String -> String
|
||||||
|
openIdAuthLink flags provider =
|
||||||
|
flags.config.baseUrl ++ "/api/v1/open/auth/openid/" ++ provider
|
||||||
|
|
||||||
|
|
||||||
login : Flags -> UserPass -> (Result Http.Error AuthResult -> msg) -> Cmd msg
|
login : Flags -> UserPass -> (Result Http.Error AuthResult -> msg) -> Cmd msg
|
||||||
login flags up receive =
|
login flags up receive =
|
||||||
Http.post
|
Http.post
|
||||||
|
@ -82,6 +82,9 @@ init key url flags_ settings =
|
|||||||
( csm, csc ) =
|
( csm, csc ) =
|
||||||
Page.CollectiveSettings.Data.init flags
|
Page.CollectiveSettings.Data.init flags
|
||||||
|
|
||||||
|
( loginm, loginc ) =
|
||||||
|
Page.Login.Data.init flags (Page.loginPageReferrer page)
|
||||||
|
|
||||||
homeViewMode =
|
homeViewMode =
|
||||||
if settings.searchMenuVisible then
|
if settings.searchMenuVisible then
|
||||||
Page.Home.Data.SearchView
|
Page.Home.Data.SearchView
|
||||||
@ -94,7 +97,7 @@ init key url flags_ settings =
|
|||||||
, page = page
|
, page = page
|
||||||
, version = Api.Model.VersionInfo.empty
|
, version = Api.Model.VersionInfo.empty
|
||||||
, homeModel = Page.Home.Data.init flags homeViewMode
|
, homeModel = Page.Home.Data.init flags homeViewMode
|
||||||
, loginModel = Page.Login.Data.emptyModel
|
, loginModel = loginm
|
||||||
, manageDataModel = mdm
|
, manageDataModel = mdm
|
||||||
, collSettingsModel = csm
|
, collSettingsModel = csm
|
||||||
, userSettingsModel = um
|
, userSettingsModel = um
|
||||||
@ -116,6 +119,7 @@ init key url flags_ settings =
|
|||||||
[ Cmd.map UserSettingsMsg uc
|
[ Cmd.map UserSettingsMsg uc
|
||||||
, Cmd.map ManageDataMsg mdc
|
, Cmd.map ManageDataMsg mdc
|
||||||
, Cmd.map CollSettingsMsg csc
|
, Cmd.map CollSettingsMsg csc
|
||||||
|
, Cmd.map LoginMsg loginc
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -158,7 +158,7 @@ updateWithSub msg model =
|
|||||||
|
|
||||||
LogoutResp _ ->
|
LogoutResp _ ->
|
||||||
( { model | loginModel = Page.Login.Data.emptyModel }
|
( { model | loginModel = Page.Login.Data.emptyModel }
|
||||||
, Page.goto (LoginPage Nothing)
|
, Page.goto (LoginPage Page.emptyLoginData)
|
||||||
, Sub.none
|
, Sub.none
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -216,6 +216,7 @@ updateWithSub msg model =
|
|||||||
NavRequest req ->
|
NavRequest req ->
|
||||||
case req of
|
case req of
|
||||||
Internal url ->
|
Internal url ->
|
||||||
|
if String.startsWith "/app" url.path then
|
||||||
let
|
let
|
||||||
isCurrent =
|
isCurrent =
|
||||||
Page.fromUrl url
|
Page.fromUrl url
|
||||||
@ -231,6 +232,9 @@ updateWithSub msg model =
|
|||||||
, Sub.none
|
, Sub.none
|
||||||
)
|
)
|
||||||
|
|
||||||
|
else
|
||||||
|
( model, Nav.load <| Url.toString url, Sub.none )
|
||||||
|
|
||||||
External url ->
|
External url ->
|
||||||
( model
|
( model
|
||||||
, Nav.load url
|
, Nav.load url
|
||||||
|
@ -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
|
||||||
]
|
]
|
||||||
|
@ -17,6 +17,12 @@ module Data.Flags exposing
|
|||||||
import Api.Model.AuthResult exposing (AuthResult)
|
import Api.Model.AuthResult exposing (AuthResult)
|
||||||
|
|
||||||
|
|
||||||
|
type alias OpenIdAuth =
|
||||||
|
{ provider : String
|
||||||
|
, name : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
type alias Config =
|
type alias Config =
|
||||||
{ appName : String
|
{ appName : String
|
||||||
, baseUrl : String
|
, baseUrl : String
|
||||||
@ -27,6 +33,7 @@ type alias Config =
|
|||||||
, maxPageSize : Int
|
, maxPageSize : Int
|
||||||
, maxNoteLength : Int
|
, maxNoteLength : Int
|
||||||
, showClassificationSettings : Bool
|
, showClassificationSettings : Bool
|
||||||
|
, openIdAuth : List OpenIdAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -26,6 +26,7 @@ gb err =
|
|||||||
, invalidInput = "Invalid input when processing the request."
|
, invalidInput = "Invalid input when processing the request."
|
||||||
, notFound = "The requested resource doesn't exist."
|
, notFound = "The requested resource doesn't exist."
|
||||||
, invalidBody = \str -> "There was an error decoding the response: " ++ str
|
, invalidBody = \str -> "There was an error decoding the response: " ++ str
|
||||||
|
, accessDenied = "Access denied"
|
||||||
}
|
}
|
||||||
in
|
in
|
||||||
errorToString texts err
|
errorToString texts err
|
||||||
@ -44,6 +45,7 @@ de err =
|
|||||||
, invalidInput = "Die Daten im Request waren ungültig."
|
, invalidInput = "Die Daten im Request waren ungültig."
|
||||||
, notFound = "Die angegebene Ressource wurde nicht gefunden."
|
, notFound = "Die angegebene Ressource wurde nicht gefunden."
|
||||||
, invalidBody = \str -> "Es gab einen Fehler beim Dekodieren der Antwort: " ++ str
|
, invalidBody = \str -> "Es gab einen Fehler beim Dekodieren der Antwort: " ++ str
|
||||||
|
, accessDenied = "Zugriff verweigert"
|
||||||
}
|
}
|
||||||
in
|
in
|
||||||
errorToString texts err
|
errorToString texts err
|
||||||
@ -61,6 +63,7 @@ type alias Texts =
|
|||||||
, invalidInput : String
|
, invalidInput : String
|
||||||
, notFound : String
|
, notFound : String
|
||||||
, invalidBody : String -> String
|
, invalidBody : String -> String
|
||||||
|
, accessDenied : String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -90,6 +93,9 @@ errorToString texts error =
|
|||||||
if sc == 404 then
|
if sc == 404 then
|
||||||
texts.notFound
|
texts.notFound
|
||||||
|
|
||||||
|
else if sc == 403 then
|
||||||
|
texts.accessDenied
|
||||||
|
|
||||||
else if sc >= 400 && sc < 500 then
|
else if sc >= 400 && sc < 500 then
|
||||||
texts.invalidInput
|
texts.invalidInput
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
@ -29,6 +29,7 @@ type alias Texts =
|
|||||||
, noAccount : String
|
, noAccount : String
|
||||||
, signupLink : String
|
, signupLink : String
|
||||||
, otpCode : String
|
, otpCode : String
|
||||||
|
, or : String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -47,6 +48,7 @@ gb =
|
|||||||
, noAccount = "No account?"
|
, noAccount = "No account?"
|
||||||
, signupLink = "Sign up!"
|
, signupLink = "Sign up!"
|
||||||
, otpCode = "Authentication code"
|
, otpCode = "Authentication code"
|
||||||
|
, or = "Or"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -65,4 +67,5 @@ de =
|
|||||||
, noAccount = "Kein Konto?"
|
, noAccount = "Kein Konto?"
|
||||||
, signupLink = "Hier registrieren!"
|
, signupLink = "Hier registrieren!"
|
||||||
, otpCode = "Authentifizierungscode"
|
, otpCode = "Authentifizierungscode"
|
||||||
|
, or = "Oder"
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,9 @@
|
|||||||
|
|
||||||
|
|
||||||
module Page exposing
|
module Page exposing
|
||||||
( Page(..)
|
( LoginData
|
||||||
|
, Page(..)
|
||||||
|
, emptyLoginData
|
||||||
, fromUrl
|
, fromUrl
|
||||||
, goto
|
, goto
|
||||||
, hasSidebar
|
, hasSidebar
|
||||||
@ -31,9 +33,24 @@ import Url.Parser.Query as Query
|
|||||||
import Util.Maybe
|
import Util.Maybe
|
||||||
|
|
||||||
|
|
||||||
|
type alias LoginData =
|
||||||
|
{ referrer : Maybe Page
|
||||||
|
, session : Maybe String
|
||||||
|
, openid : Int
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
emptyLoginData : LoginData
|
||||||
|
emptyLoginData =
|
||||||
|
{ referrer = Nothing
|
||||||
|
, session = Nothing
|
||||||
|
, openid = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
type Page
|
type Page
|
||||||
= HomePage
|
= HomePage
|
||||||
| LoginPage (Maybe Page)
|
| LoginPage LoginData
|
||||||
| ManageDataPage
|
| ManageDataPage
|
||||||
| CollectiveSettingPage
|
| CollectiveSettingPage
|
||||||
| UserSettingPage
|
| UserSettingPage
|
||||||
@ -99,10 +116,10 @@ loginPage : Page -> Page
|
|||||||
loginPage p =
|
loginPage p =
|
||||||
case p of
|
case p of
|
||||||
LoginPage _ ->
|
LoginPage _ ->
|
||||||
LoginPage Nothing
|
LoginPage emptyLoginData
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
LoginPage (Just p)
|
LoginPage { emptyLoginData | referrer = Just p }
|
||||||
|
|
||||||
|
|
||||||
pageName : Page -> String
|
pageName : Page -> String
|
||||||
@ -144,14 +161,14 @@ pageName page =
|
|||||||
"Item"
|
"Item"
|
||||||
|
|
||||||
|
|
||||||
loginPageReferrer : Page -> Maybe Page
|
loginPageReferrer : Page -> LoginData
|
||||||
loginPageReferrer page =
|
loginPageReferrer page =
|
||||||
case page of
|
case page of
|
||||||
LoginPage r ->
|
LoginPage data ->
|
||||||
r
|
data
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
Nothing
|
emptyLoginData
|
||||||
|
|
||||||
|
|
||||||
uploadId : Page -> Maybe String
|
uploadId : Page -> Maybe String
|
||||||
@ -170,8 +187,8 @@ pageToString page =
|
|||||||
HomePage ->
|
HomePage ->
|
||||||
"/app/home"
|
"/app/home"
|
||||||
|
|
||||||
LoginPage referer ->
|
LoginPage data ->
|
||||||
case referer of
|
case data.referrer of
|
||||||
Just (LoginPage _) ->
|
Just (LoginPage _) ->
|
||||||
"/app/login"
|
"/app/login"
|
||||||
|
|
||||||
@ -253,7 +270,7 @@ parser =
|
|||||||
, s pathPrefix </> s "home"
|
, s pathPrefix </> s "home"
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
, Parser.map LoginPage (s pathPrefix </> s "login" <?> pageQuery)
|
, Parser.map LoginPage (s pathPrefix </> s "login" <?> loginPageParser)
|
||||||
, Parser.map ManageDataPage (s pathPrefix </> s "managedata")
|
, Parser.map ManageDataPage (s pathPrefix </> s "managedata")
|
||||||
, Parser.map CollectiveSettingPage (s pathPrefix </> s "csettings")
|
, Parser.map CollectiveSettingPage (s pathPrefix </> s "csettings")
|
||||||
, Parser.map UserSettingPage (s pathPrefix </> s "usettings")
|
, Parser.map UserSettingPage (s pathPrefix </> s "usettings")
|
||||||
@ -280,6 +297,21 @@ fromString str =
|
|||||||
fromUrl url
|
fromUrl url
|
||||||
|
|
||||||
|
|
||||||
|
loginPageOAuthQuery : Query.Parser Int
|
||||||
|
loginPageOAuthQuery =
|
||||||
|
Query.map (Maybe.withDefault 0) (Query.int "openid")
|
||||||
|
|
||||||
|
|
||||||
|
loginPageSessionQuery : Query.Parser (Maybe String)
|
||||||
|
loginPageSessionQuery =
|
||||||
|
Query.string "auth"
|
||||||
|
|
||||||
|
|
||||||
|
loginPageParser : Query.Parser LoginData
|
||||||
|
loginPageParser =
|
||||||
|
Query.map3 LoginData pageQuery loginPageSessionQuery loginPageOAuthQuery
|
||||||
|
|
||||||
|
|
||||||
pageQuery : Query.Parser (Maybe Page)
|
pageQuery : Query.Parser (Maybe Page)
|
||||||
pageQuery =
|
pageQuery =
|
||||||
let
|
let
|
||||||
|
@ -11,11 +11,14 @@ module Page.Login.Data exposing
|
|||||||
, Model
|
, Model
|
||||||
, Msg(..)
|
, Msg(..)
|
||||||
, emptyModel
|
, emptyModel
|
||||||
|
, init
|
||||||
)
|
)
|
||||||
|
|
||||||
|
import Api
|
||||||
import Api.Model.AuthResult exposing (AuthResult)
|
import Api.Model.AuthResult exposing (AuthResult)
|
||||||
|
import Data.Flags exposing (Flags)
|
||||||
import Http
|
import Http
|
||||||
import Page exposing (Page(..))
|
import Page exposing (LoginData, Page(..))
|
||||||
|
|
||||||
|
|
||||||
type alias Model =
|
type alias Model =
|
||||||
@ -37,7 +40,7 @@ type FormState
|
|||||||
|
|
||||||
type AuthStep
|
type AuthStep
|
||||||
= StepLogin
|
= StepLogin
|
||||||
| StepOtp AuthResult
|
| StepOtp String
|
||||||
|
|
||||||
|
|
||||||
emptyModel : Model
|
emptyModel : Model
|
||||||
@ -51,6 +54,19 @@ emptyModel =
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : Flags -> LoginData -> ( Model, Cmd Msg )
|
||||||
|
init flags ld =
|
||||||
|
let
|
||||||
|
cmd =
|
||||||
|
if ld.openid > 0 then
|
||||||
|
Api.loginSession flags AuthResp
|
||||||
|
|
||||||
|
else
|
||||||
|
Cmd.none
|
||||||
|
in
|
||||||
|
( emptyModel, cmd )
|
||||||
|
|
||||||
|
|
||||||
type Msg
|
type Msg
|
||||||
= SetUsername String
|
= SetUsername String
|
||||||
| SetPassword String
|
| SetPassword String
|
||||||
@ -58,4 +74,4 @@ type Msg
|
|||||||
| Authenticate
|
| Authenticate
|
||||||
| AuthResp (Result Http.Error AuthResult)
|
| AuthResp (Result Http.Error AuthResult)
|
||||||
| SetOtp String
|
| SetOtp String
|
||||||
| AuthOtp AuthResult
|
| AuthOtp String
|
||||||
|
@ -10,13 +10,13 @@ module Page.Login.Update exposing (update)
|
|||||||
import Api
|
import Api
|
||||||
import Api.Model.AuthResult exposing (AuthResult)
|
import Api.Model.AuthResult exposing (AuthResult)
|
||||||
import Data.Flags exposing (Flags)
|
import Data.Flags exposing (Flags)
|
||||||
import Page exposing (Page(..))
|
import Page exposing (LoginData, Page(..))
|
||||||
import Page.Login.Data exposing (..)
|
import Page.Login.Data exposing (..)
|
||||||
import Ports
|
import Ports
|
||||||
|
|
||||||
|
|
||||||
update : Maybe Page -> Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe AuthResult )
|
update : LoginData -> Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe AuthResult )
|
||||||
update referrer flags msg model =
|
update loginData flags msg model =
|
||||||
case msg of
|
case msg of
|
||||||
SetUsername str ->
|
SetUsername str ->
|
||||||
( { model | username = str }, Cmd.none, Nothing )
|
( { model | username = str }, Cmd.none, Nothing )
|
||||||
@ -40,11 +40,11 @@ update referrer flags msg model =
|
|||||||
in
|
in
|
||||||
( model, Api.login flags userPass AuthResp, Nothing )
|
( model, Api.login flags userPass AuthResp, Nothing )
|
||||||
|
|
||||||
AuthOtp acc ->
|
AuthOtp token ->
|
||||||
let
|
let
|
||||||
sf =
|
sf =
|
||||||
{ rememberMe = model.rememberMe
|
{ rememberMe = model.rememberMe
|
||||||
, token = Maybe.withDefault "" acc.token
|
, token = token
|
||||||
, otp = model.otp
|
, otp = model.otp
|
||||||
}
|
}
|
||||||
in
|
in
|
||||||
@ -53,7 +53,7 @@ update referrer flags msg model =
|
|||||||
AuthResp (Ok lr) ->
|
AuthResp (Ok lr) ->
|
||||||
let
|
let
|
||||||
gotoRef =
|
gotoRef =
|
||||||
Maybe.withDefault HomePage referrer |> Page.goto
|
Maybe.withDefault HomePage loginData.referrer |> Page.goto
|
||||||
in
|
in
|
||||||
if lr.success && not lr.requireSecondFactor then
|
if lr.success && not lr.requireSecondFactor then
|
||||||
( { model | formState = AuthSuccess lr, password = "" }
|
( { model | formState = AuthSuccess lr, password = "" }
|
||||||
@ -62,7 +62,11 @@ update referrer flags msg model =
|
|||||||
)
|
)
|
||||||
|
|
||||||
else if lr.success && lr.requireSecondFactor then
|
else if lr.success && lr.requireSecondFactor then
|
||||||
( { model | formState = FormInitial, authStep = StepOtp lr, password = "" }
|
( { model
|
||||||
|
| formState = FormInitial
|
||||||
|
, authStep = StepOtp <| Maybe.withDefault "" lr.token
|
||||||
|
, password = ""
|
||||||
|
}
|
||||||
, Cmd.none
|
, Cmd.none
|
||||||
, Nothing
|
, Nothing
|
||||||
)
|
)
|
||||||
@ -77,7 +81,18 @@ update referrer flags msg model =
|
|||||||
let
|
let
|
||||||
empty =
|
empty =
|
||||||
Api.Model.AuthResult.empty
|
Api.Model.AuthResult.empty
|
||||||
|
|
||||||
|
session =
|
||||||
|
Maybe.withDefault "" loginData.session
|
||||||
in
|
in
|
||||||
|
-- A value of 2 indicates that TOTP is required
|
||||||
|
if loginData.openid == 2 then
|
||||||
|
( { model | formState = FormInitial, authStep = StepOtp session, password = "" }
|
||||||
|
, Cmd.none
|
||||||
|
, Nothing
|
||||||
|
)
|
||||||
|
|
||||||
|
else
|
||||||
( { model | password = "", formState = HttpError err }
|
( { model | password = "", formState = HttpError err }
|
||||||
, Ports.removeAccount ()
|
, Ports.removeAccount ()
|
||||||
, Just empty
|
, Just empty
|
||||||
|
@ -7,8 +7,10 @@
|
|||||||
|
|
||||||
module Page.Login.View2 exposing (viewContent, viewSidebar)
|
module Page.Login.View2 exposing (viewContent, viewSidebar)
|
||||||
|
|
||||||
|
import Api
|
||||||
import Api.Model.AuthResult exposing (AuthResult)
|
import Api.Model.AuthResult exposing (AuthResult)
|
||||||
import Api.Model.VersionInfo exposing (VersionInfo)
|
import Api.Model.VersionInfo exposing (VersionInfo)
|
||||||
|
import Comp.Basic as B
|
||||||
import Data.Flags exposing (Flags)
|
import Data.Flags exposing (Flags)
|
||||||
import Data.UiSettings exposing (UiSettings)
|
import Data.UiSettings exposing (UiSettings)
|
||||||
import Html exposing (..)
|
import Html exposing (..)
|
||||||
@ -53,6 +55,7 @@ viewContent texts flags versionInfo _ model =
|
|||||||
|
|
||||||
StepLogin ->
|
StepLogin ->
|
||||||
loginForm texts flags model
|
loginForm texts flags model
|
||||||
|
, openIdLinks texts flags
|
||||||
]
|
]
|
||||||
, a
|
, a
|
||||||
[ class "inline-flex items-center mt-4 text-xs opacity-50 hover:opacity-90"
|
[ class "inline-flex items-center mt-4 text-xs opacity-50 hover:opacity-90"
|
||||||
@ -72,11 +75,40 @@ viewContent texts flags versionInfo _ model =
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
otpForm : Texts -> Flags -> Model -> AuthResult -> Html Msg
|
openIdLinks : Texts -> Flags -> Html Msg
|
||||||
otpForm texts flags model acc =
|
openIdLinks texts flags =
|
||||||
|
let
|
||||||
|
renderLink prov =
|
||||||
|
a
|
||||||
|
[ href (Api.openIdAuthLink flags prov.provider)
|
||||||
|
, class S.link
|
||||||
|
]
|
||||||
|
[ i [ class "fab fa-openid mr-1" ] []
|
||||||
|
, text prov.name
|
||||||
|
]
|
||||||
|
in
|
||||||
|
case flags.config.openIdAuth of
|
||||||
|
[] ->
|
||||||
|
span [ class "hidden" ] []
|
||||||
|
|
||||||
|
provs ->
|
||||||
|
div [ class "mt-3" ]
|
||||||
|
[ B.horizontalDivider
|
||||||
|
{ label = texts.or
|
||||||
|
, topCss = "w-2/3 mb-4 hidden md:inline-flex w-full"
|
||||||
|
, labelCss = "px-4 bg-gray-200 bg-opacity-50"
|
||||||
|
, lineColor = "bg-gray-300 dark:bg-bluegray-600"
|
||||||
|
}
|
||||||
|
, div [ class "flex flex-row space-x-4 items-center justify-center" ]
|
||||||
|
(List.map renderLink provs)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
otpForm : Texts -> Flags -> Model -> String -> Html Msg
|
||||||
|
otpForm texts flags model token =
|
||||||
Html.form
|
Html.form
|
||||||
[ action "#"
|
[ action "#"
|
||||||
, onSubmit (AuthOtp acc)
|
, onSubmit (AuthOtp token)
|
||||||
, autocomplete False
|
, autocomplete False
|
||||||
]
|
]
|
||||||
[ div [ class "flex flex-col mt-6" ]
|
[ div [ class "flex flex-col mt-6" ]
|
||||||
|
@ -97,7 +97,7 @@ update flags msg model =
|
|||||||
|
|
||||||
cmd =
|
cmd =
|
||||||
if r.success then
|
if r.success then
|
||||||
Page.goto (LoginPage Nothing)
|
Page.goto (LoginPage Page.emptyLoginData)
|
||||||
|
|
||||||
else
|
else
|
||||||
Cmd.none
|
Cmd.none
|
||||||
|
@ -232,7 +232,7 @@ viewContent texts flags _ model =
|
|||||||
[ text texts.alreadySignedUp
|
[ text texts.alreadySignedUp
|
||||||
]
|
]
|
||||||
, a
|
, a
|
||||||
[ Page.href (LoginPage Nothing)
|
[ Page.href (LoginPage Page.emptyLoginData)
|
||||||
, class ("ml-2" ++ S.link)
|
, class ("ml-2" ++ S.link)
|
||||||
]
|
]
|
||||||
[ i [ class "fa fa-user-plus mr-1" ] []
|
[ i [ class "fa fa-user-plus mr-1" ] []
|
||||||
|
@ -48,6 +48,19 @@ in
|
|||||||
header-value = "test123";
|
header-value = "test123";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
openid = [
|
||||||
|
{ enabled = true;
|
||||||
|
display = "Local";
|
||||||
|
provider = {
|
||||||
|
provider-id = "local";
|
||||||
|
client-id = "cid1";
|
||||||
|
client-secret = "csecret-1";
|
||||||
|
authorize-url = "http:auth";
|
||||||
|
token-url = "http:token";
|
||||||
|
sign-key = "b64:uiaeuae";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
];
|
||||||
inherit full-text-search;
|
inherit full-text-search;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -61,6 +61,23 @@ let
|
|||||||
valid = "30 days";
|
valid = "30 days";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
openid = {
|
||||||
|
enabled = false;
|
||||||
|
display = "";
|
||||||
|
provider = {
|
||||||
|
provider-id = null;
|
||||||
|
client-id = null;
|
||||||
|
client-secret = null;
|
||||||
|
scope = "profile";
|
||||||
|
authorize-url = null;
|
||||||
|
token-url = null;
|
||||||
|
user-url = "";
|
||||||
|
sign-key = "";
|
||||||
|
sig-algo = "RS256";
|
||||||
|
};
|
||||||
|
user-key = "preferred_username";
|
||||||
|
collective-key = "lookup:preferred_username";
|
||||||
|
};
|
||||||
backend = {
|
backend = {
|
||||||
mail-debug = false;
|
mail-debug = false;
|
||||||
jdbc = {
|
jdbc = {
|
||||||
@ -226,6 +243,90 @@ in {
|
|||||||
description = "Authentication";
|
description = "Authentication";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
openid = mkOption {
|
||||||
|
type = types.listOf (types.submodule {
|
||||||
|
options = {
|
||||||
|
enabled = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = defaults.openid.enabled;
|
||||||
|
description = "Whether to use these settings.";
|
||||||
|
};
|
||||||
|
display = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = defaults.openid.display;
|
||||||
|
example = "via Keycloak";
|
||||||
|
description = "The name for the button on the login page.";
|
||||||
|
};
|
||||||
|
user-key = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = defaults.openid.user-key;
|
||||||
|
description = "The key to retrieve the username";
|
||||||
|
};
|
||||||
|
collective-key = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = defaults.openid.collective-key;
|
||||||
|
description = "How to retrieve the collective name.";
|
||||||
|
};
|
||||||
|
provider = mkOption {
|
||||||
|
type = (types.submodule {
|
||||||
|
options = {
|
||||||
|
provider-id = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = defaults.openid.provider.provider-id;
|
||||||
|
example = "keycloak";
|
||||||
|
description = "The id of the provider, used in the URL and to distinguish other providers.";
|
||||||
|
};
|
||||||
|
client-id = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = defaults.openid.provider.client-id;
|
||||||
|
description = "The client-id as registered at the OP.";
|
||||||
|
};
|
||||||
|
client-secret = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = defaults.openid.provider.client-secret;
|
||||||
|
description = "The client-secret as registered at the OP.";
|
||||||
|
};
|
||||||
|
scope = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = defaults.openid.provider.scope;
|
||||||
|
description = "A scope to define what data to return from OP";
|
||||||
|
};
|
||||||
|
authorize-url = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = defaults.openid.provider.authorize-url;
|
||||||
|
description = "The URL used to authenticate the user";
|
||||||
|
};
|
||||||
|
token-url = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = defaults.openid.provider.token-url;
|
||||||
|
description = "The URL used to retrieve the token.";
|
||||||
|
};
|
||||||
|
user-url = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = defaults.openid.provider.user-url;
|
||||||
|
description = "The URL to the user-info endpoint.";
|
||||||
|
};
|
||||||
|
sign-key = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = defaults.openid.provider.sign-key;
|
||||||
|
description = "The key for verifying the jwt signature.";
|
||||||
|
};
|
||||||
|
sig-algo = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = defaults.openid.provider.sig-algo;
|
||||||
|
description = "The expected algorithm used to sign the token.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
default = defaults.openid.provider;
|
||||||
|
description = "The config for an OpenID Connect provider.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
default = [];
|
||||||
|
description = "A list of OIDC provider configurations.";
|
||||||
|
};
|
||||||
|
|
||||||
integration-endpoint = mkOption {
|
integration-endpoint = mkOption {
|
||||||
type = types.submodule({
|
type = types.submodule({
|
||||||
options = {
|
options = {
|
||||||
|
@ -23,6 +23,7 @@ object Dependencies {
|
|||||||
val Icu4jVersion = "69.1"
|
val Icu4jVersion = "69.1"
|
||||||
val javaOtpVersion = "0.3.0"
|
val javaOtpVersion = "0.3.0"
|
||||||
val JsoupVersion = "1.14.2"
|
val JsoupVersion = "1.14.2"
|
||||||
|
val JwtScalaVersion = "9.0.1"
|
||||||
val KindProjectorVersion = "0.10.3"
|
val KindProjectorVersion = "0.10.3"
|
||||||
val KittensVersion = "2.3.2"
|
val KittensVersion = "2.3.2"
|
||||||
val LevigoJbig2Version = "2.0"
|
val LevigoJbig2Version = "2.0"
|
||||||
@ -48,6 +49,10 @@ object Dependencies {
|
|||||||
val JQueryVersion = "3.5.1"
|
val JQueryVersion = "3.5.1"
|
||||||
val ViewerJSVersion = "0.5.9"
|
val ViewerJSVersion = "0.5.9"
|
||||||
|
|
||||||
|
val jwtScala = Seq(
|
||||||
|
"com.github.jwt-scala" %% "jwt-circe" % JwtScalaVersion
|
||||||
|
)
|
||||||
|
|
||||||
val scodecBits = Seq(
|
val scodecBits = Seq(
|
||||||
"org.scodec" %% "scodec-bits" % ScodecBitsVersion
|
"org.scodec" %% "scodec-bits" % ScodecBitsVersion
|
||||||
)
|
)
|
||||||
|
@ -44,6 +44,14 @@ must be `docspell_auth` and a custom header must be named
|
|||||||
The admin route (see below) `/admin/user/resetPassword` can be used to
|
The admin route (see below) `/admin/user/resetPassword` can be used to
|
||||||
reset a password of a user.
|
reset a password of a user.
|
||||||
|
|
||||||
|
### OpenID Connect
|
||||||
|
|
||||||
|
Docspell can be configured to be a relying party for OpenID Connect.
|
||||||
|
Please see [the config
|
||||||
|
section](@/docs/configure/_index.md#openid-connect-oauth2) for
|
||||||
|
details.
|
||||||
|
|
||||||
|
|
||||||
## Admin
|
## Admin
|
||||||
|
|
||||||
There are some endpoints available for adminstration tasks, for
|
There are some endpoints available for adminstration tasks, for
|
||||||
|
@ -342,6 +342,91 @@ The `session-valid` determines how long a token is valid. This can be
|
|||||||
just some minutes, the web application obtains new ones
|
just some minutes, the web application obtains new ones
|
||||||
periodically. So a rather short time is recommended.
|
periodically. So a rather short time is recommended.
|
||||||
|
|
||||||
|
### OpenID Connect / OAuth2
|
||||||
|
|
||||||
|
You can integrate Docspell into your SSO solution via [OpenID
|
||||||
|
Connect](https://openid.net/connect/) (OIDC). This requires to set up
|
||||||
|
an OpenID Provider (OP) somewhere and to configure Docspell
|
||||||
|
accordingly to act as the relying party.
|
||||||
|
|
||||||
|
You can define multiple OPs to use. For some examples, please see the
|
||||||
|
default configuration file [below](#rest-server).
|
||||||
|
|
||||||
|
The configuration of a provider highly depends on how it is setup.
|
||||||
|
Here is an example for a setup using
|
||||||
|
[keycloak](https://www.keycloak.org):
|
||||||
|
|
||||||
|
``` conf
|
||||||
|
provider = {
|
||||||
|
provider-id = "keycloak",
|
||||||
|
client-id = "docspell",
|
||||||
|
client-secret = "example-secret-439e-bf06-911e4cdd56a6",
|
||||||
|
scope = "profile", # scope is required for OIDC
|
||||||
|
authorize-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/auth",
|
||||||
|
token-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/token",
|
||||||
|
#User URL is not used when signature key is set.
|
||||||
|
#user-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/userinfo",
|
||||||
|
sign-key = "b64:MII…ZYL09vAwLn8EAcSkCAwEAAQ==",
|
||||||
|
sig-algo = "RS512"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `provider-id` is some identifier that is used in the URL to
|
||||||
|
distinguish between possibly multiple providers. The `client-id` and
|
||||||
|
`client-secret` define the two parameters required for a "confidential
|
||||||
|
client". The different URLs are best explained at the [keycloak
|
||||||
|
docs](https://www.keycloak.org/docs/latest/server_admin/#_oidc-endpoints).
|
||||||
|
They are available for all OPs in some way. The `user-url` is not
|
||||||
|
required, if the access token is already containing the necessary
|
||||||
|
data. If not, then docspell performs another request to the
|
||||||
|
`user-url`, which must be the user-info endpoint, to obtain the
|
||||||
|
required user data.
|
||||||
|
|
||||||
|
If the data is taken from the token directly and not via a request to
|
||||||
|
the user-info endpoint, then the token must be validated using the
|
||||||
|
given `sign-key` and `sig-algo`. These two values are then required to
|
||||||
|
specify! However, if the user-info endpoint should be used, then leave
|
||||||
|
the `sign-key` empty and specify the correct url in `user-url`. When
|
||||||
|
specifying the `sign-key` use a prefix of `b64:` if it is Base64
|
||||||
|
encoded or `hex:` if it is hex encoded. Otherwise the unicode bytes
|
||||||
|
are used, which is most probably not wanted for this setting.
|
||||||
|
|
||||||
|
Once the user is authenticated, docspell tries to setup an account and
|
||||||
|
does some checks. For this it must get to the username and collective
|
||||||
|
name somehow. How it does this, can be specified by the `user-key` and
|
||||||
|
`collective-key` settings:
|
||||||
|
|
||||||
|
``` conf
|
||||||
|
# The collective of the user is given in the access token as
|
||||||
|
# property `docspell_collective`.
|
||||||
|
collective-key = "lookup:docspell_collective",
|
||||||
|
# The username to use for the docspell account
|
||||||
|
user-key = "preferred_username"
|
||||||
|
```
|
||||||
|
|
||||||
|
The `user-key` is some string that is used to search the JSON response
|
||||||
|
from the OP for an object with that key. The search happens
|
||||||
|
recursively, so the field can be in a nested object. The found value
|
||||||
|
is used as the user name. Keycloak transmits the `preferred_username`
|
||||||
|
when asking for the `profile` scope. This can be used as the user
|
||||||
|
name.
|
||||||
|
|
||||||
|
The collective name can be obtained by different ways. For example,
|
||||||
|
you can instruct your OP (like keycloak) to provide a collective name
|
||||||
|
in the token and/or user-info responses. If you do this, then use the
|
||||||
|
`lookup:` prefix as in the example above. This instructs docspell to
|
||||||
|
search for a value the same way as the `user-key`. You can also set a
|
||||||
|
fixed collective, using `fixed:` prefix; in this case all users are in
|
||||||
|
the same collective! A third option is to prefix it with `account:` -
|
||||||
|
then the value that is looked up is interpreted as the full account
|
||||||
|
name, like `collective/user` and the `user-key` setting is ignored. If
|
||||||
|
you want to put each user in its own collective, you can just use the
|
||||||
|
same value as in `user-key`, only prefixed with `lookup:`. In the
|
||||||
|
example it would be `lookup:preferred_username`.
|
||||||
|
|
||||||
|
If you find that these methods do not suffice for your case, please
|
||||||
|
open an issue.
|
||||||
|
|
||||||
|
|
||||||
## File Processing
|
## File Processing
|
||||||
|
|
||||||
|
@ -31,8 +31,11 @@ description = "A list of features and limitations."
|
|||||||
jobs, set priorities
|
jobs, set priorities
|
||||||
- Everything available via a [documented](https://www.openapis.org/)
|
- Everything available via a [documented](https://www.openapis.org/)
|
||||||
[REST Api](@/docs/api/_index.md); allows to [generate
|
[REST Api](@/docs/api/_index.md); allows to [generate
|
||||||
clients](https://openapi-generator.tech/docs/generators) for
|
clients](https://openapi-generator.tech/docs/generators) for many
|
||||||
(almost) any language
|
languages
|
||||||
|
- [OpenID Connect](@/docs/configure/_index.md#openid-connect-oauth2)
|
||||||
|
support allows Docspell to integrate into your SSO setup, for
|
||||||
|
example with keycloak.
|
||||||
- mobile-friendly Web-UI with dark and light theme
|
- mobile-friendly Web-UI with dark and light theme
|
||||||
- [Create anonymous
|
- [Create anonymous
|
||||||
“upload-urls”](@/docs/webapp/uploading.md#anonymous-upload) to
|
“upload-urls”](@/docs/webapp/uploading.md#anonymous-upload) to
|
||||||
|
Loading…
x
Reference in New Issue
Block a user