Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import org.apache.texera.auth.SessionUser
import org.apache.texera.dao.SqlServer
import org.apache.texera.web.auth.JwtAuth.setupJwtAuth
import org.apache.texera.web.resource._
import org.apache.texera.web.resource.auth.{AuthResource, GoogleAuthResource}
import org.apache.texera.web.resource.auth.{AuthResource, GoogleAuthResource, OrcidAuthResource}
import org.apache.texera.web.resource.dashboard.DashboardResource
import org.apache.texera.web.resource.dashboard.admin.execution.AdminExecutionResource
import org.apache.texera.web.resource.dashboard.admin.user.AdminUserResource
Expand Down Expand Up @@ -143,6 +143,7 @@ class TexeraWebApplication

environment.jersey.register(classOf[AuthResource])
environment.jersey.register(classOf[GoogleAuthResource])
environment.jersey.register(classOf[OrcidAuthResource])
environment.jersey.register(classOf[UserConfigResource])
environment.jersey.register(classOf[FeedbackResource])
environment.jersey.register(classOf[AdminUserResource])
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

package org.apache.texera.web.model.http.request.auth

/**
* The address supplied by a signed-in user whose account has none — see `AuthResource.setEmail`.
* There is no uid: the account is the one the request is authenticated as.
*/
case class SetEmailRequest(email: String)
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,31 @@
package org.apache.texera.web.resource.auth

import com.typesafe.scalalogging.Logger
import io.dropwizard.auth.Auth
import org.apache.texera.auth.JwtAuth.{jwtClaims, jwtToken}
import org.apache.texera.auth.SessionUser
import org.apache.texera.common.config.UserSystemConfig
import org.apache.texera.common.util.EmailUtil
import org.apache.texera.dao.SqlServer
import org.apache.texera.dao.jooq.generated.Tables.{AUTH_PROVIDER, USER}
import org.apache.texera.dao.jooq.generated.Tables.{AUTH_PROVIDER, USER, USER_LAST_ACTIVE_TIME}
import org.apache.texera.dao.jooq.generated.enums.{ProviderTypeEnum, UserRoleEnum}
import org.apache.texera.dao.jooq.generated.tables.daos.UserDao
import org.apache.texera.dao.jooq.generated.tables.pojos.User
import org.apache.texera.web.model.http.request.auth.{UserLoginRequest, UserRegistrationRequest}
import org.apache.texera.web.model.http.request.auth.{
SetEmailRequest,
UserLoginRequest,
UserRegistrationRequest
}
import org.apache.texera.web.model.http.response.TokenIssueResponse
import org.apache.texera.web.resource.auth.AuthResource._
import org.jooq.DSLContext
import org.jooq.impl.DSL

import java.time.Instant
import java.time.temporal.ChronoUnit
import javax.annotation.security.RolesAllowed
import javax.ws.rs._
import javax.ws.rs.core.MediaType
import javax.ws.rs.core.{MediaType, Response}

object AuthResource {
private val logger: Logger = Logger(classOf[AuthResource])
Expand Down Expand Up @@ -145,6 +153,122 @@ object AuthResource {
@Produces(Array(MediaType.APPLICATION_JSON))
class AuthResource {

/**
* Give the signed-in account the email address it does not have yet, and reissue its token so
* the `email` claim stops being null.
*
* This exists because an identity-only provider (ORCID) authenticates someone without
* asserting an address, while email is what the rest of the product addresses a user by —
* dataset paths are built from it and every access grant names one. So the account is real and
* signed in, but inert until this runs.
*
* The address is whatever the user typed, so it buys nothing that a verified one would:
*
* - It may create the account's own identity (the ordinary case) or claim a contributor
* placeholder, both of which the register path already does on an unverified address
* (see `register`).
* - It may never attach the caller to an account that already holds a credential. That
* account's owner has not consented, and anyone can type their address — it is the takeover
* [[ExternalProfile]] describes. Those callers are told to sign in with that account
* instead, and can link ORCID to it afterwards.
*
* Filling a blank only: changing an address that is already set is a different operation, with
* a different threat model, and is refused here.
*/
@PUT
@Path("/email")
@RolesAllowed(Array("INACTIVE", "RESTRICTED", "REGULAR", "ADMIN"))
def setEmail(request: SetEmailRequest, @Auth sessionUser: SessionUser): TokenIssueResponse = {
val email = Option(request.email).getOrElse("").trim
if (email.isEmpty) throw new NotAcceptableException("Email cannot be empty")
if (!EmailUtil.isValid(email)) throw new NotAcceptableException("Email format is invalid.")

val user = SqlServer.withTransaction(SqlServer.getInstance().createDSLContext()) { ctx =>
val txUserDao = new UserDao(ctx.configuration())

// Re-read inside the transaction: the pojo on the session was built from the token and may
// be minutes old, so it is not evidence about the row as it stands now.
val current = txUserDao.fetchOneByUid(sessionUser.getUid)
if (current == null) throw new NotAuthorizedException("Login credentials are incorrect.")
if (current.getEmail != null) {
throw new WebApplicationException(
"This account already has an email address.",
Response.Status.CONFLICT
)
}

Option(fetchUserByEmailIgnoreCase(ctx, email)) match {
case None =>
current.setEmail(email)
txUserDao.update(current)
current

case Some(existing) if existing.getIsPlaceholder =>
adoptPlaceholder(ctx, txUserDao, current, existing)

case Some(_) =>
throw new WebApplicationException(
"That email address already belongs to an account. Sign in to that account instead.",
Response.Status.CONFLICT
)
}
}

TokenIssueResponse(jwtToken(jwtClaims(user)))
}

/**
* Move the caller's external identity onto the contributor placeholder that owns `email`, and
* drop the account the identity provider created moments ago.
*
* Keeping the placeholder's uid is the whole point: dataset contributor rows already reference
* it, and re-pointing those instead would mean touching every table that FKs to `"user"`. It
* mirrors what `register` does when a registration presents a placeholder's address.
*
* Discarding the caller's own row is only safe because of what it cannot have accumulated: it
* has no email, so nothing email-keyed can name it, and it is INACTIVE, which no endpoint in any
* service admits — `setEmail` above is the single `@RolesAllowed` that names INACTIVE, and all
* of the others require REGULAR or ADMIN. So such an account has been refused everywhere it
* could have created something. A caller past INACTIVE keeps its account and is refused instead.
*/
private def adoptPlaceholder(
ctx: DSLContext,
txUserDao: UserDao,
current: User,
placeholder: User
): User = {
val callerIsEmpty = current.getRole == UserRoleEnum.INACTIVE
val placeholderHasCredential = ctx.fetchExists(
ctx.selectFrom(AUTH_PROVIDER).where(AUTH_PROVIDER.UID.eq(placeholder.getUid))
)
if (!callerIsEmpty || placeholderHasCredential) {
throw new WebApplicationException(
"That email address already belongs to an account. Sign in to that account instead.",
Response.Status.CONFLICT
)
}

ctx
.update(AUTH_PROVIDER)
.set(AUTH_PROVIDER.UID, placeholder.getUid)
.where(AUTH_PROVIDER.UID.eq(current.getUid))
.execute()

// The provider's display name is the user's own, so it wins over the one whoever listed them
// as a contributor typed.
placeholder.setName(current.getName)
claimPlaceholder(placeholder)
txUserDao.update(placeholder)

ctx
.deleteFrom(USER_LAST_ACTIVE_TIME)
.where(USER_LAST_ACTIVE_TIME.UID.eq(current.getUid))
.execute()

txUserDao.deleteById(current.getUid)
placeholder
}

@POST
@Path("/login")
def login(request: UserLoginRequest): TokenIssueResponse = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,20 @@ import java.time.OffsetDateTime
import scala.util.chaining.scalaUtilChainingOps

/**
* A verified external identity (Google, Facebook, ...) reduced to the fields we persist.
* A verified external identity (Google, ORCID, ...) reduced to the fields we persist.
*
* `email` must be non-blank and provider-verified: `loginOrProvision` links the identity to the
* account owning that address and claims its placeholder, so an unverified address is a
* takeover. Each provider checks this in its own mapping function (Google: `email_verified`).
* `email`, when present, must be non-blank and provider-verified: `loginOrProvision` links the
* identity to the account owning that address and claims its placeholder, so an unverified
* address is a takeover. Each provider checks this in its own mapping function (Google:
* `email_verified`).
*
* `None` means the provider authenticates an identity without asserting an address — ORCID,
* whose `/authenticate` scope yields an iD and a name and nothing else. Such a login provisions
* an account with a NULL email and is deliberately never matched to an existing one, because
* the only address available for matching would be one the user typed. The account is
* functional for signing in but not for the email-keyed parts of the product (dataset paths,
* access grants), so the frontend collects an address before it is useful — see
* `AuthResource.setEmail`.
*
* `avatar` is the complete URL the provider supplied, already sanitized by `AvatarUtil`.
* `None` means the provider offered no avatar we would store, in which case the account keeps
Expand All @@ -46,7 +55,7 @@ final case class ExternalProfile(
providerType: ProviderTypeEnum,
providerId: String,
name: String,
email: String,
email: Option[String],
avatar: Option[String]
)

Expand Down Expand Up @@ -98,7 +107,9 @@ object ExternalAuthProvisioner extends LazyLogging {
}

case None =>
val user = userByEmailIgnoreCase(ctx, profile.email) match {
// An identity-only provider (`email` is None) skips the lookup entirely rather than
// matching on nothing, so it always lands in the insert branch below.
val user = profile.email.flatMap(userByEmailIgnoreCase(ctx, _)) match {
case Some(existing) =>
existing.tap { user =>
val wasPlaceholder = user.getIsPlaceholder
Expand All @@ -109,7 +120,9 @@ object ExternalAuthProvisioner extends LazyLogging {
case None =>
val created = new User()
created.setName(profile.name)
created.setEmail(profile.email)
// Left NULL for an identity-only provider. The column is nullable and its UNIQUE
// index tolerates repeated NULLs, so several such accounts can coexist.
profile.email.foreach(created.setEmail)
profile.avatar.foreach(created.setAvatar)
created.setRole(UserRoleEnum.INACTIVE)
txUserDao.insert(created)
Expand Down Expand Up @@ -137,15 +150,19 @@ object ExternalAuthProvisioner extends LazyLogging {
/**
* Mutate `user` in place to match `profile`, returning true iff anything changed
* (so the caller only issues an UPDATE when needed).
*
* A field the provider did not assert is left as it is rather than blanked: an identity-only
* provider carries no address, and on a returning login the account may well have one by then
* — collected through `AuthResource.setEmail` — which this must not undo.
*/
private def refresh(user: User, profile: ExternalProfile): Boolean = {
var changed = false
if (user.getName != profile.name) {
user.setName(profile.name)
changed = true
}
if (user.getEmail != profile.email) {
user.setEmail(profile.email)
profile.email.filter(_ != user.getEmail).foreach { email =>
user.setEmail(email)
changed = true
}
profile.avatar.filter(_ != user.getAvatar).foreach { url =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ object GoogleAuthResource {
ProviderTypeEnum.GOOGLE,
payload.getSubject,
Option(payload.get("name").asInstanceOf[String]).filter(_.nonEmpty).getOrElse(googleEmail),
googleEmail,
// Always `Some`: the checks above refuse a payload without a verified address, so Google
// remains an email-asserting provider and keeps linking to existing accounts.
Some(googleEmail),
avatar = AvatarUtil.sanitize(Option(payload.get("picture").asInstanceOf[String]))
)
}
Expand Down
Loading
Loading