diff --git a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala index 98b7c68c974..09c3e91b270 100644 --- a/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala +++ b/amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala @@ -33,7 +33,11 @@ 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, + GoogleDriveAuthResource +} 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.settings.AdminSettingsResource @@ -160,6 +164,7 @@ class TexeraWebApplication environment.jersey.register(classOf[UserQuotaResource]) environment.jersey.register(classOf[AdminSettingsResource]) environment.jersey.register(classOf[AIAssistantResource]) + environment.jersey.register(classOf[GoogleDriveAuthResource]) AuthResource.createAdminUser() diff --git a/amber/src/main/scala/org/apache/texera/web/model/http/response/DriveTokenIssueResponse.scala b/amber/src/main/scala/org/apache/texera/web/model/http/response/DriveTokenIssueResponse.scala new file mode 100644 index 00000000000..45d9498c2c8 --- /dev/null +++ b/amber/src/main/scala/org/apache/texera/web/model/http/response/DriveTokenIssueResponse.scala @@ -0,0 +1,25 @@ +/* + * 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.response + +case class DriveTokenIssueResponse( + status: String, + accessToken: Option[String] +) diff --git a/amber/src/main/scala/org/apache/texera/web/model/http/response/GoogleAuthConfigResponse.scala b/amber/src/main/scala/org/apache/texera/web/model/http/response/GoogleAuthConfigResponse.scala new file mode 100644 index 00000000000..efc2584f374 --- /dev/null +++ b/amber/src/main/scala/org/apache/texera/web/model/http/response/GoogleAuthConfigResponse.scala @@ -0,0 +1,22 @@ +/* + * 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.response + +case class GoogleAuthConfigResponse(clientId: String, apiKey: String) diff --git a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala index 2f99b9c1bd3..208a24dd1cc 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala @@ -29,6 +29,7 @@ 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.web.model.http.response.TokenIssueResponse +import org.apache.texera.web.model.http.response.GoogleAuthConfigResponse import org.apache.texera.web.resource.auth.GoogleAuthResource.userDao import java.util.Collections @@ -48,11 +49,21 @@ object GoogleAuthResource { @Path("/auth/google") class GoogleAuthResource { final private lazy val clientId = UserSystemConfig.googleClientId + final private lazy val apiKey = UserSystemConfig.googleApiKey @GET @Path("/clientid") def getClientId: String = clientId + @GET + @Path("/config") + def getConfig: GoogleAuthConfigResponse = { + GoogleAuthConfigResponse(clientId, apiKey) + } + + @Path("/drive") + def getDriveResource: GoogleDriveAuthResource = new GoogleDriveAuthResource() + @POST @Consumes(Array(MediaType.TEXT_PLAIN)) @Produces(Array(MediaType.APPLICATION_JSON)) diff --git a/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleDriveAuthResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleDriveAuthResource.scala new file mode 100644 index 00000000000..4bbec8c5743 --- /dev/null +++ b/amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleDriveAuthResource.scala @@ -0,0 +1,209 @@ +/* + * 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.{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 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 java.util.concurrent.ConcurrentHashMap +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 STATE_TTL_MS = 10 * 60 * 1000L + + private val mapper = new ObjectMapper() + + // state token → (uid, expiresAtMs) + private val pendingStates = new ConcurrentHashMap[String, (Int, Long)]() + + 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 entry = pendingStates.remove(state) + if (entry == null || System.currentTimeMillis() > entry._2) { + return Response + .status(Response.Status.UNAUTHORIZED) + .entity("OAuth state token is invalid or expired") + .build() + } + + val uid = entry._1 + + 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 = + """
""".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 stateToken = java.util.UUID.randomUUID().toString + pendingStates.put(stateToken, (sessionUser.getUid, System.currentTimeMillis() + STATE_TTL_MS)) + + val url = new GoogleAuthorizationCodeRequestUrl( + clientId, + redirectUri, + java.util.Arrays.asList("https://www.googleapis.com/auth/drive") + ) + .setState(stateToken) + .setAccessType("offline") + .set("prompt", if (reauth) "consent" else null) + .set("include_granted_scopes", true) + .build() + + Response.ok(url).build() + } +} diff --git a/common/auth/src/main/scala/org/apache/texera/auth/TokenEncryptionService.scala b/common/auth/src/main/scala/org/apache/texera/auth/TokenEncryptionService.scala new file mode 100644 index 00000000000..3d0803e037b --- /dev/null +++ b/common/auth/src/main/scala/org/apache/texera/auth/TokenEncryptionService.scala @@ -0,0 +1,50 @@ +/* + * 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.auth + +import org.apache.texera.config.AuthConfig +import org.jose4j.jwe.{ + ContentEncryptionAlgorithmIdentifiers, + JsonWebEncryption, + KeyManagementAlgorithmIdentifiers +} +import org.jose4j.keys.AesKey + +import java.nio.charset.StandardCharsets + +object TokenEncryptionService { + private val key = new AesKey(AuthConfig.encryptionSecretKey.getBytes(StandardCharsets.UTF_8)) + + def encrypt(plaintext: String): String = { + val jwe = new JsonWebEncryption() + jwe.setAlgorithmHeaderValue(KeyManagementAlgorithmIdentifiers.DIRECT) + jwe.setEncryptionMethodHeaderParameter(ContentEncryptionAlgorithmIdentifiers.AES_256_GCM) + jwe.setKey(key) + jwe.setPayload(plaintext) + jwe.getCompactSerialization + } + + def decrypt(ciphertext: String): String = { + val jwe = new JsonWebEncryption() + jwe.setKey(key) + jwe.setCompactSerialization(ciphertext) + jwe.getPayload + } +} diff --git a/common/auth/src/test/scala/org/apache/texera/auth/TokenEncryptionServiceSpec.scala b/common/auth/src/test/scala/org/apache/texera/auth/TokenEncryptionServiceSpec.scala new file mode 100644 index 00000000000..1ce7b0526d5 --- /dev/null +++ b/common/auth/src/test/scala/org/apache/texera/auth/TokenEncryptionServiceSpec.scala @@ -0,0 +1,35 @@ +/* + * 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.auth + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class TokenEncryptionServiceSpec extends AnyFlatSpec with Matchers { + + "TokenEncryptionService" should "round-trip a plaintext string" in { + val plaintext = """{"refreshToken":"1//0gtoken","scopes":"https://www.googleapis.com/auth/drive"}""" + TokenEncryptionService.decrypt(TokenEncryptionService.encrypt(plaintext)) shouldBe plaintext + } + + it should "throw when decrypting a non-JWE string" in { + an[Exception] should be thrownBy TokenEncryptionService.decrypt("not-a-jwe-token") + } +} diff --git a/common/config/src/main/resources/auth.conf b/common/config/src/main/resources/auth.conf index c99db10c85e..d8978949ecb 100644 --- a/common/config/src/main/resources/auth.conf +++ b/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 = "9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e" + 256-bit-secret = ${?AUTH_ENCRYPTION_SECRET} + } } \ No newline at end of file diff --git a/common/config/src/main/resources/user-system.conf b/common/config/src/main/resources/user-system.conf index ffda7e2435a..41386e65ac3 100644 --- a/common/config/src/main/resources/user-system.conf +++ b/common/config/src/main/resources/user-system.conf @@ -26,6 +26,12 @@ user-sys { google { clientId = "" clientId = ${?USER_SYS_GOOGLE_CLIENT_ID} + + clientSecret = "" + clientSecret = ${?USER_SYS_GOOGLE_CLIENT_SECRET} + + apiKey = "" + apiKey = ${?USER_SYS_GOOGLE_API_KEY} smtp { gmail = "" diff --git a/common/config/src/main/scala/org/apache/texera/config/AuthConfig.scala b/common/config/src/main/scala/org/apache/texera/config/AuthConfig.scala index 748db036c98..1aae76cf855 100644 --- a/common/config/src/main/scala/org/apache/texera/config/AuthConfig.scala +++ b/common/config/src/main/scala/org/apache/texera/config/AuthConfig.scala @@ -30,6 +30,7 @@ object AuthConfig { // For storing the generated/configured secret @volatile private var secretKey: String = _ + @volatile private var eSecretKey: String = _ // Read JWT secret key with support for random generation def jwtSecretKey: String = { @@ -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 + case key => key + } + } + } + eSecretKey + } + private def getRandomHexString: String = { val bytes = 32 val r = new SecureRandom() diff --git a/common/config/src/main/scala/org/apache/texera/config/UserSystemConfig.scala b/common/config/src/main/scala/org/apache/texera/config/UserSystemConfig.scala index b78eed02024..d263256a313 100644 --- a/common/config/src/main/scala/org/apache/texera/config/UserSystemConfig.scala +++ b/common/config/src/main/scala/org/apache/texera/config/UserSystemConfig.scala @@ -30,6 +30,8 @@ object UserSystemConfig { val adminUsername: String = conf.getString("user-sys.admin-username") val adminPassword: String = conf.getString("user-sys.admin-password") val googleClientId: String = conf.getString("user-sys.google.clientId") + val googleClientSecret: String = conf.getString("user-sys.google.clientSecret") + val googleApiKey: String = conf.getString("user-sys.google.apiKey") val gmail: String = conf.getString("user-sys.google.smtp.gmail") val smtpPassword: String = conf.getString("user-sys.google.smtp.password") val inviteOnly: Boolean = conf.getBoolean("user-sys.invite-only") diff --git a/frontend/src/app/app-routing.constant.ts b/frontend/src/app/app-routing.constant.ts index 4181df8a954..be22a595ebe 100644 --- a/frontend/src/app/app-routing.constant.ts +++ b/frontend/src/app/app-routing.constant.ts @@ -46,3 +46,5 @@ export const DASHBOARD_ADMIN_EXECUTION = `${DASHBOARD_ADMIN}/execution`; export const DASHBOARD_ADMIN_SETTINGS = `${DASHBOARD_ADMIN}/settings`; export const DASHBOARD_SEARCH = `${DASHBOARD}/search`; + +export const DASHBOARD_USER_GOOGLE_DRIVE = `${DASHBOARD_USER}/google-drive`; diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 179caf5c088..959665a815b 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -39,6 +39,7 @@ import { UserDatasetComponent } from "./dashboard/component/user/user-dataset/us import { HubWorkflowDetailComponent } from "./hub/component/workflow/detail/hub-workflow-detail.component"; import { LandingPageComponent } from "./hub/component/landing-page/landing-page.component"; import { DASHBOARD_ABOUT, DASHBOARD_USER_WORKFLOW } from "./app-routing.constant"; +import { GoogleDriveConnectComponent } from "./dashboard/component/user/google-drive-connect/google-drive-connect.component"; import { HubSearchResultComponent } from "./hub/component/hub-search-result/hub-search-result.component"; import { AdminSettingsComponent } from "./dashboard/component/admin/settings/admin-settings.component"; import { GuiConfigService } from "./common/service/gui-config.service"; @@ -143,6 +144,10 @@ routes.push({ path: "discussion", component: FlarumComponent, }, + { + path: "google-drive", + component: GoogleDriveConnectComponent, + }, ], }, { diff --git a/frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.spec.ts b/frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.spec.ts new file mode 100644 index 00000000000..4071e7bfdaf --- /dev/null +++ b/frontend/src/app/dashboard/component/user/google-drive-connect/google-drive-connect.component.spec.ts @@ -0,0 +1,115 @@ +/** + * 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. + */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { GoogleDriveConnectComponent } from "./google-drive-connect.component"; +import { DriveService } from "../../../service/user/google-drive/drive.service"; +import { of, Subject, throwError } from "rxjs"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import type { Mocked } from "vitest"; + +describe("GoogleDriveConnectComponent", () => { + let component: GoogleDriveConnectComponent; + let fixture: ComponentFixtureGoogle Drive Status: {{ status }}
+ + + +Export to: {{ selectedFolder.name }}
+