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>
+6 -5
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 () => {
await authApi.logout()
authStore.clearAuth()
window.location.assign(SSO_SIGNOUT_URL)
try {
await authApi.logout()
} finally {
authStore.clearAuth()
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
+74 -66
View File
@@ -14,39 +14,43 @@ type OAuthClient struct {
}
type Config struct {
AppEnv string
HTTPAddr string
PublicBaseURL string
MySQLDSN string
AutoMigrate bool
JWTSecret string
JWTIssuer string
JWTTTLMinutes int
SAMLEntityID string
SAMLACSURL string
SAMLSPCert string
SAMLSPKey string
SAMLIDPMetaURL string
WayenLoginURL string
WayenTargetURL string
WayenUsernameKey string
WayenPasswordKey string
WayenLoginFormat string
WayenLoginValue string
WayenOAuthRef string
OAuthClientID string
OAuthClientSecret string
OAuthRedirectURI string
OAuthCodeTTLSeconds int
OIDCIssuer string
OIDCAuthorizeURL string
OIDCTokenURL string
OIDCUserInfoURL string
OIDCJWKSURL string
CloudDMClientID string
CloudDMClientSecret string
CloudDMRedirectURI string
CloudDMTargetURL string
AppEnv string
HTTPAddr string
PublicBaseURL string
MySQLDSN string
AutoMigrate bool
JWTSecret string
JWTIssuer string
JWTTTLMinutes int
SAMLEntityID string
SAMLACSURL string
SAMLSPCert string
SAMLSPKey string
SAMLIDPMetaURL string
WayenLoginURL string
WayenTargetURL string
WayenUsernameKey string
WayenPasswordKey string
WayenLoginFormat string
WayenLoginValue string
WayenOAuthRef string
WayenOAuthLoginURL string
WayneInternalAPIBaseURL string
WayneServiceName string
WayneServiceAPISecretKey string
OAuthClientID string
OAuthClientSecret string
OAuthRedirectURI string
OAuthCodeTTLSeconds int
OIDCIssuer string
OIDCAuthorizeURL string
OIDCTokenURL string
OIDCUserInfoURL string
OIDCJWKSURL string
CloudDMClientID string
CloudDMClientSecret string
CloudDMRedirectURI string
CloudDMTargetURL string
}
func Load() Config {
@@ -59,39 +63,43 @@ func Load() Config {
oidcIssuer = strings.TrimRight(oidcIssuer, "/")
return Config{
AppEnv: env("APP_ENV", "dev"),
HTTPAddr: httpAddr,
PublicBaseURL: publicBaseURL,
MySQLDSN: env("MYSQL_DSN", "auth:auth@tcp(127.0.0.1:3306)/authserver?charset=utf8mb4&parseTime=True&loc=Local"),
AutoMigrate: envBool("AUTO_MIGRATE", true),
JWTSecret: env("JWT_SECRET", "change-this-secret"),
JWTIssuer: env("JWT_ISSUER", "authserver"),
JWTTTLMinutes: envInt("JWT_TTL_MINUTES", 120),
SAMLEntityID: samlEntityID,
SAMLACSURL: samlACSURL,
SAMLSPCert: env("SAML_SP_CERT_FILE", "certs/sp.crt"),
SAMLSPKey: env("SAML_SP_KEY_FILE", "certs/sp.key"),
SAMLIDPMetaURL: env("SAML_IDP_METADATA_URL", "http://sso-internal.dev.qiniu.io/saml2/meta"),
WayenLoginURL: env("WAYEN_LOGIN_URL", ""),
WayenTargetURL: env("WAYEN_TARGET_URL", ""),
WayenUsernameKey: env("WAYEN_USERNAME_KEY", "email"),
WayenPasswordKey: env("WAYEN_PASSWORD_KEY", "password"),
WayenLoginFormat: env("WAYEN_LOGIN_FORMAT", "form"),
WayenLoginValue: env("WAYEN_LOGIN_VALUE", "email"),
WayenOAuthRef: env("WAYEN_OAUTH_REF", "/portal/namespace/1/app"),
OAuthClientID: env("OAUTH_WAYNE_CLIENT_ID", "wayne"),
OAuthClientSecret: env("OAUTH_WAYNE_CLIENT_SECRET", "wayne-secret"),
OAuthRedirectURI: env("OAUTH_WAYNE_REDIRECT_URI", ""),
OAuthCodeTTLSeconds: envInt("OAUTH_CODE_TTL_SECONDS", 120),
OIDCIssuer: oidcIssuer,
OIDCAuthorizeURL: trimURL(env("OIDC_AUTHORIZATION_ENDPOINT", oidcIssuer+"/oauth/authorize")),
OIDCTokenURL: trimURL(env("OIDC_TOKEN_ENDPOINT", oidcIssuer+"/oauth/token")),
OIDCUserInfoURL: trimURL(env("OIDC_USERINFO_ENDPOINT", oidcIssuer+"/oauth/userinfo")),
OIDCJWKSURL: trimURL(env("OIDC_JWKS_URI", oidcIssuer+"/oauth/jwks")),
CloudDMClientID: env("OIDC_CLOUDDM_CLIENT_ID", "clouddm"),
CloudDMClientSecret: env("OIDC_CLOUDDM_CLIENT_SECRET", ""),
CloudDMRedirectURI: env("OIDC_CLOUDDM_REDIRECT_URI", ""),
CloudDMTargetURL: env("CLOUDDM_TARGET_URL", ""),
AppEnv: env("APP_ENV", "dev"),
HTTPAddr: httpAddr,
PublicBaseURL: publicBaseURL,
MySQLDSN: env("MYSQL_DSN", "auth:auth@tcp(127.0.0.1:3306)/authserver?charset=utf8mb4&parseTime=True&loc=Local"),
AutoMigrate: envBool("AUTO_MIGRATE", true),
JWTSecret: env("JWT_SECRET", "change-this-secret"),
JWTIssuer: env("JWT_ISSUER", "authserver"),
JWTTTLMinutes: envInt("JWT_TTL_MINUTES", 120),
SAMLEntityID: samlEntityID,
SAMLACSURL: samlACSURL,
SAMLSPCert: env("SAML_SP_CERT_FILE", "certs/sp.crt"),
SAMLSPKey: env("SAML_SP_KEY_FILE", "certs/sp.key"),
SAMLIDPMetaURL: env("SAML_IDP_METADATA_URL", "http://sso-internal.dev.qiniu.io/saml2/meta"),
WayenLoginURL: env("WAYEN_LOGIN_URL", ""),
WayenTargetURL: env("WAYEN_TARGET_URL", ""),
WayenUsernameKey: env("WAYEN_USERNAME_KEY", "email"),
WayenPasswordKey: env("WAYEN_PASSWORD_KEY", "password"),
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", ""),
OAuthCodeTTLSeconds: envInt("OAUTH_CODE_TTL_SECONDS", 120),
OIDCIssuer: oidcIssuer,
OIDCAuthorizeURL: trimURL(env("OIDC_AUTHORIZATION_ENDPOINT", oidcIssuer+"/oauth/authorize")),
OIDCTokenURL: trimURL(env("OIDC_TOKEN_ENDPOINT", oidcIssuer+"/oauth/token")),
OIDCUserInfoURL: trimURL(env("OIDC_USERINFO_ENDPOINT", oidcIssuer+"/oauth/userinfo")),
OIDCJWKSURL: trimURL(env("OIDC_JWKS_URI", oidcIssuer+"/oauth/jwks")),
CloudDMClientID: env("OIDC_CLOUDDM_CLIENT_ID", "clouddm"),
CloudDMClientSecret: env("OIDC_CLOUDDM_CLIENT_SECRET", ""),
CloudDMRedirectURI: env("OIDC_CLOUDDM_REDIRECT_URI", ""),
CloudDMTargetURL: env("CLOUDDM_TARGET_URL", ""),
}
}
+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)
}
}