feat(auth): complete sso subsystem integration

This commit is contained in:
mac
2026-07-16 15:52:56 +08:00
parent f8c389606f
commit eb2621d914
24 changed files with 1977 additions and 111 deletions
-4
View File
@@ -12,14 +12,10 @@ declare module 'vue' {
AppSidebar: typeof import('./src/components/Layout/AppSidebar.vue')['default']
AuditLogTable: typeof import('./src/components/AuditLogTable.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElOption: typeof import('element-plus/es')['ElOption']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSpace: typeof import('element-plus/es')['ElSpace']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SubsystemCard: typeof import('./src/components/SubsystemCard.vue')['default']
+2 -5
View File
@@ -4,8 +4,7 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { authApi } from '@/api/auth'
import { getToken, removeToken } from '@/utils/auth'
import { getToken } from '@/utils/auth'
import { redirectToSSO } from '@/utils/sso'
import { useAuthStore } from '@/stores/auth'
@@ -18,11 +17,9 @@ onMounted(async () => {
}
try {
const { data } = await authApi.getUserInfo()
authStore.setAuth(token, data)
await authStore.refreshUser()
} catch {
authStore.clearAuth()
removeToken()
redirectToSSO()
}
})
+15 -2
View File
@@ -25,8 +25,21 @@ export const authApi = {
return Promise.reject(new Error('password login is disabled'))
},
logout(): Promise<ApiResponse<null>> {
return Promise.resolve({ code: 0, message: 'success', data: null })
async logout(): Promise<ApiResponse<null>> {
const token = getToken()
const response = await fetch('/auth/api/v1/logout', {
method: 'POST',
headers: {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
credentials: 'include',
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || `HTTP ${response.status}`)
}
return { code: 0, message: 'success', data: null }
},
async getUserInfo(): Promise<ApiResponse<UserInfo>> {
@@ -47,6 +47,9 @@
<router-link to="/subsystem" class="nav-item" active-class="active">
<span class="ic">↗</span>子系统导航
</router-link>
<router-link to="/subsystem/authz" class="nav-item" active-class="active">
<span class="ic">▦</span>子系统赋权
</router-link>
</div>
<div class="nav-group">
<div class="nav-label">审计</div>
+4 -3
View File
@@ -2,8 +2,6 @@ import { useAuthStore } from '@/stores/auth'
import { authApi } from '@/api/auth'
import { redirectToSSO } from '@/utils/sso'
const SSO_SIGNOUT_URL = 'http://bo-staging-sso-internal.jfcs-k8s-qa1.qiniu.io/signout'
export function useAuth() {
const authStore = useAuthStore()
@@ -15,9 +13,12 @@ export function useAuth() {
}
const logout = async () => {
try {
await authApi.logout()
} finally {
authStore.clearAuth()
window.location.assign(SSO_SIGNOUT_URL)
window.location.assign('/login?logged_out=1')
}
}
const checkAuth = () => {
+28 -9
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import { getToken } from '@/utils/auth'
import { consumeSSOToken, redirectToSSO } from '@/utils/sso'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
@@ -32,6 +33,12 @@ const router = createRouter({
component: () => import('@/views/subsystem/Navigation.vue'),
meta: { title: '子系统导航' },
},
{
path: 'subsystem/authz',
name: 'SubsystemAuthz',
component: () => import('@/views/subsystem/Authorization.vue'),
meta: { title: '子系统赋权' },
},
{
path: 'audit/login',
name: 'LoginAudit',
@@ -97,19 +104,31 @@ const router = createRouter({
],
})
// 路由守卫
router.beforeEach((to, _from, next) => {
consumeSSOToken()
const token = getToken()
router.beforeEach(async (to) => {
const authStore = useAuthStore()
const ssoToken = consumeSSOToken()
if (ssoToken) {
authStore.setSessionToken(ssoToken)
}
const token = authStore.token || getToken()
if (to.meta.requiresAuth !== false && !token) {
redirectToSSO()
return
} else if (to.path === '/login' && token) {
next('/')
} else {
next()
return false
}
if (token && (!authStore.user || ssoToken)) {
try {
await authStore.refreshUser()
} catch {
authStore.clearAuth()
redirectToSSO()
return false
}
}
if (to.path === '/login' && token) {
return '/'
}
return true
})
export default router
+20
View File
@@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { authApi } from '@/api/auth'
import { getToken, setToken, removeToken, getUser, setUser, removeUser } from '@/utils/auth'
export const useAuthStore = defineStore('auth', () => {
@@ -13,6 +14,23 @@ export const useAuthStore = defineStore('auth', () => {
setUser(newUser)
}
function setSessionToken(newToken: string) {
token.value = newToken
setToken(newToken)
}
async function refreshUser() {
const currentToken = token.value || getToken()
if (!currentToken) {
clearAuth()
return null
}
setSessionToken(currentToken)
const response = await authApi.getUserInfo()
setAuth(currentToken, response.data)
return response.data
}
function clearAuth() {
token.value = null
user.value = null
@@ -28,6 +46,8 @@ export const useAuthStore = defineStore('auth', () => {
token,
user,
setAuth,
setSessionToken,
refreshUser,
clearAuth,
isLoggedIn,
}
+11 -3
View File
@@ -2,7 +2,15 @@ import { setToken } from '@/utils/auth'
const SSO_LOGIN_PATH = '/auth/api/v1/login/internal-sso'
export function relayState(openApp = ''): string {
export function relayState(openApp = '', fallbackPath = ''): string {
if (fallbackPath) {
const target = new URL(fallbackPath, window.location.origin)
if (openApp) {
target.searchParams.set('open_app', openApp)
}
return `${target.pathname}${target.search}${target.hash}` || '/'
}
const url = new URL(window.location.href)
url.searchParams.delete('sso_token')
url.searchParams.delete('open_app')
@@ -12,8 +20,8 @@ export function relayState(openApp = ''): string {
return `${url.pathname}${url.search}${url.hash}` || '/'
}
export function redirectToSSO(openApp = ''): void {
window.location.assign(`${SSO_LOGIN_PATH}?relay_state=${encodeURIComponent(relayState(openApp))}`)
export function redirectToSSO(openApp = '', fallbackPath = ''): void {
window.location.assign(`${SSO_LOGIN_PATH}?relay_state=${encodeURIComponent(relayState(openApp, fallbackPath))}`)
}
export function consumeSSOToken(): string {
+16 -7
View File
@@ -6,12 +6,10 @@
<div class="mark">xi</div>
xinfra
</div>
<h2>统一基础设施平台</h2>
<p>正在跳转到 SSO 登录</p>
<h2>{{ title }}</h2>
<p>{{ subtitle }}</p>
</div>
<el-button type="primary" size="large" style="width: 100%" @click="redirectToSSO()">
重新跳转
</el-button>
<el-button type="primary" size="large" style="width: 100%" @click="redirectToSSO('', '/')">{{ buttonText }}</el-button>
<div class="login-footer">
<p>统一 LDAP 账号,同账号同密码</p>
</div>
@@ -20,10 +18,21 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { redirectToSSO } from '@/utils/sso'
onMounted(() => redirectToSSO())
const route = useRoute()
const loggedOut = computed(() => route.query.logged_out === '1')
const title = computed(() => loggedOut.value ? '已退出登录' : '统一基础设施平台')
const subtitle = computed(() => loggedOut.value ? '本地登录态已清除' : '正在跳转到 SSO 登录')
const buttonText = computed(() => loggedOut.value ? '重新登录' : '重新跳转')
onMounted(() => {
if (!loggedOut.value) {
redirectToSSO('', '/')
}
})
</script>
<style scoped>
@@ -0,0 +1,443 @@
<template>
<div>
<div class="page-head">
<div>
<h1>子系统赋权</h1>
<p>Wayne / CloudDM 入口权限、默认角色与授权状态</p>
</div>
<el-button type="primary">新增授权</el-button>
</div>
<div class="stat-row">
<div class="stat-card">
<div class="label">接入子系统</div>
<div class="value">2</div>
<div class="delta">Wayne · CloudDM</div>
</div>
<div class="stat-card">
<div class="label">授权主体</div>
<div class="value">6</div>
<div class="delta">用户 3 · 用户组 3</div>
</div>
<div class="stat-card">
<div class="label">待审批</div>
<div class="value" style="color: var(--warn)">2</div>
<div class="delta">最近提交 10:18</div>
</div>
<div class="stat-card">
<div class="label">默认授权</div>
<div class="value" style="font-size: 16px; color: var(--accent)">● 生效</div>
<div class="delta">新用户默认只读</div>
</div>
</div>
<div class="toolbar">
<el-select v-model="filters.system" placeholder="全部子系统" style="width: 150px">
<el-option label="全部子系统" value="" />
<el-option label="Wayne" value="Wayne" />
<el-option label="CloudDM" value="CloudDM" />
</el-select>
<el-select v-model="filters.type" placeholder="全部主体" style="width: 150px">
<el-option label="全部主体" value="" />
<el-option label="用户" value="user" />
<el-option label="用户组" value="group" />
</el-select>
<el-select v-model="filters.status" placeholder="全部状态" style="width: 150px">
<el-option label="全部状态" value="" />
<el-option label="已生效" value="active" />
<el-option label="待审批" value="pending" />
<el-option label="已停用" value="disabled" />
</el-select>
<el-input v-model="filters.keyword" placeholder="搜索账号 / 用户组 / 角色" style="flex: 1" />
</div>
<div class="matrix">
<div v-for="system in systems" :key="system.name" class="system-card">
<div class="system-top">
<div class="logo" :class="system.className">{{ system.icon }}</div>
<div>
<h3>{{ system.name }}</h3>
<p>{{ system.defaultPolicy }}</p>
</div>
</div>
<div class="role-grid">
<div v-for="role in system.roles" :key="role.name" class="role-cell">
<span>{{ role.name }}</span>
<strong>{{ role.count }}</strong>
</div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h3>授权列表</h3>
<span class="meta">数据源:AuthServer · 子系统授权</span>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>授权主体</th>
<th>类型</th>
<th>子系统</th>
<th>角色 / 范围</th>
<th>来源</th>
<th>状态</th>
<th>最近变更</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in filteredGrants" :key="item.id" class="tr-hover">
<td>
<div class="principal">
<span class="avatar">{{ item.initial }}</span>
<div>
<div class="strong">{{ item.principal }}</div>
<div class="sub mono">{{ item.detail }}</div>
</div>
</div>
</td>
<td><span class="tag">{{ item.type === 'user' ? '用户' : '用户组' }}</span></td>
<td class="mono">{{ item.system }}</td>
<td>
<span class="role">{{ item.role }}</span>
<span class="scope mono">{{ item.scope }}</span>
</td>
<td class="mono">{{ item.source }}</td>
<td :class="['status-text', item.statusClass]">● {{ item.statusText }}</td>
<td class="mono">{{ item.updatedAt }}</td>
<td>
<div class="actions">
<button type="button">编辑</button>
<button type="button" class="danger">停用</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive } from 'vue'
const filters = reactive({
system: '',
type: '',
status: '',
keyword: '',
})
const systems = [
{
name: 'Wayne',
icon: 'W',
className: 'wayne',
defaultPolicy: '默认 namespace 只读 · DemoGroupId=23',
roles: [
{ name: '只读', count: 18 },
{ name: '发布', count: 6 },
{ name: '管理员', count: 2 },
],
},
{
name: 'CloudDM',
icon: 'DM',
className: 'clouddm',
defaultPolicy: 'OIDC 登录 · SQL 审核角色映射',
roles: [
{ name: '查询', count: 21 },
{ name: '审核', count: 5 },
{ name: '管理员', count: 1 },
],
},
]
const grants = [
{ id: 1, principal: 'eastsales@qiniu.com', initial: 'E', detail: 'eastsales', type: 'user', system: 'Wayne', role: '默认只读', scope: 'namespace=demo', source: 'SSO 自动初始化', status: 'active', statusText: '已生效', statusClass: 'ok', updatedAt: '2026-07-15 10:12' },
{ id: 2, principal: 'platform-admin', initial: 'P', detail: 'LDAP group', type: 'group', system: 'Wayne', role: '管理员', scope: 'all namespaces', source: '手动授权', status: 'active', statusText: '已生效', statusClass: 'ok', updatedAt: '2026-07-14 18:40' },
{ id: 3, principal: 'dba-reviewers', initial: 'D', detail: 'LDAP group', type: 'group', system: 'CloudDM', role: 'SQL 审核', scope: 'prod / staging', source: '手动授权', status: 'active', statusText: '已生效', statusClass: 'ok', updatedAt: '2026-07-14 16:05' },
{ id: 4, principal: 'las-dev', initial: 'L', detail: 'LDAP group', type: 'group', system: 'CloudDM', role: '查询', scope: 'las schemas', source: '审批流', status: 'pending', statusText: '待审批', statusClass: 'warn', updatedAt: '2026-07-15 10:18' },
{ id: 5, principal: 'ops-user1@qiniu.com', initial: 'O', detail: 'ops-user1', type: 'user', system: 'Wayne', role: '发布', scope: 'namespace=demo', source: '审批流', status: 'pending', statusText: '待审批', statusClass: 'warn', updatedAt: '2026-07-15 09:55' },
{ id: 6, principal: 'temp-sql@qiniu.com', initial: 'T', detail: 'temp-sql', type: 'user', system: 'CloudDM', role: '查询', scope: 'expired', source: '临时授权', status: 'disabled', statusText: '已停用', statusClass: 'idle', updatedAt: '2026-07-13 20:30' },
]
const filteredGrants = computed(() => {
const keyword = filters.keyword.trim().toLowerCase()
return grants.filter((item) => {
if (filters.system && item.system !== filters.system) return false
if (filters.type && item.type !== filters.type) return false
if (filters.status && item.status !== filters.status) return false
if (!keyword) return true
return [item.principal, item.detail, item.system, item.role, item.scope]
.join(' ')
.toLowerCase()
.includes(keyword)
})
})
</script>
<style scoped>
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 18px;
}
.page-head h1 {
font-size: 19px;
margin: 0 0 4px;
font-weight: 700;
}
.page-head p {
margin: 0;
color: var(--text-dim);
font-size: 12.5px;
}
.stat-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 18px;
}
.stat-card {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px 16px;
}
.stat-card .label {
font-size: 11px;
color: var(--text-dim);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-card .value {
font-family: var(--mono);
font-size: 24px;
font-weight: 600;
}
.stat-card .delta {
font-size: 11px;
color: var(--text-dim);
margin-top: 4px;
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 14px;
align-items: center;
}
.matrix {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.system-card {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px;
}
.system-top {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 14px;
}
.logo {
width: 38px;
height: 38px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-weight: 700;
font-size: 13px;
}
.logo.wayne {
background: #1C2A3A;
color: #7FB8FF;
}
.logo.clouddm {
background: #1C3A2E;
color: #7FFFC2;
}
.system-top h3 {
margin: 0 0 4px;
font-size: 14px;
}
.system-top p {
margin: 0;
color: var(--text-dim);
font-size: 11.5px;
}
.role-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.role-cell {
border: 1px solid var(--line-soft);
background: var(--bg-panel-2);
border-radius: 6px;
padding: 10px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--text-mid);
}
.role-cell strong {
font-family: var(--mono);
color: var(--text-hi);
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 16px;
border-bottom: 1px solid var(--line-soft);
}
.panel-head h3 {
margin: 0;
font-size: 13.5px;
font-weight: 600;
}
.panel-head .meta {
font-size: 11.5px;
color: var(--text-dim);
font-family: var(--mono);
}
.panel-body {
padding: 4px 0;
overflow-x: auto;
}
table {
width: 100%;
min-width: 980px;
border-collapse: collapse;
font-size: 12.5px;
}
th {
text-align: left;
color: var(--text-dim);
font-weight: 500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 9px 16px;
border-bottom: 1px solid var(--line-soft);
}
td {
padding: 11px 16px;
border-bottom: 1px solid var(--line-soft);
color: var(--text-mid);
}
.tr-hover:hover {
background: #171D28;
}
.strong {
color: var(--text-hi);
font-weight: 600;
}
.sub {
color: var(--text-dim);
font-size: 11px;
margin-top: 2px;
}
.principal {
display: flex;
align-items: center;
gap: 10px;
}
.avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: #2A3142;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
color: var(--text-hi);
font-size: 11px;
}
.role {
color: var(--text-hi);
margin-right: 8px;
}
.scope {
color: var(--text-dim);
font-size: 11px;
}
.actions {
display: flex;
gap: 8px;
}
.actions button {
border: 1px solid var(--line);
background: var(--bg-panel-2);
color: var(--text-mid);
border-radius: 5px;
padding: 4px 8px;
font-size: 12px;
}
.actions button:hover {
color: var(--text-hi);
border-color: #3A4356;
}
.actions button.danger {
color: #FF9A95;
}
</style>
+58
View File
@@ -283,6 +283,64 @@ Wayne 会把回调地址拼成:
因此 `OAUTH_WAYNE_REDIRECT_URI` 必须和 Wayne 实际回调地址完全一致。浏览器访问 Wayne OAuth 登录入口后,如果 AuthServer 还没有登录态,会先跳内部 SAML;SAML 成功后再回到 OAuth authorize,签发 code 给 Wayne。
`WAYEN_OAUTH_REF` 是 AuthServer 发起 Wayne 登录时写入 Wayne `next` 参数的登录完成页,默认 `/portal/namespace/1/app`,对应 Wayne `DemoNamespaceId = 1` 的默认 namespace。不要配置成 `oauth` 或 `/oauth`,否则 Wayne 回调会把它当成前端路由跳到 `/oauth`。
## Wayne 授权代理接口
AuthServer 的 Wayne 授权代理接口不要求调用方传 Wayne user ID。后端会从当前 `authserver_token` 里取 `email`,把它作为 Wayne username 传给 Wayne internal API。
对外接口:
```text
GET /auth/api/v1/wayne/namespaces
GET /auth/api/v1/wayne/groups
GET /auth/api/v1/wayne/users/me/roles
GET /auth/api/v1/wayne/namespaces/:namespaceid/operator-permissions
GET /auth/api/v1/wayne/apps/:appid/operator-permissions
PUT /auth/api/v1/wayne/namespaces/:namespaceid/roles
DELETE /auth/api/v1/wayne/namespaces/:namespaceid/roles
PUT /auth/api/v1/wayne/apps/:appid/roles
DELETE /auth/api/v1/wayne/apps/:appid/roles
```
示例:
```http
PUT /auth/api/v1/wayne/namespaces/1/roles
Authorization: Bearer <authserver_token>
Content-Type: application/json
{
"groupIds": [10, 11],
"replace": false,
"requestId": "req-001",
"reason": "grant namespace access"
}
```
AuthServer 转发到 Wayne internal API 时会使用 token email:
```text
PUT /api/v1/internal/namespaces/1/users/<token-email>/roles
```
并覆盖请求体中的 `operatorName` 为 token email,忽略外部传入的 `operatorUserId`。
相关配置:
```env
WAYNE_INTERNAL_API_BASE_URL=http://wayne-backend.demo.svc.cluster.local:8080
WAYNE_SERVICE_NAME=xinfra
WAYNE_SERVICE_API_SECRET_KEY=<wayne-service-secret>
```
Wayne internal API 签名规则:
```text
bodyHash = SHA256_HEX(rawBody)
payload = METHOD + "\n" + URI + "\n" + timestamp + "\n" + nonce + "\n" + bodyHash
signature = HMAC_SHA256_HEX(secret, payload)
X-Wayne-Signature = "sha256=" + signature
```
管理员可查看当前 SAML metadata 配置:
```text
+8
View File
@@ -34,6 +34,10 @@ type Config struct {
WayenLoginFormat string
WayenLoginValue string
WayenOAuthRef string
WayenOAuthLoginURL string
WayneInternalAPIBaseURL string
WayneServiceName string
WayneServiceAPISecretKey string
OAuthClientID string
OAuthClientSecret string
OAuthRedirectURI string
@@ -79,6 +83,10 @@ func Load() Config {
WayenLoginFormat: env("WAYEN_LOGIN_FORMAT", "form"),
WayenLoginValue: env("WAYEN_LOGIN_VALUE", "email"),
WayenOAuthRef: env("WAYEN_OAUTH_REF", "/portal/namespace/1/app"),
WayenOAuthLoginURL: trimURL(env("WAYEN_OAUTH_LOGIN_URL", "")),
WayneInternalAPIBaseURL: trimURL(env("WAYNE_INTERNAL_API_BASE_URL", "")),
WayneServiceName: env("WAYNE_SERVICE_NAME", "xinfra"),
WayneServiceAPISecretKey: env("WAYNE_SERVICE_API_SECRET_KEY", ""),
OAuthClientID: env("OAUTH_WAYNE_CLIENT_ID", "wayne"),
OAuthClientSecret: env("OAUTH_WAYNE_CLIENT_SECRET", "wayne-secret"),
OAuthRedirectURI: env("OAUTH_WAYNE_REDIRECT_URI", ""),
+20 -7
View File
@@ -62,6 +62,22 @@ func (h *SAMLHandler) Login(c *gin.Context) {
c.Redirect(http.StatusFound, redirectURL)
}
func (h *SAMLHandler) Logout(c *gin.Context) {
expired := time.Unix(0, 0)
for _, path := range []string{"/auth/", "/"} {
http.SetCookie(c.Writer, &http.Cookie{
Name: AuthSessionCookieName,
Value: "",
Path: path,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
Expires: expired,
})
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *SAMLHandler) ACS(c *gin.Context) {
if err := c.Request.ParseForm(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -97,15 +113,12 @@ func (h *SAMLHandler) ACS(c *gin.Context) {
func ssoRedirectURL(relayState, token string) string {
target := strings.TrimSpace(relayState)
if target == "" || !strings.HasPrefix(target, "/auth/") {
target = "/auth/"
}
if strings.HasPrefix(target, "//") {
target = "/auth/"
if target == "" {
target = "/"
}
parsed, err := url.Parse(target)
if err != nil || parsed.IsAbs() {
parsed = &url.URL{Path: "/auth/"}
if err != nil || parsed.IsAbs() || !strings.HasPrefix(target, "/") || strings.HasPrefix(target, "//") || strings.HasPrefix(parsed.Path, "/auth/api/") {
parsed = &url.URL{Path: "/"}
}
values := parsed.Query()
values.Set("sso_token", token)
+53
View File
@@ -0,0 +1,53 @@
package handler
import "testing"
func TestSSORedirectURLAllowsFrontendRoutes(t *testing.T) {
tests := []struct {
name string
relay string
want string
}{
{
name: "root",
relay: "/",
want: "/?sso_token=token-001",
},
{
name: "frontend route",
relay: "/subsystem?open_app=wayne#section",
want: "/subsystem?open_app=wayne&sso_token=token-001#section",
},
{
name: "oauth authorize route",
relay: "/auth/oauth/authorize?client_id=wayne",
want: "/auth/oauth/authorize?client_id=wayne&sso_token=token-001",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ssoRedirectURL(tt.relay, "token-001"); got != tt.want {
t.Fatalf("ssoRedirectURL(%q) = %q, want %q", tt.relay, got, tt.want)
}
})
}
}
func TestSSORedirectURLRejectsUnsafeRelayState(t *testing.T) {
tests := []string{
"",
"https://evil.example.com/",
"//evil.example.com/",
"dashboard",
"/auth/api/v1/users/me",
}
for _, relay := range tests {
t.Run(relay, func(t *testing.T) {
if got := ssoRedirectURL(relay, "token-001"); got != "/?sso_token=token-001" {
t.Fatalf("ssoRedirectURL(%q) = %q, want root fallback", relay, got)
}
})
}
}
@@ -0,0 +1,315 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/1024XEngineer/xinfra/server/internal/service"
"github.com/gin-gonic/gin"
)
type WayneRoleBindingHandler struct {
wayne *service.WayneRoleBindingService
audit *service.AuditService
}
func NewWayneRoleBindingHandler(wayne *service.WayneRoleBindingService, audit *service.AuditService) *WayneRoleBindingHandler {
return &WayneRoleBindingHandler{wayne: wayne, audit: audit}
}
func (h *WayneRoleBindingHandler) BindNamespace(c *gin.Context) {
h.handle(c, "namespace", http.MethodPut)
}
func (h *WayneRoleBindingHandler) UnbindNamespace(c *gin.Context) {
h.handle(c, "namespace", http.MethodDelete)
}
func (h *WayneRoleBindingHandler) BindApp(c *gin.Context) {
h.handle(c, "app", http.MethodPut)
}
func (h *WayneRoleBindingHandler) UnbindApp(c *gin.Context) {
h.handle(c, "app", http.MethodDelete)
}
func (h *WayneRoleBindingHandler) ListNamespaces(c *gin.Context) {
h.handleQuery(c, "namespaces", 0, "")
}
func (h *WayneRoleBindingHandler) ListGroups(c *gin.Context) {
var groupType *int
if raw := strings.TrimSpace(c.Query("type")); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || (value != 0 && value != 1) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid type"})
return
}
groupType = &value
}
result, err := h.wayne.ListGroups(c.Request.Context(), groupType)
if err != nil {
h.writeQueryAudit(c, "groups", 0, "deny", err.Error())
writeWayneRoleBindingError(c, result, err)
return
}
h.writeQueryAudit(c, "groups", 0, "allow", "")
writeWayneRoleBindingResult(c, result)
}
func (h *WayneRoleBindingHandler) GetCurrentUserRoles(c *gin.Context) {
username, ok := currentTokenEmail(c)
if !ok {
return
}
h.handleQuery(c, "user_roles", 0, username)
}
func (h *WayneRoleBindingHandler) NamespaceOperatorPermissions(c *gin.Context) {
namespaceID, ok := parseUintPathParam(c, "namespaceid")
if !ok {
return
}
h.handleOperatorPermissions(c, "namespace", namespaceID)
}
func (h *WayneRoleBindingHandler) AppOperatorPermissions(c *gin.Context) {
appID, ok := parseUintPathParam(c, "appid")
if !ok {
return
}
h.handleOperatorPermissions(c, "app", appID)
}
func (h *WayneRoleBindingHandler) handle(c *gin.Context, scope string, method string) {
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
operatorEmail := strings.TrimSpace(claims.Email)
if operatorEmail == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email is missing in token"})
return
}
resourceParam := "namespaceid"
if scope == "app" {
resourceParam = "appid"
}
resourceID, ok := parseUintPathParam(c, resourceParam)
if !ok {
return
}
req, ok := parseRoleBindingRequest(c)
if !ok {
return
}
if method == http.MethodPut && len(req.GroupIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "groupIds is required"})
return
}
result, err := h.call(c, scope, method, resourceID, operatorEmail, req)
if err != nil {
h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, operatorEmail, "deny", req.RequestID, err.Error())
writeWayneRoleBindingError(c, result, err)
return
}
h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, operatorEmail, "allow", req.RequestID, "")
writeWayneRoleBindingResult(c, result)
}
func (h *WayneRoleBindingHandler) handleQuery(c *gin.Context, resourceType string, resourceID uint64, username string) {
var result *service.WayneRoleBindingResult
var err error
switch resourceType {
case "namespaces":
result, err = h.wayne.ListNamespaces(c.Request.Context())
case "user_roles":
result, err = h.wayne.GetUserRoles(c.Request.Context(), username)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported query resource"})
return
}
if err != nil {
h.writeQueryAudit(c, resourceType, resourceID, "deny", err.Error())
writeWayneRoleBindingError(c, result, err)
return
}
h.writeQueryAudit(c, resourceType, resourceID, "allow", "")
writeWayneRoleBindingResult(c, result)
}
func (h *WayneRoleBindingHandler) handleOperatorPermissions(c *gin.Context, scope string, resourceID uint64) {
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
operatorEmail := strings.TrimSpace(claims.Email)
if operatorEmail == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email is missing in token"})
return
}
var result *service.WayneRoleBindingResult
var err error
if scope == "namespace" {
result, err = h.wayne.NamespaceOperatorPermissions(c.Request.Context(), resourceID, operatorEmail)
} else {
result, err = h.wayne.AppOperatorPermissions(c.Request.Context(), resourceID, operatorEmail)
}
resourceType := scope + "_operator_permissions"
if err != nil {
h.writeQueryAudit(c, resourceType, resourceID, "deny", err.Error())
writeWayneRoleBindingError(c, result, err)
return
}
h.writeQueryAudit(c, resourceType, resourceID, "allow", "")
writeWayneRoleBindingResult(c, result)
}
func (h *WayneRoleBindingHandler) call(c *gin.Context, scope, method string, resourceID uint64, operatorEmail string, req service.WayneRoleBindingRequest) (*service.WayneRoleBindingResult, error) {
if scope == "namespace" {
if method == http.MethodPut {
return h.wayne.BindNamespace(c.Request.Context(), resourceID, operatorEmail, req)
}
return h.wayne.UnbindNamespace(c.Request.Context(), resourceID, operatorEmail, req)
}
if method == http.MethodPut {
return h.wayne.BindApp(c.Request.Context(), resourceID, operatorEmail, req)
}
return h.wayne.UnbindApp(c.Request.Context(), resourceID, operatorEmail, req)
}
func parseRoleBindingRequest(c *gin.Context) (service.WayneRoleBindingRequest, bool) {
var req service.WayneRoleBindingRequest
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return req, false
}
if strings.TrimSpace(string(body)) == "" {
return req, true
}
if err := json.Unmarshal(body, &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return req, false
}
return req, true
}
func parseUintPathParam(c *gin.Context, name string) (uint64, bool) {
raw := strings.TrimSpace(c.Param(name))
value, err := strconv.ParseUint(raw, 10, 64)
if err != nil || value == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s", name)})
return 0, false
}
return value, true
}
func writeWayneRoleBindingResult(c *gin.Context, result *service.WayneRoleBindingResult) {
if result == nil {
c.JSON(http.StatusOK, gin.H{"data": gin.H{}})
return
}
contentType := result.ContentType
if contentType == "" {
contentType = "application/json"
}
if len(result.Body) == 0 {
c.Status(result.StatusCode)
return
}
c.Data(result.StatusCode, contentType, result.Body)
}
func writeWayneRoleBindingError(c *gin.Context, result *service.WayneRoleBindingResult, err error) {
if result != nil && len(result.Body) > 0 {
contentType := result.ContentType
if contentType == "" {
contentType = "application/json"
}
c.Data(result.StatusCode, contentType, result.Body)
return
}
var httpErr *service.WayneRoleBindingHTTPError
switch {
case errors.Is(err, service.ErrWayneRoleBindingNotConfigured):
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrWayenEmailMissing):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case errors.As(err, &httpErr):
c.JSON(httpErr.StatusCode, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
func currentTokenEmail(c *gin.Context) (string, bool) {
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return "", false
}
email := strings.TrimSpace(claims.Email)
if email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email is missing in token"})
return "", false
}
return email, true
}
func (h *WayneRoleBindingHandler) writeAudit(c *gin.Context, userID uint64, operatorEmail, scope string, resourceID uint64, targetUsername string, decision, requestID, reason string) {
h.audit.Write(service.AuditEntry{
RequestID: requestID,
ActorUserID: userID,
ActorUsername: operatorEmail,
ClientIP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
Action: "wayne.role_binding." + decision,
ResourceType: "wayne_" + scope,
ResourceID: strconv.FormatUint(resourceID, 10),
ScopeType: scope,
ScopeID: resourceID,
Decision: decision,
Reason: reason,
Metadata: map[string]any{
"targetUsername": targetUsername,
},
})
}
func (h *WayneRoleBindingHandler) writeQueryAudit(c *gin.Context, resourceType string, resourceID uint64, decision, reason string) {
claims, _ := CurrentClaims(c)
var actorUserID uint64
var actorUsername string
if claims != nil {
actorUserID = claims.UserID
actorUsername = strings.TrimSpace(claims.Email)
if actorUsername == "" {
actorUsername = claims.Username
}
}
h.audit.Write(service.AuditEntry{
ActorUserID: actorUserID,
ActorUsername: actorUsername,
ClientIP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
Action: "wayne.role_query." + decision,
ResourceType: "wayne_" + resourceType,
ResourceID: strconv.FormatUint(resourceID, 10),
Decision: decision,
Reason: reason,
})
}
+12
View File
@@ -69,10 +69,12 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
auditService := service.NewAuditService(deps.DB)
authService := service.NewAuthService(deps.Config, deps.DB, auditService)
wayenService := service.NewWayenService(deps.Config, deps.DB)
wayneRoleBindingService := service.NewWayneRoleBindingService(deps.Config)
healthHandler := handler.NewHealthHandler(deps.DB)
userHandler := handler.NewUserHandler()
wayenHandler := handler.NewWayenHandler(deps.DB, wayenService, auditService)
wayneRoleBindingHandler := handler.NewWayneRoleBindingHandler(wayneRoleBindingService, auditService)
clouddmHandler := handler.NewCloudDMHandler(deps.Config, auditService)
samlHandler := handler.NewSAMLHandler(deps.Config, authService)
oauthHandler := handler.NewOAuthHandler(deps.Config, deps.DB, auditService)
@@ -88,6 +90,7 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
v1 := r.Group("/auth/api/v1")
{
v1.GET("/login/internal-sso", samlHandler.Login)
v1.POST("/logout", samlHandler.Logout)
v1.GET("/saml/metadata", samlHandler.Metadata)
v1.POST("/saml/acs", samlHandler.ACS)
@@ -97,6 +100,15 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
protected.GET("/wayen/login", wayenHandler.Login)
protected.GET("/wayen/credential", wayenHandler.GetCredential)
protected.PUT("/wayen/credential", wayenHandler.SaveCredential)
protected.GET("/wayne/namespaces", wayneRoleBindingHandler.ListNamespaces)
protected.GET("/wayne/groups", wayneRoleBindingHandler.ListGroups)
protected.GET("/wayne/users/me/roles", wayneRoleBindingHandler.GetCurrentUserRoles)
protected.GET("/wayne/namespaces/:namespaceid/operator-permissions", wayneRoleBindingHandler.NamespaceOperatorPermissions)
protected.GET("/wayne/apps/:appid/operator-permissions", wayneRoleBindingHandler.AppOperatorPermissions)
protected.PUT("/wayne/namespaces/:namespaceid/roles", wayneRoleBindingHandler.BindNamespace)
protected.DELETE("/wayne/namespaces/:namespaceid/roles", wayneRoleBindingHandler.UnbindNamespace)
protected.PUT("/wayne/apps/:appid/roles", wayneRoleBindingHandler.BindApp)
protected.DELETE("/wayne/apps/:appid/roles", wayneRoleBindingHandler.UnbindApp)
protected.GET("/clouddm/login", clouddmHandler.Login)
}
}
+6 -2
View File
@@ -53,8 +53,12 @@ func (s *WayenService) Login(email, username string) (*WayenLoginResult, error)
if email == "" {
return nil, ErrWayenEmailMissing
}
if strings.TrimSpace(s.cfg.OAuthRedirectURI) != "" && strings.TrimSpace(s.cfg.WayenTargetURL) != "" {
target, err := s.oauthLoginURL(s.cfg.OAuthRedirectURI, s.cfg.WayenTargetURL)
oauthLoginURL := strings.TrimSpace(s.cfg.WayenOAuthLoginURL)
if oauthLoginURL == "" {
oauthLoginURL = strings.TrimSpace(s.cfg.OAuthRedirectURI)
}
if oauthLoginURL != "" && strings.TrimSpace(s.cfg.WayenTargetURL) != "" {
target, err := s.oauthLoginURL(oauthLoginURL, s.cfg.WayenTargetURL)
if err != nil {
return nil, err
}
+69
View File
@@ -0,0 +1,69 @@
package service
import (
"net/url"
"testing"
"github.com/1024XEngineer/xinfra/server/internal/config"
)
func TestWayenLoginUsesDedicatedOAuthLoginURL(t *testing.T) {
service := NewWayenService(config.Config{
WayenOAuthLoginURL: "http://218.11.5.223:32000/login/oauth2/oauth2",
OAuthRedirectURI: "http://218.11.5.223:30008/login/oauth2/oauth2",
WayenTargetURL: "http://218.11.5.223:32000/",
WayenOAuthRef: "/portal/namespace/1/app",
}, nil)
result, err := service.Login("eastsales@qiniu.com", "eastsales@qiniu.com")
if err != nil {
t.Fatalf("Login() error = %v", err)
}
parsed, err := url.Parse(result.TargetURL)
if err != nil {
t.Fatalf("invalid target url: %v", err)
}
if parsed.Host != "218.11.5.223:32000" {
t.Fatalf("target host = %q, want Wayne frontend 32000", parsed.Host)
}
if parsed.Path != "/login/oauth2/oauth2" {
t.Fatalf("target path = %q", parsed.Path)
}
next := parsed.Query().Get("next")
if next == "" {
t.Fatal("next is empty")
}
parsedNext, err := url.Parse(next)
if err != nil {
t.Fatalf("invalid next url: %v", err)
}
if parsedNext.Host != "218.11.5.223:32000" {
t.Fatalf("next host = %q, want Wayne frontend 32000", parsedNext.Host)
}
if parsedNext.Path != "/sign-in" {
t.Fatalf("next path = %q, want /sign-in", parsedNext.Path)
}
if parsedNext.Query().Get("ref") != "/portal/namespace/1/app" {
t.Fatalf("next ref = %q", parsedNext.Query().Get("ref"))
}
}
func TestWayenLoginFallsBackToOAuthRedirectURI(t *testing.T) {
service := NewWayenService(config.Config{
OAuthRedirectURI: "http://218.11.5.223:32000/login/oauth2/oauth2",
WayenTargetURL: "http://218.11.5.223:32000/",
}, nil)
result, err := service.Login("eastsales@qiniu.com", "eastsales@qiniu.com")
if err != nil {
t.Fatalf("Login() error = %v", err)
}
parsed, err := url.Parse(result.TargetURL)
if err != nil {
t.Fatalf("invalid target url: %v", err)
}
if parsed.Host != "218.11.5.223:32000" {
t.Fatalf("target host = %q, want fallback redirect host", parsed.Host)
}
}
@@ -0,0 +1,250 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/1024XEngineer/xinfra/server/internal/config"
"github.com/1024XEngineer/xinfra/server/internal/wayne"
)
var (
ErrWayneRoleBindingNotConfigured = errors.New("wayne internal role binding api is not configured")
ErrWayneRoleBindingRequestFailed = errors.New("wayne internal role binding request failed")
)
type WayneRoleBindingRequest struct {
GroupIDs []uint64 `json:"groupIds,omitempty"`
OperatorUserID *uint64 `json:"operatorUserId,omitempty"`
OperatorName string `json:"operatorName,omitempty"`
Replace *bool `json:"replace,omitempty"`
RequestID string `json:"requestId,omitempty"`
Reason string `json:"reason,omitempty"`
DryRun bool `json:"dryRun,omitempty"`
}
type WayneRoleBindingResult struct {
StatusCode int
ContentType string
Body []byte
}
type WayneRoleBindingHTTPError struct {
StatusCode int
Body []byte
}
func (e *WayneRoleBindingHTTPError) Error() string {
body := strings.TrimSpace(string(e.Body))
if body == "" {
return fmt.Sprintf("%s: status %d", ErrWayneRoleBindingRequestFailed, e.StatusCode)
}
if len(body) > 512 {
body = body[:512]
}
return fmt.Sprintf("%s: status %d: %s", ErrWayneRoleBindingRequestFailed, e.StatusCode, body)
}
type WayneRoleBindingService struct {
cfg config.Config
client *http.Client
now func() time.Time
}
func NewWayneRoleBindingService(cfg config.Config) *WayneRoleBindingService {
return &WayneRoleBindingService{
cfg: cfg,
client: &http.Client{
Timeout: 10 * time.Second,
},
now: time.Now,
}
}
func (s *WayneRoleBindingService) BindNamespace(ctx context.Context, namespaceID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
return s.call(ctx, http.MethodPut, fmt.Sprintf("/api/v1/internal/namespaces/%d/users/%s/roles", namespaceID, url.PathEscape(username)), username, req)
}
func (s *WayneRoleBindingService) UnbindNamespace(ctx context.Context, namespaceID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
return s.call(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/internal/namespaces/%d/users/%s/roles", namespaceID, url.PathEscape(username)), username, req)
}
func (s *WayneRoleBindingService) BindApp(ctx context.Context, appID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
return s.call(ctx, http.MethodPut, fmt.Sprintf("/api/v1/internal/apps/%d/users/%s/roles", appID, url.PathEscape(username)), username, req)
}
func (s *WayneRoleBindingService) UnbindApp(ctx context.Context, appID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
return s.call(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/internal/apps/%d/users/%s/roles", appID, url.PathEscape(username)), username, req)
}
func (s *WayneRoleBindingService) ListNamespaces(ctx context.Context) (*WayneRoleBindingResult, error) {
return s.callRaw(ctx, http.MethodGet, "/api/v1/internal/namespaces", nil)
}
func (s *WayneRoleBindingService) ListGroups(ctx context.Context, groupType *int) (*WayneRoleBindingResult, error) {
internalPath := "/api/v1/internal/groups"
if groupType != nil {
values := url.Values{}
values.Set("type", strconv.Itoa(*groupType))
internalPath += "?" + values.Encode()
}
return s.callRaw(ctx, http.MethodGet, internalPath, nil)
}
func (s *WayneRoleBindingService) GetUserRoles(ctx context.Context, username string) (*WayneRoleBindingResult, error) {
username = strings.TrimSpace(username)
if username == "" {
return nil, ErrWayenEmailMissing
}
return s.callRaw(ctx, http.MethodGet, fmt.Sprintf("/api/v1/internal/users/%s/roles", url.PathEscape(username)), nil)
}
func (s *WayneRoleBindingService) NamespaceOperatorPermissions(ctx context.Context, namespaceID uint64, operatorEmail string) (*WayneRoleBindingResult, error) {
return s.operatorPermissions(ctx, fmt.Sprintf("/api/v1/internal/namespaces/%d/operator-permissions", namespaceID), operatorEmail)
}
func (s *WayneRoleBindingService) AppOperatorPermissions(ctx context.Context, appID uint64, operatorEmail string) (*WayneRoleBindingResult, error) {
return s.operatorPermissions(ctx, fmt.Sprintf("/api/v1/internal/apps/%d/operator-permissions", appID), operatorEmail)
}
func (s *WayneRoleBindingService) call(ctx context.Context, method, internalPath, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
if err := s.validateConfig(); err != nil {
return nil, err
}
operatorEmail = strings.TrimSpace(operatorEmail)
if operatorEmail == "" {
return nil, ErrWayenEmailMissing
}
req.OperatorUserID = nil
req.OperatorName = operatorEmail
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
return s.callRaw(ctx, method, internalPath, body)
}
func (s *WayneRoleBindingService) operatorPermissions(ctx context.Context, internalPath, operatorEmail string) (*WayneRoleBindingResult, error) {
operatorEmail = strings.TrimSpace(operatorEmail)
if operatorEmail == "" {
return nil, ErrWayenEmailMissing
}
values := url.Values{}
values.Set("operatorName", operatorEmail)
return s.callRaw(ctx, http.MethodGet, internalPath+"?"+values.Encode(), nil)
}
func (s *WayneRoleBindingService) callRaw(ctx context.Context, method, internalPath string, body []byte) (*WayneRoleBindingResult, error) {
if err := s.validateConfig(); err != nil {
return nil, err
}
if body == nil {
body = []byte{}
}
target, signingURI, err := s.requestURL(internalPath)
if err != nil {
return nil, err
}
log.Printf(
"wayne role binding request: method=%s target=%s signing_uri=%s body_bytes=%d service_name=%s secret_configured=%t",
method,
target,
signingURI,
len(body),
s.cfg.WayneServiceName,
strings.TrimSpace(s.cfg.WayneServiceAPISecretKey) != "",
)
httpReq, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
if err != nil {
return nil, err
}
if len(body) > 0 {
httpReq.Header.Set("Content-Type", "application/json")
}
httpReq.Header.Set("Accept", "application/json")
headers, err := wayne.BuildSignedHeaders(s.cfg.WayneServiceName, s.cfg.WayneServiceAPISecretKey, method, signingURI, body, s.now())
if err != nil {
return nil, err
}
headers.Apply(httpReq)
resp, err := s.client.Do(httpReq)
if err != nil {
log.Printf("wayne role binding request failed: method=%s target=%s error=%v", method, target, err)
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
result := &WayneRoleBindingResult{
StatusCode: resp.StatusCode,
ContentType: resp.Header.Get("Content-Type"),
Body: respBody,
}
log.Printf(
"wayne role binding response: method=%s target=%s status=%d content_type=%q body=%q",
method,
target,
resp.StatusCode,
result.ContentType,
truncateForDebugLog(string(respBody), 512),
)
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
return result, &WayneRoleBindingHTTPError{StatusCode: resp.StatusCode, Body: respBody}
}
return result, nil
}
func (s *WayneRoleBindingService) validateConfig() error {
baseConfigured := strings.TrimSpace(s.cfg.WayneInternalAPIBaseURL) != ""
serviceConfigured := strings.TrimSpace(s.cfg.WayneServiceName) != ""
secretConfigured := strings.TrimSpace(s.cfg.WayneServiceAPISecretKey) != ""
if !baseConfigured || !serviceConfigured || !secretConfigured {
log.Printf(
"wayne role binding config invalid: base_url_configured=%t service_name_configured=%t secret_configured=%t",
baseConfigured,
serviceConfigured,
secretConfigured,
)
return ErrWayneRoleBindingNotConfigured
}
return nil
}
func (s *WayneRoleBindingService) requestURL(internalPath string) (string, string, error) {
base, err := url.Parse(strings.TrimRight(strings.TrimSpace(s.cfg.WayneInternalAPIBaseURL), "/"))
if err != nil {
return "", "", err
}
if base.Scheme == "" || base.Host == "" {
return "", "", fmt.Errorf("invalid wayne internal api base url: %s", s.cfg.WayneInternalAPIBaseURL)
}
path, rawQuery, _ := strings.Cut(internalPath, "?")
base.Path = strings.TrimRight(base.Path, "/") + path
base.RawQuery = rawQuery
return base.String(), base.RequestURI(), nil
}
func truncateForDebugLog(value string, limit int) string {
value = strings.TrimSpace(value)
if limit <= 0 || len(value) <= limit {
return value
}
return value[:limit] + "...(truncated)"
}
@@ -0,0 +1,248 @@
package service
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/1024XEngineer/xinfra/server/internal/config"
"github.com/1024XEngineer/xinfra/server/internal/wayne"
)
func TestWayneRoleBindingServiceBindNamespaceSignsAndOverridesOperator(t *testing.T) {
var requestPath string
var payload WayneRoleBindingRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestPath = r.URL.RequestURI()
body := readTestBody(t, r)
if !wayne.Verify("service-secret", r.Header.Get(wayne.HeaderSignature), r.Method, r.URL.RequestURI(), r.Header.Get(wayne.HeaderTimestamp), r.Header.Get(wayne.HeaderNonce), body) {
t.Fatalf("invalid signature headers: %#v body=%s", r.Header, string(body))
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("invalid request body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"changed":true}}`))
}))
defer server.Close()
svc := NewWayneRoleBindingService(config.Config{
WayneInternalAPIBaseURL: server.URL,
WayneServiceName: "xinfra",
WayneServiceAPISecretKey: "service-secret",
})
svc.now = func() time.Time { return time.Unix(1721000000, 0) }
operatorUserID := uint64(123)
replace := false
result, err := svc.BindNamespace(context.Background(), 1, "eastsales@qiniu.com", WayneRoleBindingRequest{
GroupIDs: []uint64{10, 11},
OperatorUserID: &operatorUserID,
OperatorName: "attacker@example.com",
Replace: &replace,
RequestID: "req-001",
Reason: "grant",
})
if err != nil {
t.Fatalf("BindNamespace() error = %v", err)
}
if result.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200", result.StatusCode)
}
if requestPath != "/api/v1/internal/namespaces/1/users/eastsales@qiniu.com/roles" {
t.Fatalf("requestPath = %q", requestPath)
}
if payload.OperatorName != "eastsales@qiniu.com" {
t.Fatalf("OperatorName = %q, want token email", payload.OperatorName)
}
if payload.OperatorUserID != nil {
t.Fatalf("OperatorUserID should be omitted, got %v", *payload.OperatorUserID)
}
if payload.Replace == nil || *payload.Replace {
t.Fatalf("Replace = %v, want false", payload.Replace)
}
}
func TestWayneRoleBindingServiceCallsAllDocumentedEndpoints(t *testing.T) {
tests := []struct {
name string
call func(*WayneRoleBindingService) (*WayneRoleBindingResult, error)
want string
}{
{
name: "unbind namespace",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.UnbindNamespace(context.Background(), 1, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{10}})
},
want: "DELETE /api/v1/internal/namespaces/1/users/eastsales@qiniu.com/roles",
},
{
name: "bind app",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.BindApp(context.Background(), 3, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
},
want: "PUT /api/v1/internal/apps/3/users/eastsales@qiniu.com/roles",
},
{
name: "unbind app",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.UnbindApp(context.Background(), 3, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
},
want: "DELETE /api/v1/internal/apps/3/users/eastsales@qiniu.com/roles",
},
{
name: "list namespaces",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.ListNamespaces(context.Background())
},
want: "GET /api/v1/internal/namespaces",
},
{
name: "list namespace groups",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
groupType := 1
return s.ListGroups(context.Background(), &groupType)
},
want: "GET /api/v1/internal/groups?type=1",
},
{
name: "list all groups",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.ListGroups(context.Background(), nil)
},
want: "GET /api/v1/internal/groups",
},
{
name: "get user roles",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.GetUserRoles(context.Background(), "eastsales@qiniu.com")
},
want: "GET /api/v1/internal/users/eastsales@qiniu.com/roles",
},
{
name: "namespace operator permissions",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.NamespaceOperatorPermissions(context.Background(), 1, "eastsales@qiniu.com")
},
want: "GET /api/v1/internal/namespaces/1/operator-permissions?operatorName=eastsales%40qiniu.com",
},
{
name: "app operator permissions",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.AppOperatorPermissions(context.Background(), 3, "eastsales@qiniu.com")
},
want: "GET /api/v1/internal/apps/3/operator-permissions?operatorName=eastsales%40qiniu.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Method + " " + r.URL.RequestURI()
_, _ = w.Write([]byte(`{"data":{"changed":true}}`))
}))
defer server.Close()
svc := NewWayneRoleBindingService(config.Config{
WayneInternalAPIBaseURL: server.URL,
WayneServiceName: "xinfra",
WayneServiceAPISecretKey: "service-secret",
})
if _, err := tt.call(svc); err != nil {
t.Fatalf("call error = %v", err)
}
if got != tt.want {
t.Fatalf("got endpoint %q, want %q", got, tt.want)
}
})
}
}
func TestWayneRoleBindingServiceQuerySignsEmptyBodyAndQueryURI(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := readTestBody(t, r)
if len(body) != 0 {
t.Fatalf("GET body length = %d, want 0", len(body))
}
if r.URL.RequestURI() != "/api/v1/internal/groups?type=1" {
t.Fatalf("RequestURI = %q", r.URL.RequestURI())
}
if !wayne.Verify("service-secret", r.Header.Get(wayne.HeaderSignature), r.Method, r.URL.RequestURI(), r.Header.Get(wayne.HeaderTimestamp), r.Header.Get(wayne.HeaderNonce), body) {
t.Fatalf("invalid GET signature headers: %#v", r.Header)
}
_, _ = w.Write([]byte(`{"data":[]}`))
}))
defer server.Close()
svc := NewWayneRoleBindingService(config.Config{
WayneInternalAPIBaseURL: server.URL,
WayneServiceName: "xinfra",
WayneServiceAPISecretKey: "service-secret",
})
groupType := 1
if _, err := svc.ListGroups(context.Background(), &groupType); err != nil {
t.Fatalf("ListGroups() error = %v", err)
}
}
func TestWayneRoleBindingServiceOperatorPermissionsSignsQueryURI(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := readTestBody(t, r)
if r.URL.RequestURI() != "/api/v1/internal/namespaces/1/operator-permissions?operatorName=eastsales%40qiniu.com" {
t.Fatalf("RequestURI = %q", r.URL.RequestURI())
}
if !wayne.Verify("service-secret", r.Header.Get(wayne.HeaderSignature), r.Method, r.URL.RequestURI(), r.Header.Get(wayne.HeaderTimestamp), r.Header.Get(wayne.HeaderNonce), body) {
t.Fatalf("invalid operator permissions signature headers: %#v", r.Header)
}
_, _ = w.Write([]byte(`{"data":{"permissions":{"create":true,"update":true,"delete":false}}}`))
}))
defer server.Close()
svc := NewWayneRoleBindingService(config.Config{
WayneInternalAPIBaseURL: server.URL,
WayneServiceName: "xinfra",
WayneServiceAPISecretKey: "service-secret",
})
if _, err := svc.NamespaceOperatorPermissions(context.Background(), 1, "eastsales@qiniu.com"); err != nil {
t.Fatalf("NamespaceOperatorPermissions() error = %v", err)
}
}
func TestWayneRoleBindingServiceHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"code":403,"msg":"denied"}`))
}))
defer server.Close()
svc := NewWayneRoleBindingService(config.Config{
WayneInternalAPIBaseURL: server.URL,
WayneServiceName: "xinfra",
WayneServiceAPISecretKey: "service-secret",
})
result, err := svc.BindApp(context.Background(), 3, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
if err == nil {
t.Fatal("expected error")
}
if result == nil || result.StatusCode != http.StatusForbidden {
t.Fatalf("result = %#v, want 403", result)
}
if !strings.Contains(err.Error(), "denied") {
t.Fatalf("error = %q, want denied body", err.Error())
}
}
func readTestBody(t *testing.T, r *http.Request) []byte {
t.Helper()
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return body
}
+40 -1
View File
@@ -27,6 +27,13 @@ import (
const (
samlHTTPRedirectBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
samlHTTPPostBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
xmlEncAES128CBC = "http://www.w3.org/2001/04/xmlenc#aes128-cbc"
xmlEncAES192CBC = "http://www.w3.org/2001/04/xmlenc#aes192-cbc"
xmlEncAES256CBC = "http://www.w3.org/2001/04/xmlenc#aes256-cbc"
xmlEncAES128GCM = "http://www.w3.org/2009/xmlenc11#aes128-gcm"
xmlEncAES192GCM = "http://www.w3.org/2009/xmlenc11#aes192-gcm"
xmlEncAES256GCM = "http://www.w3.org/2009/xmlenc11#aes256-gcm"
)
type LoginConfig struct {
@@ -178,7 +185,7 @@ func decryptEncryptedAssertion(encrypted encryptedAssertion, privateKeyFile stri
if err != nil {
return "", assertion{}, fmt.Errorf("decode saml encrypted assertion: %w", err)
}
plain, err := decryptAESCBC(encryptedValue, sessionKey)
plain, err := decryptAssertionCipherValue(encryptedValue, sessionKey, encrypted.EncryptedData.EncryptionMethod.Algorithm)
if err != nil {
return "", assertion{}, err
}
@@ -216,6 +223,17 @@ func readRSAPrivateKey(path string) (*rsa.PrivateKey, error) {
return key, nil
}
func decryptAssertionCipherValue(value, key []byte, algorithm string) ([]byte, error) {
switch strings.TrimSpace(algorithm) {
case "", xmlEncAES128CBC, xmlEncAES192CBC, xmlEncAES256CBC:
return decryptAESCBC(value, key)
case xmlEncAES128GCM, xmlEncAES192GCM, xmlEncAES256GCM:
return decryptAESGCM(value, key)
default:
return nil, fmt.Errorf("unsupported saml assertion encryption algorithm: %s", algorithm)
}
}
func decryptAESCBC(value, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
@@ -235,6 +253,27 @@ func decryptAESCBC(value, key []byte) ([]byte, error) {
return plain, nil
}
func decryptAESGCM(value, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("init saml assertion cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("init saml assertion gcm: %w", err)
}
if len(value) <= aead.NonceSize()+aead.Overhead() {
return nil, errors.New("invalid saml encrypted assertion gcm length")
}
nonce := value[:aead.NonceSize()]
cipherText := value[aead.NonceSize():]
plain, err := aead.Open(nil, nonce, cipherText, nil)
if err != nil {
return nil, fmt.Errorf("decrypt saml assertion gcm: %w", err)
}
return plain, nil
}
func pkcs7Unpad(value []byte, blockSize int) ([]byte, error) {
if len(value) == 0 || len(value)%blockSize != 0 {
return nil, errors.New("invalid saml assertion padding length")
+65
View File
@@ -143,6 +143,53 @@ func TestDecodeSAMLResponseDecryptsEncryptedAssertion(t *testing.T) {
}
}
func TestDecodeSAMLResponseDecryptsGCMEncryptedAssertion(t *testing.T) {
t.Parallel()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
keyFile := writeTestPrivateKey(t, key)
assertion := `<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
<saml:Subject><saml:NameID>bob@example.com</saml:NameID></saml:Subject>
<saml:AttributeStatement>
<saml:Attribute Name="email"><saml:AttributeValue>bob@example.com</saml:AttributeValue></saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>`
sessionKey := []byte("0123456789abcdef0123456789abcdef")
encryptedAssertion := encryptTestAssertionGCM(t, []byte(assertion), sessionKey)
encryptedKey, err := rsa.EncryptOAEP(sha1.New(), rand.Reader, &key.PublicKey, sessionKey, nil)
if err != nil {
t.Fatalf("encrypt key: %v", err)
}
response := `<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
<saml:EncryptedAssertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
<xenc:EncryptedData xmlns:xenc="http://www.w3.org/2001/04/xmlenc#">
<xenc:EncryptionMethod Algorithm="` + xmlEncAES256GCM + `"></xenc:EncryptionMethod>
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<xenc:EncryptedKey>
<xenc:CipherData><xenc:CipherValue>` + base64.StdEncoding.EncodeToString(encryptedKey) + `</xenc:CipherValue></xenc:CipherData>
</xenc:EncryptedKey>
</ds:KeyInfo>
<xenc:CipherData><xenc:CipherValue>` + base64.StdEncoding.EncodeToString(encryptedAssertion) + `</xenc:CipherValue></xenc:CipherData>
</xenc:EncryptedData>
</saml:EncryptedAssertion>
</samlp:Response>`
info, err := DecodeSAMLResponse(base64.StdEncoding.EncodeToString([]byte(response)), keyFile)
if err != nil {
t.Fatalf("DecodeSAMLResponse returned error: %v", err)
}
if info.NameID != "bob@example.com" {
t.Fatalf("unexpected name id: %q", info.NameID)
}
if got := info.Attributes["email"]; len(got) != 1 || got[0] != "bob@example.com" {
t.Fatalf("unexpected email attribute: %#v", got)
}
}
func writeTestPrivateKey(t *testing.T, key *rsa.PrivateKey) string {
t.Helper()
file, err := os.CreateTemp(t.TempDir(), "sp-*.key")
@@ -174,6 +221,24 @@ func encryptTestAssertion(t *testing.T, plain, key []byte) []byte {
return out
}
func encryptTestAssertionGCM(t *testing.T, plain, key []byte) []byte {
t.Helper()
block, err := aes.NewCipher(key)
if err != nil {
t.Fatalf("init cipher: %v", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
t.Fatalf("init gcm: %v", err)
}
nonce := bytes.Repeat([]byte{2}, aead.NonceSize())
cipherText := aead.Seal(nil, nonce, plain, nil)
out := make([]byte, len(nonce)+len(cipherText))
copy(out, nonce)
copy(out[len(nonce):], cipherText)
return out
}
func pkcs7Pad(value []byte, blockSize int) []byte {
padding := blockSize - len(value)%blockSize
return append(value, bytes.Repeat([]byte{byte(padding)}, padding)...)
+123
View File
@@ -0,0 +1,123 @@
package wayne
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
const (
HeaderService = "X-Wayne-Service"
HeaderTimestamp = "X-Wayne-Timestamp"
HeaderNonce = "X-Wayne-Nonce"
HeaderSignature = "X-Wayne-Signature"
SignaturePrefix = "sha256="
)
var (
ErrMissingSecret = errors.New("wayne service api secret key is empty")
ErrMissingHeader = errors.New("wayne service header value is empty")
)
type SignedHeaders struct {
Service string
Timestamp string
Nonce string
Signature string
}
func BodySHA256Hex(body []byte) string {
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])
}
func SigningPayload(method, uri, timestamp, nonce string, body []byte) string {
return strings.Join([]string{
strings.ToUpper(strings.TrimSpace(method)),
uri,
timestamp,
nonce,
BodySHA256Hex(body),
}, "\n")
}
func Sign(secret, method, uri, timestamp, nonce string, body []byte) (string, error) {
secret = secretValue(secret)
if secret == "" {
return "", ErrMissingSecret
}
payload := SigningPayload(method, uri, timestamp, nonce, body)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(payload))
return SignaturePrefix + hex.EncodeToString(mac.Sum(nil)), nil
}
func Verify(secret, signature, method, uri, timestamp, nonce string, body []byte) bool {
expected, err := Sign(secret, method, uri, timestamp, nonce, body)
if err != nil {
return false
}
return subtle.ConstantTimeCompare([]byte(normalizeSignature(signature)), []byte(expected)) == 1
}
func BuildSignedHeaders(service, secret, method, uri string, body []byte, now time.Time) (SignedHeaders, error) {
service = strings.TrimSpace(service)
if service == "" {
return SignedHeaders{}, fmt.Errorf("%w: service", ErrMissingHeader)
}
timestamp := strconv.FormatInt(now.Unix(), 10)
nonce, err := NewNonce()
if err != nil {
return SignedHeaders{}, err
}
signature, err := Sign(secret, method, uri, timestamp, nonce, body)
if err != nil {
return SignedHeaders{}, err
}
return SignedHeaders{
Service: service,
Timestamp: timestamp,
Nonce: nonce,
Signature: signature,
}, nil
}
func (h SignedHeaders) Apply(req *http.Request) {
req.Header.Set(HeaderService, h.Service)
req.Header.Set(HeaderTimestamp, h.Timestamp)
req.Header.Set(HeaderNonce, h.Nonce)
req.Header.Set(HeaderSignature, h.Signature)
}
func NewNonce() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func normalizeSignature(signature string) string {
signature = strings.TrimSpace(signature)
if strings.HasPrefix(signature, SignaturePrefix) {
return signature
}
return SignaturePrefix + signature
}
func secretValue(secret string) string {
secret = strings.TrimSpace(secret)
if _, value, ok := strings.Cut(secret, ":"); ok {
return strings.TrimSpace(value)
}
return secret
}
+100
View File
@@ -0,0 +1,100 @@
package wayne
import (
"net/http"
"strings"
"testing"
"time"
)
func TestBodySHA256Hex(t *testing.T) {
got := BodySHA256Hex([]byte("hello"))
want := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
if got != want {
t.Fatalf("BodySHA256Hex() = %q, want %q", got, want)
}
}
func TestSigningPayload(t *testing.T) {
body := []byte(`{"groupIds":[10]}`)
got := SigningPayload("put", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", body)
want := strings.Join([]string{
"PUT",
"/api/v1/internal/namespaces/1/users/2001/roles",
"1721000000",
"nonce-001",
"cf296504b2a434969ee151c1a48aa07fabb64634c77e569af74260cf234080f2",
}, "\n")
if got != want {
t.Fatalf("SigningPayload() = %q, want %q", got, want)
}
}
func TestSignAndVerify(t *testing.T) {
body := []byte(`{"groupIds":[10]}`)
signature, err := Sign("test-secret", "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", body)
if err != nil {
t.Fatalf("Sign() error = %v", err)
}
want := "sha256=6da70ea095fde90b534d0677da62db867d0b8092f6b15ed86cb52d77571b5b1d"
if signature != want {
t.Fatalf("Sign() = %q, want %q", signature, want)
}
if !Verify("test-secret", signature, "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", body) {
t.Fatal("Verify() rejected valid signature")
}
if !Verify("test-secret", strings.TrimPrefix(signature, SignaturePrefix), "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", body) {
t.Fatal("Verify() rejected valid signature without prefix")
}
if Verify("test-secret", signature, "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", []byte(`{"groupIds":[11]}`)) {
t.Fatal("Verify() accepted tampered body")
}
}
func TestSignAcceptsServicePrefixedSecret(t *testing.T) {
body := []byte(`{"groupIds":[10]}`)
plainSecretSignature, err := Sign("test-secret", "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", body)
if err != nil {
t.Fatalf("Sign() with plain secret error = %v", err)
}
prefixedSecretSignature, err := Sign("xinfra:test-secret", "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", body)
if err != nil {
t.Fatalf("Sign() with prefixed secret error = %v", err)
}
if prefixedSecretSignature != plainSecretSignature {
t.Fatalf("prefixed secret signature = %q, want %q", prefixedSecretSignature, plainSecretSignature)
}
}
func TestBuildSignedHeadersAndApply(t *testing.T) {
body := []byte(`{"groupIds":[10]}`)
now := time.Unix(1721000000, 0)
headers, err := BuildSignedHeaders("xinfra", "test-secret", "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", body, now)
if err != nil {
t.Fatalf("BuildSignedHeaders() error = %v", err)
}
if headers.Service != "xinfra" {
t.Fatalf("headers.Service = %q, want xinfra", headers.Service)
}
if headers.Timestamp != "1721000000" {
t.Fatalf("headers.Timestamp = %q, want 1721000000", headers.Timestamp)
}
if headers.Nonce == "" {
t.Fatal("headers.Nonce is empty")
}
if !Verify("test-secret", headers.Signature, "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", headers.Timestamp, headers.Nonce, body) {
t.Fatal("generated headers signature is invalid")
}
req, err := http.NewRequest(http.MethodPut, "http://wayne.example.com/api/v1/internal/namespaces/1/users/2001/roles", strings.NewReader(string(body)))
if err != nil {
t.Fatalf("NewRequest() error = %v", err)
}
headers.Apply(req)
if req.Header.Get(HeaderService) != headers.Service ||
req.Header.Get(HeaderTimestamp) != headers.Timestamp ||
req.Header.Get(HeaderNonce) != headers.Nonce ||
req.Header.Get(HeaderSignature) != headers.Signature {
t.Fatalf("Apply() did not write expected signed headers: %#v", req.Header)
}
}