diff --git a/helm-charts/core/ci/build/values.yaml b/helm-charts/core/ci/build/values.yaml index 0728df0ada3..9c378683d23 100644 --- a/helm-charts/core/ci/build/values.yaml +++ b/helm-charts/core/ci/build/values.yaml @@ -768,7 +768,10 @@ notify: # openapi Deployment openapi: - enabled: true + enabled: false + secret: + enabled: false + content: "" replicas: 1 podLabels: {} resources: diff --git a/helm-charts/core/ci/templates/openapi/deployment.yaml b/helm-charts/core/ci/templates/openapi/deployment.yaml index 9d09862b5d9..daaaf581966 100644 --- a/helm-charts/core/ci/templates/openapi/deployment.yaml +++ b/helm-charts/core/ci/templates/openapi/deployment.yaml @@ -113,6 +113,11 @@ spec: - mountPath: /data/workspace/openapi/jvm name: log-volume subPathExpr: bkci/jvm/$(POD_NAME) + {{ if .Values.openapi.secret.enabled }} + - mountPath: {{ .Values.config.bkCiOpenapiApiPubOuter | splitList "/" | initial | join "/" }} + name: bk-key-volume + readOnly: true + {{ end }} lifecycle: preStop: exec: @@ -124,4 +129,9 @@ spec: - hostPath: path: /data name: log-volume + {{ if .Values.openapi.secret.enabled }} + - name: bk-key-volume + secret: + secretName: openapi-bk-key + {{ end }} {{- end -}} diff --git a/helm-charts/core/ci/templates/openapi/secret.yaml b/helm-charts/core/ci/templates/openapi/secret.yaml new file mode 100644 index 00000000000..a9a3b03e5ff --- /dev/null +++ b/helm-charts/core/ci/templates/openapi/secret.yaml @@ -0,0 +1,9 @@ +{{ if and .Values.openapi.enabled .Values.openapi.secret.enabled }} +kind: Secret +apiVersion: v1 +metadata: + name: openapi-bk-key +data: + {{ .Values.config.bkCiOpenapiApiPubOuter | splitList "/" | last }}: {{ .Values.openapi.secret.content }} +type: Opaque +{{ end }} diff --git a/scripts/bkenv.properties b/scripts/bkenv.properties index 1e84b3fdec1..aadb8286c0f 100644 --- a/scripts/bkenv.properties +++ b/scripts/bkenv.properties @@ -281,6 +281,14 @@ BK_CI_REPOSITORY_GITHUB_PRIVATEKEY= BK_CI_REPOSITORY_GITHUB_APPNAME= # BK_CI_REPOSITORY_GITHUB_SERVER github回调的服务名,如果是stream环境,应该改成stream BK_CI_REPOSITORY_GITHUB_SERVER=repository +# BK_CI_OPENAPI_API_BLUEKING_ENABLE 用于是否开启blueking api filter +BK_CI_OPENAPI_API_BLUEKING_ENABLE=false +# BK_CI_OPENAPI_API_PUB_OUTER 用于blueking api filter jwt鉴权,内容为pub文件完整路径 +BK_CI_OPENAPI_API_PUB_OUTER= +# BK_CI_OPENAPI_API_AUTH 用于blueking api filter 区分鉴权模式 +BK_CI_OPENAPI_API_AUTH=true +# BK_CI_OPENAPI_VERIFY_PROJECT 在 blueking api filter 中使用,是否开启projectId强校验。 +BK_CI_OPENAPI_VERIFY_PROJECT=false ########## # 4-微服务依赖 diff --git a/src/backend/ci/core/openapi/biz-openapi/src/main/kotlin/com/tencent/devops/openapi/filter/impl/BlueKingApiFilter.kt b/src/backend/ci/core/openapi/biz-openapi/src/main/kotlin/com/tencent/devops/openapi/filter/impl/BlueKingApiFilter.kt new file mode 100644 index 00000000000..7d165609467 --- /dev/null +++ b/src/backend/ci/core/openapi/biz-openapi/src/main/kotlin/com/tencent/devops/openapi/filter/impl/BlueKingApiFilter.kt @@ -0,0 +1,233 @@ +/* + * Tencent is pleased to support the open source community by making BK-CI 蓝鲸持续集成平台 available. + * + * Copyright (C) 2019 THL A29 Limited, a Tencent company. All rights reserved. + * + * BK-CI 蓝鲸持续集成平台 is licensed under the MIT license. + * + * A copy of the MIT License is included in this file. + * + * + * Terms of the MIT License: + * --------------------------------------------------- + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of + * the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN + * NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.tencent.devops.openapi.filter.impl + +import com.tencent.devops.common.api.auth.AUTH_HEADER_DEVOPS_APP_CODE +import com.tencent.devops.common.api.auth.AUTH_HEADER_DEVOPS_APP_SECRET +import com.tencent.devops.common.api.auth.AUTH_HEADER_DEVOPS_USER_ID +import com.tencent.devops.common.api.exception.ErrorCodeException +import com.tencent.devops.common.api.util.JsonUtil +import com.tencent.devops.common.service.utils.SpringContextUtil +import com.tencent.devops.common.web.RequestFilter +import com.tencent.devops.openapi.constant.OpenAPIMessageCode.ERROR_OPENAPI_JWT_PARSE_FAIL +import com.tencent.devops.openapi.filter.ApiFilter +import com.tencent.devops.openapi.utils.ApiGatewayPubFile +import com.tencent.devops.openapi.utils.ApiGatewayUtil +import io.jsonwebtoken.Jwts +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.openssl.PEMParser +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import java.io.ByteArrayInputStream +import java.io.InputStreamReader +import java.security.Security +import javax.ws.rs.container.ContainerRequestContext +import javax.ws.rs.container.PreMatching +import javax.ws.rs.core.Response +import javax.ws.rs.ext.Provider + +@Provider +@PreMatching +@RequestFilter +@Suppress("UNUSED") +class BlueKingApiFilter( + private val apiGatewayUtil: ApiGatewayUtil +) : ApiFilter { + + @Value("\${api.blueKing.enable:#{null}}") + private val apiFilterEnabled: Boolean? = false + + companion object { + private val logger = LoggerFactory.getLogger(BlueKingApiFilter::class.java) + private const val appCodeHeader = "app_code" + private const val appSecHeader = "app_secret" + private const val jwtHeader = "X-Bkapi-JWT" + } + + enum class ApiType(val startContextPath: String, val verify: Boolean) { + DEFAULT("/api/apigw/", true), + USER("/api/apigw-user/", true), + APP("/api/apigw-app/", true), + OP("/api/op/", false), + SWAGGER("/api/swagger.json", false); + + companion object { + fun parseType(path: String): ApiType? { + values().forEach { type -> + if (path.contains(other = type.startContextPath, ignoreCase = true)) { + return type + } + } + return null + } + } + } + + @Suppress("UNCHECKED_CAST", "ComplexMethod", "NestedBlockDepth", "ReturnCount") + override fun verifyJWT(requestContext: ContainerRequestContext): Boolean { + // path为为空的时候,直接退出 + val path = requestContext.uriInfo.requestUri.path + // 判断是否为合法的路径 + val apiType = ApiType.parseType(path) ?: return false + // 如果是op的接口访问直接跳过jwt认证 + if (!apiType.verify) return true + + logger.info("FILTER| url=$path") + val bkApiJwt = requestContext.getHeaderString(jwtHeader) + if (bkApiJwt.isNullOrBlank()) { + logger.error("Request bk api jwt is empty for ${requestContext.request}") + requestContext.abortWith( + Response.status(Response.Status.BAD_REQUEST) + .entity("Request bkapi jwt is empty.") + .build() + ) + return false + } + + val jwt = parseJwt(bkApiJwt) + logger.debug("Get the bkApiJwt header|X-Bkapi-JWT={}|jwt={}", bkApiJwt, jwt) + + // 验证应用身份信息 + if (jwt.contains("app")) { + val app = jwt["app"] as Map + // 应用身份登录 + if (app.contains(appCodeHeader)) { + val appCode = app[appCodeHeader]?.toString() + val verified = app["verified"].toString().toBoolean() + if (apiType == ApiType.APP && (appCode.isNullOrEmpty() || !verified)) { + return false + } else { + if (!appCode.isNullOrBlank()) { + // 将appCode头部置空 + requestContext.headers[AUTH_HEADER_DEVOPS_APP_CODE]?.set(0, null) + if (requestContext.headers[AUTH_HEADER_DEVOPS_APP_CODE] != null) { + requestContext.headers[AUTH_HEADER_DEVOPS_APP_CODE]?.set(0, appCode) + } else { + requestContext.headers.add(AUTH_HEADER_DEVOPS_APP_CODE, appCode) + } + } + } + } + } + // 在验证应用身份信息 + if (jwt.contains("user")) { + // 先做app的验证再做 + val user = jwt["user"] as Map + // 用户身份登录 + if (user.contains("username")) { + val username = user["username"]?.toString() ?: "" + val verified = user["verified"].toString().toBoolean() + // 名字为空或者没有通过认证的时候,直接失败 + if (username.isNotBlank() && verified) { + // 将user头部置空 + requestContext.headers[AUTH_HEADER_DEVOPS_USER_ID]?.set(0, null) + if (requestContext.headers[AUTH_HEADER_DEVOPS_USER_ID] != null) { + requestContext.headers[AUTH_HEADER_DEVOPS_USER_ID]?.set(0, username) + } else { + requestContext.headers.add(AUTH_HEADER_DEVOPS_USER_ID, username) + } + } else if (apiType == ApiType.USER) { + requestContext.abortWith( + Response.status(Response.Status.BAD_REQUEST) + .entity("Request don't has user's access_token.") + .build() + ) + return false + } + } + } + return true + } + + override fun filter(requestContext: ContainerRequestContext) { + if (apiFilterEnabled != true) { + return + } + if (!apiGatewayUtil.isAuth()) { + // 将query中的app_code和app_secret设置成头部 + setupHeader(requestContext) + } else { + // 验证通过 + if (!verifyJWT(requestContext)) { + requestContext.abortWith( + Response.status(Response.Status.BAD_REQUEST) + .entity("Devops OpenAPI Auth fail:user or app auth fail.") + .build() + ) + return + } + } + } + + private fun setupHeader(requestContext: ContainerRequestContext) { + requestContext.uriInfo?.pathParameters?.forEach { pathParam -> + if (pathParam.key == appCodeHeader && pathParam.value.isNotEmpty()) { + requestContext.headers[AUTH_HEADER_DEVOPS_APP_CODE]?.set(0, null) + if (requestContext.headers[AUTH_HEADER_DEVOPS_APP_CODE] != null) { + requestContext.headers[AUTH_HEADER_DEVOPS_APP_CODE]?.set(0, pathParam.value[0]) + } else { + requestContext.headers.add(AUTH_HEADER_DEVOPS_APP_CODE, pathParam.value[0]) + } + } else if (pathParam.key == appSecHeader && pathParam.value.isNotEmpty()) { + requestContext.headers[AUTH_HEADER_DEVOPS_APP_SECRET]?.set(0, null) + if (requestContext.headers[AUTH_HEADER_DEVOPS_APP_SECRET] != null) { + requestContext.headers[AUTH_HEADER_DEVOPS_APP_SECRET]?.set(0, pathParam.value[0]) + } else { + requestContext.headers.add(AUTH_HEADER_DEVOPS_APP_CODE, pathParam.value[0]) + } + } + } + } + + private fun parseJwt(bkApiJwt: String): Map { + var reader: PEMParser? = null + try { + val key = SpringContextUtil.getBean(ApiGatewayPubFile::class.java).getPubOuter().toByteArray() + + Security.addProvider(BouncyCastleProvider()) + val bais = ByteArrayInputStream(key) + reader = PEMParser(InputStreamReader(bais)) + val publicKeyInfo = reader.readObject() as SubjectPublicKeyInfo + val publicKey = JcaPEMKeyConverter().getPublicKey(publicKeyInfo) + val jwtParser = Jwts.parserBuilder().setSigningKey(publicKey).build() + val parse = jwtParser.parse(bkApiJwt) + logger.info("Get the parse body(${parse.body}) and header(${parse.header})") + return JsonUtil.toMap(parse.body) + } catch (ignored: Exception) { + logger.error("BKSystemErrorMonitor| Parse jwt failed.", ignored) + throw ErrorCodeException( + errorCode = ERROR_OPENAPI_JWT_PARSE_FAIL, + defaultMessage = "Parse jwt failed", + params = arrayOf(bkApiJwt) + ) + } finally { + reader?.close() + } + } +} diff --git a/src/backend/ci/core/openapi/biz-openapi/src/main/kotlin/com/tencent/devops/openapi/filter/impl/SampleApiFilter.kt b/src/backend/ci/core/openapi/biz-openapi/src/main/kotlin/com/tencent/devops/openapi/filter/impl/SampleApiFilter.kt index 82431e9fbb1..21c412acc07 100644 --- a/src/backend/ci/core/openapi/biz-openapi/src/main/kotlin/com/tencent/devops/openapi/filter/impl/SampleApiFilter.kt +++ b/src/backend/ci/core/openapi/biz-openapi/src/main/kotlin/com/tencent/devops/openapi/filter/impl/SampleApiFilter.kt @@ -27,9 +27,6 @@ class SampleApiFilter constructor( private val apiFilterEnabled: Boolean? = false override fun verifyJWT(requestContext: ContainerRequestContext): Boolean { - if (apiFilterEnabled != true) { - return true - } val accessToken = requestContext.uriInfo.queryParameters.getFirst(API_ACCESS_TOKEN_PROPERTY) if (accessToken.isNullOrBlank()) { logger.warn("OPENAPI|verifyJWT accessToken is blank|" + @@ -58,6 +55,9 @@ class SampleApiFilter constructor( } override fun filter(requestContext: ContainerRequestContext) { + if (apiFilterEnabled != true) { + return + } if (!verifyJWT(requestContext)) { return } diff --git a/src/backend/ci/core/openapi/biz-openapi/src/main/kotlin/com/tencent/devops/openapi/utils/ApiGatewayPubFile.kt b/src/backend/ci/core/openapi/biz-openapi/src/main/kotlin/com/tencent/devops/openapi/utils/ApiGatewayPubFile.kt index d677a833381..dff646e41eb 100644 --- a/src/backend/ci/core/openapi/biz-openapi/src/main/kotlin/com/tencent/devops/openapi/utils/ApiGatewayPubFile.kt +++ b/src/backend/ci/core/openapi/biz-openapi/src/main/kotlin/com/tencent/devops/openapi/utils/ApiGatewayPubFile.kt @@ -48,11 +48,7 @@ class ApiGatewayPubFile { @Value("\${api.gateway.pub.file.outer:#{null}}") private val pubFileOuter: String? = null - @Value("\${api.gateway.pub.file.inner:#{null}}") - private val pubFileInner: String? = null - private var pubOuter: String? = null - private var pubInner: String? = null fun getPubOuter(): String { if (pubOuter == null) { @@ -97,48 +93,4 @@ class ApiGatewayPubFile { return pubOuter!! } - - fun getPubInner(): String { - if (pubInner == null) { - synchronized(this) { - if (pubInner != null) { - return pubInner!! - } - if (pubFileInner == null) { - throw InvalidConfigException( - message = "Api gateway pub file is not settle", - errorCode = ERROR_OPENAPI_APIGW_PUBFILE_NOT_SETTLE - ) - } - - val file = File(pubFileInner) - if (!file.exists()) { - throw InvalidConfigException( - message = "The pub file (${file.absolutePath}) is not exist", - errorCode = ERROR_OPENAPI_APIGW_PUBFILE_NOT_EXIST, - params = arrayOf(file.absolutePath) - ) - } - pubInner = file.readText() - if (pubInner == null) { - throw InvalidConfigException( - message = "Can't read the pub content from ${file.absolutePath}", - errorCode = ERROR_OPENAPI_APIGW_PUBFILE_READ_ERROR, - params = arrayOf(file.absolutePath) - ) - } - - if (pubInner!!.trim().isEmpty()) { - throw InvalidConfigException( - message = "The pub file is empty from ${file.absolutePath}", - errorCode = ERROR_OPENAPI_APIGW_PUBFILE_CONTENT_EMPTY, - params = arrayOf(file.absolutePath) - ) - } - logger.info("Get the pub($pubInner) from ${file.absolutePath}") - } - } - - return pubInner!! - } } diff --git a/support-files/templates/#etc#ci#application-openapi.yml b/support-files/templates/#etc#ci#application-openapi.yml index d6766112003..885ee8a2328 100644 --- a/support-files/templates/#etc#ci#application-openapi.yml +++ b/support-files/templates/#etc#ci#application-openapi.yml @@ -12,9 +12,14 @@ server: # 是否开启apifilter和aspect功能 api: gateway: - auth: false + pub: + file: + outer: __BK_CI_OPENAPI_API_PUB_OUTER__ + auth: __BK_CI_OPENAPI_API_AUTH__ + blueKing: + enable: __BK_CI_OPENAPI_API_BLUEKING_ENABLE__ # 是否开启openAPI 切面内校验path内project的开关。打开后若openAPI接口内没有projectId相关字段,需要对应接口需要加@IgnoreProjectId openapi: verify: - project: false + project: __BK_CI_OPENAPI_VERIFY_PROJECT__