Merge pull request #1053 from eikek/feature/openid

Feature/openid
This commit is contained in:
mergify[bot] 2021-09-06 12:56:46 +00:00 committed by GitHub
commit e943b4c60d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1991 additions and 130 deletions

View File

@ -254,6 +254,12 @@ val openapiScalaSettings = Seq(
field => field =>
field field
.copy(typeDef = TypeDef("LenientUri", Imports("docspell.common.LenientUri"))) .copy(typeDef = TypeDef("LenientUri", Imports("docspell.common.LenientUri")))
case "accountsource" =>
field =>
field
.copy(typeDef =
TypeDef("AccountSource", Imports("docspell.common.AccountSource"))
)
}) })
) )
@ -502,6 +508,24 @@ val backend = project
) )
.dependsOn(store, joexapi, ftsclient, totp) .dependsOn(store, joexapi, ftsclient, totp)
val oidc = project
.in(file("modules/oidc"))
.disablePlugins(RevolverPlugin)
.settings(sharedSettings)
.settings(testSettingsMUnit)
.settings(
name := "docspell-oidc",
libraryDependencies ++=
Dependencies.loggingApi ++
Dependencies.fs2 ++
Dependencies.http4sClient ++
Dependencies.http4sCirce ++
Dependencies.http4sDsl ++
Dependencies.circe ++
Dependencies.jwtScala
)
.dependsOn(common)
val webapp = project val webapp = project
.in(file("modules/webapp")) .in(file("modules/webapp"))
.disablePlugins(RevolverPlugin) .disablePlugins(RevolverPlugin)
@ -615,7 +639,7 @@ val restserver = project
} }
} }
) )
.dependsOn(restapi, joexapi, backend, webapp, ftssolr) .dependsOn(restapi, joexapi, backend, webapp, ftssolr, oidc)
// --- Website Documentation // --- Website Documentation
@ -695,7 +719,8 @@ val root = project
restserver, restserver,
query.jvm, query.jvm,
query.js, query.js,
totp totp,
oidc
) )
// --- Helpers // --- Helpers

View File

@ -23,6 +23,8 @@ import scodec.bits.ByteVector
trait Login[F[_]] { trait Login[F[_]] {
def loginExternal(config: Config)(accountId: AccountId): F[Result]
def loginSession(config: Config)(sessionKey: String): F[Result] def loginSession(config: Config)(sessionKey: String): F[Result]
def loginUserPass(config: Config)(up: UserPass): F[Result] def loginUserPass(config: Config)(up: UserPass): F[Result]
@ -93,6 +95,16 @@ object Login {
private val logF = Logger.log4s(logger) private val logF = Logger.log4s(logger)
def loginExternal(config: Config)(accountId: AccountId): F[Result] =
for {
data <- store.transact(QLogin.findUser(accountId))
_ <- logF.trace(s"Account lookup: $data")
res <-
if (data.exists(checkNoPassword(_, Set(AccountSource.OpenId))))
doLogin(config, accountId, false)
else Result.invalidAuth.pure[F]
} yield res
def loginSession(config: Config)(sessionKey: String): F[Result] = def loginSession(config: Config)(sessionKey: String): F[Result] =
AuthToken.fromString(sessionKey) match { AuthToken.fromString(sessionKey) match {
case Right(at) => case Right(at) =>
@ -110,24 +122,11 @@ object Login {
def loginUserPass(config: Config)(up: UserPass): F[Result] = def loginUserPass(config: Config)(up: UserPass): F[Result] =
AccountId.parse(up.user) match { AccountId.parse(up.user) match {
case Right(acc) => case Right(acc) =>
val okResult =
for {
require2FA <- store.transact(RTotp.isEnabled(acc))
_ <-
if (require2FA) ().pure[F]
else store.transact(RUser.updateLogin(acc))
token <- AuthToken.user(acc, require2FA, config.serverSecret)
rem <- OptionT
.whenF(!require2FA && up.rememberMe && config.rememberMe.enabled)(
insertRememberToken(store, acc, config)
)
.value
} yield Result.ok(token, rem)
for { for {
data <- store.transact(QLogin.findUser(acc)) data <- store.transact(QLogin.findUser(acc))
_ <- Sync[F].delay(logger.trace(s"Account lookup: $data")) _ <- Sync[F].delay(logger.trace(s"Account lookup: $data"))
res <- res <-
if (data.exists(check(up.pass))) okResult if (data.exists(check(up.pass))) doLogin(config, acc, up.rememberMe)
else Result.invalidAuth.pure[F] else Result.invalidAuth.pure[F]
} yield res } yield res
case Left(_) => case Left(_) =>
@ -194,7 +193,7 @@ object Login {
logF.info(s"Account lookup via remember me: $data") logF.info(s"Account lookup via remember me: $data")
) )
res <- OptionT.liftF( res <- OptionT.liftF(
if (checkNoPassword(data)) if (checkNoPassword(data, AccountSource.all.toList.toSet))
logF.info("RememberMe auth successful") *> okResult(data.account) logF.info("RememberMe auth successful") *> okResult(data.account)
else else
logF.warn("RememberMe auth not successful") *> Result.invalidAuth.pure[F] logF.warn("RememberMe auth not successful") *> Result.invalidAuth.pure[F]
@ -247,6 +246,24 @@ object Login {
0.pure[F] 0.pure[F]
} }
private def doLogin(
config: Config,
acc: AccountId,
rememberMe: Boolean
): F[Result] =
for {
require2FA <- store.transact(RTotp.isEnabled(acc))
_ <-
if (require2FA) ().pure[F]
else store.transact(RUser.updateLogin(acc))
token <- AuthToken.user(acc, require2FA, config.serverSecret)
rem <- OptionT
.whenF(!require2FA && rememberMe && config.rememberMe.enabled)(
insertRememberToken(store, acc, config)
)
.value
} yield Result.ok(token, rem)
private def insertRememberToken( private def insertRememberToken(
store: Store[F], store: Store[F],
acc: AccountId, acc: AccountId,
@ -260,13 +277,17 @@ object Login {
private def check(given: String)(data: QLogin.Data): Boolean = { private def check(given: String)(data: QLogin.Data): Boolean = {
val passOk = BCrypt.checkpw(given, data.password.pass) val passOk = BCrypt.checkpw(given, data.password.pass)
checkNoPassword(data) && passOk checkNoPassword(data, Set(AccountSource.Local)) && passOk
} }
private def checkNoPassword(data: QLogin.Data): Boolean = { def checkNoPassword(
data: QLogin.Data,
expectedSources: Set[AccountSource]
): Boolean = {
val collOk = data.collectiveState == CollectiveState.Active || val collOk = data.collectiveState == CollectiveState.Active ||
data.collectiveState == CollectiveState.ReadOnly data.collectiveState == CollectiveState.ReadOnly
val userOk = data.userState == UserState.Active val userOk =
data.userState == UserState.Active && expectedSources.contains(data.source)
collOk && userOk collOk && userOk
} }
}) })

View File

@ -95,9 +95,11 @@ object OCollective {
object PassResetResult { object PassResetResult {
case class Success(newPw: Password) extends PassResetResult case class Success(newPw: Password) extends PassResetResult
case object NotFound extends PassResetResult case object NotFound extends PassResetResult
case object UserNotLocal extends PassResetResult
def success(np: Password): PassResetResult = Success(np) def success(np: Password): PassResetResult = Success(np)
def notFound: PassResetResult = NotFound def notFound: PassResetResult = NotFound
def userNotLocal: PassResetResult = UserNotLocal
} }
sealed trait PassChangeResult sealed trait PassChangeResult
@ -105,34 +107,14 @@ object OCollective {
case object UserNotFound extends PassChangeResult case object UserNotFound extends PassChangeResult
case object PasswordMismatch extends PassChangeResult case object PasswordMismatch extends PassChangeResult
case object UpdateFailed extends PassChangeResult case object UpdateFailed extends PassChangeResult
case object UserNotLocal extends PassChangeResult
case object Success extends PassChangeResult case object Success extends PassChangeResult
def userNotFound: PassChangeResult = UserNotFound def userNotFound: PassChangeResult = UserNotFound
def passwordMismatch: PassChangeResult = PasswordMismatch def passwordMismatch: PassChangeResult = PasswordMismatch
def success: PassChangeResult = Success def success: PassChangeResult = Success
def updateFailed: PassChangeResult = UpdateFailed def updateFailed: PassChangeResult = UpdateFailed
} def userNotLocal: PassChangeResult = UserNotLocal
case class RegisterData(
collName: Ident,
login: Ident,
password: Password,
invite: Option[Ident]
)
sealed trait RegisterResult {
def toEither: Either[Throwable, Unit]
}
object RegisterResult {
case object Success extends RegisterResult {
val toEither = Right(())
}
case class CollectiveExists(id: Ident) extends RegisterResult {
val toEither = Left(new Exception())
}
case class Error(ex: Throwable) extends RegisterResult {
val toEither = Left(ex)
}
} }
def apply[F[_]: Async]( def apply[F[_]: Async](
@ -245,11 +227,14 @@ object OCollective {
def resetPassword(accountId: AccountId): F[PassResetResult] = def resetPassword(accountId: AccountId): F[PassResetResult] =
for { for {
newPass <- Password.generate[F] newPass <- Password.generate[F]
optUser <- store.transact(RUser.findByAccount(accountId))
n <- store.transact( n <- store.transact(
RUser.updatePassword(accountId, PasswordCrypt.crypt(newPass)) RUser.updatePassword(accountId, PasswordCrypt.crypt(newPass))
) )
res = res =
if (n <= 0) PassResetResult.notFound if (optUser.exists(_.source != AccountSource.Local))
PassResetResult.userNotLocal
else if (n <= 0) PassResetResult.notFound
else PassResetResult.success(newPass) else PassResetResult.success(newPass)
} yield res } yield res
@ -270,6 +255,8 @@ object OCollective {
res = check match { res = check match {
case Some(true) => case Some(true) =>
if (n.getOrElse(0) > 0) PassChangeResult.success if (n.getOrElse(0) > 0) PassChangeResult.success
else if (optUser.exists(_.source != AccountSource.Local))
PassChangeResult.userNotLocal
else PassChangeResult.updateFailed else PassChangeResult.updateFailed
case Some(false) => case Some(false) =>
PassChangeResult.passwordMismatch PassChangeResult.passwordMismatch

View File

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

View File

@ -10,7 +10,6 @@ import cats.effect.{Async, Resource}
import cats.implicits._ import cats.implicits._
import docspell.backend.PasswordCrypt import docspell.backend.PasswordCrypt
import docspell.backend.ops.OCollective.RegisterData
import docspell.common._ import docspell.common._
import docspell.common.syntax.all._ import docspell.common.syntax.all._
import docspell.store.records.{RCollective, RInvitation, RUser} import docspell.store.records.{RCollective, RInvitation, RUser}
@ -23,6 +22,9 @@ trait OSignup[F[_]] {
def register(cfg: Config)(data: RegisterData): F[SignupResult] def register(cfg: Config)(data: RegisterData): F[SignupResult]
/** Creates the given account if it doesn't exist. */
def setupExternal(cfg: Config)(data: ExternalAccount): F[SignupResult]
def newInvite(cfg: Config)(password: Password): F[NewInviteResult] def newInvite(cfg: Config)(password: Password): F[NewInviteResult]
} }
@ -77,6 +79,37 @@ object OSignup {
} }
} }
def setupExternal(cfg: Config)(data: ExternalAccount): F[SignupResult] =
cfg.mode match {
case Config.Mode.Closed =>
SignupResult.signupClosed.pure[F]
case _ =>
if (data.source == AccountSource.Local)
SignupResult
.failure(new Exception("Account source must not be LOCAL!"))
.pure[F]
else
for {
recs <- makeRecords(data.collName, data.login, Password(""), data.source)
cres <- store.add(
RCollective.insert(recs._1),
RCollective.existsById(data.collName)
)
ures <- store.add(RUser.insert(recs._2), RUser.exists(data.login))
res = cres match {
case AddResult.Failure(ex) =>
SignupResult.failure(ex)
case _ =>
ures match {
case AddResult.Failure(ex) =>
SignupResult.failure(ex)
case _ =>
SignupResult.success
}
}
} yield res
}
private def retryInvite(res: SignupResult): Boolean = private def retryInvite(res: SignupResult): Boolean =
res match { res match {
case SignupResult.CollectiveExists => case SignupResult.CollectiveExists =>
@ -92,30 +125,6 @@ object OSignup {
} }
private def addUser(data: RegisterData): F[AddResult] = { private def addUser(data: RegisterData): F[AddResult] = {
def toRecords: F[(RCollective, RUser)] =
for {
id2 <- Ident.randomId[F]
now <- Timestamp.current[F]
c = RCollective(
data.collName,
CollectiveState.Active,
Language.German,
true,
now
)
u = RUser(
id2,
data.login,
data.collName,
PasswordCrypt.crypt(data.password),
UserState.Active,
None,
0,
None,
now
)
} yield (c, u)
def insert(coll: RCollective, user: RUser): ConnectionIO[Int] = def insert(coll: RCollective, user: RUser): ConnectionIO[Int] =
for { for {
n1 <- RCollective.insert(coll) n1 <- RCollective.insert(coll)
@ -127,9 +136,29 @@ object OSignup {
val msg = s"The collective '${data.collName}' already exists." val msg = s"The collective '${data.collName}' already exists."
for { for {
cu <- toRecords cu <- makeRecords(data.collName, data.login, data.password, AccountSource.Local)
save <- store.add(insert(cu._1, cu._2), collectiveExists) save <- store.add(insert(cu._1, cu._2), collectiveExists)
} yield save.fold(identity, _.withMsg(msg), identity) } yield save.fold(identity, _.withMsg(msg), identity)
} }
private def makeRecords(
collName: Ident,
login: Ident,
password: Password,
source: AccountSource
): F[(RCollective, RUser)] =
for {
id2 <- Ident.randomId[F]
now <- Timestamp.current[F]
c = RCollective.makeDefault(collName, now)
u = RUser.makeDefault(
id2,
login,
collName,
PasswordCrypt.crypt(password),
source,
now
)
} yield (c, u)
}) })
} }

View File

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

View File

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

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

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

View File

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

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

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

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

View File

@ -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] =
???
}

View File

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

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

View File

@ -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!'")
)
}

View File

@ -42,6 +42,7 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/VersionInfo" $ref: "#/components/schemas/VersionInfo"
/open/auth/login: /open/auth/login:
post: post:
operationId: "open-auth-login" operationId: "open-auth-login"
@ -93,6 +94,51 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/AuthResult" $ref: "#/components/schemas/AuthResult"
/open/auth/openid/{providerId}:
get:
operationId: "open-auth-openid"
tags: [ Authentication ]
summary: Authenticates via OIDC at the external provider given by its id
description: |
Initiates the ["Authorization Code
Flow"](https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth)
as described in the OpenID Connect specification. This only is
enabled, if an external provider has been configured correctly
in the config file.
This will redirect to the external provider to authenticate
the user. Once authenticated, the user is redirected back to
the `/resume` endpoint.
parameters:
- $ref: "#/components/parameters/providerId"
responses:
302:
description: Found. Redirect to external authentication provider
200:
description: Not used, is only here because openid requires it
/open/auth/openid/{providerId}/resume:
get:
operationId: "open-auth-openid-resume"
tags: [ Authentication ]
summary: The callback URL for the authentication provider
description: |
This URL is used to redirect the user back to the application
by the authentication provider after login is completed.
This will then try to find (or create) the account at docspell
using information about the user provided by the
authentication provider. If the required information cannot be
found, the user cannot be logged into the application.
If the process completed successfully, this endpoint redirects
into the web application which will take over from here.
parameters:
- $ref: "#/components/parameters/providerId"
responses:
303:
description: See Other. Redirect to the webapp
200:
description: Not used, is only here because openid requires it
/open/checkfile/{id}/{checksum}: /open/checkfile/{id}/{checksum}:
get: get:
@ -5405,6 +5451,7 @@ components:
- id - id
- login - login
- state - state
- source
- loginCount - loginCount
- created - created
properties: properties:
@ -5420,6 +5467,12 @@ components:
enum: enum:
- active - active
- disabled - disabled
source:
type: string
format: accountsource
enum:
- local
- openid
password: password:
type: string type: string
format: password format: password
@ -6262,3 +6315,10 @@ components:
some identifier for a client application some identifier for a client application
schema: schema:
type: string type: string
providerId:
name: providerId
in: path
required: true
schema:
type: string
format: ident

View File

@ -61,6 +61,131 @@ docspell.server {
} }
} }
# Configures OpenID Connect (OIDC) or OAuth2 authentication. Only
# the "Authorization Code Flow" is supported.
#
# Multiple authentication providers can be defined. Each is
# configured in the array below. The `provider` block gives all
# details necessary to authenticate agains an external OIDC or OAuth
# provider. This requires at least two URLs for OIDC and three for
# OAuth2. The `user-url` is only required for OIDC, if the account
# data is to be retrieved from the user-info endpoint and not from
# the JWT token. The access token is then used to authenticate at
# the provider to obtain user info. Thus, it doesn't need to be
# validated here and therefore no `sign-key` setting is needed.
# However, if you want to extract the account information from the
# access token, it must be validated here and therefore the correct
# signature key and algorithm must be provided. This would save
# another request. If the `sign-key` is left empty, the `user-url`
# is used and must be specified. If the `sign-key` is _not_ empty,
# the response from the authentication provider is validated using
# this key.
#
# After successful authentication, docspell needs to create the
# account. For this a username and collective name is required. The
# username is defined by the `user-key` setting. The `user-key` is
# used to search the JSON structure, that is obtained from the JWT
# token or the user-info endpoint, for the login name to use. It
# traverses the JSON structure recursively, until it finds an object
# with that key. The first value is used.
#
# There are the following ways to specify how to retrieve the full
# account id depending on the value of `collective-key`:
#
# - If it starts with `fixed:`, like "fixed:collective", the name
# after the `fixed:` prefix is used as collective as is. So all
# users are in the same collective.
#
# - If it starts with `lookup:`, like "lookup:collective_name", the
# value after the prefix is used to search the JSON response for
# an object with this key, just like it works with the `user-key`.
#
# - If it starts with `account:`, like "account:ds-account", it
# works the same as `lookup:` only that the value is interpreted
# as the full account name of form `collective/login`. The
# `user-key` value is ignored in this case.
#
# If these values cannot be obtained from the response, docspell
# fails the authentication by denying access. It is then assumed
# that the successfully authenticated user has not enough
# permissions to access docspell.
#
# Below are examples for OpenID Connect (keycloak) and OAuth2
# (github).
openid =
[ { enabled = false,
# The name to render on the login link/button.
display = "Keycloak"
# This illustrates to use a custom keycloak setup as the
# authentication provider. For details, please refer to the
# keycloak documentation. The settings here assume a certain
# configuration at keycloak.
#
# Keycloak can be configured to return the collective name for
# each user in the access token. It may also be configured to
# return it in the user info response. If it is already in the
# access token, an additional request can be omitted. Set the
# `sign-key` to an empty string then. Otherwise provide the
# algo and key from your realm settings. In this example, the
# realm is called "home".
provider = {
provider-id = "keycloak",
client-id = "docspell",
client-secret = "example-secret-439e-bf06-911e4cdd56a6",
scope = "profile", # scope is required for OIDC
authorize-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/auth",
token-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/token",
#User URL is not used when signature key is set.
#user-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/userinfo",
sign-key = "b64:MII…ZYL09vAwLn8EAcSkCAwEAAQ==",
sig-algo = "RS512"
},
# The collective of the user is given in the access token as
# property `docspell_collective`.
collective-key = "lookup:docspell_collective",
# The username to use for the docspell account
user-key = "preferred_username"
},
{ enabled = false,
# The name to render on the login link/button.
display = "Github"
# Provider settings for using github as an authentication
# provider. Note that this is only an example to illustrate
# how it works. Usually you wouldn't want to let every user on
# github in ;-).
#
# Github doesn't have full OpenIdConnect, but supports the
# OAuth2 code flow (which is very similar). It mainly means,
# that there is no standardized token to validate and get
# information from. So the user-url must be used in this case.
provider = {
provider-id = "github",
client-id = "<your github client id>",
client-secret = "<your github client secret>",
scope = "", # scope is not needed for github
authorize-url = "https://github.com/login/oauth/authorize",
token-url = "https://github.com/login/oauth/access_token",
user-url = "https://api.github.com/user",
sign-key = "" # this must be set empty
sig-algo = "RS256" #unused but must be set to something
},
# If the authentication provider doesn't provide the
# collective name, simply use a fixed one. This means all
# users from this provider are in the same collective!
collective-key = "fixed:demo",
# Github provides the login name via the `login` property as
# response from the user-url. This value is used to construct
# the account in docspell.
user-key = "login"
}
]
# This endpoint allows to upload files to any collective. The # This endpoint allows to upload files to any collective. The
# intention is that local software integrates with docspell more # intention is that local software integrates with docspell more
# easily. Therefore the endpoint is not protected by the usual # easily. Therefore the endpoint is not protected by the usual

View File

@ -10,6 +10,9 @@ import docspell.backend.auth.Login
import docspell.backend.{Config => BackendConfig} import docspell.backend.{Config => BackendConfig}
import docspell.common._ import docspell.common._
import docspell.ftssolr.SolrConfig import docspell.ftssolr.SolrConfig
import docspell.oidc.ProviderConfig
import docspell.restserver.Config.OpenIdConfig
import docspell.restserver.auth.OpenId
import com.comcast.ip4s.IpAddress import com.comcast.ip4s.IpAddress
@ -25,8 +28,12 @@ case class Config(
maxItemPageSize: Int, maxItemPageSize: Int,
maxNoteLength: Int, maxNoteLength: Int,
fullTextSearch: Config.FullTextSearch, fullTextSearch: Config.FullTextSearch,
adminEndpoint: Config.AdminEndpoint adminEndpoint: Config.AdminEndpoint,
) openid: List[OpenIdConfig]
) {
def openIdEnabled: Boolean =
openid.exists(_.enabled)
}
object Config { object Config {
@ -70,4 +77,12 @@ object Config {
object FullTextSearch {} object FullTextSearch {}
final case class OpenIdConfig(
enabled: Boolean,
display: String,
collectiveKey: OpenId.UserInfo.Extractor,
userKey: String,
provider: ProviderConfig
)
} }

View File

@ -6,8 +6,14 @@
package docspell.restserver package docspell.restserver
import cats.Semigroup
import cats.data.{Validated, ValidatedNec}
import cats.implicits._
import docspell.backend.signup.{Config => SignupConfig} import docspell.backend.signup.{Config => SignupConfig}
import docspell.common.config.Implicits._ import docspell.common.config.Implicits._
import docspell.oidc.{ProviderConfig, SignatureAlgo}
import docspell.restserver.auth.OpenId
import pureconfig._ import pureconfig._
import pureconfig.generic.auto._ import pureconfig.generic.auto._
@ -16,10 +22,71 @@ object ConfigFile {
import Implicits._ import Implicits._
def loadConfig: Config = def loadConfig: Config =
ConfigSource.default.at("docspell.server").loadOrThrow[Config] Validate(ConfigSource.default.at("docspell.server").loadOrThrow[Config])
object Implicits { object Implicits {
implicit val signupModeReader: ConfigReader[SignupConfig.Mode] = implicit val signupModeReader: ConfigReader[SignupConfig.Mode] =
ConfigReader[String].emap(reason(SignupConfig.Mode.fromString)) ConfigReader[String].emap(reason(SignupConfig.Mode.fromString))
implicit val sigAlgoReader: ConfigReader[SignatureAlgo] =
ConfigReader[String].emap(reason(SignatureAlgo.fromString))
implicit val openIdExtractorReader: ConfigReader[OpenId.UserInfo.Extractor] =
ConfigReader[String].emap(reason(OpenId.UserInfo.Extractor.fromString))
}
object Validate {
implicit val firstConfigSemigroup: Semigroup[Config] =
Semigroup.first
def apply(config: Config): Config =
all(config).foldLeft(valid(config))(_.combine(_)) match {
case Validated.Valid(cfg) => cfg
case Validated.Invalid(errs) =>
val msg = errs.toList.mkString("- ", "\n- ", "\n")
throw sys.error(s"\n\n$msg")
}
def all(cfg: Config) = List(
duplicateOpenIdProvider(cfg),
signKeyVsUserUrl(cfg)
)
private def valid(cfg: Config): ValidatedNec[String, Config] =
Validated.validNec(cfg)
def duplicateOpenIdProvider(cfg: Config): ValidatedNec[String, Config] = {
val dupes =
cfg.openid
.filter(_.enabled)
.groupBy(_.provider.providerId)
.filter(_._2.size > 1)
.map(_._1.id)
.toList
val dupesStr = dupes.mkString(", ")
if (dupes.isEmpty) valid(cfg)
else Validated.invalidNec(s"There is a duplicate openId provider: $dupesStr")
}
def signKeyVsUserUrl(cfg: Config): ValidatedNec[String, Config] = {
def checkProvider(p: ProviderConfig): ValidatedNec[String, Config] =
if (p.signKey.isEmpty && p.userUrl.isEmpty)
Validated.invalidNec(
s"Either user-url or sign-key must be set for provider ${p.providerId.id}"
)
else if (p.signKey.nonEmpty && p.scope.isEmpty)
Validated.invalidNec(
s"A scope is missing for OIDC auth at provider ${p.providerId.id}"
)
else Validated.valid(cfg)
cfg.openid
.filter(_.enabled)
.map(_.provider)
.map(checkProvider)
.foldLeft(valid(cfg))(_.combine(_))
}
} }
} }

View File

@ -12,12 +12,16 @@ import fs2.Stream
import docspell.backend.auth.AuthToken import docspell.backend.auth.AuthToken
import docspell.common._ import docspell.common._
import docspell.oidc.CodeFlowRoutes
import docspell.restserver.auth.OpenId
import docspell.restserver.http4s.EnvMiddleware import docspell.restserver.http4s.EnvMiddleware
import docspell.restserver.routes._ import docspell.restserver.routes._
import docspell.restserver.webapp._ import docspell.restserver.webapp._
import org.http4s._ import org.http4s._
import org.http4s.blaze.client.BlazeClientBuilder
import org.http4s.blaze.server.BlazeServerBuilder import org.http4s.blaze.server.BlazeServerBuilder
import org.http4s.client.Client
import org.http4s.dsl.Http4sDsl import org.http4s.dsl.Http4sDsl
import org.http4s.headers.Location import org.http4s.headers.Location
import org.http4s.implicits._ import org.http4s.implicits._
@ -33,9 +37,10 @@ object RestServer {
restApp <- restApp <-
RestAppImpl RestAppImpl
.create[F](cfg, pools.connectEC, pools.httpClientEC) .create[F](cfg, pools.connectEC, pools.httpClientEC)
httpClient <- BlazeClientBuilder[F](pools.httpClientEC).resource
httpApp = Router( httpApp = Router(
"/api/info" -> routes.InfoRoutes(), "/api/info" -> routes.InfoRoutes(),
"/api/v1/open/" -> openRoutes(cfg, restApp), "/api/v1/open/" -> openRoutes(cfg, httpClient, restApp),
"/api/v1/sec/" -> Authenticate(restApp.backend.login, cfg.auth) { token => "/api/v1/sec/" -> Authenticate(restApp.backend.login, cfg.auth) { token =>
securedRoutes(cfg, restApp, token) securedRoutes(cfg, restApp, token)
}, },
@ -98,8 +103,18 @@ object RestServer {
"clientSettings" -> ClientSettingsRoutes(restApp.backend, token) "clientSettings" -> ClientSettingsRoutes(restApp.backend, token)
) )
def openRoutes[F[_]: Async](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] = def openRoutes[F[_]: Async](
cfg: Config,
client: Client[F],
restApp: RestApp[F]
): HttpRoutes[F] =
Router( Router(
"auth/openid" -> CodeFlowRoutes(
cfg.openIdEnabled,
OpenId.handle[F](restApp.backend, cfg),
OpenId.codeFlowConfig(cfg),
client
),
"auth" -> LoginRoutes.login(restApp.backend.login, cfg), "auth" -> LoginRoutes.login(restApp.backend.login, cfg),
"signup" -> RegisterRoutes(restApp.backend, cfg), "signup" -> RegisterRoutes(restApp.backend, cfg),
"upload" -> UploadRoutes.open(restApp.backend, cfg), "upload" -> UploadRoutes.open(restApp.backend, cfg),

View File

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

View File

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

View File

@ -10,8 +10,7 @@ import cats.effect._
import cats.implicits._ import cats.implicits._
import docspell.backend.BackendApp import docspell.backend.BackendApp
import docspell.backend.ops.OCollective.RegisterData import docspell.backend.signup.{NewInviteResult, RegisterData, SignupResult}
import docspell.backend.signup.{NewInviteResult, SignupResult}
import docspell.restapi.model._ import docspell.restapi.model._
import docspell.restserver.Config import docspell.restserver.Config
import docspell.restserver.http4s.ResponseGenerator import docspell.restserver.http4s.ResponseGenerator

View File

@ -86,6 +86,12 @@ object UserRoutes {
Password(""), Password(""),
"Password update failed. User not found." "Password update failed. User not found."
) )
case OCollective.PassResetResult.UserNotLocal =>
ResetPasswordResult(
false,
Password(""),
"Password update failed. User is not local, passwords are managed externally."
)
}) })
} yield resp } yield resp
} }

View File

@ -7,7 +7,7 @@
package docspell.restserver.webapp package docspell.restserver.webapp
import docspell.backend.signup.{Config => SignupConfig} import docspell.backend.signup.{Config => SignupConfig}
import docspell.common.LenientUri import docspell.common.{Ident, LenientUri}
import docspell.restserver.{BuildInfo, Config} import docspell.restserver.{BuildInfo, Config}
import io.circe._ import io.circe._
@ -25,7 +25,8 @@ case class Flags(
maxPageSize: Int, maxPageSize: Int,
maxNoteLength: Int, maxNoteLength: Int,
showClassificationSettings: Boolean, showClassificationSettings: Boolean,
uiVersion: Int uiVersion: Int,
openIdAuth: List[Flags.OpenIdAuth]
) )
object Flags { object Flags {
@ -40,9 +41,20 @@ object Flags {
cfg.maxItemPageSize, cfg.maxItemPageSize,
cfg.maxNoteLength, cfg.maxNoteLength,
cfg.showClassificationSettings, cfg.showClassificationSettings,
uiVersion uiVersion,
cfg.openid.filter(_.enabled).map(c => OpenIdAuth(c.provider.providerId, c.display))
) )
final case class OpenIdAuth(provider: Ident, name: String)
object OpenIdAuth {
implicit val jsonDecoder: Decoder[OpenIdAuth] =
deriveDecoder[OpenIdAuth]
implicit val jsonEncoder: Encoder[OpenIdAuth] =
deriveEncoder[OpenIdAuth]
}
private def getBaseUrl(cfg: Config): String = private def getBaseUrl(cfg: Config): String =
if (cfg.baseUrl.isLocal) cfg.baseUrl.rootPathToEmpty.path.asString if (cfg.baseUrl.isLocal) cfg.baseUrl.rootPathToEmpty.path.asString
else cfg.baseUrl.rootPathToEmpty.asString else cfg.baseUrl.rootPathToEmpty.asString
@ -50,6 +62,10 @@ object Flags {
implicit val jsonEncoder: Encoder[Flags] = implicit val jsonEncoder: Encoder[Flags] =
deriveEncoder[Flags] deriveEncoder[Flags]
implicit def yamuscaIdentConverter: ValueConverter[Ident] =
ValueConverter.of(id => Value.fromString(id.id))
implicit def yamuscaOpenIdAuthConverter: ValueConverter[OpenIdAuth] =
ValueConverter.deriveConverter[OpenIdAuth]
implicit def yamuscaSignupModeConverter: ValueConverter[SignupConfig.Mode] = implicit def yamuscaSignupModeConverter: ValueConverter[SignupConfig.Mode] =
ValueConverter.of(m => Value.fromString(m.name)) ValueConverter.of(m => Value.fromString(m.name))
implicit def yamuscaUriConverter: ValueConverter[LenientUri] = implicit def yamuscaUriConverter: ValueConverter[LenientUri] =

View File

@ -0,0 +1,8 @@
ALTER TABLE "user_"
ADD COLUMN "account_source" varchar(254);
UPDATE "user_"
SET "account_source" = 'local';
ALTER TABLE "user_"
ALTER COLUMN "account_source" SET NOT NULL;

View File

@ -0,0 +1,8 @@
ALTER TABLE `user_`
ADD COLUMN (`account_source` varchar(254));
UPDATE `user_`
SET `account_source` = 'local';
ALTER TABLE `user_`
MODIFY `account_source` varchar(254) NOT NULL;

View File

@ -0,0 +1,8 @@
ALTER TABLE "user_"
ADD COLUMN "account_source" varchar(254);
UPDATE "user_"
SET "account_source" = 'local';
ALTER TABLE "user_"
ALTER COLUMN "account_source" SET NOT NULL;

View File

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

View File

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

View File

@ -37,6 +37,9 @@ object RCollective {
val all = NonEmptyList.of[Column[_]](id, state, language, integration, created) val all = NonEmptyList.of[Column[_]](id, state, language, integration, created)
} }
def makeDefault(collName: Ident, created: Timestamp): RCollective =
RCollective(collName, CollectiveState.Active, Language.German, true, created)
val T = Table(None) val T = Table(None)
def as(alias: String): Table = def as(alias: String): Table =
Table(Some(alias)) Table(Some(alias))

View File

@ -21,6 +21,7 @@ case class RUser(
cid: Ident, cid: Ident,
password: Password, password: Password,
state: UserState, state: UserState,
source: AccountSource,
email: Option[String], email: Option[String],
loginCount: Int, loginCount: Int,
lastLogin: Option[Timestamp], lastLogin: Option[Timestamp],
@ -28,6 +29,28 @@ case class RUser(
) {} ) {}
object RUser { object RUser {
def makeDefault(
id: Ident,
login: Ident,
collName: Ident,
password: Password,
source: AccountSource,
created: Timestamp
): RUser =
RUser(
id,
login,
collName,
password,
UserState.Active,
source,
None,
0,
None,
created
)
final case class Table(alias: Option[String]) extends TableDef { final case class Table(alias: Option[String]) extends TableDef {
val tableName = "user_" val tableName = "user_"
@ -36,6 +59,7 @@ object RUser {
val cid = Column[Ident]("cid", this) val cid = Column[Ident]("cid", this)
val password = Column[Password]("password", this) val password = Column[Password]("password", this)
val state = Column[UserState]("state", this) val state = Column[UserState]("state", this)
val source = Column[AccountSource]("account_source", this)
val email = Column[String]("email", this) val email = Column[String]("email", this)
val loginCount = Column[Int]("logincount", this) val loginCount = Column[Int]("logincount", this)
val lastLogin = Column[Timestamp]("lastlogin", this) val lastLogin = Column[Timestamp]("lastlogin", this)
@ -48,6 +72,7 @@ object RUser {
cid, cid,
password, password,
state, state,
source,
email, email,
loginCount, loginCount,
lastLogin, lastLogin,
@ -65,7 +90,7 @@ object RUser {
DML.insert( DML.insert(
t, t,
t.all, t.all,
fr"${v.uid},${v.login},${v.cid},${v.password},${v.state},${v.email},${v.loginCount},${v.lastLogin},${v.created}" fr"${v.uid},${v.login},${v.cid},${v.password},${v.state},${v.source},${v.email},${v.loginCount},${v.lastLogin},${v.created}"
) )
} }
@ -134,7 +159,7 @@ object RUser {
val t = Table(None) val t = Table(None)
DML.update( DML.update(
t, t,
t.cid === accountId.collective && t.login === accountId.user, t.cid === accountId.collective && t.login === accountId.user && t.source === AccountSource.Local,
DML.set(t.password.setTo(hashedPass)) DML.set(t.password.setTo(hashedPass))
) )
} }

View File

@ -87,6 +87,7 @@ module Api exposing
, mergeItems , mergeItems
, moveAttachmentBefore , moveAttachmentBefore
, newInvite , newInvite
, openIdAuthLink
, postCustomField , postCustomField
, postEquipment , postEquipment
, postNewUser , postNewUser
@ -935,6 +936,11 @@ newInvite flags req receive =
--- Login --- Login
openIdAuthLink : Flags -> String -> String
openIdAuthLink flags provider =
flags.config.baseUrl ++ "/api/v1/open/auth/openid/" ++ provider
login : Flags -> UserPass -> (Result Http.Error AuthResult -> msg) -> Cmd msg login : Flags -> UserPass -> (Result Http.Error AuthResult -> msg) -> Cmd msg
login flags up receive = login flags up receive =
Http.post Http.post

View File

@ -82,6 +82,9 @@ init key url flags_ settings =
( csm, csc ) = ( csm, csc ) =
Page.CollectiveSettings.Data.init flags Page.CollectiveSettings.Data.init flags
( loginm, loginc ) =
Page.Login.Data.init flags (Page.loginPageReferrer page)
homeViewMode = homeViewMode =
if settings.searchMenuVisible then if settings.searchMenuVisible then
Page.Home.Data.SearchView Page.Home.Data.SearchView
@ -94,7 +97,7 @@ init key url flags_ settings =
, page = page , page = page
, version = Api.Model.VersionInfo.empty , version = Api.Model.VersionInfo.empty
, homeModel = Page.Home.Data.init flags homeViewMode , homeModel = Page.Home.Data.init flags homeViewMode
, loginModel = Page.Login.Data.emptyModel , loginModel = loginm
, manageDataModel = mdm , manageDataModel = mdm
, collSettingsModel = csm , collSettingsModel = csm
, userSettingsModel = um , userSettingsModel = um
@ -116,6 +119,7 @@ init key url flags_ settings =
[ Cmd.map UserSettingsMsg uc [ Cmd.map UserSettingsMsg uc
, Cmd.map ManageDataMsg mdc , Cmd.map ManageDataMsg mdc
, Cmd.map CollSettingsMsg csc , Cmd.map CollSettingsMsg csc
, Cmd.map LoginMsg loginc
] ]
) )

View File

@ -158,7 +158,7 @@ updateWithSub msg model =
LogoutResp _ -> LogoutResp _ ->
( { model | loginModel = Page.Login.Data.emptyModel } ( { model | loginModel = Page.Login.Data.emptyModel }
, Page.goto (LoginPage Nothing) , Page.goto (LoginPage Page.emptyLoginData)
, Sub.none , Sub.none
) )
@ -216,20 +216,24 @@ updateWithSub msg model =
NavRequest req -> NavRequest req ->
case req of case req of
Internal url -> Internal url ->
let if String.startsWith "/app" url.path then
isCurrent = let
Page.fromUrl url isCurrent =
|> Maybe.map (\p -> p == model.page) Page.fromUrl url
|> Maybe.withDefault True |> Maybe.map (\p -> p == model.page)
in |> Maybe.withDefault True
( model in
, if isCurrent then ( model
Cmd.none , if isCurrent then
Cmd.none
else else
Nav.pushUrl model.key (Url.toString url) Nav.pushUrl model.key (Url.toString url)
, Sub.none , Sub.none
) )
else
( model, Nav.load <| Url.toString url, Sub.none )
External url -> External url ->
( model ( model

View File

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

View File

@ -17,6 +17,12 @@ module Data.Flags exposing
import Api.Model.AuthResult exposing (AuthResult) import Api.Model.AuthResult exposing (AuthResult)
type alias OpenIdAuth =
{ provider : String
, name : String
}
type alias Config = type alias Config =
{ appName : String { appName : String
, baseUrl : String , baseUrl : String
@ -27,6 +33,7 @@ type alias Config =
, maxPageSize : Int , maxPageSize : Int
, maxNoteLength : Int , maxNoteLength : Int
, showClassificationSettings : Bool , showClassificationSettings : Bool
, openIdAuth : List OpenIdAuth
} }

View File

@ -26,6 +26,7 @@ gb err =
, invalidInput = "Invalid input when processing the request." , invalidInput = "Invalid input when processing the request."
, notFound = "The requested resource doesn't exist." , notFound = "The requested resource doesn't exist."
, invalidBody = \str -> "There was an error decoding the response: " ++ str , invalidBody = \str -> "There was an error decoding the response: " ++ str
, accessDenied = "Access denied"
} }
in in
errorToString texts err errorToString texts err
@ -44,6 +45,7 @@ de err =
, invalidInput = "Die Daten im Request waren ungültig." , invalidInput = "Die Daten im Request waren ungültig."
, notFound = "Die angegebene Ressource wurde nicht gefunden." , notFound = "Die angegebene Ressource wurde nicht gefunden."
, invalidBody = \str -> "Es gab einen Fehler beim Dekodieren der Antwort: " ++ str , invalidBody = \str -> "Es gab einen Fehler beim Dekodieren der Antwort: " ++ str
, accessDenied = "Zugriff verweigert"
} }
in in
errorToString texts err errorToString texts err
@ -61,6 +63,7 @@ type alias Texts =
, invalidInput : String , invalidInput : String
, notFound : String , notFound : String
, invalidBody : String -> String , invalidBody : String -> String
, accessDenied : String
} }
@ -90,6 +93,9 @@ errorToString texts error =
if sc == 404 then if sc == 404 then
texts.notFound texts.notFound
else if sc == 403 then
texts.accessDenied
else if sc >= 400 && sc < 500 then else if sc >= 400 && sc < 500 then
texts.invalidInput texts.invalidInput

View File

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

View File

@ -29,6 +29,7 @@ type alias Texts =
, noAccount : String , noAccount : String
, signupLink : String , signupLink : String
, otpCode : String , otpCode : String
, or : String
} }
@ -47,6 +48,7 @@ gb =
, noAccount = "No account?" , noAccount = "No account?"
, signupLink = "Sign up!" , signupLink = "Sign up!"
, otpCode = "Authentication code" , otpCode = "Authentication code"
, or = "Or"
} }
@ -65,4 +67,5 @@ de =
, noAccount = "Kein Konto?" , noAccount = "Kein Konto?"
, signupLink = "Hier registrieren!" , signupLink = "Hier registrieren!"
, otpCode = "Authentifizierungscode" , otpCode = "Authentifizierungscode"
, or = "Oder"
} }

View File

@ -6,7 +6,9 @@
module Page exposing module Page exposing
( Page(..) ( LoginData
, Page(..)
, emptyLoginData
, fromUrl , fromUrl
, goto , goto
, hasSidebar , hasSidebar
@ -31,9 +33,24 @@ import Url.Parser.Query as Query
import Util.Maybe import Util.Maybe
type alias LoginData =
{ referrer : Maybe Page
, session : Maybe String
, openid : Int
}
emptyLoginData : LoginData
emptyLoginData =
{ referrer = Nothing
, session = Nothing
, openid = 0
}
type Page type Page
= HomePage = HomePage
| LoginPage (Maybe Page) | LoginPage LoginData
| ManageDataPage | ManageDataPage
| CollectiveSettingPage | CollectiveSettingPage
| UserSettingPage | UserSettingPage
@ -99,10 +116,10 @@ loginPage : Page -> Page
loginPage p = loginPage p =
case p of case p of
LoginPage _ -> LoginPage _ ->
LoginPage Nothing LoginPage emptyLoginData
_ -> _ ->
LoginPage (Just p) LoginPage { emptyLoginData | referrer = Just p }
pageName : Page -> String pageName : Page -> String
@ -144,14 +161,14 @@ pageName page =
"Item" "Item"
loginPageReferrer : Page -> Maybe Page loginPageReferrer : Page -> LoginData
loginPageReferrer page = loginPageReferrer page =
case page of case page of
LoginPage r -> LoginPage data ->
r data
_ -> _ ->
Nothing emptyLoginData
uploadId : Page -> Maybe String uploadId : Page -> Maybe String
@ -170,8 +187,8 @@ pageToString page =
HomePage -> HomePage ->
"/app/home" "/app/home"
LoginPage referer -> LoginPage data ->
case referer of case data.referrer of
Just (LoginPage _) -> Just (LoginPage _) ->
"/app/login" "/app/login"
@ -253,7 +270,7 @@ parser =
, s pathPrefix </> s "home" , s pathPrefix </> s "home"
] ]
) )
, Parser.map LoginPage (s pathPrefix </> s "login" <?> pageQuery) , Parser.map LoginPage (s pathPrefix </> s "login" <?> loginPageParser)
, Parser.map ManageDataPage (s pathPrefix </> s "managedata") , Parser.map ManageDataPage (s pathPrefix </> s "managedata")
, Parser.map CollectiveSettingPage (s pathPrefix </> s "csettings") , Parser.map CollectiveSettingPage (s pathPrefix </> s "csettings")
, Parser.map UserSettingPage (s pathPrefix </> s "usettings") , Parser.map UserSettingPage (s pathPrefix </> s "usettings")
@ -280,6 +297,21 @@ fromString str =
fromUrl url fromUrl url
loginPageOAuthQuery : Query.Parser Int
loginPageOAuthQuery =
Query.map (Maybe.withDefault 0) (Query.int "openid")
loginPageSessionQuery : Query.Parser (Maybe String)
loginPageSessionQuery =
Query.string "auth"
loginPageParser : Query.Parser LoginData
loginPageParser =
Query.map3 LoginData pageQuery loginPageSessionQuery loginPageOAuthQuery
pageQuery : Query.Parser (Maybe Page) pageQuery : Query.Parser (Maybe Page)
pageQuery = pageQuery =
let let

View File

@ -11,11 +11,14 @@ module Page.Login.Data exposing
, Model , Model
, Msg(..) , Msg(..)
, emptyModel , emptyModel
, init
) )
import Api
import Api.Model.AuthResult exposing (AuthResult) import Api.Model.AuthResult exposing (AuthResult)
import Data.Flags exposing (Flags)
import Http import Http
import Page exposing (Page(..)) import Page exposing (LoginData, Page(..))
type alias Model = type alias Model =
@ -37,7 +40,7 @@ type FormState
type AuthStep type AuthStep
= StepLogin = StepLogin
| StepOtp AuthResult | StepOtp String
emptyModel : Model emptyModel : Model
@ -51,6 +54,19 @@ emptyModel =
} }
init : Flags -> LoginData -> ( Model, Cmd Msg )
init flags ld =
let
cmd =
if ld.openid > 0 then
Api.loginSession flags AuthResp
else
Cmd.none
in
( emptyModel, cmd )
type Msg type Msg
= SetUsername String = SetUsername String
| SetPassword String | SetPassword String
@ -58,4 +74,4 @@ type Msg
| Authenticate | Authenticate
| AuthResp (Result Http.Error AuthResult) | AuthResp (Result Http.Error AuthResult)
| SetOtp String | SetOtp String
| AuthOtp AuthResult | AuthOtp String

View File

@ -10,13 +10,13 @@ module Page.Login.Update exposing (update)
import Api import Api
import Api.Model.AuthResult exposing (AuthResult) import Api.Model.AuthResult exposing (AuthResult)
import Data.Flags exposing (Flags) import Data.Flags exposing (Flags)
import Page exposing (Page(..)) import Page exposing (LoginData, Page(..))
import Page.Login.Data exposing (..) import Page.Login.Data exposing (..)
import Ports import Ports
update : Maybe Page -> Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe AuthResult ) update : LoginData -> Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe AuthResult )
update referrer flags msg model = update loginData flags msg model =
case msg of case msg of
SetUsername str -> SetUsername str ->
( { model | username = str }, Cmd.none, Nothing ) ( { model | username = str }, Cmd.none, Nothing )
@ -40,11 +40,11 @@ update referrer flags msg model =
in in
( model, Api.login flags userPass AuthResp, Nothing ) ( model, Api.login flags userPass AuthResp, Nothing )
AuthOtp acc -> AuthOtp token ->
let let
sf = sf =
{ rememberMe = model.rememberMe { rememberMe = model.rememberMe
, token = Maybe.withDefault "" acc.token , token = token
, otp = model.otp , otp = model.otp
} }
in in
@ -53,7 +53,7 @@ update referrer flags msg model =
AuthResp (Ok lr) -> AuthResp (Ok lr) ->
let let
gotoRef = gotoRef =
Maybe.withDefault HomePage referrer |> Page.goto Maybe.withDefault HomePage loginData.referrer |> Page.goto
in in
if lr.success && not lr.requireSecondFactor then if lr.success && not lr.requireSecondFactor then
( { model | formState = AuthSuccess lr, password = "" } ( { model | formState = AuthSuccess lr, password = "" }
@ -62,7 +62,11 @@ update referrer flags msg model =
) )
else if lr.success && lr.requireSecondFactor then else if lr.success && lr.requireSecondFactor then
( { model | formState = FormInitial, authStep = StepOtp lr, password = "" } ( { model
| formState = FormInitial
, authStep = StepOtp <| Maybe.withDefault "" lr.token
, password = ""
}
, Cmd.none , Cmd.none
, Nothing , Nothing
) )
@ -77,11 +81,22 @@ update referrer flags msg model =
let let
empty = empty =
Api.Model.AuthResult.empty Api.Model.AuthResult.empty
session =
Maybe.withDefault "" loginData.session
in in
( { model | password = "", formState = HttpError err } -- A value of 2 indicates that TOTP is required
, Ports.removeAccount () if loginData.openid == 2 then
, Just empty ( { model | formState = FormInitial, authStep = StepOtp session, password = "" }
) , Cmd.none
, Nothing
)
else
( { model | password = "", formState = HttpError err }
, Ports.removeAccount ()
, Just empty
)
setAccount : AuthResult -> Cmd msg setAccount : AuthResult -> Cmd msg

View File

@ -7,8 +7,10 @@
module Page.Login.View2 exposing (viewContent, viewSidebar) module Page.Login.View2 exposing (viewContent, viewSidebar)
import Api
import Api.Model.AuthResult exposing (AuthResult) import Api.Model.AuthResult exposing (AuthResult)
import Api.Model.VersionInfo exposing (VersionInfo) import Api.Model.VersionInfo exposing (VersionInfo)
import Comp.Basic as B
import Data.Flags exposing (Flags) import Data.Flags exposing (Flags)
import Data.UiSettings exposing (UiSettings) import Data.UiSettings exposing (UiSettings)
import Html exposing (..) import Html exposing (..)
@ -53,6 +55,7 @@ viewContent texts flags versionInfo _ model =
StepLogin -> StepLogin ->
loginForm texts flags model loginForm texts flags model
, openIdLinks texts flags
] ]
, a , a
[ class "inline-flex items-center mt-4 text-xs opacity-50 hover:opacity-90" [ class "inline-flex items-center mt-4 text-xs opacity-50 hover:opacity-90"
@ -72,11 +75,40 @@ viewContent texts flags versionInfo _ model =
] ]
otpForm : Texts -> Flags -> Model -> AuthResult -> Html Msg openIdLinks : Texts -> Flags -> Html Msg
otpForm texts flags model acc = openIdLinks texts flags =
let
renderLink prov =
a
[ href (Api.openIdAuthLink flags prov.provider)
, class S.link
]
[ i [ class "fab fa-openid mr-1" ] []
, text prov.name
]
in
case flags.config.openIdAuth of
[] ->
span [ class "hidden" ] []
provs ->
div [ class "mt-3" ]
[ B.horizontalDivider
{ label = texts.or
, topCss = "w-2/3 mb-4 hidden md:inline-flex w-full"
, labelCss = "px-4 bg-gray-200 bg-opacity-50"
, lineColor = "bg-gray-300 dark:bg-bluegray-600"
}
, div [ class "flex flex-row space-x-4 items-center justify-center" ]
(List.map renderLink provs)
]
otpForm : Texts -> Flags -> Model -> String -> Html Msg
otpForm texts flags model token =
Html.form Html.form
[ action "#" [ action "#"
, onSubmit (AuthOtp acc) , onSubmit (AuthOtp token)
, autocomplete False , autocomplete False
] ]
[ div [ class "flex flex-col mt-6" ] [ div [ class "flex flex-col mt-6" ]

View File

@ -97,7 +97,7 @@ update flags msg model =
cmd = cmd =
if r.success then if r.success then
Page.goto (LoginPage Nothing) Page.goto (LoginPage Page.emptyLoginData)
else else
Cmd.none Cmd.none

View File

@ -232,7 +232,7 @@ viewContent texts flags _ model =
[ text texts.alreadySignedUp [ text texts.alreadySignedUp
] ]
, a , a
[ Page.href (LoginPage Nothing) [ Page.href (LoginPage Page.emptyLoginData)
, class ("ml-2" ++ S.link) , class ("ml-2" ++ S.link)
] ]
[ i [ class "fa fa-user-plus mr-1" ] [] [ i [ class "fa fa-user-plus mr-1" ] []

View File

@ -48,6 +48,19 @@ in
header-value = "test123"; header-value = "test123";
}; };
}; };
openid = [
{ enabled = true;
display = "Local";
provider = {
provider-id = "local";
client-id = "cid1";
client-secret = "csecret-1";
authorize-url = "http:auth";
token-url = "http:token";
sign-key = "b64:uiaeuae";
};
}
];
inherit full-text-search; inherit full-text-search;
}; };

View File

@ -61,6 +61,23 @@ let
valid = "30 days"; valid = "30 days";
}; };
}; };
openid = {
enabled = false;
display = "";
provider = {
provider-id = null;
client-id = null;
client-secret = null;
scope = "profile";
authorize-url = null;
token-url = null;
user-url = "";
sign-key = "";
sig-algo = "RS256";
};
user-key = "preferred_username";
collective-key = "lookup:preferred_username";
};
backend = { backend = {
mail-debug = false; mail-debug = false;
jdbc = { jdbc = {
@ -226,6 +243,90 @@ in {
description = "Authentication"; description = "Authentication";
}; };
openid = mkOption {
type = types.listOf (types.submodule {
options = {
enabled = mkOption {
type = types.bool;
default = defaults.openid.enabled;
description = "Whether to use these settings.";
};
display = mkOption {
type = types.str;
default = defaults.openid.display;
example = "via Keycloak";
description = "The name for the button on the login page.";
};
user-key = mkOption {
type = types.str;
default = defaults.openid.user-key;
description = "The key to retrieve the username";
};
collective-key = mkOption {
type = types.str;
default = defaults.openid.collective-key;
description = "How to retrieve the collective name.";
};
provider = mkOption {
type = (types.submodule {
options = {
provider-id = mkOption {
type = types.str;
default = defaults.openid.provider.provider-id;
example = "keycloak";
description = "The id of the provider, used in the URL and to distinguish other providers.";
};
client-id = mkOption {
type = types.str;
default = defaults.openid.provider.client-id;
description = "The client-id as registered at the OP.";
};
client-secret = mkOption {
type = types.str;
default = defaults.openid.provider.client-secret;
description = "The client-secret as registered at the OP.";
};
scope = mkOption {
type = types.str;
default = defaults.openid.provider.scope;
description = "A scope to define what data to return from OP";
};
authorize-url = mkOption {
type = types.str;
default = defaults.openid.provider.authorize-url;
description = "The URL used to authenticate the user";
};
token-url = mkOption {
type = types.str;
default = defaults.openid.provider.token-url;
description = "The URL used to retrieve the token.";
};
user-url = mkOption {
type = types.str;
default = defaults.openid.provider.user-url;
description = "The URL to the user-info endpoint.";
};
sign-key = mkOption {
type = types.str;
default = defaults.openid.provider.sign-key;
description = "The key for verifying the jwt signature.";
};
sig-algo = mkOption {
type = types.str;
default = defaults.openid.provider.sig-algo;
description = "The expected algorithm used to sign the token.";
};
};
});
default = defaults.openid.provider;
description = "The config for an OpenID Connect provider.";
};
};
});
default = [];
description = "A list of OIDC provider configurations.";
};
integration-endpoint = mkOption { integration-endpoint = mkOption {
type = types.submodule({ type = types.submodule({
options = { options = {

View File

@ -23,6 +23,7 @@ object Dependencies {
val Icu4jVersion = "69.1" val Icu4jVersion = "69.1"
val javaOtpVersion = "0.3.0" val javaOtpVersion = "0.3.0"
val JsoupVersion = "1.14.2" val JsoupVersion = "1.14.2"
val JwtScalaVersion = "9.0.1"
val KindProjectorVersion = "0.10.3" val KindProjectorVersion = "0.10.3"
val KittensVersion = "2.3.2" val KittensVersion = "2.3.2"
val LevigoJbig2Version = "2.0" val LevigoJbig2Version = "2.0"
@ -48,6 +49,10 @@ object Dependencies {
val JQueryVersion = "3.5.1" val JQueryVersion = "3.5.1"
val ViewerJSVersion = "0.5.9" val ViewerJSVersion = "0.5.9"
val jwtScala = Seq(
"com.github.jwt-scala" %% "jwt-circe" % JwtScalaVersion
)
val scodecBits = Seq( val scodecBits = Seq(
"org.scodec" %% "scodec-bits" % ScodecBitsVersion "org.scodec" %% "scodec-bits" % ScodecBitsVersion
) )

View File

@ -44,6 +44,14 @@ must be `docspell_auth` and a custom header must be named
The admin route (see below) `/admin/user/resetPassword` can be used to The admin route (see below) `/admin/user/resetPassword` can be used to
reset a password of a user. reset a password of a user.
### OpenID Connect
Docspell can be configured to be a relying party for OpenID Connect.
Please see [the config
section](@/docs/configure/_index.md#openid-connect-oauth2) for
details.
## Admin ## Admin
There are some endpoints available for adminstration tasks, for There are some endpoints available for adminstration tasks, for

View File

@ -342,6 +342,91 @@ The `session-valid` determines how long a token is valid. This can be
just some minutes, the web application obtains new ones just some minutes, the web application obtains new ones
periodically. So a rather short time is recommended. periodically. So a rather short time is recommended.
### OpenID Connect / OAuth2
You can integrate Docspell into your SSO solution via [OpenID
Connect](https://openid.net/connect/) (OIDC). This requires to set up
an OpenID Provider (OP) somewhere and to configure Docspell
accordingly to act as the relying party.
You can define multiple OPs to use. For some examples, please see the
default configuration file [below](#rest-server).
The configuration of a provider highly depends on how it is setup.
Here is an example for a setup using
[keycloak](https://www.keycloak.org):
``` conf
provider = {
provider-id = "keycloak",
client-id = "docspell",
client-secret = "example-secret-439e-bf06-911e4cdd56a6",
scope = "profile", # scope is required for OIDC
authorize-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/auth",
token-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/token",
#User URL is not used when signature key is set.
#user-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/userinfo",
sign-key = "b64:MII…ZYL09vAwLn8EAcSkCAwEAAQ==",
sig-algo = "RS512"
}
```
The `provider-id` is some identifier that is used in the URL to
distinguish between possibly multiple providers. The `client-id` and
`client-secret` define the two parameters required for a "confidential
client". The different URLs are best explained at the [keycloak
docs](https://www.keycloak.org/docs/latest/server_admin/#_oidc-endpoints).
They are available for all OPs in some way. The `user-url` is not
required, if the access token is already containing the necessary
data. If not, then docspell performs another request to the
`user-url`, which must be the user-info endpoint, to obtain the
required user data.
If the data is taken from the token directly and not via a request to
the user-info endpoint, then the token must be validated using the
given `sign-key` and `sig-algo`. These two values are then required to
specify! However, if the user-info endpoint should be used, then leave
the `sign-key` empty and specify the correct url in `user-url`. When
specifying the `sign-key` use a prefix of `b64:` if it is Base64
encoded or `hex:` if it is hex encoded. Otherwise the unicode bytes
are used, which is most probably not wanted for this setting.
Once the user is authenticated, docspell tries to setup an account and
does some checks. For this it must get to the username and collective
name somehow. How it does this, can be specified by the `user-key` and
`collective-key` settings:
``` conf
# The collective of the user is given in the access token as
# property `docspell_collective`.
collective-key = "lookup:docspell_collective",
# The username to use for the docspell account
user-key = "preferred_username"
```
The `user-key` is some string that is used to search the JSON response
from the OP for an object with that key. The search happens
recursively, so the field can be in a nested object. The found value
is used as the user name. Keycloak transmits the `preferred_username`
when asking for the `profile` scope. This can be used as the user
name.
The collective name can be obtained by different ways. For example,
you can instruct your OP (like keycloak) to provide a collective name
in the token and/or user-info responses. If you do this, then use the
`lookup:` prefix as in the example above. This instructs docspell to
search for a value the same way as the `user-key`. You can also set a
fixed collective, using `fixed:` prefix; in this case all users are in
the same collective! A third option is to prefix it with `account:` -
then the value that is looked up is interpreted as the full account
name, like `collective/user` and the `user-key` setting is ignored. If
you want to put each user in its own collective, you can just use the
same value as in `user-key`, only prefixed with `lookup:`. In the
example it would be `lookup:preferred_username`.
If you find that these methods do not suffice for your case, please
open an issue.
## File Processing ## File Processing

View File

@ -31,8 +31,11 @@ description = "A list of features and limitations."
jobs, set priorities jobs, set priorities
- Everything available via a [documented](https://www.openapis.org/) - Everything available via a [documented](https://www.openapis.org/)
[REST Api](@/docs/api/_index.md); allows to [generate [REST Api](@/docs/api/_index.md); allows to [generate
clients](https://openapi-generator.tech/docs/generators) for clients](https://openapi-generator.tech/docs/generators) for many
(almost) any language languages
- [OpenID Connect](@/docs/configure/_index.md#openid-connect-oauth2)
support allows Docspell to integrate into your SSO setup, for
example with keycloak.
- mobile-friendly Web-UI with dark and light theme - mobile-friendly Web-UI with dark and light theme
- [Create anonymous - [Create anonymous
“upload-urls”](@/docs/webapp/uploading.md#anonymous-upload) to “upload-urls”](@/docs/webapp/uploading.md#anonymous-upload) to