mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-04-04 10:29:34 +00:00
Authenticate with external accounts using OIDC
After successful authentication at the provider, an account is automatically created at docspell and the user is logged in.
This commit is contained in:
parent
7edb96a297
commit
f8362329a9
@ -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(_) =>
|
||||
@ -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,
|
||||
|
@ -9,6 +9,7 @@ package docspell.backend.ops
|
||||
import cats.effect.{Async, Resource}
|
||||
import cats.implicits._
|
||||
import fs2.Stream
|
||||
|
||||
import docspell.backend.JobFactory
|
||||
import docspell.backend.PasswordCrypt
|
||||
import docspell.backend.ops.OCollective._
|
||||
@ -19,6 +20,7 @@ import docspell.store.queue.JobQueue
|
||||
import docspell.store.records._
|
||||
import docspell.store.usertask.{UserTask, UserTaskScope, UserTaskStore}
|
||||
import docspell.store.{AddResult, Store}
|
||||
|
||||
import com.github.eikek.calev._
|
||||
|
||||
trait OCollective[F[_]] {
|
||||
|
@ -1,3 +1,9 @@
|
||||
/*
|
||||
* Copyright 2020 Docspell Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.signup
|
||||
|
||||
import docspell.common._
|
||||
@ -11,3 +17,9 @@ final case class ExternalAccount(
|
||||
def toAccountId: AccountId =
|
||||
AccountId(collName, login)
|
||||
}
|
||||
|
||||
object ExternalAccount {
|
||||
def apply(accountId: AccountId): ExternalAccount =
|
||||
ExternalAccount(accountId.collective, accountId.user, AccountSource.OpenId)
|
||||
|
||||
}
|
||||
|
@ -8,11 +8,13 @@ package docspell.backend.signup
|
||||
|
||||
import cats.effect.{Async, Resource}
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.PasswordCrypt
|
||||
import docspell.common._
|
||||
import docspell.common.syntax.all._
|
||||
import docspell.store.records.{RCollective, RInvitation, RUser}
|
||||
import docspell.store.{AddResult, Store}
|
||||
|
||||
import doobie.free.connection.ConnectionIO
|
||||
import org.log4s.getLogger
|
||||
|
||||
@ -83,23 +85,29 @@ object OSignup {
|
||||
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
|
||||
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 =
|
||||
|
@ -1,3 +1,9 @@
|
||||
/*
|
||||
* Copyright 2020 Docspell Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.signup
|
||||
import docspell.common._
|
||||
|
||||
|
@ -1,6 +1,13 @@
|
||||
/*
|
||||
* 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 =>
|
||||
|
@ -6,12 +6,10 @@
|
||||
|
||||
package docspell.oidc
|
||||
|
||||
import cats.data.OptionT
|
||||
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
|
||||
@ -22,7 +20,16 @@ import org.log4s.getLogger
|
||||
object CodeFlowRoutes {
|
||||
private[this] val log4sLogger = getLogger
|
||||
|
||||
def apply[F[_]: Async, A](
|
||||
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]
|
||||
|
@ -20,21 +20,26 @@ object UserInfoDecoder {
|
||||
findSomeId("preferred_username")
|
||||
|
||||
/** Looks recursively in the JSON for the first attribute with name `key` and returns
|
||||
* its value (expecting an Ident).
|
||||
* its value.
|
||||
*/
|
||||
def findSomeId(key: String): Decoder[Ident] =
|
||||
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'")
|
||||
.flatMap(normalizeUid)
|
||||
.left
|
||||
.map(msg => DecodingFailure(msg, Nil))
|
||||
}
|
||||
|
||||
private def normalizeUid(uid: String): Either[String, Ident] =
|
||||
/** 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!'")
|
||||
|
@ -64,17 +64,62 @@ docspell.server {
|
||||
# Configures OpenID Connect or OAuth2 authentication. Only the
|
||||
# "Authorization Code Flow" is supported.
|
||||
#
|
||||
# When using OpenID Connect, a scope is mandatory.
|
||||
# TODO
|
||||
# Multiple authentication providers are supported. Each is
|
||||
# configured in the array below. The `provider` block gives all
|
||||
# details necessary to authenticate agains an external OpenIdConnect
|
||||
# or OAuth provider. This requires at least two URLs for
|
||||
# OpenIdConnect and three for OAuth2. The `user-url` is only
|
||||
# required for OpenIdConnect, if the account data is to be retrieved
|
||||
# from the user-info endpoint and not from the access token. This
|
||||
# will use the access token to authenticate at the provider to
|
||||
# obtain user info. Thus, it doesn't need to be validated 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.
|
||||
#
|
||||
# After successful authentication, docspell needs to create the
|
||||
# account. For this a username and collective name is required.
|
||||
# There are the following ways to specify how to retrieve this info
|
||||
# depending on the value of `collective-key`. 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.
|
||||
#
|
||||
# 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:doscpell-collective",
|
||||
# it works the same as `lookup:` only that it is interpreted as the
|
||||
# account name of form `collective/name`. The `user-key` value is
|
||||
# ignored in this case.
|
||||
#
|
||||
# Below are examples for OpenID Connect (keycloak) and OAuth2
|
||||
# (github).
|
||||
openid =
|
||||
[ { enabled = false,
|
||||
|
||||
# This illustrates to use a custom keycloak setup as the
|
||||
# authentication provider. For details, please refer to its
|
||||
# documentation.
|
||||
#
|
||||
# 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 = "21cd4550-6328-439e-bf06-911e4cdd56a6",
|
||||
client-secret = "example-secret-439e-bf06-911e4cdd56a6",
|
||||
scope = "docspell",
|
||||
authorize-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/auth",
|
||||
token-url = "http://localhost:8080/auth/realms/home/protocol/openid-connect/token",
|
||||
@ -82,9 +127,22 @@ docspell.server {
|
||||
#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,
|
||||
|
||||
# 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 yet, but supports the
|
||||
# OAuth2 code flow.
|
||||
provider = {
|
||||
provider-id = "github",
|
||||
client-id = "<your github client id>",
|
||||
@ -95,7 +153,17 @@ docspell.server {
|
||||
user-url = "https://api.github.com/user",
|
||||
sign-key = ""
|
||||
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"
|
||||
}
|
||||
]
|
||||
|
||||
|
@ -12,6 +12,7 @@ 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
|
||||
|
||||
@ -29,7 +30,10 @@ case class Config(
|
||||
fullTextSearch: Config.FullTextSearch,
|
||||
adminEndpoint: Config.AdminEndpoint,
|
||||
openid: List[OpenIdConfig]
|
||||
)
|
||||
) {
|
||||
def openIdEnabled: Boolean =
|
||||
openid.exists(_.enabled)
|
||||
}
|
||||
|
||||
object Config {
|
||||
|
||||
@ -73,6 +77,11 @@ object Config {
|
||||
|
||||
object FullTextSearch {}
|
||||
|
||||
final case class OpenIdConfig(enabled: Boolean, provider: ProviderConfig)
|
||||
final case class OpenIdConfig(
|
||||
enabled: Boolean,
|
||||
collectiveKey: OpenId.UserInfo.Extractor,
|
||||
userKey: String,
|
||||
provider: ProviderConfig
|
||||
)
|
||||
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ package docspell.restserver
|
||||
import docspell.backend.signup.{Config => SignupConfig}
|
||||
import docspell.common.config.Implicits._
|
||||
import docspell.oidc.SignatureAlgo
|
||||
import docspell.restserver.auth.OpenId
|
||||
|
||||
import pureconfig._
|
||||
import pureconfig.generic.auto._
|
||||
@ -25,5 +26,8 @@ object ConfigFile {
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
@ -110,6 +110,7 @@ object RestServer {
|
||||
): HttpRoutes[F] =
|
||||
Router(
|
||||
"auth/oauth" -> CodeFlowRoutes(
|
||||
cfg.openIdEnabled,
|
||||
OpenId.handle[F](restApp.backend, cfg),
|
||||
OpenId.codeFlowConfig(cfg),
|
||||
client
|
||||
|
@ -7,12 +7,25 @@
|
||||
package docspell.restserver.auth
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.BackendApp
|
||||
import docspell.oidc.{CodeFlowConfig, OnUserInfo}
|
||||
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(
|
||||
@ -23,35 +36,183 @@ object OpenId {
|
||||
config.openid.filter(_.enabled).find(_.provider.providerId == id).map(_.provider)
|
||||
)
|
||||
|
||||
def handle[F[_]: Async](backend: BackendApp[F], cfg: Config): OnUserInfo[F] =
|
||||
OnUserInfo((req, provider, userInfo) =>
|
||||
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("oauth", "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) =>
|
||||
println(s"$backend $cfg")
|
||||
OnUserInfo.logInfo[F].handle(req, provider, Some(userJson))
|
||||
case None =>
|
||||
OnUserInfo.logInfo[F].handle(req, provider, None)
|
||||
}
|
||||
)
|
||||
val extractColl = cfg.collectiveKey.find(userJson)
|
||||
|
||||
// u <- userInfo
|
||||
// newAcc <- OptionT.liftF(
|
||||
// NewAccount.create(u ++ Ident.unsafe(":") ++ p.providerId, AccountSource.OAuth(p.id.id))
|
||||
// )
|
||||
// acc <- OptionT.liftF(S.account.createIfMissing(newAcc))
|
||||
// accId = acc.accountId(None)
|
||||
// _ <- OptionT.liftF(S.account.updateLoginStats(accId))
|
||||
// token <- OptionT.liftF(
|
||||
// AuthToken.user[F](accId, cfg.backend.auth.serverSecret)
|
||||
// )
|
||||
// } yield token
|
||||
//
|
||||
// val uri = getBaseUrl( req).withQuery("oauth", "1") / "app" / "login"
|
||||
// val location = Location(Uri.unsafeFromString(uri.asString))
|
||||
// userId.value.flatMap {
|
||||
// case Some(t) =>
|
||||
// TemporaryRedirect(location)
|
||||
// .map(_.addCookie(CookieData(t).asCookie(getBaseUrl(req))))
|
||||
// case None => TemporaryRedirect(location)
|
||||
// }
|
||||
extractColl match {
|
||||
case ExtractResult.Failure(message) =>
|
||||
logger.error(s"Error retrieving user data: $message") *>
|
||||
TemporaryRedirect(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.error(s"Error retrieving user data: $message") *>
|
||||
TemporaryRedirect(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!") *>
|
||||
TemporaryRedirect(location)
|
||||
|
||||
case SignupResult.SignupClosed =>
|
||||
logger.error(s"External accounts don't work when signup is closed!") *>
|
||||
TemporaryRedirect(location)
|
||||
|
||||
case SignupResult.CollectiveExists =>
|
||||
logger.error(
|
||||
s"Error when creating external accounts! Collective exists error reported. This is a bug!"
|
||||
) *>
|
||||
TemporaryRedirect(location)
|
||||
|
||||
case SignupResult.InvalidInvitationKey =>
|
||||
logger.error(
|
||||
s"Error when creating external accounts! Invalid invitation key reported. This is a bug!"
|
||||
) *>
|
||||
TemporaryRedirect(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, _) =>
|
||||
TemporaryRedirect(location)
|
||||
.map(_.addCookie(CookieData(session).asCookie(baseUrl)))
|
||||
|
||||
case failed =>
|
||||
Logger.log4s(log).error(s"External login failed: $failed") *>
|
||||
TemporaryRedirect(location)
|
||||
}
|
||||
} yield resp
|
||||
}
|
||||
|
||||
object UserInfo {
|
||||
|
||||
sealed trait Extractor {
|
||||
def find(json: Json): ExtractResult
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user