xuang7 commented on code in PR #5250:
URL: https://github.com/apache/texera/pull/5250#discussion_r3315377809
##########
amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala:
##########
@@ -39,7 +39,19 @@ import javax.ws.rs.core.SecurityContext
}
val GUEST: User =
- new User(null, "guest", null, null, null, null, UserRoleEnum.REGULAR,
null, null, null, null)
+ new User(
+ null,
+ "guest",
+ null,
+ null,
+ null,
+ null,
Review Comment:
I didn't quite get why this change is needed. Could you revert this
unrelated formatting change and run the formatter based on the contributing
guide before the final update?
##########
bin/k8s/values-development.yaml:
##########
@@ -291,6 +291,10 @@ texeraEnvVars:
value: "true"
- name: USER_SYS_GOOGLE_CLIENT_ID
value: ""
+ - name: USER_SYS_GOOGLE_CLIENT_SECRET
+ value: ""
+ - name: USER_SYS_GOOGLE_API_KEY
+ value: ""
Review Comment:
This seems a bit outside the scope of this PR.
##########
sql/updates/23.sql:
##########
@@ -0,0 +1,36 @@
+/*
+ * 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.
+ */
+
+\c texera_db
+
+SET search_path TO texera_db;
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS user_oauth_token (
+ otid SERIAL PRIMARY KEY,
+ uid INT NOT NULL,
+ provider VARCHAR(64) NOT NULL,
+ auth_blob TEXT NOT NULL,
+
Review Comment:
We could consider adding a creation_time field.
##########
common/config/src/main/resources/auth.conf:
##########
@@ -25,4 +25,8 @@ auth {
256-bit-secret = "8a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d"
256-bit-secret = ${?AUTH_JWT_SECRET}
}
+ encryption {
+ 256-bit-secret = "8a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d"
Review Comment:
It would be better to use a different default value for the token encryption
secret.
##########
amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleDriveAuthResource.scala:
##########
@@ -0,0 +1,205 @@
+/*
+ * 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 io.dropwizard.auth.Auth
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.typesafe.scalalogging.LazyLogging
+import org.apache.texera.auth.{JwtParser, SessionUser, TokenEncryptionService}
+import org.apache.texera.web.model.http.response.DriveTokenIssueResponse
+import org.apache.texera.web.resource.auth.GoogleDriveAuthResource._
+import org.apache.texera.dao.jooq.generated.tables.daos.UserOauthTokenDao
+import org.apache.texera.dao.jooq.generated.tables.pojos.UserOauthToken
+import org.apache.texera.dao.SqlServer
+import org.apache.texera.config.UserSystemConfig
+import org.apache.texera.auth.JwtAuth.{TOKEN_EXPIRE_TIME_IN_MINUTES, jwtClaims}
+import org.apache.texera.auth.JwtAuth
+import com.google.api.client.googleapis.auth.oauth2.{
+ GoogleAuthorizationCodeRequestUrl,
+ GoogleAuthorizationCodeTokenRequest,
+ GoogleRefreshTokenRequest,
+ GoogleTokenResponse
+}
+import com.google.api.client.auth.oauth2.TokenResponseException
+import com.google.api.client.http.javanet.NetHttpTransport
+import com.google.api.client.json.gson.GsonFactory
+
+import javax.annotation.security.RolesAllowed
+import javax.ws.rs._
+import javax.ws.rs.core.MediaType
+import javax.ws.rs.core.Response
+
+object GoogleDriveAuthResource {
+ private val STATUS_OK = "ok"
+ private val STATUS_NO_REFRESH_TOKEN = "no_refresh_token"
+ private val STATUS_INVALID_GRANT = "invalid_grant"
+ private val PROVIDER_GOOGLE_DRIVE = "google_drive"
+
+ private val mapper = new ObjectMapper()
+
+ private def oauthTokenDao =
+ new UserOauthTokenDao(
+ SqlServer
+ .getInstance()
+ .createDSLContext()
+ .configuration
+ )
+}
+
+@Consumes(Array(MediaType.APPLICATION_JSON))
+@Produces(Array(MediaType.APPLICATION_JSON))
+class GoogleDriveAuthResource extends LazyLogging {
+ final private lazy val clientId = UserSystemConfig.googleClientId
+ final private lazy val clientSecret = UserSystemConfig.googleClientSecret
+ final private lazy val redirectUri = UserSystemConfig.appDomain
+ .map(domain => s"https://$domain/api/auth/google/drive/callback")
+ .getOrElse("http://localhost:4200/api/auth/google/drive/callback")
+
+ @GET
+ @Path("/token")
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ def getDriveAccessToken(@Auth sessionUser: SessionUser): Response = {
+ val uid = sessionUser.getUid
+ val record = oauthTokenDao.fetchByUid(uid).stream()
+ .filter(r => r.getProvider == PROVIDER_GOOGLE_DRIVE)
+ .findFirst()
+ .orElse(null)
+
+ if (record == null) {
+ return Response.ok(DriveTokenIssueResponse(STATUS_NO_REFRESH_TOKEN,
None)).build()
+ }
+
+ try {
+ val blob =
mapper.readTree(TokenEncryptionService.decrypt(record.getAuthBlob))
+ val refreshToken = blob.get("refreshToken").asText()
+
+ val tokenResponse = new GoogleRefreshTokenRequest(
+ new NetHttpTransport(),
+ GsonFactory.getDefaultInstance,
+ refreshToken,
+ clientId,
+ clientSecret
+ ).execute()
+
+ Response.ok(DriveTokenIssueResponse(STATUS_OK,
Some(tokenResponse.getAccessToken))).build()
+ } catch {
+ case e: TokenResponseException =>
+ if (e.getDetails != null && e.getDetails.getError ==
STATUS_INVALID_GRANT) {
+ Response.ok(DriveTokenIssueResponse(STATUS_INVALID_GRANT,
None)).build()
+ } else {
+ logger.error("Failed to refresh access token", e)
+ Response.status(Response.Status.INTERNAL_SERVER_ERROR).build()
+ }
+ case e: Exception =>
+ logger.error("Unexpected error refreshing access token", e)
+ Response.status(Response.Status.INTERNAL_SERVER_ERROR).build()
+ }
+ }
+
+ @GET
+ @Path("/callback")
+ @Produces(Array(MediaType.TEXT_HTML, MediaType.APPLICATION_JSON))
+ def getCallback(
+ @QueryParam("code") @DefaultValue("") code: String,
+ @QueryParam("state") @DefaultValue("") state: String
+ ): Response = {
+ if (code.isEmpty || state.isEmpty) {
+ return Response.status(Response.Status.BAD_REQUEST).build()
+ }
+ try {
+ val sessionUserOpt = JwtParser.parseToken(state)
+ if (!sessionUserOpt.isPresent) {
+ return Response
+ .status(Response.Status.UNAUTHORIZED)
+ .entity("User is not authenticated")
+ .build()
+ }
+
+ val uid = sessionUserOpt.get().getUid
+
+ val tokenResponse: GoogleTokenResponse = new
GoogleAuthorizationCodeTokenRequest(
+ new NetHttpTransport(),
+ GsonFactory.getDefaultInstance,
+ clientId,
+ clientSecret,
+ code,
+ redirectUri
+ ).execute()
+
+ val blobMap = new java.util.HashMap[String, String]()
+ blobMap.put("refreshToken", tokenResponse.getRefreshToken)
+ blobMap.put("scopes", tokenResponse.getScope)
+ val blobJson = mapper.writeValueAsString(blobMap)
+ val encryptedBlob = TokenEncryptionService.encrypt(blobJson)
+
+ val existing = oauthTokenDao.fetchByUid(uid).stream()
+ .filter(r => r.getProvider == PROVIDER_GOOGLE_DRIVE)
+ .findFirst()
+
+ if (existing.isPresent) {
+ existing.get().setAuthBlob(encryptedBlob)
+ oauthTokenDao.update(existing.get())
+ } else {
+ val record = new UserOauthToken()
+ record.setUid(uid)
+ record.setProvider(PROVIDER_GOOGLE_DRIVE)
+ record.setAuthBlob(encryptedBlob)
+ oauthTokenDao.insert(record)
+ }
+
+ val html =
+ """<html><body><script>
+ |window.opener.postMessage('gdrive-connected',
window.location.origin);
+ |window.close();
+ |</script></body></html>""".stripMargin
+ Response.ok(html).build()
+ } catch {
+ case e: TokenResponseException =>
+ logger.error("Google token exchange failed in callback", e)
+ Response.status(Response.Status.BAD_GATEWAY).build()
+ case e: Exception =>
+ logger.error("Unexpected error in OAuth callback", e)
+ Response.status(Response.Status.INTERNAL_SERVER_ERROR).build()
+ }
+ }
+
+ @GET
+ @Path("/connect")
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ def getOAuth(
+ @Auth sessionUser: SessionUser,
+ @QueryParam("reauth") @DefaultValue("false") reauth: Boolean
+ ): Response = {
+ val user = sessionUser.getUser
+ val state = JwtAuth.jwtToken(jwtClaims(user, TOKEN_EXPIRE_TIME_IN_MINUTES))
Review Comment:
We should avoid using the normal session JWT as the OAuth state. Since it is
still a valid login token before expiration, it may be safer to use a dedicated
short-lived OAuth state token instead.
##########
amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleDriveAuthResource.scala:
##########
@@ -0,0 +1,205 @@
+/*
+ * 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 io.dropwizard.auth.Auth
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.typesafe.scalalogging.LazyLogging
+import org.apache.texera.auth.{JwtParser, SessionUser, TokenEncryptionService}
+import org.apache.texera.web.model.http.response.DriveTokenIssueResponse
+import org.apache.texera.web.resource.auth.GoogleDriveAuthResource._
+import org.apache.texera.dao.jooq.generated.tables.daos.UserOauthTokenDao
+import org.apache.texera.dao.jooq.generated.tables.pojos.UserOauthToken
+import org.apache.texera.dao.SqlServer
+import org.apache.texera.config.UserSystemConfig
+import org.apache.texera.auth.JwtAuth.{TOKEN_EXPIRE_TIME_IN_MINUTES, jwtClaims}
+import org.apache.texera.auth.JwtAuth
+import com.google.api.client.googleapis.auth.oauth2.{
+ GoogleAuthorizationCodeRequestUrl,
+ GoogleAuthorizationCodeTokenRequest,
+ GoogleRefreshTokenRequest,
+ GoogleTokenResponse
+}
+import com.google.api.client.auth.oauth2.TokenResponseException
+import com.google.api.client.http.javanet.NetHttpTransport
+import com.google.api.client.json.gson.GsonFactory
+
+import javax.annotation.security.RolesAllowed
+import javax.ws.rs._
+import javax.ws.rs.core.MediaType
+import javax.ws.rs.core.Response
+
+object GoogleDriveAuthResource {
+ private val STATUS_OK = "ok"
+ private val STATUS_NO_REFRESH_TOKEN = "no_refresh_token"
+ private val STATUS_INVALID_GRANT = "invalid_grant"
+ private val PROVIDER_GOOGLE_DRIVE = "google_drive"
+
+ private val mapper = new ObjectMapper()
+
+ private def oauthTokenDao =
+ new UserOauthTokenDao(
+ SqlServer
+ .getInstance()
+ .createDSLContext()
+ .configuration
+ )
+}
+
+@Consumes(Array(MediaType.APPLICATION_JSON))
+@Produces(Array(MediaType.APPLICATION_JSON))
+class GoogleDriveAuthResource extends LazyLogging {
+ final private lazy val clientId = UserSystemConfig.googleClientId
+ final private lazy val clientSecret = UserSystemConfig.googleClientSecret
+ final private lazy val redirectUri = UserSystemConfig.appDomain
+ .map(domain => s"https://$domain/api/auth/google/drive/callback")
+ .getOrElse("http://localhost:4200/api/auth/google/drive/callback")
+
+ @GET
+ @Path("/token")
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ def getDriveAccessToken(@Auth sessionUser: SessionUser): Response = {
+ val uid = sessionUser.getUid
+ val record = oauthTokenDao.fetchByUid(uid).stream()
+ .filter(r => r.getProvider == PROVIDER_GOOGLE_DRIVE)
+ .findFirst()
+ .orElse(null)
+
+ if (record == null) {
+ return Response.ok(DriveTokenIssueResponse(STATUS_NO_REFRESH_TOKEN,
None)).build()
+ }
+
+ try {
+ val blob =
mapper.readTree(TokenEncryptionService.decrypt(record.getAuthBlob))
+ val refreshToken = blob.get("refreshToken").asText()
+
+ val tokenResponse = new GoogleRefreshTokenRequest(
+ new NetHttpTransport(),
+ GsonFactory.getDefaultInstance,
+ refreshToken,
+ clientId,
+ clientSecret
+ ).execute()
+
+ Response.ok(DriveTokenIssueResponse(STATUS_OK,
Some(tokenResponse.getAccessToken))).build()
+ } catch {
+ case e: TokenResponseException =>
+ if (e.getDetails != null && e.getDetails.getError ==
STATUS_INVALID_GRANT) {
+ Response.ok(DriveTokenIssueResponse(STATUS_INVALID_GRANT,
None)).build()
+ } else {
+ logger.error("Failed to refresh access token", e)
+ Response.status(Response.Status.INTERNAL_SERVER_ERROR).build()
+ }
+ case e: Exception =>
+ logger.error("Unexpected error refreshing access token", e)
+ Response.status(Response.Status.INTERNAL_SERVER_ERROR).build()
+ }
+ }
+
+ @GET
+ @Path("/callback")
+ @Produces(Array(MediaType.TEXT_HTML, MediaType.APPLICATION_JSON))
+ def getCallback(
+ @QueryParam("code") @DefaultValue("") code: String,
+ @QueryParam("state") @DefaultValue("") state: String
+ ): Response = {
+ if (code.isEmpty || state.isEmpty) {
+ return Response.status(Response.Status.BAD_REQUEST).build()
+ }
+ try {
+ val sessionUserOpt = JwtParser.parseToken(state)
+ if (!sessionUserOpt.isPresent) {
+ return Response
+ .status(Response.Status.UNAUTHORIZED)
+ .entity("User is not authenticated")
+ .build()
+ }
+
+ val uid = sessionUserOpt.get().getUid
+
+ val tokenResponse: GoogleTokenResponse = new
GoogleAuthorizationCodeTokenRequest(
+ new NetHttpTransport(),
+ GsonFactory.getDefaultInstance,
+ clientId,
+ clientSecret,
+ code,
+ redirectUri
+ ).execute()
+
+ val blobMap = new java.util.HashMap[String, String]()
+ blobMap.put("refreshToken", tokenResponse.getRefreshToken)
+ blobMap.put("scopes", tokenResponse.getScope)
+ val blobJson = mapper.writeValueAsString(blobMap)
+ val encryptedBlob = TokenEncryptionService.encrypt(blobJson)
+
+ val existing = oauthTokenDao.fetchByUid(uid).stream()
+ .filter(r => r.getProvider == PROVIDER_GOOGLE_DRIVE)
+ .findFirst()
+
+ if (existing.isPresent) {
+ existing.get().setAuthBlob(encryptedBlob)
+ oauthTokenDao.update(existing.get())
+ } else {
+ val record = new UserOauthToken()
+ record.setUid(uid)
+ record.setProvider(PROVIDER_GOOGLE_DRIVE)
+ record.setAuthBlob(encryptedBlob)
+ oauthTokenDao.insert(record)
+ }
+
+ val html =
+ """<html><body><script>
+ |window.opener.postMessage('gdrive-connected',
window.location.origin);
+ |window.close();
+ |</script></body></html>""".stripMargin
+ Response.ok(html).build()
+ } catch {
+ case e: TokenResponseException =>
+ logger.error("Google token exchange failed in callback", e)
+ Response.status(Response.Status.BAD_GATEWAY).build()
+ case e: Exception =>
+ logger.error("Unexpected error in OAuth callback", e)
Review Comment:
We could consider returning an error message to the opener window and
closing the OAuth popup.
##########
bin/k8s/values.yaml:
##########
@@ -273,6 +273,10 @@ texeraEnvVars:
value: "true"
- name: USER_SYS_GOOGLE_CLIENT_ID
value: ""
+ - name: USER_SYS_GOOGLE_CLIENT_SECRET
Review Comment:
This seems a bit outside the scope of this PR.
##########
common/config/src/main/scala/org/apache/texera/config/AuthConfig.scala:
##########
@@ -44,6 +45,18 @@ object AuthConfig {
secretKey
}
+ def encryptionSecretKey: String = {
+ synchronized {
+ if (eSecretKey == null) {
+ eSecretKey =
conf.getString("auth.encryption.256-bit-secret").toLowerCase() match {
+ case "random" => getRandomHexString
Review Comment:
For token encryption, I think we should avoid supporting the random option.
Since this key is used to decrypt persisted OAuth tokens, generating a new key
after restart could make existing encrypted auth blobs undecryptable.
##########
amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleDriveAuthResource.scala:
##########
@@ -0,0 +1,205 @@
+/*
+ * 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 io.dropwizard.auth.Auth
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.typesafe.scalalogging.LazyLogging
+import org.apache.texera.auth.{JwtParser, SessionUser, TokenEncryptionService}
+import org.apache.texera.web.model.http.response.DriveTokenIssueResponse
+import org.apache.texera.web.resource.auth.GoogleDriveAuthResource._
+import org.apache.texera.dao.jooq.generated.tables.daos.UserOauthTokenDao
+import org.apache.texera.dao.jooq.generated.tables.pojos.UserOauthToken
+import org.apache.texera.dao.SqlServer
+import org.apache.texera.config.UserSystemConfig
+import org.apache.texera.auth.JwtAuth.{TOKEN_EXPIRE_TIME_IN_MINUTES, jwtClaims}
+import org.apache.texera.auth.JwtAuth
+import com.google.api.client.googleapis.auth.oauth2.{
+ GoogleAuthorizationCodeRequestUrl,
+ GoogleAuthorizationCodeTokenRequest,
+ GoogleRefreshTokenRequest,
+ GoogleTokenResponse
+}
+import com.google.api.client.auth.oauth2.TokenResponseException
+import com.google.api.client.http.javanet.NetHttpTransport
+import com.google.api.client.json.gson.GsonFactory
+
+import javax.annotation.security.RolesAllowed
+import javax.ws.rs._
+import javax.ws.rs.core.MediaType
+import javax.ws.rs.core.Response
+
+object GoogleDriveAuthResource {
+ private val STATUS_OK = "ok"
+ private val STATUS_NO_REFRESH_TOKEN = "no_refresh_token"
+ private val STATUS_INVALID_GRANT = "invalid_grant"
+ private val PROVIDER_GOOGLE_DRIVE = "google_drive"
+
+ private val mapper = new ObjectMapper()
+
+ private def oauthTokenDao =
+ new UserOauthTokenDao(
+ SqlServer
+ .getInstance()
+ .createDSLContext()
+ .configuration
+ )
+}
+
+@Consumes(Array(MediaType.APPLICATION_JSON))
+@Produces(Array(MediaType.APPLICATION_JSON))
+class GoogleDriveAuthResource extends LazyLogging {
+ final private lazy val clientId = UserSystemConfig.googleClientId
+ final private lazy val clientSecret = UserSystemConfig.googleClientSecret
+ final private lazy val redirectUri = UserSystemConfig.appDomain
+ .map(domain => s"https://$domain/api/auth/google/drive/callback")
+ .getOrElse("http://localhost:4200/api/auth/google/drive/callback")
+
+ @GET
+ @Path("/token")
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ def getDriveAccessToken(@Auth sessionUser: SessionUser): Response = {
+ val uid = sessionUser.getUid
+ val record = oauthTokenDao.fetchByUid(uid).stream()
+ .filter(r => r.getProvider == PROVIDER_GOOGLE_DRIVE)
+ .findFirst()
+ .orElse(null)
+
+ if (record == null) {
+ return Response.ok(DriveTokenIssueResponse(STATUS_NO_REFRESH_TOKEN,
None)).build()
+ }
+
+ try {
+ val blob =
mapper.readTree(TokenEncryptionService.decrypt(record.getAuthBlob))
+ val refreshToken = blob.get("refreshToken").asText()
Review Comment:
Suggest using path("refreshToken").asText("") here to avoid a possible NPE
when the field is missing.
##########
amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleDriveAuthResource.scala:
##########
@@ -0,0 +1,205 @@
+/*
+ * 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 io.dropwizard.auth.Auth
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.typesafe.scalalogging.LazyLogging
+import org.apache.texera.auth.{JwtParser, SessionUser, TokenEncryptionService}
+import org.apache.texera.web.model.http.response.DriveTokenIssueResponse
+import org.apache.texera.web.resource.auth.GoogleDriveAuthResource._
+import org.apache.texera.dao.jooq.generated.tables.daos.UserOauthTokenDao
+import org.apache.texera.dao.jooq.generated.tables.pojos.UserOauthToken
+import org.apache.texera.dao.SqlServer
+import org.apache.texera.config.UserSystemConfig
+import org.apache.texera.auth.JwtAuth.{TOKEN_EXPIRE_TIME_IN_MINUTES, jwtClaims}
+import org.apache.texera.auth.JwtAuth
+import com.google.api.client.googleapis.auth.oauth2.{
+ GoogleAuthorizationCodeRequestUrl,
+ GoogleAuthorizationCodeTokenRequest,
+ GoogleRefreshTokenRequest,
+ GoogleTokenResponse
+}
+import com.google.api.client.auth.oauth2.TokenResponseException
+import com.google.api.client.http.javanet.NetHttpTransport
+import com.google.api.client.json.gson.GsonFactory
+
+import javax.annotation.security.RolesAllowed
+import javax.ws.rs._
+import javax.ws.rs.core.MediaType
+import javax.ws.rs.core.Response
+
+object GoogleDriveAuthResource {
+ private val STATUS_OK = "ok"
+ private val STATUS_NO_REFRESH_TOKEN = "no_refresh_token"
+ private val STATUS_INVALID_GRANT = "invalid_grant"
+ private val PROVIDER_GOOGLE_DRIVE = "google_drive"
+
+ private val mapper = new ObjectMapper()
+
+ private def oauthTokenDao =
+ new UserOauthTokenDao(
+ SqlServer
+ .getInstance()
+ .createDSLContext()
+ .configuration
+ )
+}
+
+@Consumes(Array(MediaType.APPLICATION_JSON))
+@Produces(Array(MediaType.APPLICATION_JSON))
+class GoogleDriveAuthResource extends LazyLogging {
+ final private lazy val clientId = UserSystemConfig.googleClientId
+ final private lazy val clientSecret = UserSystemConfig.googleClientSecret
+ final private lazy val redirectUri = UserSystemConfig.appDomain
+ .map(domain => s"https://$domain/api/auth/google/drive/callback")
+ .getOrElse("http://localhost:4200/api/auth/google/drive/callback")
+
+ @GET
+ @Path("/token")
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ def getDriveAccessToken(@Auth sessionUser: SessionUser): Response = {
+ val uid = sessionUser.getUid
+ val record = oauthTokenDao.fetchByUid(uid).stream()
+ .filter(r => r.getProvider == PROVIDER_GOOGLE_DRIVE)
+ .findFirst()
+ .orElse(null)
+
+ if (record == null) {
+ return Response.ok(DriveTokenIssueResponse(STATUS_NO_REFRESH_TOKEN,
None)).build()
+ }
+
+ try {
+ val blob =
mapper.readTree(TokenEncryptionService.decrypt(record.getAuthBlob))
+ val refreshToken = blob.get("refreshToken").asText()
+
+ val tokenResponse = new GoogleRefreshTokenRequest(
+ new NetHttpTransport(),
+ GsonFactory.getDefaultInstance,
+ refreshToken,
+ clientId,
+ clientSecret
+ ).execute()
+
+ Response.ok(DriveTokenIssueResponse(STATUS_OK,
Some(tokenResponse.getAccessToken))).build()
+ } catch {
+ case e: TokenResponseException =>
+ if (e.getDetails != null && e.getDetails.getError ==
STATUS_INVALID_GRANT) {
+ Response.ok(DriveTokenIssueResponse(STATUS_INVALID_GRANT,
None)).build()
+ } else {
+ logger.error("Failed to refresh access token", e)
+ Response.status(Response.Status.INTERNAL_SERVER_ERROR).build()
+ }
+ case e: Exception =>
+ logger.error("Unexpected error refreshing access token", e)
+ Response.status(Response.Status.INTERNAL_SERVER_ERROR).build()
+ }
+ }
+
+ @GET
+ @Path("/callback")
+ @Produces(Array(MediaType.TEXT_HTML, MediaType.APPLICATION_JSON))
+ def getCallback(
+ @QueryParam("code") @DefaultValue("") code: String,
+ @QueryParam("state") @DefaultValue("") state: String
+ ): Response = {
+ if (code.isEmpty || state.isEmpty) {
+ return Response.status(Response.Status.BAD_REQUEST).build()
+ }
+ try {
+ val sessionUserOpt = JwtParser.parseToken(state)
+ if (!sessionUserOpt.isPresent) {
+ return Response
+ .status(Response.Status.UNAUTHORIZED)
+ .entity("User is not authenticated")
+ .build()
+ }
+
+ val uid = sessionUserOpt.get().getUid
+
+ val tokenResponse: GoogleTokenResponse = new
GoogleAuthorizationCodeTokenRequest(
+ new NetHttpTransport(),
+ GsonFactory.getDefaultInstance,
+ clientId,
+ clientSecret,
+ code,
+ redirectUri
+ ).execute()
+
+ val blobMap = new java.util.HashMap[String, String]()
+ blobMap.put("refreshToken", tokenResponse.getRefreshToken)
+ blobMap.put("scopes", tokenResponse.getScope)
+ val blobJson = mapper.writeValueAsString(blobMap)
+ val encryptedBlob = TokenEncryptionService.encrypt(blobJson)
+
+ val existing = oauthTokenDao.fetchByUid(uid).stream()
+ .filter(r => r.getProvider == PROVIDER_GOOGLE_DRIVE)
+ .findFirst()
+
+ if (existing.isPresent) {
+ existing.get().setAuthBlob(encryptedBlob)
+ oauthTokenDao.update(existing.get())
+ } else {
+ val record = new UserOauthToken()
+ record.setUid(uid)
+ record.setProvider(PROVIDER_GOOGLE_DRIVE)
+ record.setAuthBlob(encryptedBlob)
+ oauthTokenDao.insert(record)
+ }
+
+ val html =
+ """<html><body><script>
+ |window.opener.postMessage('gdrive-connected',
window.location.origin);
+ |window.close();
+ |</script></body></html>""".stripMargin
+ Response.ok(html).build()
+ } catch {
+ case e: TokenResponseException =>
+ logger.error("Google token exchange failed in callback", e)
+ Response.status(Response.Status.BAD_GATEWAY).build()
+ case e: Exception =>
+ logger.error("Unexpected error in OAuth callback", e)
+ Response.status(Response.Status.INTERNAL_SERVER_ERROR).build()
+ }
+ }
+
+ @GET
+ @Path("/connect")
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ def getOAuth(
+ @Auth sessionUser: SessionUser,
+ @QueryParam("reauth") @DefaultValue("false") reauth: Boolean
+ ): Response = {
+ val user = sessionUser.getUser
+ val state = JwtAuth.jwtToken(jwtClaims(user, TOKEN_EXPIRE_TIME_IN_MINUTES))
+
+ val url = new GoogleAuthorizationCodeRequestUrl(
+ clientId,
+ redirectUri,
+ java.util.Arrays.asList("https://www.googleapis.com/auth/drive")
Review Comment:
We could consider using a narrower Google Drive scope, such as `drive.file`,
instead of the full `drive` scope.
--
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]