feat(auth): complete sso subsystem integration
This commit is contained in:
Vendored
-4
@@ -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']
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)...)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user