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
|
||||
.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)
|
||||
|
||||
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
|
||||
.in(file("modules/webapp"))
|
||||
.disablePlugins(RevolverPlugin)
|
||||
@ -615,7 +639,7 @@ val restserver = project
|
||||
}
|
||||
}
|
||||
)
|
||||
.dependsOn(restapi, joexapi, backend, webapp, ftssolr)
|
||||
.dependsOn(restapi, joexapi, backend, webapp, ftssolr, oidc)
|
||||
|
||||
// --- Website Documentation
|
||||
|
||||
@ -695,7 +719,8 @@ val root = project
|
||||
restserver,
|
||||
query.jvm,
|
||||
query.js,
|
||||
totp
|
||||
totp,
|
||||
oidc
|
||||
)
|
||||
|
||||
// --- Helpers
|
||||
|
@ -23,6 +23,8 @@ import scodec.bits.ByteVector
|
||||
|
||||
trait Login[F[_]] {
|
||||
|
||||
def loginExternal(config: Config)(accountId: AccountId): F[Result]
|
||||
|
||||
def loginSession(config: Config)(sessionKey: String): F[Result]
|
||||
|
||||
def loginUserPass(config: Config)(up: UserPass): F[Result]
|
||||
@ -93,6 +95,16 @@ object Login {
|
||||
|
||||
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] =
|
||||
AuthToken.fromString(sessionKey) match {
|
||||
case Right(at) =>
|
||||
@ -110,24 +122,11 @@ object Login {
|
||||
def loginUserPass(config: Config)(up: UserPass): F[Result] =
|
||||
AccountId.parse(up.user) match {
|
||||
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 {
|
||||
data <- store.transact(QLogin.findUser(acc))
|
||||
_ <- Sync[F].delay(logger.trace(s"Account lookup: $data"))
|
||||
res <-
|
||||
if (data.exists(check(up.pass))) okResult
|
||||
if (data.exists(check(up.pass))) doLogin(config, acc, up.rememberMe)
|
||||
else Result.invalidAuth.pure[F]
|
||||
} yield res
|
||||
case Left(_) =>
|
||||
@ -194,7 +193,7 @@ object Login {
|
||||
logF.info(s"Account lookup via remember me: $data")
|
||||
)
|
||||
res <- OptionT.liftF(
|
||||
if (checkNoPassword(data))
|
||||
if (checkNoPassword(data, AccountSource.all.toList.toSet))
|
||||
logF.info("RememberMe auth successful") *> okResult(data.account)
|
||||
else
|
||||
logF.warn("RememberMe auth not successful") *> Result.invalidAuth.pure[F]
|
||||
@ -247,6 +246,24 @@ object Login {
|
||||
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(
|
||||
store: Store[F],
|
||||
acc: AccountId,
|
||||
@ -260,13 +277,17 @@ object Login {
|
||||
|
||||
private def check(given: String)(data: QLogin.Data): Boolean = {
|
||||
val passOk = BCrypt.checkpw(given, data.password.pass)
|
||||
checkNoPassword(data) && passOk
|
||||
checkNoPassword(data, Set(AccountSource.Local)) && passOk
|
||||
}
|
||||
|
||||
private def checkNoPassword(data: QLogin.Data): Boolean = {
|
||||
def checkNoPassword(
|
||||
data: QLogin.Data,
|
||||
expectedSources: Set[AccountSource]
|
||||
): Boolean = {
|
||||
val collOk = data.collectiveState == CollectiveState.Active ||
|
||||
data.collectiveState == CollectiveState.ReadOnly
|
||||
val userOk = data.userState == UserState.Active
|
||||
val userOk =
|
||||
data.userState == UserState.Active && expectedSources.contains(data.source)
|
||||
collOk && userOk
|
||||
}
|
||||
})
|
||||
|
@ -95,9 +95,11 @@ object OCollective {
|
||||
object PassResetResult {
|
||||
case class Success(newPw: Password) extends PassResetResult
|
||||
case object NotFound extends PassResetResult
|
||||
case object UserNotLocal extends PassResetResult
|
||||
|
||||
def success(np: Password): PassResetResult = Success(np)
|
||||
def notFound: PassResetResult = NotFound
|
||||
def userNotLocal: PassResetResult = UserNotLocal
|
||||
}
|
||||
|
||||
sealed trait PassChangeResult
|
||||
@ -105,34 +107,14 @@ object OCollective {
|
||||
case object UserNotFound extends PassChangeResult
|
||||
case object PasswordMismatch extends PassChangeResult
|
||||
case object UpdateFailed extends PassChangeResult
|
||||
case object UserNotLocal extends PassChangeResult
|
||||
case object Success extends PassChangeResult
|
||||
|
||||
def userNotFound: PassChangeResult = UserNotFound
|
||||
def passwordMismatch: PassChangeResult = PasswordMismatch
|
||||
def success: PassChangeResult = Success
|
||||
def updateFailed: PassChangeResult = UpdateFailed
|
||||
}
|
||||
|
||||
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 userNotLocal: PassChangeResult = UserNotLocal
|
||||
}
|
||||
|
||||
def apply[F[_]: Async](
|
||||
@ -245,11 +227,14 @@ object OCollective {
|
||||
def resetPassword(accountId: AccountId): F[PassResetResult] =
|
||||
for {
|
||||
newPass <- Password.generate[F]
|
||||
optUser <- store.transact(RUser.findByAccount(accountId))
|
||||
n <- store.transact(
|
||||
RUser.updatePassword(accountId, PasswordCrypt.crypt(newPass))
|
||||
)
|
||||
res =
|
||||
if (n <= 0) PassResetResult.notFound
|
||||
if (optUser.exists(_.source != AccountSource.Local))
|
||||
PassResetResult.userNotLocal
|
||||
else if (n <= 0) PassResetResult.notFound
|
||||
else PassResetResult.success(newPass)
|
||||
} yield res
|
||||
|
||||
@ -270,6 +255,8 @@ object OCollective {
|
||||
res = check match {
|
||||
case Some(true) =>
|
||||
if (n.getOrElse(0) > 0) PassChangeResult.success
|
||||
else if (optUser.exists(_.source != AccountSource.Local))
|
||||
PassChangeResult.userNotLocal
|
||||
else PassChangeResult.updateFailed
|
||||
case Some(false) =>
|
||||
PassChangeResult.passwordMismatch
|
||||
|
@ -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 docspell.backend.PasswordCrypt
|
||||
import docspell.backend.ops.OCollective.RegisterData
|
||||
import docspell.common._
|
||||
import docspell.common.syntax.all._
|
||||
import docspell.store.records.{RCollective, RInvitation, RUser}
|
||||
@ -23,6 +22,9 @@ trait OSignup[F[_]] {
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
@ -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 =
|
||||
res match {
|
||||
case SignupResult.CollectiveExists =>
|
||||
@ -92,30 +125,6 @@ object OSignup {
|
||||
}
|
||||
|
||||
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] =
|
||||
for {
|
||||
n1 <- RCollective.insert(coll)
|
||||
@ -127,9 +136,29 @@ object OSignup {
|
||||
|
||||
val msg = s"The collective '${data.collName}' already exists."
|
||||
for {
|
||||
cu <- toRecords
|
||||
cu <- makeRecords(data.collName, data.login, data.password, AccountSource.Local)
|
||||
save <- store.add(insert(cu._1, cu._2), collectiveExists)
|
||||
} 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:
|
||||
schema:
|
||||
$ref: "#/components/schemas/VersionInfo"
|
||||
|
||||
/open/auth/login:
|
||||
post:
|
||||
operationId: "open-auth-login"
|
||||
@ -93,6 +94,51 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$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}:
|
||||
get:
|
||||
@ -5405,6 +5451,7 @@ components:
|
||||
- id
|
||||
- login
|
||||
- state
|
||||
- source
|
||||
- loginCount
|
||||
- created
|
||||
properties:
|
||||
@ -5420,6 +5467,12 @@ components:
|
||||
enum:
|
||||
- active
|
||||
- disabled
|
||||
source:
|
||||
type: string
|
||||
format: accountsource
|
||||
enum:
|
||||
- local
|
||||
- openid
|
||||
password:
|
||||
type: string
|
||||
format: password
|
||||
@ -6262,3 +6315,10 @@ components:
|
||||
some identifier for a client application
|
||||
schema:
|
||||
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
|
||||
# intention is that local software integrates with docspell more
|
||||
# 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.common._
|
||||
import docspell.ftssolr.SolrConfig
|
||||
import docspell.oidc.ProviderConfig
|
||||
import docspell.restserver.Config.OpenIdConfig
|
||||
import docspell.restserver.auth.OpenId
|
||||
|
||||
import com.comcast.ip4s.IpAddress
|
||||
|
||||
@ -25,8 +28,12 @@ case class Config(
|
||||
maxItemPageSize: Int,
|
||||
maxNoteLength: Int,
|
||||
fullTextSearch: Config.FullTextSearch,
|
||||
adminEndpoint: Config.AdminEndpoint
|
||||
)
|
||||
adminEndpoint: Config.AdminEndpoint,
|
||||
openid: List[OpenIdConfig]
|
||||
) {
|
||||
def openIdEnabled: Boolean =
|
||||
openid.exists(_.enabled)
|
||||
}
|
||||
|
||||
object Config {
|
||||
|
||||
@ -70,4 +77,12 @@ object Config {
|
||||
|
||||
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
|
||||
|
||||
import cats.Semigroup
|
||||
import cats.data.{Validated, ValidatedNec}
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.signup.{Config => SignupConfig}
|
||||
import docspell.common.config.Implicits._
|
||||
import docspell.oidc.{ProviderConfig, SignatureAlgo}
|
||||
import docspell.restserver.auth.OpenId
|
||||
|
||||
import pureconfig._
|
||||
import pureconfig.generic.auto._
|
||||
@ -16,10 +22,71 @@ object ConfigFile {
|
||||
import Implicits._
|
||||
|
||||
def loadConfig: Config =
|
||||
ConfigSource.default.at("docspell.server").loadOrThrow[Config]
|
||||
Validate(ConfigSource.default.at("docspell.server").loadOrThrow[Config])
|
||||
|
||||
object Implicits {
|
||||
implicit val signupModeReader: ConfigReader[SignupConfig.Mode] =
|
||||
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.common._
|
||||
import docspell.oidc.CodeFlowRoutes
|
||||
import docspell.restserver.auth.OpenId
|
||||
import docspell.restserver.http4s.EnvMiddleware
|
||||
import docspell.restserver.routes._
|
||||
import docspell.restserver.webapp._
|
||||
|
||||
import org.http4s._
|
||||
import org.http4s.blaze.client.BlazeClientBuilder
|
||||
import org.http4s.blaze.server.BlazeServerBuilder
|
||||
import org.http4s.client.Client
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.http4s.headers.Location
|
||||
import org.http4s.implicits._
|
||||
@ -33,9 +37,10 @@ object RestServer {
|
||||
restApp <-
|
||||
RestAppImpl
|
||||
.create[F](cfg, pools.connectEC, pools.httpClientEC)
|
||||
httpClient <- BlazeClientBuilder[F](pools.httpClientEC).resource
|
||||
httpApp = Router(
|
||||
"/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 =>
|
||||
securedRoutes(cfg, restApp, token)
|
||||
},
|
||||
@ -98,8 +103,18 @@ object RestServer {
|
||||
"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(
|
||||
"auth/openid" -> CodeFlowRoutes(
|
||||
cfg.openIdEnabled,
|
||||
OpenId.handle[F](restApp.backend, cfg),
|
||||
OpenId.codeFlowConfig(cfg),
|
||||
client
|
||||
),
|
||||
"auth" -> LoginRoutes.login(restApp.backend.login, cfg),
|
||||
"signup" -> RegisterRoutes(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.login,
|
||||
ru.state,
|
||||
ru.source,
|
||||
None,
|
||||
ru.email,
|
||||
ru.lastLogin,
|
||||
@ -537,6 +538,7 @@ trait Conversions {
|
||||
cid,
|
||||
u.password.getOrElse(Password.empty),
|
||||
u.state,
|
||||
u.source,
|
||||
u.email,
|
||||
0,
|
||||
None,
|
||||
@ -551,6 +553,7 @@ trait Conversions {
|
||||
cid,
|
||||
u.password.getOrElse(Password.empty),
|
||||
u.state,
|
||||
u.source,
|
||||
u.email,
|
||||
u.loginCount,
|
||||
u.lastLogin,
|
||||
@ -706,6 +709,8 @@ trait Conversions {
|
||||
case PassChangeResult.PasswordMismatch =>
|
||||
BasicResult(false, "The current password is incorrect.")
|
||||
case PassChangeResult.UserNotFound => BasicResult(false, "User not found.")
|
||||
case PassChangeResult.UserNotLocal =>
|
||||
BasicResult(false, "User is not local, passwords are managed externally.")
|
||||
}
|
||||
|
||||
def basicResult(e: Either[Throwable, _], successMsg: String): BasicResult =
|
||||
|
@ -10,8 +10,7 @@ import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.BackendApp
|
||||
import docspell.backend.ops.OCollective.RegisterData
|
||||
import docspell.backend.signup.{NewInviteResult, SignupResult}
|
||||
import docspell.backend.signup.{NewInviteResult, RegisterData, SignupResult}
|
||||
import docspell.restapi.model._
|
||||
import docspell.restserver.Config
|
||||
import docspell.restserver.http4s.ResponseGenerator
|
||||
|
@ -86,6 +86,12 @@ object UserRoutes {
|
||||
Password(""),
|
||||
"Password update failed. User not found."
|
||||
)
|
||||
case OCollective.PassResetResult.UserNotLocal =>
|
||||
ResetPasswordResult(
|
||||
false,
|
||||
Password(""),
|
||||
"Password update failed. User is not local, passwords are managed externally."
|
||||
)
|
||||
})
|
||||
} yield resp
|
||||
}
|
||||
|
@ -7,7 +7,7 @@
|
||||
package docspell.restserver.webapp
|
||||
|
||||
import docspell.backend.signup.{Config => SignupConfig}
|
||||
import docspell.common.LenientUri
|
||||
import docspell.common.{Ident, LenientUri}
|
||||
import docspell.restserver.{BuildInfo, Config}
|
||||
|
||||
import io.circe._
|
||||
@ -25,7 +25,8 @@ case class Flags(
|
||||
maxPageSize: Int,
|
||||
maxNoteLength: Int,
|
||||
showClassificationSettings: Boolean,
|
||||
uiVersion: Int
|
||||
uiVersion: Int,
|
||||
openIdAuth: List[Flags.OpenIdAuth]
|
||||
)
|
||||
|
||||
object Flags {
|
||||
@ -40,9 +41,20 @@ object Flags {
|
||||
cfg.maxItemPageSize,
|
||||
cfg.maxNoteLength,
|
||||
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 =
|
||||
if (cfg.baseUrl.isLocal) cfg.baseUrl.rootPathToEmpty.path.asString
|
||||
else cfg.baseUrl.rootPathToEmpty.asString
|
||||
@ -50,6 +62,10 @@ object Flags {
|
||||
implicit val jsonEncoder: Encoder[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] =
|
||||
ValueConverter.of(m => Value.fromString(m.name))
|
||||
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
|
||||
)
|
||||
|
||||
implicit val metaAccountSource: Meta[AccountSource] =
|
||||
Meta[String].imap(AccountSource.unsafeFromString)(_.name)
|
||||
|
||||
implicit val metaDuration: Meta[Duration] =
|
||||
Meta[Long].imap(Duration.millis)(_.millis)
|
||||
|
||||
|
@ -24,7 +24,8 @@ object QLogin {
|
||||
account: AccountId,
|
||||
password: Password,
|
||||
collectiveState: CollectiveState,
|
||||
userState: UserState
|
||||
userState: UserState,
|
||||
source: AccountSource
|
||||
)
|
||||
|
||||
def findUser(acc: AccountId): ConnectionIO[Option[Data]] = {
|
||||
@ -32,7 +33,7 @@ object QLogin {
|
||||
val coll = RCollective.as("c")
|
||||
val sql =
|
||||
Select(
|
||||
select(user.cid, user.login, user.password, coll.state, user.state),
|
||||
select(user.cid, user.login, user.password, coll.state, user.state, user.source),
|
||||
from(user).innerJoin(coll, user.cid === coll.id),
|
||||
user.login === acc.user && user.cid === acc.collective
|
||||
).build
|
||||
|
@ -37,6 +37,9 @@ object RCollective {
|
||||
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)
|
||||
def as(alias: String): Table =
|
||||
Table(Some(alias))
|
||||
|
@ -21,6 +21,7 @@ case class RUser(
|
||||
cid: Ident,
|
||||
password: Password,
|
||||
state: UserState,
|
||||
source: AccountSource,
|
||||
email: Option[String],
|
||||
loginCount: Int,
|
||||
lastLogin: Option[Timestamp],
|
||||
@ -28,6 +29,28 @@ case class 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 {
|
||||
val tableName = "user_"
|
||||
|
||||
@ -36,6 +59,7 @@ object RUser {
|
||||
val cid = Column[Ident]("cid", this)
|
||||
val password = Column[Password]("password", this)
|
||||
val state = Column[UserState]("state", this)
|
||||
val source = Column[AccountSource]("account_source", this)
|
||||
val email = Column[String]("email", this)
|
||||
val loginCount = Column[Int]("logincount", this)
|
||||
val lastLogin = Column[Timestamp]("lastlogin", this)
|
||||
@ -48,6 +72,7 @@ object RUser {
|
||||
cid,
|
||||
password,
|
||||
state,
|
||||
source,
|
||||
email,
|
||||
loginCount,
|
||||
lastLogin,
|
||||
@ -65,7 +90,7 @@ object RUser {
|
||||
DML.insert(
|
||||
t,
|
||||
t.all,
|
||||
fr"${v.uid},${v.login},${v.cid},${v.password},${v.state},${v.email},${v.loginCount},${v.lastLogin},${v.created}"
|
||||
fr"${v.uid},${v.login},${v.cid},${v.password},${v.state},${v.source},${v.email},${v.loginCount},${v.lastLogin},${v.created}"
|
||||
)
|
||||
}
|
||||
|
||||
@ -134,7 +159,7 @@ object RUser {
|
||||
val t = Table(None)
|
||||
DML.update(
|
||||
t,
|
||||
t.cid === accountId.collective && t.login === accountId.user,
|
||||
t.cid === accountId.collective && t.login === accountId.user && t.source === AccountSource.Local,
|
||||
DML.set(t.password.setTo(hashedPass))
|
||||
)
|
||||
}
|
||||
|
@ -87,6 +87,7 @@ module Api exposing
|
||||
, mergeItems
|
||||
, moveAttachmentBefore
|
||||
, newInvite
|
||||
, openIdAuthLink
|
||||
, postCustomField
|
||||
, postEquipment
|
||||
, postNewUser
|
||||
@ -935,6 +936,11 @@ newInvite flags req receive =
|
||||
--- 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 up receive =
|
||||
Http.post
|
||||
|
@ -82,6 +82,9 @@ init key url flags_ settings =
|
||||
( csm, csc ) =
|
||||
Page.CollectiveSettings.Data.init flags
|
||||
|
||||
( loginm, loginc ) =
|
||||
Page.Login.Data.init flags (Page.loginPageReferrer page)
|
||||
|
||||
homeViewMode =
|
||||
if settings.searchMenuVisible then
|
||||
Page.Home.Data.SearchView
|
||||
@ -94,7 +97,7 @@ init key url flags_ settings =
|
||||
, page = page
|
||||
, version = Api.Model.VersionInfo.empty
|
||||
, homeModel = Page.Home.Data.init flags homeViewMode
|
||||
, loginModel = Page.Login.Data.emptyModel
|
||||
, loginModel = loginm
|
||||
, manageDataModel = mdm
|
||||
, collSettingsModel = csm
|
||||
, userSettingsModel = um
|
||||
@ -116,6 +119,7 @@ init key url flags_ settings =
|
||||
[ Cmd.map UserSettingsMsg uc
|
||||
, Cmd.map ManageDataMsg mdc
|
||||
, Cmd.map CollSettingsMsg csc
|
||||
, Cmd.map LoginMsg loginc
|
||||
]
|
||||
)
|
||||
|
||||
|
@ -158,7 +158,7 @@ updateWithSub msg model =
|
||||
|
||||
LogoutResp _ ->
|
||||
( { model | loginModel = Page.Login.Data.emptyModel }
|
||||
, Page.goto (LoginPage Nothing)
|
||||
, Page.goto (LoginPage Page.emptyLoginData)
|
||||
, Sub.none
|
||||
)
|
||||
|
||||
@ -216,20 +216,24 @@ updateWithSub msg model =
|
||||
NavRequest req ->
|
||||
case req of
|
||||
Internal url ->
|
||||
let
|
||||
isCurrent =
|
||||
Page.fromUrl url
|
||||
|> Maybe.map (\p -> p == model.page)
|
||||
|> Maybe.withDefault True
|
||||
in
|
||||
( model
|
||||
, if isCurrent then
|
||||
Cmd.none
|
||||
if String.startsWith "/app" url.path then
|
||||
let
|
||||
isCurrent =
|
||||
Page.fromUrl url
|
||||
|> Maybe.map (\p -> p == model.page)
|
||||
|> Maybe.withDefault True
|
||||
in
|
||||
( model
|
||||
, if isCurrent then
|
||||
Cmd.none
|
||||
|
||||
else
|
||||
Nav.pushUrl model.key (Url.toString url)
|
||||
, Sub.none
|
||||
)
|
||||
else
|
||||
Nav.pushUrl model.key (Url.toString url)
|
||||
, Sub.none
|
||||
)
|
||||
|
||||
else
|
||||
( model, Nav.load <| Url.toString url, Sub.none )
|
||||
|
||||
External url ->
|
||||
( model
|
||||
|
@ -66,6 +66,7 @@ view2 texts model =
|
||||
[ th [ class "w-px whitespace-nowrap" ] []
|
||||
, th [ class "text-left" ] [ text texts.login ]
|
||||
, th [ class "text-center" ] [ text texts.state ]
|
||||
, th [ class "text-center" ] [ text texts.source ]
|
||||
, th [ class "hidden md:table-cell text-left" ] [ text texts.email ]
|
||||
, th [ class "hidden md:table-cell text-center" ] [ text texts.logins ]
|
||||
, th [ class "hidden sm:table-cell text-center" ] [ text texts.lastLogin ]
|
||||
@ -92,6 +93,9 @@ renderUserLine2 texts model user =
|
||||
, td [ class "text-center" ]
|
||||
[ text user.state
|
||||
]
|
||||
, td [ class "text-center" ]
|
||||
[ text user.source
|
||||
]
|
||||
, td [ class "hidden md:table-cell text-left" ]
|
||||
[ Maybe.withDefault "" user.email |> text
|
||||
]
|
||||
|
@ -17,6 +17,12 @@ module Data.Flags exposing
|
||||
import Api.Model.AuthResult exposing (AuthResult)
|
||||
|
||||
|
||||
type alias OpenIdAuth =
|
||||
{ provider : String
|
||||
, name : String
|
||||
}
|
||||
|
||||
|
||||
type alias Config =
|
||||
{ appName : String
|
||||
, baseUrl : String
|
||||
@ -27,6 +33,7 @@ type alias Config =
|
||||
, maxPageSize : Int
|
||||
, maxNoteLength : Int
|
||||
, showClassificationSettings : Bool
|
||||
, openIdAuth : List OpenIdAuth
|
||||
}
|
||||
|
||||
|
||||
|
@ -26,6 +26,7 @@ gb err =
|
||||
, invalidInput = "Invalid input when processing the request."
|
||||
, notFound = "The requested resource doesn't exist."
|
||||
, invalidBody = \str -> "There was an error decoding the response: " ++ str
|
||||
, accessDenied = "Access denied"
|
||||
}
|
||||
in
|
||||
errorToString texts err
|
||||
@ -44,6 +45,7 @@ de err =
|
||||
, invalidInput = "Die Daten im Request waren ungültig."
|
||||
, notFound = "Die angegebene Ressource wurde nicht gefunden."
|
||||
, invalidBody = \str -> "Es gab einen Fehler beim Dekodieren der Antwort: " ++ str
|
||||
, accessDenied = "Zugriff verweigert"
|
||||
}
|
||||
in
|
||||
errorToString texts err
|
||||
@ -61,6 +63,7 @@ type alias Texts =
|
||||
, invalidInput : String
|
||||
, notFound : String
|
||||
, invalidBody : String -> String
|
||||
, accessDenied : String
|
||||
}
|
||||
|
||||
|
||||
@ -90,6 +93,9 @@ errorToString texts error =
|
||||
if sc == 404 then
|
||||
texts.notFound
|
||||
|
||||
else if sc == 403 then
|
||||
texts.accessDenied
|
||||
|
||||
else if sc >= 400 && sc < 500 then
|
||||
texts.invalidInput
|
||||
|
||||
|
@ -20,6 +20,7 @@ type alias Texts =
|
||||
{ basics : Messages.Basics.Texts
|
||||
, login : String
|
||||
, state : String
|
||||
, source : String
|
||||
, email : String
|
||||
, logins : String
|
||||
, lastLogin : String
|
||||
@ -32,6 +33,7 @@ gb =
|
||||
{ basics = Messages.Basics.gb
|
||||
, login = "Login"
|
||||
, state = "State"
|
||||
, source = "Type"
|
||||
, email = "E-Mail"
|
||||
, logins = "Logins"
|
||||
, lastLogin = "Last Login"
|
||||
@ -44,6 +46,7 @@ de =
|
||||
{ basics = Messages.Basics.de
|
||||
, login = "Benutzername"
|
||||
, state = "Status"
|
||||
, source = "Typ"
|
||||
, email = "E-Mail"
|
||||
, logins = "Anmeldungen"
|
||||
, lastLogin = "Letzte Anmeldung"
|
||||
|
@ -29,6 +29,7 @@ type alias Texts =
|
||||
, noAccount : String
|
||||
, signupLink : String
|
||||
, otpCode : String
|
||||
, or : String
|
||||
}
|
||||
|
||||
|
||||
@ -47,6 +48,7 @@ gb =
|
||||
, noAccount = "No account?"
|
||||
, signupLink = "Sign up!"
|
||||
, otpCode = "Authentication code"
|
||||
, or = "Or"
|
||||
}
|
||||
|
||||
|
||||
@ -65,4 +67,5 @@ de =
|
||||
, noAccount = "Kein Konto?"
|
||||
, signupLink = "Hier registrieren!"
|
||||
, otpCode = "Authentifizierungscode"
|
||||
, or = "Oder"
|
||||
}
|
||||
|
@ -6,7 +6,9 @@
|
||||
|
||||
|
||||
module Page exposing
|
||||
( Page(..)
|
||||
( LoginData
|
||||
, Page(..)
|
||||
, emptyLoginData
|
||||
, fromUrl
|
||||
, goto
|
||||
, hasSidebar
|
||||
@ -31,9 +33,24 @@ import Url.Parser.Query as Query
|
||||
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
|
||||
= HomePage
|
||||
| LoginPage (Maybe Page)
|
||||
| LoginPage LoginData
|
||||
| ManageDataPage
|
||||
| CollectiveSettingPage
|
||||
| UserSettingPage
|
||||
@ -99,10 +116,10 @@ loginPage : Page -> Page
|
||||
loginPage p =
|
||||
case p of
|
||||
LoginPage _ ->
|
||||
LoginPage Nothing
|
||||
LoginPage emptyLoginData
|
||||
|
||||
_ ->
|
||||
LoginPage (Just p)
|
||||
LoginPage { emptyLoginData | referrer = Just p }
|
||||
|
||||
|
||||
pageName : Page -> String
|
||||
@ -144,14 +161,14 @@ pageName page =
|
||||
"Item"
|
||||
|
||||
|
||||
loginPageReferrer : Page -> Maybe Page
|
||||
loginPageReferrer : Page -> LoginData
|
||||
loginPageReferrer page =
|
||||
case page of
|
||||
LoginPage r ->
|
||||
r
|
||||
LoginPage data ->
|
||||
data
|
||||
|
||||
_ ->
|
||||
Nothing
|
||||
emptyLoginData
|
||||
|
||||
|
||||
uploadId : Page -> Maybe String
|
||||
@ -170,8 +187,8 @@ pageToString page =
|
||||
HomePage ->
|
||||
"/app/home"
|
||||
|
||||
LoginPage referer ->
|
||||
case referer of
|
||||
LoginPage data ->
|
||||
case data.referrer of
|
||||
Just (LoginPage _) ->
|
||||
"/app/login"
|
||||
|
||||
@ -253,7 +270,7 @@ parser =
|
||||
, 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 CollectiveSettingPage (s pathPrefix </> s "csettings")
|
||||
, Parser.map UserSettingPage (s pathPrefix </> s "usettings")
|
||||
@ -280,6 +297,21 @@ fromString str =
|
||||
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 =
|
||||
let
|
||||
|
@ -11,11 +11,14 @@ module Page.Login.Data exposing
|
||||
, Model
|
||||
, Msg(..)
|
||||
, emptyModel
|
||||
, init
|
||||
)
|
||||
|
||||
import Api
|
||||
import Api.Model.AuthResult exposing (AuthResult)
|
||||
import Data.Flags exposing (Flags)
|
||||
import Http
|
||||
import Page exposing (Page(..))
|
||||
import Page exposing (LoginData, Page(..))
|
||||
|
||||
|
||||
type alias Model =
|
||||
@ -37,7 +40,7 @@ type FormState
|
||||
|
||||
type AuthStep
|
||||
= StepLogin
|
||||
| StepOtp AuthResult
|
||||
| StepOtp String
|
||||
|
||||
|
||||
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
|
||||
= SetUsername String
|
||||
| SetPassword String
|
||||
@ -58,4 +74,4 @@ type Msg
|
||||
| Authenticate
|
||||
| AuthResp (Result Http.Error AuthResult)
|
||||
| SetOtp String
|
||||
| AuthOtp AuthResult
|
||||
| AuthOtp String
|
||||
|
@ -10,13 +10,13 @@ module Page.Login.Update exposing (update)
|
||||
import Api
|
||||
import Api.Model.AuthResult exposing (AuthResult)
|
||||
import Data.Flags exposing (Flags)
|
||||
import Page exposing (Page(..))
|
||||
import Page exposing (LoginData, Page(..))
|
||||
import Page.Login.Data exposing (..)
|
||||
import Ports
|
||||
|
||||
|
||||
update : Maybe Page -> Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe AuthResult )
|
||||
update referrer flags msg model =
|
||||
update : LoginData -> Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe AuthResult )
|
||||
update loginData flags msg model =
|
||||
case msg of
|
||||
SetUsername str ->
|
||||
( { model | username = str }, Cmd.none, Nothing )
|
||||
@ -40,11 +40,11 @@ update referrer flags msg model =
|
||||
in
|
||||
( model, Api.login flags userPass AuthResp, Nothing )
|
||||
|
||||
AuthOtp acc ->
|
||||
AuthOtp token ->
|
||||
let
|
||||
sf =
|
||||
{ rememberMe = model.rememberMe
|
||||
, token = Maybe.withDefault "" acc.token
|
||||
, token = token
|
||||
, otp = model.otp
|
||||
}
|
||||
in
|
||||
@ -53,7 +53,7 @@ update referrer flags msg model =
|
||||
AuthResp (Ok lr) ->
|
||||
let
|
||||
gotoRef =
|
||||
Maybe.withDefault HomePage referrer |> Page.goto
|
||||
Maybe.withDefault HomePage loginData.referrer |> Page.goto
|
||||
in
|
||||
if lr.success && not lr.requireSecondFactor then
|
||||
( { model | formState = AuthSuccess lr, password = "" }
|
||||
@ -62,7 +62,11 @@ update referrer flags msg model =
|
||||
)
|
||||
|
||||
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
|
||||
, Nothing
|
||||
)
|
||||
@ -77,11 +81,22 @@ update referrer flags msg model =
|
||||
let
|
||||
empty =
|
||||
Api.Model.AuthResult.empty
|
||||
|
||||
session =
|
||||
Maybe.withDefault "" loginData.session
|
||||
in
|
||||
( { model | password = "", formState = HttpError err }
|
||||
, Ports.removeAccount ()
|
||||
, Just empty
|
||||
)
|
||||
-- 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 }
|
||||
, Ports.removeAccount ()
|
||||
, Just empty
|
||||
)
|
||||
|
||||
|
||||
setAccount : AuthResult -> Cmd msg
|
||||
|
@ -7,8 +7,10 @@
|
||||
|
||||
module Page.Login.View2 exposing (viewContent, viewSidebar)
|
||||
|
||||
import Api
|
||||
import Api.Model.AuthResult exposing (AuthResult)
|
||||
import Api.Model.VersionInfo exposing (VersionInfo)
|
||||
import Comp.Basic as B
|
||||
import Data.Flags exposing (Flags)
|
||||
import Data.UiSettings exposing (UiSettings)
|
||||
import Html exposing (..)
|
||||
@ -53,6 +55,7 @@ viewContent texts flags versionInfo _ model =
|
||||
|
||||
StepLogin ->
|
||||
loginForm texts flags model
|
||||
, openIdLinks texts flags
|
||||
]
|
||||
, a
|
||||
[ 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
|
||||
otpForm texts flags model acc =
|
||||
openIdLinks : Texts -> Flags -> Html Msg
|
||||
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
|
||||
[ action "#"
|
||||
, onSubmit (AuthOtp acc)
|
||||
, onSubmit (AuthOtp token)
|
||||
, autocomplete False
|
||||
]
|
||||
[ div [ class "flex flex-col mt-6" ]
|
||||
|
@ -97,7 +97,7 @@ update flags msg model =
|
||||
|
||||
cmd =
|
||||
if r.success then
|
||||
Page.goto (LoginPage Nothing)
|
||||
Page.goto (LoginPage Page.emptyLoginData)
|
||||
|
||||
else
|
||||
Cmd.none
|
||||
|
@ -232,7 +232,7 @@ viewContent texts flags _ model =
|
||||
[ text texts.alreadySignedUp
|
||||
]
|
||||
, a
|
||||
[ Page.href (LoginPage Nothing)
|
||||
[ Page.href (LoginPage Page.emptyLoginData)
|
||||
, class ("ml-2" ++ S.link)
|
||||
]
|
||||
[ i [ class "fa fa-user-plus mr-1" ] []
|
||||
|
@ -48,6 +48,19 @@ in
|
||||
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;
|
||||
};
|
||||
|
||||
|
@ -61,6 +61,23 @@ let
|
||||
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 = {
|
||||
mail-debug = false;
|
||||
jdbc = {
|
||||
@ -226,6 +243,90 @@ in {
|
||||
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 {
|
||||
type = types.submodule({
|
||||
options = {
|
||||
|
@ -23,6 +23,7 @@ object Dependencies {
|
||||
val Icu4jVersion = "69.1"
|
||||
val javaOtpVersion = "0.3.0"
|
||||
val JsoupVersion = "1.14.2"
|
||||
val JwtScalaVersion = "9.0.1"
|
||||
val KindProjectorVersion = "0.10.3"
|
||||
val KittensVersion = "2.3.2"
|
||||
val LevigoJbig2Version = "2.0"
|
||||
@ -48,6 +49,10 @@ object Dependencies {
|
||||
val JQueryVersion = "3.5.1"
|
||||
val ViewerJSVersion = "0.5.9"
|
||||
|
||||
val jwtScala = Seq(
|
||||
"com.github.jwt-scala" %% "jwt-circe" % JwtScalaVersion
|
||||
)
|
||||
|
||||
val scodecBits = Seq(
|
||||
"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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
@ -31,8 +31,11 @@ description = "A list of features and limitations."
|
||||
jobs, set priorities
|
||||
- Everything available via a [documented](https://www.openapis.org/)
|
||||
[REST Api](@/docs/api/_index.md); allows to [generate
|
||||
clients](https://openapi-generator.tech/docs/generators) for
|
||||
(almost) any language
|
||||
clients](https://openapi-generator.tech/docs/generators) for many
|
||||
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
|
||||
- [Create anonymous
|
||||
“upload-urls”](@/docs/webapp/uploading.md#anonymous-upload) to
|
||||
|
Loading…
x
Reference in New Issue
Block a user