Skip to content

Commit

Permalink
Merge pull request #8227 from yongyiduan/issue_8226
Browse files Browse the repository at this point in the history
openapi 支持蓝鲸网关jwt鉴权 #8226
  • Loading branch information
irwinsun authored Feb 1, 2023
2 parents a7ee574 + 42ec211 commit d05056b
Show file tree
Hide file tree
Showing 8 changed files with 274 additions and 54 deletions.
5 changes: 4 additions & 1 deletion helm-charts/core/ci/build/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -768,7 +768,10 @@ notify:

# openapi Deployment
openapi:
enabled: true
enabled: false
secret:
enabled: false
content: ""
replicas: 1
podLabels: {}
resources:
Expand Down
10 changes: 10 additions & 0 deletions helm-charts/core/ci/templates/openapi/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 -}}
9 changes: 9 additions & 0 deletions helm-charts/core/ci/templates/openapi/secret.yaml
Original file line number Diff line number Diff line change
@@ -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 }}
8 changes: 8 additions & 0 deletions scripts/bkenv.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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-微服务依赖
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Any>
// 应用身份登录
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<String, Any>
// 用户身份登录
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<String, Any> {
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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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|" +
Expand Down Expand Up @@ -58,6 +55,9 @@ class SampleApiFilter constructor(
}

override fun filter(requestContext: ContainerRequestContext) {
if (apiFilterEnabled != true) {
return
}
if (!verifyJWT(requestContext)) {
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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!!
}
}
9 changes: 7 additions & 2 deletions support-files/templates/#etc#ci#application-openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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__

0 comments on commit d05056b

Please sign in to comment.