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,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
Expand Down Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
UserRoleEnum.REGULAR,
null,
null,
null,
null
)
}

@PreMatching
Expand Down
Original file line number Diff line number Diff line change
@@ -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]
)
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
)
.setState(state)
.setAccessType("offline")
.set("prompt", if (reauth) "consent" else null)
.set("include_granted_scopes", true)
.build()

Response.ok(url).build()
}
}
4 changes: 4 additions & 0 deletions bin/k8s/values-development.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
- name: USER_SYS_GOOGLE_SMTP_GMAIL
value: ""
- name: USER_SYS_GOOGLE_SMTP_PASSWORD
Expand Down
4 changes: 4 additions & 0 deletions bin/k8s/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,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: ""
- name: USER_SYS_GOOGLE_SMTP_GMAIL
value: ""
- name: USER_SYS_GOOGLE_SMTP_PASSWORD
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading