aglinxinyuan commented on code in PR #7055:
URL: https://github.com/apache/texera/pull/7055#discussion_r3752838568


##########
amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResource.scala:
##########
@@ -73,14 +77,23 @@ class AdminUserResource {
   @Path("/list")
   @Produces(Array(MediaType.APPLICATION_JSON))
   def list(): util.List[UserInfo] = {
+
+    val googleProvider = AUTH_PROVIDER.as("google_provider")
+
     AdminUserResource.context
       .select(
         USER.UID,
         USER.NAME,
         USER.EMAIL,
-        USER.GOOGLE_ID,
+        // fetchInto maps onto a Scala case class POSITIONALLY, not by name: a 
case class has no
+        // no-arg constructor, so jOOQ falls through to ImmutablePOJOMapper. 
So the column order
+        // below must track the UserInfo field order — adding, removing or 
reordering a projected
+        // column here without doing the same to UserInfo silently shifts 
every later field.
+        // `last_active_time` landing on `lastLogin` only works because of 
that. The aliases are
+        // documentation; they do not drive the mapping.
+        googleProvider.PROVIDER_ID.as("googleId"),

Review Comment:
   The LOCAL handle is now what a user types to log in, and it can diverge from 
`"user".name`: an external login rewrites the display name but never the 
handle, and `33.sql` mints suffixed handles (`john-2`) for accounts that shared 
a name. Nothing surfaces it, though — this projection returns `USER.NAME`, and 
the migration's own NOTICE says the affected users *"cannot guess their handle 
and must be told it or given a reset."*
   
   So the only way for an operator to answer "what is this user's login 
handle?" is a direct `auth_provider` query. The join machinery is already here; 
a second alias makes it recoverable:
   
   ```scala
   val localProvider = AUTH_PROVIDER.as("local_provider")
   // ...
   localProvider.PROVIDER_ID.as("loginHandle"),
   // ...
   .leftJoin(localProvider)
   .on(localProvider.PROVIDER_TYPE.eq(ProviderTypeEnum.LOCAL))
   .and(localProvider.UID.eq(USER.UID))
   ```
   
   plus the matching `UserInfo` field in the same position — per the 
positional-mapping warning added just above. Fine as a follow-up if you'd 
rather keep this PR scoped, but it should land before `33.sql` runs anywhere 
real.



##########
amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala:
##########
@@ -19,30 +19,38 @@
 
 package org.apache.texera.web.resource.auth
 
-import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier
+import com.google.api.client.googleapis.auth.oauth2.{GoogleIdToken, 
GoogleIdTokenVerifier}
 import com.google.api.client.http.javanet.NetHttpTransport
 import com.google.api.client.json.gson.GsonFactory
-import org.apache.texera.auth.JwtAuth.{TOKEN_EXPIRE_TIME_IN_MINUTES, 
jwtClaims, jwtToken}
+import org.apache.texera.auth.JwtAuth.{jwtClaims, jwtToken}
 import org.apache.texera.common.config.UserSystemConfig
-import org.apache.texera.dao.SqlServer
-import org.apache.texera.dao.jooq.generated.enums.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.dao.jooq.generated.enums.ProviderTypeEnum
 import org.apache.texera.web.model.http.response.TokenIssueResponse
-import org.apache.texera.web.resource.auth.GoogleAuthResource.userDao
 
 import java.util.Collections
 import javax.ws.rs._
 import javax.ws.rs.core.MediaType
 
 object GoogleAuthResource {
-  private def userDao =
-    new UserDao(
-      SqlServer
-        .getInstance()
-        .createDSLContext()
-        .configuration
+
+  /**
+    * Reduce a verified Google id-token payload to the fields we persist. 
Google omits `name`
+    * for accounts with no profile name, and the provisioner writes `name` 
straight to a NOT
+    * NULL column, so the address stands in for it. Only the last path segment 
of `picture` is
+    * kept — the frontend rebuilds the full `lh3.googleusercontent.com` URL 
around it.
+    */
+  private[auth] def profileOf(payload: GoogleIdToken.Payload): ExternalProfile 
= {
+    val googleEmail = payload.getEmail
+    ExternalProfile(
+      ProviderTypeEnum.GOOGLE,
+      payload.getSubject,
+      
Option(payload.get("name").asInstanceOf[String]).filter(_.nonEmpty).getOrElse(googleEmail),

Review Comment:
   Two things about the payload reduction:
   
   **`email_verified` is dropped.** `ExternalAuthProvisioner.provision` links a 
new external identity to any account whose email matches, and claims a 
placeholder while doing it — so an id token asserting an *unverified* address 
is enough to take over an existing account. Google doesn't guarantee 
`email_verified` is true; it can be false for Workspace/custom-domain accounts. 
Pre-existing behavior for Google, but this PR promotes the path to *the* 
generic one that IEEE/GitHub logins will reuse, so gating it here now is much 
cheaper than gating it in three providers later.
   
   **Null email.** `getEmail` feeds `userByEmailIgnoreCase` → 
`EmailUtil.normalize`, which is `email.trim.toLowerCase` and NPEs on null. 
`name` and `picture` are both guarded here; `email` is the one that isn't — and 
it's also what `name` falls back to, so a null email additionally means a NOT 
NULL violation on `"user".name`. Also pre-existing, but this method is now the 
single place that guard belongs.



##########
amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala:
##########
@@ -53,67 +61,34 @@ class GoogleAuthResource {
   @Path("/clientid")
   def getClientId: String = clientId
 
+  private lazy val verifier =

Review Comment:
   Jersey instantiates this resource per request by default, so a 
per-*instance* `lazy val` still builds a `GoogleIdTokenVerifier` — and a 
`NetHttpTransport`, which carries its own connection pool — on every login. 
That's the same cost as the old inline construction inside `login`, so the 
`lazy val` doesn't buy what it looks like it buys.
   
   `GoogleIdTokenVerifier` is documented thread-safe, so moving it to the 
companion object (alongside `profileOf`) makes it one per JVM.



##########
amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResource.scala:
##########
@@ -119,14 +135,18 @@ class AdminUserResource {
   @POST
   @Path("/add")
   def addUser(): Unit = {
-    val random = System.currentTimeMillis().toString
-    val newUser = new User
-    newUser.setName("User" + random)
-    newUser.setPassword(new StrongPasswordEncryptor().encryptPassword(random))
-    newUser.setRole(UserRoleEnum.INACTIVE)
-    userDao.insert(newUser)
+    val random = UUID.randomUUID().toString
+    createLocalAccount("User" + random, random)
   }
 
+  /**
+    * Create a user together with the LOCAL credential it logs in with. Split 
out of `addUser`
+    * so the collision path is reachable: `addUser` derives its handle from a 
fresh UUID and
+    * so cannot produce the unique violation this maps to a 409.
+    */
+  private[user] def createLocalAccount(handle: String, rawPassword: String): 
Unit =
+    LocalAuthProvisioner.createLocalAccount(handle, rawPassword)

Review Comment:
   Two notes on this block:
   
   **The `createLocalAccount` seam has no user.** Its doc says it was *"Split 
out of `addUser` so the collision path is reachable"* — but it's a one-line 
delegate with exactly one caller (line 139), and nothing in 
`AdminUserResourceSpec` calls it. The collision path is tested directly against 
`LocalAuthProvisioner` in `LocalAuthProvisionerSpec`, which is the better home 
for it. `LocalAuthProvisioner`'s 2-arg `createLocalAccount` overload exists 
only to feed this wrapper, so both layers can collapse into one call.
   
   **The generated password is derivable from the generated handle.** 
`createLocalAccount("User" + random, random)` makes the password the same UUID 
as the visible username, so anyone who can read `/list` can authenticate as 
that account. Pre-existing — it was `System.currentTimeMillis()` before, which 
was worse — but the line is being rewritten here, and two independent values 
cost nothing.



##########
amber/src/main/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisioner.scala:
##########
@@ -0,0 +1,195 @@
+/*
+ * 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.resource.auth
+
+import com.typesafe.scalalogging.LazyLogging
+import org.apache.texera.dao.{SqlServer, SqlStates}
+import org.apache.texera.dao.jooq.generated.Tables.{AUTH_PROVIDER, USER}
+import org.apache.texera.dao.jooq.generated.enums.{ProviderTypeEnum, 
UserRoleEnum}
+import org.apache.texera.dao.jooq.generated.tables.daos.{AuthProviderDao, 
UserDao}
+import org.apache.texera.dao.jooq.generated.tables.pojos.{AuthProvider, User}
+import org.apache.texera.common.util.EmailUtil
+import org.jooq.DSLContext
+import org.jooq.exception.DataAccessException
+import org.jooq.impl.DSL
+
+import java.time.OffsetDateTime
+import scala.util.chaining.scalaUtilChainingOps
+
+/**
+  * A verified external identity (Google, Facebook, ...) reduced to the fields 
we persist.
+  */
+final case class ExternalProfile(
+    providerType: ProviderTypeEnum,
+    providerId: String,
+    name: String,
+    email: String,
+    avatar: String
+)
+
+object ExternalAuthProvisioner extends LazyLogging {
+
+  /**
+    * The account owning `email`, matched case-insensitively and within the 
caller's transaction
+    * so it reads that transaction's own writes.
+    *
+    * Case-insensitivity is required, not a nicety: `"user".email` is a plain 
case-sensitive
+    * UNIQUE and `idx_user_email_lower` is not unique, so `[email protected]` and 
`[email protected]` can
+    * coexist. Registration stores the address as the user typed it while 
contributor
+    * placeholders are stored lower-cased, so the casings provably differ in 
practice. An
+    * exact-match lookup here would miss, insert a second account without 
violating any
+    * constraint, and silently strand the original account's data.
+    *
+    * Mirrors `AuthResource.fetchUserByEmailIgnoreCase`, which cannot be 
reused directly because
+    * it opens its own DSLContext.
+    */
+  private def userByEmailIgnoreCase(ctx: DSLContext, email: String): 
Option[User] =

Review Comment:
   The scaladoc says this can't reuse `AuthResource.fetchUserByEmailIgnoreCase` 
*"because it opens its own DSLContext"* — but `SqlServer.createDSLContext()` is 
`def createDSLContext(): DSLContext = context`, i.e. it hands back the same 
shared context rather than opening anything.
   
   The real reason for the copy is transaction scope: this lookup has to run 
inside the caller's transaction so it reads that transaction's own writes. 
That's legitimate, but it's satisfiable without duplicating the query — give 
`AuthResource.fetchUserByEmailIgnoreCase` a `DSLContext` overload and route 
both call sites through it. As written, the case-insensitivity rule that both 
copies document (correctly, and at length) has to stay in sync by hand.



##########
amber/src/main/scala/org/apache/texera/web/resource/auth/ExternalAuthProvisioner.scala:
##########
@@ -0,0 +1,195 @@
+/*
+ * 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.resource.auth
+
+import com.typesafe.scalalogging.LazyLogging
+import org.apache.texera.dao.{SqlServer, SqlStates}
+import org.apache.texera.dao.jooq.generated.Tables.{AUTH_PROVIDER, USER}
+import org.apache.texera.dao.jooq.generated.enums.{ProviderTypeEnum, 
UserRoleEnum}
+import org.apache.texera.dao.jooq.generated.tables.daos.{AuthProviderDao, 
UserDao}
+import org.apache.texera.dao.jooq.generated.tables.pojos.{AuthProvider, User}
+import org.apache.texera.common.util.EmailUtil
+import org.jooq.DSLContext
+import org.jooq.exception.DataAccessException
+import org.jooq.impl.DSL
+
+import java.time.OffsetDateTime
+import scala.util.chaining.scalaUtilChainingOps
+
+/**
+  * A verified external identity (Google, Facebook, ...) reduced to the fields 
we persist.
+  */
+final case class ExternalProfile(
+    providerType: ProviderTypeEnum,
+    providerId: String,
+    name: String,
+    email: String,
+    avatar: String
+)
+
+object ExternalAuthProvisioner extends LazyLogging {
+
+  /**
+    * The account owning `email`, matched case-insensitively and within the 
caller's transaction
+    * so it reads that transaction's own writes.
+    *
+    * Case-insensitivity is required, not a nicety: `"user".email` is a plain 
case-sensitive
+    * UNIQUE and `idx_user_email_lower` is not unique, so `[email protected]` and 
`[email protected]` can
+    * coexist. Registration stores the address as the user typed it while 
contributor
+    * placeholders are stored lower-cased, so the casings provably differ in 
practice. An
+    * exact-match lookup here would miss, insert a second account without 
violating any
+    * constraint, and silently strand the original account's data.
+    *
+    * Mirrors `AuthResource.fetchUserByEmailIgnoreCase`, which cannot be 
reused directly because
+    * it opens its own DSLContext.
+    */
+  private def userByEmailIgnoreCase(ctx: DSLContext, email: String): 
Option[User] =
+    Option(
+      ctx
+        .selectFrom(USER)
+        .where(DSL.lower(USER.EMAIL).eq(EmailUtil.normalize(email)))
+        .fetchOneInto(classOf[User])
+    )
+
+  /**
+    * Resolve the user behind an external identity, creating one if necessary, 
and
+    * ensure its auth-provider row is present and up to date. Each attempt 
runs in one
+    * transaction. A unique violation is taken to mean a concurrent login won 
the race, so the
+    * attempt is re-run once; if the retry violates a constraint too, that 
exception propagates.
+    */
+  def loginOrProvision(profile: ExternalProfile): User = {
+
+    try {
+      provision(profile)
+    } catch {
+      case e: DataAccessException if e.sqlState() == 
SqlStates.UNIQUE_VIOLATION =>
+        provision(profile)
+    }
+  }
+
+  private def provision(profile: ExternalProfile) = {
+    SqlServer.withTransaction(SqlServer.getInstance().createDSLContext()) { 
ctx =>
+      val txUserDao = new UserDao(ctx.configuration())
+      val txAuthDao = new AuthProviderDao(ctx.configuration())
+
+      Option(
+        ctx
+          .select()
+          .from(USER)
+          .join(AUTH_PROVIDER)
+          .on(USER.UID.eq(AUTH_PROVIDER.UID))
+          .where(AUTH_PROVIDER.PROVIDER_TYPE.eq(profile.providerType))
+          .and(AUTH_PROVIDER.PROVIDER_ID.eq(profile.providerId))
+          .fetchOne()
+      ) match {
+        case Some(record) =>
+          txUserDao.fetchOneByUid(record.get(USER.UID)).tap { user =>

Review Comment:
   A few small things in `provision`:
   
   **Redundant round trip (this line).** The 
`select().from(USER).join(AUTH_PROVIDER)` above already fetched every `USER` 
column into `record`, and this re-reads the same row by uid. 
`AuthResource.retrieveUserByUsernameAndPassword` does the same join and maps in 
place — `record.into(USER).into(classOf[User])` — which works here too and 
saves a query on the hot returning-user path.
   
   **Line 111 — `val claimed = user.getIsPlaceholder`** reads as "was already 
claimed" but holds "is an *un*claimed placeholder", which makes `if (drifted || 
claimed)` below read backwards. `wasPlaceholder` would say it.
   
   **Line 87 — `private def provision(profile: ExternalProfile) = {`** has no 
declared return type, on a method whose inferred `User` comes out of a 25-line 
two-branch `match`. Worth writing `: User`.
   
   **Line 79** — stray blank line between `loginOrProvision`'s signature and 
its `try`.



##########
common/auth/src/main/scala/org/apache/texera/auth/JwtAuth.scala:
##########
@@ -51,14 +51,29 @@ object JwtAuth {
     jws.getCompactSerialization
   }
 
-  def jwtClaims(user: User, expireInDays: Int): JwtClaims = {
+  /**
+    * Build the claim set for `user`. The claim names are a contract with the 
hand-written
+    * TypeScript reader in 
`frontend/src/app/common/service/user/auth.service.ts`, which is not
+    * compiled against this file — so renaming one here silently breaks the 
frontend. `avatar`
+    * now lives on `"user"` rather than a Google-specific column, but the 
claim keeps its
+    * `googleAvatar` name until the frontend is migrated in lockstep.
+    *
+    * `googleId` is passed in rather than read off `user`, because the GOOGLE 
provider id lives
+    * in `auth_provider` and this module must stay DB-free: the specs in
+    * `access-control-service` / `config-service` and the token re-issue paths 
in
+    * `ResultExportService` / `ComputingUnitManagingResource` all call this 
with no
+    * `auth_provider` context, as does `AuthResource.register`. Omitting the 
claim is harmless
+    * on all of them: a service-to-service token never reaches the browser, 
and a freshly
+    * registered LOCAL account has no Google identity to name.
+    */
+  def jwtClaims(user: User, googleId: Option[String] = None): JwtClaims = {
     val claims = new JwtClaims
     claims.setSubject(user.getName)
     claims.setClaim("userId", user.getUid)
-    claims.setClaim("googleId", user.getGoogleId)
     claims.setClaim("email", user.getEmail)
     claims.setClaim("role", user.getRole)
-    claims.setClaim("googleAvatar", user.getGoogleAvatar)
+    claims.setClaim("googleAvatar", user.getAvatar)
+    googleId.foreach(claims.setClaim("googleId", _))

Review Comment:
   Worth being explicit that this changes the token's *shape*, not just where 
the value comes from: `googleId` used to be written unconditionally, so a 
local-only user's token carried `"googleId": null`. It's now absent entirely.
   
   `frontend/src/app/common/type/user.ts` declares `googleId?: string`, so the 
read at `auth.service.ts:167` is fine either way. The one place it leaks is 
`dashboard/service/user/flarum/flarum.service.ts:39,48`, which passes 
`user.googleId` as the Flarum account password — `JSON.stringify` will now omit 
the `password` key rather than send `null`. That path was already broken for 
non-Google users, so it's a different flavour of broken rather than a 
regression; flagging it in case anyone has Flarum enabled.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to