diff --git a/docs/configuration/linkis-gateway-core.md b/docs/configuration/linkis-gateway-core.md index be933b2a26f..5a4f55a3d14 100644 --- a/docs/configuration/linkis-gateway-core.md +++ b/docs/configuration/linkis-gateway-core.md @@ -36,3 +36,11 @@ |linkis-gateway-core|wds.linkis.gateway.this.schema| | gateway.this.schema| |linkis-gateway-core|wds.linkis.web.enable.water.mark|true| web.enable.water.mark| |linkis-gateway-core|wds.linkis.entrance.name| |linkis.entrance.name| +|linkis-gateway-core|wds.linkis.gateway.conf.enable.oauth.auth| false |wds.linkis.gateway.conf.enable.oauth.auth| +|linkis-gateway-core|wds.linkis.gateway.auth.oauth.authentication.url| |wds.linkis.gateway.auth.oauth.authentication.url| +|linkis-gateway-core|wds.linkis.gateway.auth.oauth.exchange.url| |wds.linkis.gateway.auth.oauth.exchange.url| +|linkis-gateway-core|wds.linkis.gateway.auth.oauth.validate.url| |wds.linkis.gateway.auth.oauth.validate.url| +|linkis-gateway-core|wds.linkis.gateway.auth.oauth.validate.field| |wds.linkis.gateway.auth.oauth.validate.field| +|linkis-gateway-core|wds.linkis.gateway.auth.oauth.client.id| |wds.linkis.gateway.auth.oauth.client.id| +|linkis-gateway-core|wds.linkis.gateway.auth.oauth.client.secret| |wds.linkis.gateway.auth.oauth.client.secret| +|linkis-gateway-core|wds.linkis.gateway.auth.oauth.scope| |wds.linkis.gateway.auth.oauth.scope| diff --git a/linkis-dist/package/conf/linkis-mg-gateway.properties b/linkis-dist/package/conf/linkis-mg-gateway.properties index 1f1d2416b41..0e4275677cc 100644 --- a/linkis-dist/package/conf/linkis-mg-gateway.properties +++ b/linkis-dist/package/conf/linkis-mg-gateway.properties @@ -30,6 +30,15 @@ wds.linkis.ldap.proxy.baseDN= wds.linkis.ldap.proxy.userNameFormat= wds.linkis.admin.user=hadoop #wds.linkis.admin.password= +##OAuth +wds.linkis.oauth.enable=false +wds.linkis.oauth.url=https://github.com/login/oauth/authorize +wds.linkis.gateway.auth.oauth.exchange.url=https://github.com/login/oauth/access_token +wds.linkis.gateway.auth.oauth.validate.url=https://api.github.com/user +wds.linkis.gateway.auth.oauth.validate.field=login +wds.linkis.gateway.auth.oauth.client.id=YOUR_CLIENT_ID +wds.linkis.gateway.auth.oauth.client.secret=YOUR_CLIENT_SECRET +wds.linkis.gateway.auth.oauth.scope=user ##Spring spring.server.port=9001 diff --git a/linkis-spring-cloud-services/linkis-service-gateway/linkis-gateway-core/src/main/scala/org/apache/linkis/gateway/config/GatewayConfiguration.scala b/linkis-spring-cloud-services/linkis-service-gateway/linkis-gateway-core/src/main/scala/org/apache/linkis/gateway/config/GatewayConfiguration.scala index 5fc80d7afc0..ccb7325b578 100644 --- a/linkis-spring-cloud-services/linkis-service-gateway/linkis-gateway-core/src/main/scala/org/apache/linkis/gateway/config/GatewayConfiguration.scala +++ b/linkis-spring-cloud-services/linkis-service-gateway/linkis-gateway-core/src/main/scala/org/apache/linkis/gateway/config/GatewayConfiguration.scala @@ -42,6 +42,15 @@ object GatewayConfiguration { val TOKEN_AUTHENTICATION_SCAN_INTERVAL = CommonVars("wds.linkis.gateway.conf.token.auth.scan.interval", 1000 * 60 * 10) + val ENABLE_OAUTH_AUTHENTICATION = CommonVars("wds.linkis.gateway.conf.enable.oauth.auth", false) + val OAUTH_AUTHENTICATION_URL = CommonVars("wds.linkis.gateway.auth.oauth.authentication.url", "") + val OAUTH_EXCHANGE_URL = CommonVars("wds.linkis.gateway.auth.oauth.exchange.url", "") + val OAUTH_VALIDATE_URL = CommonVars("wds.linkis.gateway.auth.oauth.validate.url", "") + val OAUTH_VALIDATE_FIELD = CommonVars("wds.linkis.gateway.auth.oauth.validate.field", "") + val OAUTH_CLIENT_ID = CommonVars("wds.linkis.gateway.auth.oauth.client.id", "") + val OAUTH_CLIENT_SECRET = CommonVars("wds.linkis.gateway.auth.oauth.client.secret", "") + val OAUTH_SCOPE = CommonVars("wds.linkis.gateway.auth.oauth.scope", "") + val PASS_AUTH_REQUEST_URI = CommonVars("wds.linkis.gateway.conf.url.pass.auth", "/dws/").getValue.split(",") diff --git a/linkis-spring-cloud-services/linkis-service-gateway/linkis-gateway-core/src/main/scala/org/apache/linkis/gateway/security/SecurityFilter.scala b/linkis-spring-cloud-services/linkis-service-gateway/linkis-gateway-core/src/main/scala/org/apache/linkis/gateway/security/SecurityFilter.scala index 150ae565ef5..9f170e9dd2f 100644 --- a/linkis-spring-cloud-services/linkis-service-gateway/linkis-gateway-core/src/main/scala/org/apache/linkis/gateway/security/SecurityFilter.scala +++ b/linkis-spring-cloud-services/linkis-service-gateway/linkis-gateway-core/src/main/scala/org/apache/linkis/gateway/security/SecurityFilter.scala @@ -23,6 +23,7 @@ import org.apache.linkis.common.utils.{Logging, Utils} import org.apache.linkis.gateway.config.GatewayConfiguration import org.apache.linkis.gateway.config.GatewayConfiguration._ import org.apache.linkis.gateway.http.GatewayContext +import org.apache.linkis.gateway.security.oauth.OAuth2Authentication import org.apache.linkis.gateway.security.sso.SSOInterceptor import org.apache.linkis.gateway.security.token.TokenAuthentication import org.apache.linkis.server.{validateFailed, Message} @@ -127,6 +128,8 @@ object SecurityFilter extends Logging { logger.info("No login needed for proxy uri: " + gatewayContext.getRequest.getRequestURI) } else if (TokenAuthentication.isTokenRequest(gatewayContext)) { TokenAuthentication.tokenAuth(gatewayContext) + } else if (OAuth2Authentication.isOAuth2Request(gatewayContext)) { + OAuth2Authentication.OAuth2Entry(gatewayContext) } else { val userName = Utils.tryCatch(GatewaySSOUtils.getLoginUser(gatewayContext)) { case n @ (_: NonLoginException | _: LoginExpireException) => diff --git a/linkis-spring-cloud-services/linkis-service-gateway/linkis-gateway-core/src/main/scala/org/apache/linkis/gateway/security/UserRestful.scala b/linkis-spring-cloud-services/linkis-service-gateway/linkis-gateway-core/src/main/scala/org/apache/linkis/gateway/security/UserRestful.scala index 38d06b6b173..e79296c5640 100644 --- a/linkis-spring-cloud-services/linkis-service-gateway/linkis-gateway-core/src/main/scala/org/apache/linkis/gateway/security/UserRestful.scala +++ b/linkis-spring-cloud-services/linkis-service-gateway/linkis-gateway-core/src/main/scala/org/apache/linkis/gateway/security/UserRestful.scala @@ -20,6 +20,7 @@ package org.apache.linkis.gateway.security import org.apache.linkis.common.utils.{Logging, RSAUtils, Utils} import org.apache.linkis.gateway.config.GatewayConfiguration import org.apache.linkis.gateway.http.GatewayContext +import org.apache.linkis.gateway.security.oauth.OAuth2Authentication import org.apache.linkis.gateway.security.sso.SSOInterceptor import org.apache.linkis.gateway.security.token.TokenAuthentication import org.apache.linkis.protocol.usercontrol.{ @@ -87,6 +88,20 @@ abstract class AbstractUserRestful extends UserRestful with Logging { TokenAuthentication.tokenAuth(gatewayContext, true) return } + case "oauth-login" => + Utils.tryCatch { + val loginUser = GatewaySSOUtils.getLoginUsername(gatewayContext) + Message + .ok(loginUser + " already logged in, please log out before signing in(已经登录,请先退出再进行登录)!") + .data("userName", loginUser) + }(_ => { + OAuth2Authentication.OAuth2Auth(gatewayContext, true) + return + }) + case "oauth-redirect" => { + OAuth2Authentication.OAuth2Redirect(gatewayContext) + return + } case "logout" => logout(gatewayContext) case "userInfo" => userInfo(gatewayContext) case "publicKey" => publicKey(gatewayContext) diff --git a/linkis-spring-cloud-services/linkis-service-gateway/linkis-gateway-core/src/main/scala/org/apache/linkis/gateway/security/oauth/OAuth2Authentication.scala b/linkis-spring-cloud-services/linkis-service-gateway/linkis-gateway-core/src/main/scala/org/apache/linkis/gateway/security/oauth/OAuth2Authentication.scala new file mode 100644 index 00000000000..c62ab5b3be1 --- /dev/null +++ b/linkis-spring-cloud-services/linkis-service-gateway/linkis-gateway-core/src/main/scala/org/apache/linkis/gateway/security/oauth/OAuth2Authentication.scala @@ -0,0 +1,340 @@ +/* + * 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.linkis.gateway.security.oauth + +import org.apache.linkis.common.exception.LinkisCommonErrorException +import org.apache.linkis.common.utils.{Logging, Utils} +import org.apache.linkis.gateway.config.GatewayConfiguration +import org.apache.linkis.gateway.config.GatewayConfiguration._ +import org.apache.linkis.gateway.http.GatewayContext +import org.apache.linkis.gateway.security.{GatewaySSOUtils, SecurityFilter} +import org.apache.linkis.server.Message +import org.apache.linkis.server.conf.ServerConfiguration + +import org.apache.commons.io.IOUtils +import org.apache.commons.lang3.StringUtils + +import java.io.IOException +import java.net.{HttpURLConnection, URL} + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.scala.DefaultScalaModule + +object OAuth2Authentication extends Logging { + + private val objectMapper = new ObjectMapper() + objectMapper.registerModule(DefaultScalaModule) + + def isOAuth2Request(gatewayContext: GatewayContext): Boolean = { + val path = getMethod(gatewayContext) + path == "oauth_login" || path == "oauth_redirect" + } + + def OAuth2Entry(gatewayContext: GatewayContext, login: Boolean = false): Boolean = { + val path = getMethod(gatewayContext) + if (path == "oauth_redirect") { + OAuth2Redirect(gatewayContext) + } else if (path == "oauth_redirect") { + OAuth2Auth(gatewayContext, login) + } else { + val message = + Message.noLogin(s"未知 OAuth 请求") << gatewayContext.getRequest.getRequestURI + SecurityFilter.filterResponse(gatewayContext, message) + false + } + } + + private def getMethod(gatewayContext: GatewayContext) = { + var userURI = ServerConfiguration.BDP_SERVER_USER_URI.getValue + if (!userURI.endsWith("/")) userURI += "/" + val path = gatewayContext.getRequest.getRequestURI.replace(userURI, "") + path + } + + def OAuth2Redirect(gatewayContext: GatewayContext): Boolean = { + if (!ENABLE_OAUTH_AUTHENTICATION.getValue) { + val message = + Message.noLogin( + s"Gateway 未启用 OAuth 认证,请采用其他认证方式!" + ) << gatewayContext.getRequest.getRequestURI + SecurityFilter.filterResponse(gatewayContext, message) + return false + } + val message = + Message.ok("创建链接成功!").data("redirectUrl", generateAuthenticationUrl()) + SecurityFilter.filterResponse(gatewayContext, message) + true + } + + /** + * 生成OAuth认证的URL + * + * @note + * 认证完成回调链接需要在认证服务器上进行配置 + * @return + */ + private def generateAuthenticationUrl(): String = { + var oauthServerUrl = + s"${OAUTH_AUTHENTICATION_URL.getValue}?client_id=${OAUTH_CLIENT_ID.getValue}&response_type=code" + if (StringUtils.isNotBlank(OAUTH_SCOPE.getValue)) { + oauthServerUrl += s"&scope=${OAUTH_SCOPE.getValue}" + } + oauthServerUrl + } + + def OAuth2Auth(gatewayContext: GatewayContext, login: Boolean = false): Boolean = { + if (!ENABLE_OAUTH_AUTHENTICATION.getValue) { + val message = + Message.noLogin( + s"Gateway 未启用 OAuth 认证,请采用其他认证方式!" + ) << gatewayContext.getRequest.getRequestURI + SecurityFilter.filterResponse(gatewayContext, message) + return false + } + + val code = extractCode(gatewayContext) + val host = gatewayContext.getRequest.getRequestRealIpAddr() + + if (StringUtils.isBlank(code)) { + val message = + Message.noLogin(s"请在回调查询参数中返回code,以便完成OAuth认证!") << gatewayContext.getRequest.getRequestURI + SecurityFilter.filterResponse(gatewayContext, message) + return false + } + + var authMsg: Message = + Message.noLogin(s"无效的访问令牌 $code,无法完成 OAuth 认证!") << gatewayContext.getRequest.getRequestURI + + val accessToken = Utils.tryCatch(exchangeAccessToken(code, host))(t => { + authMsg = Message.noLogin( + s"OAuth exchange failed, code: $code, reason: ${t.getMessage}" + ) << gatewayContext.getRequest.getRequestURI + null + }) + + if (StringUtils.isNotBlank(accessToken)) { + val username = validateAccessToken(accessToken, host) + logger.info( + s"OAuth authentication succeed, uri: ${gatewayContext.getRequest.getRequestURI}, accessToken: $accessToken, username: $username." + ) + + if (login) { + GatewaySSOUtils.setLoginUser(gatewayContext, username) + val msg = + Message + .ok("login successful(登录成功)!") + .data("userName", username) + .data("enableWatermark", GatewayConfiguration.ENABLE_WATER_MARK.getValue) + .data("isAdmin", false) + SecurityFilter.filterResponse(gatewayContext, msg) + return true + } + + GatewaySSOUtils.setLoginUser(gatewayContext.getRequest, username) + true + } else { + logger.info( + s"OAuth exchange fail, uri: ${gatewayContext.getRequest.getRequestURI}, code: $code, host: $host." + ) + SecurityFilter.filterResponse(gatewayContext, authMsg) + false + } + } + + private def extractCode(gatewayContext: GatewayContext): String = { + Utils.tryCatch(gatewayContext.getRequest.getQueryParams.get("code")(0))(_ => null) + } + + /** + * 验证访问码的有效性并获取访问令牌 + * + * @param code + * 访问码 + * @param host + * 客户端主机 + * @return + * 访问令牌 + */ + private def exchangeAccessToken(code: String, host: String): String = { + val exchangeUrl = OAUTH_EXCHANGE_URL.getValue + + if (StringUtils.isBlank(exchangeUrl)) { + logger.warn(s"OAuth exchange url is not set") + } + if (StringUtils.isBlank(code)) { + logger.warn(s"OAuth exchange code is empty") + } + + Utils.tryCatch({ + val response = HttpUtils.post( + exchangeUrl, + data = objectMapper.writeValueAsString( + Map( + "client_id" -> OAUTH_CLIENT_ID.getValue, + "client_secret" -> OAUTH_CLIENT_SECRET.getValue, + "code" -> code, + "host" -> host + ) + ) + ) + objectMapper.readValue(response, classOf[Map[String, String]]).get("access_token").orNull + })(t => { + logger.warn(s"OAuth exchange failed, url: $exchangeUrl, reason: ${t.getMessage}") + null + }) + } + + /** + * 验证访问令牌的有效性并兑换用户名 + * + * @param accessToken + * 访问令牌 + * @param host + * 客户端主机 + * @return + * 用户名 + */ + private def validateAccessToken(accessToken: String, host: String): String = { + val url = OAUTH_VALIDATE_URL.getValue + + if (StringUtils.isBlank(url)) { + logger.warn(s"OAuth validate url is not set") + } + + if (StringUtils.isBlank(accessToken)) { + logger.warn(s"OAuth validate accessToken is empty") + } + + Utils.tryCatch({ + val response = HttpUtils.get(url, headers = Map("Authorization" -> s"Bearer $accessToken")) + objectMapper + .readValue(response, classOf[Map[String, String]]) + .get(OAUTH_VALIDATE_FIELD.getValue) + .orNull + })(t => { + logger.warn(s"OAuth validate failed, url: $url, reason: ${t.getMessage}") + null + }) + } + +} + +object HttpUtils extends Logging { + + def get( + url: String, + headers: Map[String, String] = Map.empty, + params: Map[String, String] = Map.empty + ): String = { + Utils.tryCatch { + val fullUrl = url + (if (params.nonEmpty) { + "?" + params.map { case (key, value) => s"$key=$value" }.mkString("&") + } else { + "" + }) + val connection = new URL(fullUrl).openConnection().asInstanceOf[HttpURLConnection] + connection.setRequestMethod("GET") + + headers.foreach { case (key, value) => + connection.setRequestProperty(key, value) + } + + if (!headers.contains("Accept")) { + connection.setRequestProperty("Accept", "application/json") + } + + val responseCode = connection.getResponseCode + if (!(responseCode >= 200 && responseCode < 300)) { + throw new IOException(s"HTTP GET request failed for URL: $url - $responseCode") + } + + val inputStream = connection.getInputStream + + try { + IOUtils.toString(inputStream, "UTF-8") + } finally { + inputStream.close() + connection.disconnect() + } + } { t => + logger.warn(s"Failed to execute HTTP GET request to $url", t) + throw new LinkisCommonErrorException( + 0, + s"HTTP GET request failed for URL: $url, reason: ${t.getMessage}" + ) + } + } + + def post(url: String, data: String, headers: Map[String, String] = Map.empty): String = { + Utils.tryCatch { + val connection = new URL(url).openConnection().asInstanceOf[HttpURLConnection] + try { + connection.setRequestMethod("POST") + connection.setDoOutput(true) + connection.setDoInput(true) + + headers.foreach { case (key, value) => + connection.setRequestProperty(key, value) + } + + if (!headers.contains("Content-Type")) { + connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8") + } + + if (!headers.contains("Accept")) { + connection.setRequestProperty("Accept", "application/json") + } + + if (data != null && data.nonEmpty) { + val outputStream = connection.getOutputStream + try { + IOUtils.write(data, outputStream, "UTF-8") + } finally { + outputStream.close() + } + } + + val responseCode = connection.getResponseCode + if (!(responseCode >= 200 && responseCode < 300)) { + throw new IOException(s"HTTP POST request failed for URL: $url - $responseCode") + } + + val inputStream = connection.getInputStream + + try { + if (inputStream != null) { + IOUtils.toString(inputStream, "UTF-8") + } else { + "" + } + } finally { + if (inputStream != null) inputStream.close() + } + } finally { + connection.disconnect() + } + } { t => + logger.warn(s"Failed to execute HTTP POST request to $url", t) + throw new LinkisCommonErrorException( + 0, + s"HTTP POST request failed for URL: $url, reason: ${t.getMessage}" + ) + } + } + +} diff --git a/linkis-web/src/common/i18n/en.json b/linkis-web/src/common/i18n/en.json index aac078b18a1..23b21bca442 100644 --- a/linkis-web/src/common/i18n/en.json +++ b/linkis-web/src/common/i18n/en.json @@ -265,6 +265,7 @@ "userName": "Please enter your username", "remenber": "Remember me", "login": "Login", + "oauthLogin": "OAuth Login", "passwordHint": "Please enter your password", "password": "Please enter password!", "loginSuccess": "Login Success", diff --git a/linkis-web/src/common/i18n/zh.json b/linkis-web/src/common/i18n/zh.json index 688153101e2..cc4c24e0c25 100644 --- a/linkis-web/src/common/i18n/zh.json +++ b/linkis-web/src/common/i18n/zh.json @@ -266,6 +266,7 @@ "userName": "请输入用户名", "remenber": "记住当前用户", "login": "登录", + "oauthLogin": "OAuth 登录", "passwordHint": "请输入密码!", "loginSuccess": "登录成功", "haveLogin": "您已经登录,请不要重复登录", diff --git a/linkis-web/src/dss/router.js b/linkis-web/src/dss/router.js index 01b5ede6499..bac6af2994a 100644 --- a/linkis-web/src/dss/router.js +++ b/linkis-web/src/dss/router.js @@ -61,6 +61,16 @@ export default [ component: () => import('./view/login/index.vue'), }, + { + path: '/login/oauth/callback', + name: 'OAuthCallback', + meta: { + title: 'OAuthCallback', + publicPage: true, + }, + component: () => + import('./view/login/oauthCallback.vue'), + }, // Public pages, not subject to permission control(公用页面,不受权限控制) { path: '/500', diff --git a/linkis-web/src/dss/view/login/index.vue b/linkis-web/src/dss/view/login/index.vue index 81c6af0bdba..c3ec243b215 100644 --- a/linkis-web/src/dss/view/login/index.vue +++ b/linkis-web/src/dss/view/login/index.vue @@ -20,7 +20,7 @@ class="login" @keyup.enter.stop.prevent="handleSubmit('loginForm')"> -
+
@@ -71,6 +79,7 @@ export default { data() { return { loading: false, + OAuthRedirectUrl: null, loginForm: { user: '', password: '', @@ -97,6 +106,7 @@ export default { this.loginForm.password = userNameAndPass.split('&')[1]; } this.getPublicKey(); + this.checkOAuthStatus(); }, mounted() { }, @@ -179,6 +189,15 @@ export default { clearSession() { storage.clear(); }, + // check OAuth status(检查OAuth状态) + checkOAuthStatus() { + api.fetch('/user/oauth-redirect', {}, 'get').then((res) => { + this.OAuthRedirectUrl = res.redirectUrl; + }) + }, + handleOAuthLogin() { + window.location.href = this.OAuthRedirectUrl; + }, }, }; diff --git a/linkis-web/src/dss/view/login/oauthCallback.vue b/linkis-web/src/dss/view/login/oauthCallback.vue new file mode 100644 index 00000000000..e81bfe7bd89 --- /dev/null +++ b/linkis-web/src/dss/view/login/oauthCallback.vue @@ -0,0 +1,55 @@ + + + +