Compare commits
3 Commits
ecf3ee1ef0
...
2bc192b3c7
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bc192b3c7 | |||
| 036377f02e | |||
| 879677516f |
@@ -99,7 +99,7 @@ const enUS = {
|
|||||||
email: 'Email',
|
email: 'Email',
|
||||||
loginIdentifier: 'Phone / Email',
|
loginIdentifier: 'Phone / Email',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
rememberPassword: 'Remember password',
|
rememberAccount: 'Remember account',
|
||||||
forgotPassword: 'Forgot password',
|
forgotPassword: 'Forgot password',
|
||||||
},
|
},
|
||||||
membershipBlocked: {
|
membershipBlocked: {
|
||||||
@@ -244,6 +244,18 @@ const enUS = {
|
|||||||
title: 'AI Point Usage',
|
title: 'AI Point Usage',
|
||||||
description: 'Review AI point balance, cycle rules, and recent usage records.',
|
description: 'Review AI point balance, cycle rules, and recent usage records.',
|
||||||
},
|
},
|
||||||
|
notFound: {
|
||||||
|
title: 'Page not found',
|
||||||
|
description: 'The link you opened might have moved or no longer exists.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notFound: {
|
||||||
|
title: 'This page got lost',
|
||||||
|
description:
|
||||||
|
"We couldn't find anything at this address. The URL might be mistyped, or the page has been removed.",
|
||||||
|
pathLabel: 'Requested path',
|
||||||
|
back: 'Go back',
|
||||||
|
home: 'Back to workspace',
|
||||||
},
|
},
|
||||||
aiPoints: {
|
aiPoints: {
|
||||||
balance: 'Available Points',
|
balance: 'Available Points',
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ const zhCN = {
|
|||||||
email: "邮箱",
|
email: "邮箱",
|
||||||
loginIdentifier: "手机号 / 邮箱",
|
loginIdentifier: "手机号 / 邮箱",
|
||||||
password: "密码",
|
password: "密码",
|
||||||
rememberPassword: "记住密码",
|
rememberAccount: "记住账号",
|
||||||
forgotPassword: "忘记密码",
|
forgotPassword: "忘记密码",
|
||||||
},
|
},
|
||||||
membershipBlocked: {
|
membershipBlocked: {
|
||||||
@@ -233,6 +233,17 @@ const zhCN = {
|
|||||||
title: "AI 点数明细",
|
title: "AI 点数明细",
|
||||||
description: "查看当前套餐 AI 点数余额、周期规则和最近消耗流水。",
|
description: "查看当前套餐 AI 点数余额、周期规则和最近消耗流水。",
|
||||||
},
|
},
|
||||||
|
notFound: {
|
||||||
|
title: "页面不存在",
|
||||||
|
description: "你访问的链接可能已经失效或被移动。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notFound: {
|
||||||
|
title: "页面走丢了",
|
||||||
|
description: "我们没有找到这个地址对应的内容,可能链接拼写有误,或者页面已经下线。",
|
||||||
|
pathLabel: "请求路径",
|
||||||
|
back: "返回上一页",
|
||||||
|
home: "回到工作台",
|
||||||
},
|
},
|
||||||
aiPoints: {
|
aiPoints: {
|
||||||
balance: "可用点数",
|
balance: "可用点数",
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import type {
|
|||||||
MonitoringDashboardCompositeResponse,
|
MonitoringDashboardCompositeResponse,
|
||||||
MonitoringQuestionDetailResponse,
|
MonitoringQuestionDetailResponse,
|
||||||
PlatformAccount,
|
PlatformAccount,
|
||||||
|
PasswordPublicKeyResponse,
|
||||||
PromptRule,
|
PromptRule,
|
||||||
PromptRuleGroup,
|
PromptRuleGroup,
|
||||||
PromptRuleGroupRequest,
|
PromptRuleGroupRequest,
|
||||||
@@ -336,6 +337,9 @@ apiClient.raw.interceptors.response.use(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
|
passwordPublicKey() {
|
||||||
|
return publicClient.get<PasswordPublicKeyResponse>('/api/auth/password-key')
|
||||||
|
},
|
||||||
login(payload: LoginRequest) {
|
login(payload: LoginRequest) {
|
||||||
return publicClient.post<LoginResponse, LoginRequest>('/api/auth/login', payload)
|
return publicClient.post<LoginResponse, LoginRequest>('/api/auth/login', payload)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { PasswordPublicKeyResponse } from '@geo/shared-types'
|
||||||
|
|
||||||
|
const textEncoder = new TextEncoder()
|
||||||
|
|
||||||
|
export function browserSupportsPasswordCipher(): boolean {
|
||||||
|
return typeof window !== 'undefined' && Boolean(window.crypto?.subtle)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptPasswordForLogin(
|
||||||
|
password: string,
|
||||||
|
key: PasswordPublicKeyResponse,
|
||||||
|
): Promise<{ encrypted_password: string; password_key_id: string }> {
|
||||||
|
const cryptoKey = await importRSAOAEPKey(key.public_key)
|
||||||
|
const encrypted = await window.crypto.subtle.encrypt(
|
||||||
|
{ name: 'RSA-OAEP' },
|
||||||
|
cryptoKey,
|
||||||
|
textEncoder.encode(password),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
encrypted_password: arrayBufferToBase64(encrypted),
|
||||||
|
password_key_id: key.key_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importRSAOAEPKey(publicKeyPEM: string): Promise<CryptoKey> {
|
||||||
|
const binaryDER = pemToArrayBuffer(publicKeyPEM)
|
||||||
|
return window.crypto.subtle.importKey(
|
||||||
|
'spki',
|
||||||
|
binaryDER,
|
||||||
|
{
|
||||||
|
name: 'RSA-OAEP',
|
||||||
|
hash: 'SHA-256',
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
['encrypt'],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pemToArrayBuffer(pem: string): ArrayBuffer {
|
||||||
|
const base64 = pem
|
||||||
|
.replace(/-----BEGIN PUBLIC KEY-----/g, '')
|
||||||
|
.replace(/-----END PUBLIC KEY-----/g, '')
|
||||||
|
.replace(/\s/g, '')
|
||||||
|
const binary = window.atob(base64)
|
||||||
|
const bytes = new Uint8Array(binary.length)
|
||||||
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
|
bytes[i] = binary.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return bytes.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
let binary = ''
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte)
|
||||||
|
}
|
||||||
|
return window.btoa(binary)
|
||||||
|
}
|
||||||
@@ -262,6 +262,16 @@ const router = createRouter({
|
|||||||
navKey: '/account/ai-points',
|
navKey: '/account/ai-points',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: ':pathMatch(.*)*',
|
||||||
|
name: 'not-found',
|
||||||
|
component: () => import('@/views/NotFoundView.vue'),
|
||||||
|
meta: {
|
||||||
|
titleKey: 'route.notFound.title',
|
||||||
|
descriptionKey: 'route.notFound.description',
|
||||||
|
navKey: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { ApiClientError } from '@geo/http-client'
|
||||||
import type { LoginRequest, UserInfo } from '@geo/shared-types'
|
import type { LoginRequest, UserInfo } from '@geo/shared-types'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
refreshStoredAuthSession,
|
refreshStoredAuthSession,
|
||||||
shouldRefreshAccessToken,
|
shouldRefreshAccessToken,
|
||||||
} from '@/lib/api'
|
} from '@/lib/api'
|
||||||
|
import { browserSupportsPasswordCipher, encryptPasswordForLogin } from '@/lib/password-cipher'
|
||||||
import {
|
import {
|
||||||
clearStoredSession,
|
clearStoredSession,
|
||||||
readStoredSession,
|
readStoredSession,
|
||||||
@@ -147,7 +149,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
return loginPromise
|
return loginPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginPayload = { ...payload }
|
const loginPayload = await buildSecureLoginPayload(payload)
|
||||||
loginPromise = authApi
|
loginPromise = authApi
|
||||||
.login(loginPayload)
|
.login(loginPayload)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -166,6 +168,26 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
return loginPromise
|
return loginPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildSecureLoginPayload(payload: LoginRequest): Promise<LoginRequest> {
|
||||||
|
if (!payload.password || !browserSupportsPasswordCipher()) {
|
||||||
|
return { ...payload }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const key = await authApi.passwordPublicKey()
|
||||||
|
const encrypted = await encryptPasswordForLogin(payload.password, key)
|
||||||
|
return {
|
||||||
|
identifier: payload.identifier,
|
||||||
|
email: payload.email,
|
||||||
|
...encrypted,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiClientError && error.status === 404) {
|
||||||
|
return { ...payload }
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function logout(): Promise<void> {
|
async function logout(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (accessToken.value) {
|
if (accessToken.value) {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
v-model="formState.identifier"
|
v-model="formState.identifier"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="账户"
|
placeholder="账户"
|
||||||
autocomplete="off"
|
autocomplete="username"
|
||||||
required
|
required
|
||||||
@focus="isTyping = true"
|
@focus="isTyping = true"
|
||||||
@blur="isTyping = false"
|
@blur="isTyping = false"
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
<div class="options">
|
<div class="options">
|
||||||
<label class="remember">
|
<label class="remember">
|
||||||
<input v-model="remember" type="checkbox" />
|
<input v-model="remember" type="checkbox" />
|
||||||
{{ t('auth.rememberPassword') }}
|
{{ t('auth.rememberAccount') }}
|
||||||
</label>
|
</label>
|
||||||
<a href="#" class="forgot">{{ t('auth.forgotPassword') }}</a>
|
<a href="#" class="forgot">{{ t('auth.forgotPassword') }}</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,9 +127,8 @@ onMounted(() => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const parsed = JSON.parse(raw) as { identifier?: string; password?: string }
|
const parsed = JSON.parse(raw) as { identifier?: string; password?: string }
|
||||||
if (parsed && typeof parsed.identifier === 'string' && typeof parsed.password === 'string') {
|
if (parsed && typeof parsed.identifier === 'string') {
|
||||||
formState.identifier = parsed.identifier
|
formState.identifier = parsed.identifier
|
||||||
formState.password = parsed.password
|
|
||||||
remember.value = true
|
remember.value = true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -144,7 +143,7 @@ function persistRememberedCredentials(): void {
|
|||||||
if (remember.value) {
|
if (remember.value) {
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
REMEMBER_STORAGE_KEY,
|
REMEMBER_STORAGE_KEY,
|
||||||
JSON.stringify({ identifier: formState.identifier, password: formState.password }),
|
JSON.stringify({ identifier: formState.identifier }),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
window.localStorage.removeItem(REMEMBER_STORAGE_KEY)
|
window.localStorage.removeItem(REMEMBER_STORAGE_KEY)
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ArrowLeftOutlined, HomeOutlined } from '@ant-design/icons-vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
function goBack(): void {
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
router.back()
|
||||||
|
} else {
|
||||||
|
void router.replace('/workspace')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome(): void {
|
||||||
|
void router.replace('/workspace')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="not-found-page" role="alert" aria-labelledby="not-found-title">
|
||||||
|
<div class="not-found-card">
|
||||||
|
<div class="ambient-glow" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="error-code" aria-hidden="true">404</div>
|
||||||
|
<h1 id="not-found-title" class="not-found-title">{{ t('notFound.title') }}</h1>
|
||||||
|
<p class="not-found-description">{{ t('notFound.description') }}</p>
|
||||||
|
|
||||||
|
<div class="not-found-path" :title="route.fullPath">
|
||||||
|
<span class="not-found-path-label">{{ t('notFound.pathLabel') }}</span>
|
||||||
|
<code>{{ route.fullPath }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="not-found-actions">
|
||||||
|
<button type="button" class="btn-secondary" @click="goBack">
|
||||||
|
<ArrowLeftOutlined aria-hidden="true" />
|
||||||
|
{{ t('notFound.back') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-primary" @click="goHome">
|
||||||
|
<HomeOutlined aria-hidden="true" />
|
||||||
|
{{ t('notFound.home') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.not-found-page {
|
||||||
|
min-height: calc(100vh - 140px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-card {
|
||||||
|
width: min(640px, 100%);
|
||||||
|
padding: 72px 48px;
|
||||||
|
border-radius: 32px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
/* Softer dotted grid pattern */
|
||||||
|
background-image: radial-gradient(rgba(19, 94, 204, 0.04) 1px, transparent 1px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
background-position: center top;
|
||||||
|
border: 1px solid rgba(235, 240, 245, 0.8);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 48px -12px rgba(19, 94, 204, 0.05),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.8) inset;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fadeUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient-glow {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 400px;
|
||||||
|
height: 400px;
|
||||||
|
background: radial-gradient(circle, rgba(47, 124, 246, 0.08) 0%, rgba(255,255,255,0) 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-code {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 132px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
/* Softer, brighter gradient for consistency with the overall app */
|
||||||
|
background: linear-gradient(135deg, #70a1ff 0%, #1e6fff 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
letter-spacing: -6px;
|
||||||
|
filter: drop-shadow(0 12px 24px rgba(30, 111, 255, 0.12));
|
||||||
|
animation: float 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0% { transform: translateY(0px); }
|
||||||
|
50% { transform: translateY(-10px); }
|
||||||
|
100% { transform: translateY(0px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-title {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #27334a;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-description {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin: 0 auto 32px;
|
||||||
|
max-width: 420px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-path {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: #f4f6fb;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
border: 1px solid rgba(19, 94, 204, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-path-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8a96b0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-path code {
|
||||||
|
font-family:
|
||||||
|
'SFMono-Regular', Menlo, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #334155;
|
||||||
|
word-break: break-all;
|
||||||
|
max-width: 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-actions {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary, .btn-secondary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 28px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #135ecc 0%, #0f7ad8 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 8px 20px rgba(19, 94, 204, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, #156ae6 0%, #1186ed 100%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 12px 24px rgba(19, 94, 204, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(19, 94, 204, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #27334a;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
box-shadow: 0 2px 6px rgba(15, 32, 64, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: #9ca3af;
|
||||||
|
color: #172033;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 12px rgba(15, 32, 64, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(15, 32, 64, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.not-found-card {
|
||||||
|
padding: 48px 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
.error-code {
|
||||||
|
font-size: 100px;
|
||||||
|
letter-spacing: -4px;
|
||||||
|
}
|
||||||
|
.not-found-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.not-found-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.btn-primary, .btn-secondary {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -10,11 +10,13 @@ import type {
|
|||||||
DesktopRuntimeSessionSyncRequest,
|
DesktopRuntimeSessionSyncRequest,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
PasswordPublicKeyResponse,
|
||||||
RefreshResponse,
|
RefreshResponse,
|
||||||
UserInfo,
|
UserInfo,
|
||||||
} from '@geo/shared-types'
|
} from '@geo/shared-types'
|
||||||
|
|
||||||
import { normalizeDesktopApiBaseURL } from '../../shared/api-base-url'
|
import { normalizeDesktopApiBaseURL } from '../../shared/api-base-url'
|
||||||
|
import { browserSupportsPasswordCipher, encryptPasswordForLogin } from '../lib/password-cipher'
|
||||||
|
|
||||||
type SessionMode = 'authenticated' | 'preview'
|
type SessionMode = 'authenticated' | 'preview'
|
||||||
|
|
||||||
@@ -596,9 +598,10 @@ async function performLogin(payload: LoginRequest): Promise<void> {
|
|||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const loginPayload = await buildSecureLoginPayload(payload)
|
||||||
const authData = await createPublicClient().post<LoginResponse, LoginRequest>(
|
const authData = await createPublicClient().post<LoginResponse, LoginRequest>(
|
||||||
'/api/auth/login',
|
'/api/auth/login',
|
||||||
payload,
|
loginPayload,
|
||||||
)
|
)
|
||||||
|
|
||||||
persistSession({
|
persistSession({
|
||||||
@@ -633,6 +636,26 @@ async function performLogin(payload: LoginRequest): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildSecureLoginPayload(payload: LoginRequest): Promise<LoginRequest> {
|
||||||
|
if (!payload.password || !browserSupportsPasswordCipher()) {
|
||||||
|
return { ...payload }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const key = await createPublicClient().get<PasswordPublicKeyResponse>('/api/auth/password-key')
|
||||||
|
const encrypted = await encryptPasswordForLogin(payload.password, key)
|
||||||
|
return {
|
||||||
|
identifier: payload.identifier,
|
||||||
|
email: payload.email,
|
||||||
|
...encrypted,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiClientError && err.status === 404) {
|
||||||
|
return { ...payload }
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function login(payload: LoginRequest): Promise<void> {
|
async function login(payload: LoginRequest): Promise<void> {
|
||||||
if (loginPromise) {
|
if (loginPromise) {
|
||||||
return loginPromise
|
return loginPromise
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { PasswordPublicKeyResponse } from '@geo/shared-types'
|
||||||
|
|
||||||
|
const textEncoder = new TextEncoder()
|
||||||
|
|
||||||
|
export function browserSupportsPasswordCipher(): boolean {
|
||||||
|
return typeof window !== 'undefined' && Boolean(window.crypto?.subtle)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptPasswordForLogin(
|
||||||
|
password: string,
|
||||||
|
key: PasswordPublicKeyResponse,
|
||||||
|
): Promise<{ encrypted_password: string; password_key_id: string }> {
|
||||||
|
const cryptoKey = await importRSAOAEPKey(key.public_key)
|
||||||
|
const encrypted = await window.crypto.subtle.encrypt(
|
||||||
|
{ name: 'RSA-OAEP' },
|
||||||
|
cryptoKey,
|
||||||
|
textEncoder.encode(password),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
encrypted_password: arrayBufferToBase64(encrypted),
|
||||||
|
password_key_id: key.key_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importRSAOAEPKey(publicKeyPEM: string): Promise<CryptoKey> {
|
||||||
|
const binaryDER = pemToArrayBuffer(publicKeyPEM)
|
||||||
|
return window.crypto.subtle.importKey(
|
||||||
|
'spki',
|
||||||
|
binaryDER,
|
||||||
|
{
|
||||||
|
name: 'RSA-OAEP',
|
||||||
|
hash: 'SHA-256',
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
['encrypt'],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pemToArrayBuffer(pem: string): ArrayBuffer {
|
||||||
|
const base64 = pem
|
||||||
|
.replace(/-----BEGIN PUBLIC KEY-----/g, '')
|
||||||
|
.replace(/-----END PUBLIC KEY-----/g, '')
|
||||||
|
.replace(/\s/g, '')
|
||||||
|
const binary = window.atob(base64)
|
||||||
|
const bytes = new Uint8Array(binary.length)
|
||||||
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
|
bytes[i] = binary.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return bytes.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
let binary = ''
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte)
|
||||||
|
}
|
||||||
|
return window.btoa(binary)
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ const { apiBaseURL, error, pending, setApiBaseURL, login } = useDesktopSession()
|
|||||||
const menuOpen = ref(false)
|
const menuOpen = ref(false)
|
||||||
const showServerDialog = ref(false)
|
const showServerDialog = ref(false)
|
||||||
const showPassword = ref(false)
|
const showPassword = ref(false)
|
||||||
const rememberPassword = ref(!!localStorage.getItem('geo_rankly_saved_password'))
|
const rememberAccount = ref(!!localStorage.getItem('geo_rankly_saved_identifier'))
|
||||||
const savedIdentifier =
|
const savedIdentifier =
|
||||||
localStorage.getItem('geo_rankly_saved_identifier') ??
|
localStorage.getItem('geo_rankly_saved_identifier') ??
|
||||||
localStorage.getItem('geo_rankly_saved_email') ??
|
localStorage.getItem('geo_rankly_saved_email') ??
|
||||||
@@ -16,7 +16,7 @@ const savedIdentifier =
|
|||||||
const form = reactive({
|
const form = reactive({
|
||||||
apiBaseURL: apiBaseURL.value,
|
apiBaseURL: apiBaseURL.value,
|
||||||
identifier: savedIdentifier,
|
identifier: savedIdentifier,
|
||||||
password: localStorage.getItem('geo_rankly_saved_password') || '',
|
password: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const menuRef = ref<HTMLElement | null>(null)
|
const menuRef = ref<HTMLElement | null>(null)
|
||||||
@@ -26,7 +26,7 @@ function toggleMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onRememberChange(event: Event) {
|
function onRememberChange(event: Event) {
|
||||||
rememberPassword.value = (event.target as HTMLInputElement).checked
|
rememberAccount.value = (event.target as HTMLInputElement).checked
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOutsideClick(event: MouseEvent) {
|
function handleOutsideClick(event: MouseEvent) {
|
||||||
@@ -53,14 +53,13 @@ function saveServerSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitLogin() {
|
async function submitLogin() {
|
||||||
if (rememberPassword.value) {
|
localStorage.removeItem('geo_rankly_saved_password')
|
||||||
|
if (rememberAccount.value) {
|
||||||
localStorage.setItem('geo_rankly_saved_identifier', form.identifier)
|
localStorage.setItem('geo_rankly_saved_identifier', form.identifier)
|
||||||
localStorage.removeItem('geo_rankly_saved_email')
|
localStorage.removeItem('geo_rankly_saved_email')
|
||||||
localStorage.setItem('geo_rankly_saved_password', form.password)
|
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem('geo_rankly_saved_identifier')
|
localStorage.removeItem('geo_rankly_saved_identifier')
|
||||||
localStorage.removeItem('geo_rankly_saved_email')
|
localStorage.removeItem('geo_rankly_saved_email')
|
||||||
localStorage.removeItem('geo_rankly_saved_password')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await login({
|
await login({
|
||||||
@@ -179,8 +178,8 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<div class="options-row">
|
<div class="options-row">
|
||||||
<label class="check-label">
|
<label class="check-label">
|
||||||
<input type="checkbox" :checked="rememberPassword" @change="onRememberChange" />
|
<input type="checkbox" :checked="rememberAccount" @change="onRememberChange" />
|
||||||
<span>记住密码</span>
|
<span>记住账号</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
interface PasswordPublicKeyResponse {
|
||||||
|
key_id: string
|
||||||
|
algorithm: 'RSA-OAEP-256'
|
||||||
|
public_key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const textEncoder = new TextEncoder()
|
||||||
|
|
||||||
|
export function browserSupportsPasswordCipher(): boolean {
|
||||||
|
return typeof window !== 'undefined' && Boolean(window.crypto?.subtle)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptPasswordForLogin(
|
||||||
|
password: string,
|
||||||
|
key: PasswordPublicKeyResponse,
|
||||||
|
): Promise<{ encrypted_password: string; password_key_id: string }> {
|
||||||
|
const cryptoKey = await importRSAOAEPKey(key.public_key)
|
||||||
|
const encrypted = await window.crypto.subtle.encrypt(
|
||||||
|
{ name: 'RSA-OAEP' },
|
||||||
|
cryptoKey,
|
||||||
|
textEncoder.encode(password),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
encrypted_password: arrayBufferToBase64(encrypted),
|
||||||
|
password_key_id: key.key_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importRSAOAEPKey(publicKeyPEM: string): Promise<CryptoKey> {
|
||||||
|
const binaryDER = pemToArrayBuffer(publicKeyPEM)
|
||||||
|
return window.crypto.subtle.importKey(
|
||||||
|
'spki',
|
||||||
|
binaryDER,
|
||||||
|
{
|
||||||
|
name: 'RSA-OAEP',
|
||||||
|
hash: 'SHA-256',
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
['encrypt'],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pemToArrayBuffer(pem: string): ArrayBuffer {
|
||||||
|
const base64 = pem
|
||||||
|
.replace(/-----BEGIN PUBLIC KEY-----/g, '')
|
||||||
|
.replace(/-----END PUBLIC KEY-----/g, '')
|
||||||
|
.replace(/\s/g, '')
|
||||||
|
const binary = window.atob(base64)
|
||||||
|
const bytes = new Uint8Array(binary.length)
|
||||||
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
|
bytes[i] = binary.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return bytes.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
let binary = ''
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte)
|
||||||
|
}
|
||||||
|
return window.btoa(binary)
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import { http, resetUnauthorizedHandling } from '@/lib/http'
|
import { http, OpsApiError, resetUnauthorizedHandling } from '@/lib/http'
|
||||||
|
import { browserSupportsPasswordCipher, encryptPasswordForLogin } from '@/lib/password-cipher'
|
||||||
import {
|
import {
|
||||||
type OperatorView,
|
type OperatorView,
|
||||||
clearStoredSession,
|
clearStoredSession,
|
||||||
@@ -17,6 +18,12 @@ interface LoginResponse {
|
|||||||
operator: OperatorView
|
operator: OperatorView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PasswordPublicKeyResponse {
|
||||||
|
key_id: string
|
||||||
|
algorithm: 'RSA-OAEP-256'
|
||||||
|
public_key: string
|
||||||
|
}
|
||||||
|
|
||||||
export const useAuthStore = defineStore('ops-auth', () => {
|
export const useAuthStore = defineStore('ops-auth', () => {
|
||||||
const accessToken = ref<string | null>(null)
|
const accessToken = ref<string | null>(null)
|
||||||
const expiresAt = ref<number | null>(null)
|
const expiresAt = ref<number | null>(null)
|
||||||
@@ -41,11 +48,9 @@ export const useAuthStore = defineStore('ops-auth', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = { username, password, remember }
|
const payload = { username, password, remember }
|
||||||
|
const loginPayload = await buildSecureLoginPayload(username, password)
|
||||||
loginPromise = http
|
loginPromise = http
|
||||||
.post<LoginResponse>('/auth/login', {
|
.post<LoginResponse>('/auth/login', loginPayload)
|
||||||
username: payload.username,
|
|
||||||
password: payload.password,
|
|
||||||
})
|
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
resetUnauthorizedHandling()
|
resetUnauthorizedHandling()
|
||||||
accessToken.value = result.access_token
|
accessToken.value = result.access_token
|
||||||
@@ -69,6 +74,25 @@ export const useAuthStore = defineStore('ops-auth', () => {
|
|||||||
return loginPromise
|
return loginPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildSecureLoginPayload(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ username: string; password?: string; encrypted_password?: string; password_key_id?: string }> {
|
||||||
|
if (!browserSupportsPasswordCipher()) {
|
||||||
|
return { username, password }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const key = await http.get<PasswordPublicKeyResponse>('/auth/password-key')
|
||||||
|
const encrypted = await encryptPasswordForLogin(password, key)
|
||||||
|
return { username, ...encrypted }
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OpsApiError && error.status === 404) {
|
||||||
|
return { username, password }
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshSelf(): Promise<void> {
|
async function refreshSelf(): Promise<void> {
|
||||||
if (!accessToken.value) return
|
if (!accessToken.value) return
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
v-model="formState.username"
|
v-model="formState.username"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="请输入管理员账号"
|
placeholder="请输入管理员账号"
|
||||||
autocomplete="off"
|
autocomplete="username"
|
||||||
required
|
required
|
||||||
@focus="isTyping = true"
|
@focus="isTyping = true"
|
||||||
@blur="isTyping = false"
|
@blur="isTyping = false"
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
v-model="formState.password"
|
v-model="formState.password"
|
||||||
:type="showPassword ? 'text' : 'password'"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
placeholder="请输入密码"
|
placeholder="请输入密码"
|
||||||
|
autocomplete="current-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button type="button" class="eye-btn" @click="showPassword = !showPassword">
|
<button type="button" class="eye-btn" @click="showPassword = !showPassword">
|
||||||
|
|||||||
+1
-1
@@ -152,7 +152,7 @@ runtime:
|
|||||||
%s
|
%s
|
||||||
|
|
||||||
【输出要求】
|
【输出要求】
|
||||||
1. region 使用地域优先词,不要编造不存在的城市;没有有效地域时可输出全国、本地、华东等泛地域词。
|
1. region 使用地域优先词,不要编造不存在的城市;没有有效地域时可输出全国、华东等泛地域词。
|
||||||
2. prefix/core/industry/suffix 各输出 3 到 4 个词,必须短、热、可直接拼成搜索问题。
|
2. prefix/core/industry/suffix 各输出 3 到 4 个词,必须短、热、可直接拼成搜索问题。
|
||||||
3. core 要贴近品牌最核心的搜索需求;industry 要贴近行业/品类;suffix 偏“哪家好、怎么选、推荐、多少钱”等高转化问法。
|
3. core 要贴近品牌最核心的搜索需求;industry 要贴近行业/品类;suffix 偏“哪家好、怎么选、推荐、多少钱”等高转化问法。
|
||||||
4. 不要输出完整问题,不要带标点,不要解释,严格按 JSON Schema 输出。
|
4. 不要输出完整问题,不要带标点,不要解释,严格按 JSON Schema 输出。
|
||||||
|
|||||||
@@ -22,10 +22,18 @@ export interface AuthTokens {
|
|||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
identifier: string
|
identifier: string
|
||||||
password: string
|
password?: string
|
||||||
|
encrypted_password?: string
|
||||||
|
password_key_id?: string
|
||||||
email?: string
|
email?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PasswordPublicKeyResponse {
|
||||||
|
key_id: string
|
||||||
|
algorithm: 'RSA-OAEP-256'
|
||||||
|
public_key: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserInfo {
|
export interface UserInfo {
|
||||||
id: number
|
id: number
|
||||||
email: string | null
|
email: string | null
|
||||||
|
|||||||
@@ -94,8 +94,21 @@ func main() {
|
|||||||
|
|
||||||
auditSvc := app.NewAuditService(auditsRepo, logger).WithIPRegionResolver(ipRegionResolver)
|
auditSvc := app.NewAuditService(auditsRepo, logger).WithIPRegionResolver(ipRegionResolver)
|
||||||
issuer := app.NewTokenIssuer(cfg.JWT.Secret, cfg.JWT.AccessTTL)
|
issuer := app.NewTokenIssuer(cfg.JWT.Secret, cfg.JWT.AccessTTL)
|
||||||
|
var passwordCipher *sharedauth.PasswordCipher
|
||||||
|
if cfg.Auth.PasswordCipher.Enabled {
|
||||||
|
if cfg.Auth.PasswordCipher.PrivateKeyPEM == "" {
|
||||||
|
passwordCipher, err = sharedauth.NewEphemeralPasswordCipher(cfg.Auth.PasswordCipher.KeyID)
|
||||||
|
} else {
|
||||||
|
passwordCipher, err = sharedauth.NewPasswordCipher(cfg.Auth.PasswordCipher.PrivateKeyPEM, cfg.Auth.PasswordCipher.KeyID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Sugar().Fatalf("ops-api init password cipher: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Warn("ops auth password cipher disabled; login request payloads may expose plaintext passwords in browser developer tools")
|
||||||
|
}
|
||||||
loginGuard := sharedauth.NewLoginGuard(rdb, sharedauth.DefaultLoginGuardConfig("ops"))
|
loginGuard := sharedauth.NewLoginGuard(rdb, sharedauth.DefaultLoginGuardConfig("ops"))
|
||||||
authSvc := app.NewAuthService(accountsRepo, auditSvc, issuer, loginGuard)
|
authSvc := app.NewAuthService(accountsRepo, auditSvc, issuer, loginGuard).WithPasswordCipher(passwordCipher)
|
||||||
accountSvc := app.NewAccountService(accountsRepo, auditSvc)
|
accountSvc := app.NewAccountService(accountsRepo, auditSvc)
|
||||||
tenantLoginGuard := sharedauth.NewLoginGuard(rdb, sharedauth.DefaultLoginGuardConfig("tenant"))
|
tenantLoginGuard := sharedauth.NewLoginGuard(rdb, sharedauth.DefaultLoginGuardConfig("tenant"))
|
||||||
adminUserSvc := app.NewAdminUserService(adminUsersRepo, auditSvc, cfg.AdminUsers.DefaultPlanCode).
|
adminUserSvc := app.NewAdminUserService(adminUsersRepo, auditSvc, cfg.AdminUsers.DefaultPlanCode).
|
||||||
@@ -139,6 +152,7 @@ func main() {
|
|||||||
|
|
||||||
transport.RegisterRoutes(transport.Deps{
|
transport.RegisterRoutes(transport.Deps{
|
||||||
Engine: engine,
|
Engine: engine,
|
||||||
|
Server: cfg.Server,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
DB: pool,
|
DB: pool,
|
||||||
MonitoringDB: monitoringPool,
|
MonitoringDB: monitoringPool,
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
mode: debug
|
mode: debug
|
||||||
|
# Production should restrict this to real frontend origins.
|
||||||
|
# Empty keeps local development CORS permissive.
|
||||||
|
allowed_origins: []
|
||||||
|
security_headers:
|
||||||
|
enabled: true
|
||||||
trusted_proxies:
|
trusted_proxies:
|
||||||
- 127.0.0.1
|
- 127.0.0.1
|
||||||
- ::1
|
- ::1
|
||||||
@@ -162,6 +167,16 @@ jwt:
|
|||||||
access_ttl: 15m
|
access_ttl: 15m
|
||||||
refresh_ttl: 720h
|
refresh_ttl: 720h
|
||||||
|
|
||||||
|
auth:
|
||||||
|
login_guard:
|
||||||
|
enabled: true
|
||||||
|
password_cipher:
|
||||||
|
# Empty private_key_pem generates a per-process key. Production multi-instance deployments
|
||||||
|
# should inject the same private key through AUTH_PASSWORD_CIPHER_PRIVATE_KEY_PEM.
|
||||||
|
enabled: true
|
||||||
|
key_id: login-password-rsa-oaep-v1
|
||||||
|
private_key_pem: ""
|
||||||
|
|
||||||
llm:
|
llm:
|
||||||
provider: ark
|
provider: ark
|
||||||
base_url: https://ark.cn-beijing.volces.com/api/v3
|
base_url: https://ark.cn-beijing.volces.com/api/v3
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
mode: debug
|
mode: debug
|
||||||
|
# 生产环境请配置为真实前端域名,例如 https://app.example.com。
|
||||||
|
# 留空时沿用开发环境 CORS 放开策略。
|
||||||
|
allowed_origins: []
|
||||||
|
security_headers:
|
||||||
|
enabled: true
|
||||||
trusted_proxies:
|
trusted_proxies:
|
||||||
- 127.0.0.1
|
- 127.0.0.1
|
||||||
- ::1
|
- ::1
|
||||||
@@ -171,6 +176,11 @@ jwt:
|
|||||||
auth:
|
auth:
|
||||||
login_guard:
|
login_guard:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
password_cipher:
|
||||||
|
# 留空 private_key_pem 时会在单进程内生成临时密钥;生产多实例请通过环境变量注入同一把私钥。
|
||||||
|
enabled: true
|
||||||
|
key_id: login-password-rsa-oaep-v1
|
||||||
|
private_key_pem: ""
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: debug,info,warn,error
|
level: debug,info,warn,error
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
server:
|
server:
|
||||||
port: 8090
|
port: 8090
|
||||||
mode: debug
|
mode: debug
|
||||||
|
# 生产环境请配置为真实运维后台域名,例如 https://ops.example.com。
|
||||||
|
# 留空时沿用开发环境 CORS 放开策略。
|
||||||
|
allowed_origins: []
|
||||||
|
security_headers:
|
||||||
|
enabled: true
|
||||||
trusted_proxies:
|
trusted_proxies:
|
||||||
- 127.0.0.1
|
- 127.0.0.1
|
||||||
- ::1
|
- ::1
|
||||||
@@ -43,6 +48,13 @@ jwt:
|
|||||||
secret: "15645415841" # 必须通过 OPS_JWT_SECRET 环境变量提供
|
secret: "15645415841" # 必须通过 OPS_JWT_SECRET 环境变量提供
|
||||||
access_ttl: 8h
|
access_ttl: 8h
|
||||||
|
|
||||||
|
auth:
|
||||||
|
password_cipher:
|
||||||
|
# 留空 private_key_pem 时会在单进程内生成临时密钥;生产多实例请通过环境变量注入同一把私钥。
|
||||||
|
enabled: true
|
||||||
|
key_id: ops-password-rsa-oaep-v1
|
||||||
|
private_key_pem: ""
|
||||||
|
|
||||||
default_admin:
|
default_admin:
|
||||||
username: admin
|
username: admin
|
||||||
display_name: 系统管理员
|
display_name: 系统管理员
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ runtime:
|
|||||||
%s
|
%s
|
||||||
|
|
||||||
【输出要求】
|
【输出要求】
|
||||||
1. region 使用地域优先词,不要编造不存在的城市;没有有效地域时可输出全国、本地、华东等泛地域词。
|
1. region 使用地域优先词,不要编造不存在的城市;没有有效地域时可输出全国、华东等泛地域词。
|
||||||
2. prefix/core/industry/suffix 各输出 3 到 4 个词,必须短、热、可直接拼成搜索问题。
|
2. prefix/core/industry/suffix 各输出 3 到 4 个词,必须短、热、可直接拼成搜索问题。
|
||||||
3. core 要贴近品牌最核心的搜索需求;industry 要贴近行业/品类;suffix 偏“哪家好、怎么选、推荐、多少钱”等高转化问法。
|
3. core 要贴近品牌最核心的搜索需求;industry 要贴近行业/品类;suffix 偏“哪家好、怎么选、推荐、多少钱”等高转化问法。
|
||||||
4. 不要输出完整问题,不要带标点,不要解释,严格按 JSON Schema 输出。
|
4. 不要输出完整问题,不要带标点,不要解释,严格按 JSON Schema 输出。
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ type App struct {
|
|||||||
JWT *auth.Manager
|
JWT *auth.Manager
|
||||||
Sessions *auth.SessionStore
|
Sessions *auth.SessionStore
|
||||||
LoginGuard *auth.LoginGuard
|
LoginGuard *auth.LoginGuard
|
||||||
|
PasswordCipher *auth.PasswordCipher
|
||||||
LLM llm.Client
|
LLM llm.Client
|
||||||
ReloadableLLM *llm.ReloadableClient
|
ReloadableLLM *llm.ReloadableClient
|
||||||
RetrievalProvider retrieval.Provider
|
RetrievalProvider retrieval.Provider
|
||||||
@@ -116,6 +117,17 @@ func New(configPath string) (*App, error) {
|
|||||||
|
|
||||||
jwtMgr := auth.NewManager(cfg.JWT.Secret, cfg.JWT.AccessTTL, cfg.JWT.RefreshTTL)
|
jwtMgr := auth.NewManager(cfg.JWT.Secret, cfg.JWT.AccessTTL, cfg.JWT.RefreshTTL)
|
||||||
sessions := auth.NewSessionStore(rdb)
|
sessions := auth.NewSessionStore(rdb)
|
||||||
|
passwordCipher, err := buildPasswordCipher(cfg.Auth.PasswordCipher)
|
||||||
|
if err != nil {
|
||||||
|
pool.Close()
|
||||||
|
monitoringPool.Close()
|
||||||
|
_ = rdb.Close()
|
||||||
|
_ = mqClient.Close()
|
||||||
|
return nil, fmt.Errorf("init password cipher: %w", err)
|
||||||
|
}
|
||||||
|
if passwordCipher == nil {
|
||||||
|
logger.Warn("auth password cipher disabled; login request payloads may expose plaintext passwords in browser developer tools")
|
||||||
|
}
|
||||||
loginGuardCfg := auth.DefaultLoginGuardConfig("tenant")
|
loginGuardCfg := auth.DefaultLoginGuardConfig("tenant")
|
||||||
loginGuardCfg.Enabled = cfg.Auth.LoginGuard.Enabled
|
loginGuardCfg.Enabled = cfg.Auth.LoginGuard.Enabled
|
||||||
if !loginGuardCfg.Enabled {
|
if !loginGuardCfg.Enabled {
|
||||||
@@ -173,8 +185,14 @@ func New(configPath string) (*App, error) {
|
|||||||
middleware.Recovery(logger),
|
middleware.Recovery(logger),
|
||||||
middleware.RequestID(),
|
middleware.RequestID(),
|
||||||
middleware.Logger(logger),
|
middleware.Logger(logger),
|
||||||
middleware.CORS(),
|
|
||||||
)
|
)
|
||||||
|
if cfg.Server.SecurityHeaders.Enabled {
|
||||||
|
engine.Use(middleware.SecurityHeaders())
|
||||||
|
}
|
||||||
|
engine.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||||
|
AllowedOrigins: cfg.Server.AllowedOrigins,
|
||||||
|
AllowAll: len(cfg.Server.AllowedOrigins) == 0,
|
||||||
|
}))
|
||||||
|
|
||||||
engine.GET("/api/health/live", func(c *gin.Context) {
|
engine.GET("/api/health/live", func(c *gin.Context) {
|
||||||
response.Success(c, gin.H{"status": "alive"})
|
response.Success(c, gin.H{"status": "alive"})
|
||||||
@@ -210,6 +228,7 @@ func New(configPath string) (*App, error) {
|
|||||||
JWT: jwtMgr,
|
JWT: jwtMgr,
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
LoginGuard: loginGuard,
|
LoginGuard: loginGuard,
|
||||||
|
PasswordCipher: passwordCipher,
|
||||||
LLM: llmClient,
|
LLM: llmClient,
|
||||||
ReloadableLLM: llmClient,
|
ReloadableLLM: llmClient,
|
||||||
RetrievalProvider: retrievalProvider,
|
RetrievalProvider: retrievalProvider,
|
||||||
@@ -237,6 +256,16 @@ func New(configPath string) (*App, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildPasswordCipher(cfg config.PasswordCipherConfig) (*auth.PasswordCipher, error) {
|
||||||
|
if !cfg.Enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if cfg.PrivateKeyPEM == "" {
|
||||||
|
return auth.NewEphemeralPasswordCipher(cfg.KeyID)
|
||||||
|
}
|
||||||
|
return auth.NewPasswordCipher(cfg.PrivateKeyPEM, cfg.KeyID)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) Config() *config.Config {
|
func (a *App) Config() *config.Config {
|
||||||
if a == nil || a.ConfigStore == nil {
|
if a == nil || a.ConfigStore == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ type AuthService struct {
|
|||||||
audits *AuditService
|
audits *AuditService
|
||||||
issuer *TokenIssuer
|
issuer *TokenIssuer
|
||||||
guard *sharedauth.LoginGuard
|
guard *sharedauth.LoginGuard
|
||||||
|
cipher *sharedauth.PasswordCipher
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthService(
|
func NewAuthService(
|
||||||
@@ -132,12 +133,27 @@ func NewAuthService(
|
|||||||
return &AuthService{accounts: accounts, audits: audits, issuer: issuer, guard: guard}
|
return &AuthService{accounts: accounts, audits: audits, issuer: issuer, guard: guard}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) WithPasswordCipher(cipher *sharedauth.PasswordCipher) *AuthService {
|
||||||
|
s.cipher = cipher
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) PasswordPublicKey() *sharedauth.PasswordCipherPublicKey {
|
||||||
|
if s == nil || s.cipher == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
publicKey := s.cipher.PublicKey()
|
||||||
|
return &publicKey
|
||||||
|
}
|
||||||
|
|
||||||
type LoginInput struct {
|
type LoginInput struct {
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
IP string
|
EncryptedPassword string
|
||||||
UserAgent string
|
PasswordKeyID string
|
||||||
RequestID string
|
IP string
|
||||||
|
UserAgent string
|
||||||
|
RequestID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginResult struct {
|
type LoginResult struct {
|
||||||
@@ -176,6 +192,10 @@ func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, e
|
|||||||
if in.Username == "" {
|
if in.Username == "" {
|
||||||
return nil, response.ErrBadRequest(40000, "invalid_payload", "username is required")
|
return nil, response.ErrBadRequest(40000, "invalid_payload", "username is required")
|
||||||
}
|
}
|
||||||
|
password, err := s.loginPassword(in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
permit, err := s.acquireLoginPermit(ctx, in)
|
permit, err := s.acquireLoginPermit(ctx, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -183,6 +203,9 @@ func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, e
|
|||||||
}
|
}
|
||||||
credentialsVerified := false
|
credentialsVerified := false
|
||||||
defer func() {
|
defer func() {
|
||||||
|
if permit == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
if credentialsVerified {
|
if credentialsVerified {
|
||||||
permit.RecordSuccess(ctx)
|
permit.RecordSuccess(ctx)
|
||||||
return
|
return
|
||||||
@@ -203,7 +226,7 @@ func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, e
|
|||||||
return nil, response.ErrForbidden(40310, "account_disabled", "账号已停用")
|
return nil, response.ErrForbidden(40310, "account_disabled", "账号已停用")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(in.Password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(password)); err != nil {
|
||||||
permit.RecordFailure(ctx)
|
permit.RecordFailure(ctx)
|
||||||
s.recordLoginFailure(ctx, in, "wrong_password")
|
s.recordLoginFailure(ctx, in, "wrong_password")
|
||||||
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "账号或密码错误")
|
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "账号或密码错误")
|
||||||
@@ -237,6 +260,26 @@ func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, e
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) loginPassword(in LoginInput) (string, error) {
|
||||||
|
if strings.TrimSpace(in.EncryptedPassword) != "" {
|
||||||
|
if s.cipher == nil {
|
||||||
|
return "", response.ErrBadRequest(40002, "password_cipher_unavailable", "encrypted password is not supported by this server")
|
||||||
|
}
|
||||||
|
password, err := s.cipher.Decrypt(in.EncryptedPassword, in.PasswordKeyID)
|
||||||
|
if err != nil {
|
||||||
|
return "", response.ErrBadRequest(40003, "invalid_encrypted_password", "encrypted password is invalid")
|
||||||
|
}
|
||||||
|
if password == "" {
|
||||||
|
return "", response.ErrBadRequest(40000, "invalid_payload", "password is required")
|
||||||
|
}
|
||||||
|
return password, nil
|
||||||
|
}
|
||||||
|
if in.Password == "" {
|
||||||
|
return "", response.ErrBadRequest(40000, "invalid_payload", "password is required")
|
||||||
|
}
|
||||||
|
return in.Password, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AuthService) acquireLoginPermit(ctx context.Context, in LoginInput) (*sharedauth.LoginPermit, error) {
|
func (s *AuthService) acquireLoginPermit(ctx context.Context, in LoginInput) (*sharedauth.LoginPermit, error) {
|
||||||
if s.guard == nil {
|
if s.guard == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpsAuthLoginPasswordDecryptsEncryptedPassword(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
privatePEM := string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "RSA PRIVATE KEY",
|
||||||
|
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||||
|
}))
|
||||||
|
cipher, err := sharedauth.NewPasswordCipher(privatePEM, "ops-test-key")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
encrypted, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &key.PublicKey, []byte("Ops@1234"), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
svc := (&AuthService{}).WithPasswordCipher(cipher)
|
||||||
|
got, err := svc.loginPassword(LoginInput{
|
||||||
|
EncryptedPassword: base64.StdEncoding.EncodeToString(encrypted),
|
||||||
|
PasswordKeyID: "ops-test-key",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Ops@1234", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpsAuthLoginPasswordAllowsPlainPasswordCompatibility(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := (&AuthService{}).loginPassword(LoginInput{Password: "Ops@1234"})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Ops@1234", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpsAuthLoginPasswordRejectsEncryptedWhenCipherMissing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := (&AuthService{}).loginPassword(LoginInput{EncryptedPassword: "abc"})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*response.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "password_cipher_unavailable", appErr.Message)
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ type Config struct {
|
|||||||
RabbitMQ sharedconfig.RabbitMQConfig `mapstructure:"rabbitmq"`
|
RabbitMQ sharedconfig.RabbitMQConfig `mapstructure:"rabbitmq"`
|
||||||
Log sharedconfig.LogConfig `mapstructure:"log"`
|
Log sharedconfig.LogConfig `mapstructure:"log"`
|
||||||
JWT JWTConfig `mapstructure:"jwt"`
|
JWT JWTConfig `mapstructure:"jwt"`
|
||||||
|
Auth sharedconfig.AuthConfig `mapstructure:"auth"`
|
||||||
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
|
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
|
||||||
AdminUsers AdminUsersConfig `mapstructure:"admin_users"`
|
AdminUsers AdminUsersConfig `mapstructure:"admin_users"`
|
||||||
IPRegion IPRegionConfig `mapstructure:"ip_region"`
|
IPRegion IPRegionConfig `mapstructure:"ip_region"`
|
||||||
@@ -245,6 +246,9 @@ func Diff(previous, current *Config) []FieldChange {
|
|||||||
if previous.JWT != current.JWT {
|
if previous.JWT != current.JWT {
|
||||||
add("jwt", true)
|
add("jwt", true)
|
||||||
}
|
}
|
||||||
|
if previous.Auth != current.Auth {
|
||||||
|
add("auth", false)
|
||||||
|
}
|
||||||
if previous.AdminUsers != current.AdminUsers {
|
if previous.AdminUsers != current.AdminUsers {
|
||||||
add("admin_users", true)
|
add("admin_users", true)
|
||||||
}
|
}
|
||||||
@@ -310,6 +314,11 @@ func load(path string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applySharedDefaults(settings map[string]any) {
|
func applySharedDefaults(settings map[string]any) {
|
||||||
|
serverSettings := ensureMapSetting(settings, "server")
|
||||||
|
securityHeadersSettings := ensureMapSetting(serverSettings, "security_headers")
|
||||||
|
if _, ok := securityHeadersSettings["enabled"]; !ok {
|
||||||
|
securityHeadersSettings["enabled"] = true
|
||||||
|
}
|
||||||
cacheSettings := ensureMapSetting(settings, "cache")
|
cacheSettings := ensureMapSetting(settings, "cache")
|
||||||
if _, ok := cacheSettings["metrics_enabled"]; !ok {
|
if _, ok := cacheSettings["metrics_enabled"]; !ok {
|
||||||
cacheSettings["metrics_enabled"] = true
|
cacheSettings["metrics_enabled"] = true
|
||||||
@@ -429,12 +438,46 @@ func applyEnvOverrides(cfg *Config) error {
|
|||||||
if v := os.Getenv("OPS_SERVER_TRUSTED_PROXIES"); v != "" {
|
if v := os.Getenv("OPS_SERVER_TRUSTED_PROXIES"); v != "" {
|
||||||
cfg.Server.TrustedProxies = strings.Split(v, ",")
|
cfg.Server.TrustedProxies = strings.Split(v, ",")
|
||||||
}
|
}
|
||||||
|
if v := os.Getenv("OPS_SERVER_ALLOWED_ORIGINS"); v != "" {
|
||||||
|
cfg.Server.AllowedOrigins = strings.Split(v, ",")
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OPS_SERVER_SECURITY_HEADERS_ENABLED"); v != "" {
|
||||||
|
enabled, err := parseBoolEnv("OPS_SERVER_SECURITY_HEADERS_ENABLED", v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.Server.SecurityHeaders.Enabled = enabled
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OPS_AUTH_PASSWORD_CIPHER_ENABLED"); v != "" {
|
||||||
|
enabled, err := parseBoolEnv("OPS_AUTH_PASSWORD_CIPHER_ENABLED", v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.Auth.PasswordCipher.Enabled = enabled
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OPS_AUTH_PASSWORD_CIPHER_KEY_ID"); v != "" {
|
||||||
|
cfg.Auth.PasswordCipher.KeyID = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OPS_AUTH_PASSWORD_CIPHER_PRIVATE_KEY_PEM"); v != "" {
|
||||||
|
cfg.Auth.PasswordCipher.PrivateKeyPEM = v
|
||||||
|
}
|
||||||
if v := os.Getenv("OPS_RABBITMQ_URL"); v != "" {
|
if v := os.Getenv("OPS_RABBITMQ_URL"); v != "" {
|
||||||
cfg.RabbitMQ.URL = v
|
cfg.RabbitMQ.URL = v
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseBoolEnv(name, value string) (bool, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "1", "true", "yes", "y", "on":
|
||||||
|
return true, nil
|
||||||
|
case "0", "false", "no", "n", "off":
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("%s must be a boolean", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeOpsRabbitMQConfig(cfg *sharedconfig.RabbitMQConfig) {
|
func normalizeOpsRabbitMQConfig(cfg *sharedconfig.RabbitMQConfig) {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
Username string `json:"username" binding:"required"`
|
Username string `json:"username" binding:"required"`
|
||||||
Password string `json:"password" binding:"required"`
|
Password string `json:"password"`
|
||||||
|
EncryptedPassword string `json:"encrypted_password"`
|
||||||
|
PasswordKeyID string `json:"password_key_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type loginResponse struct {
|
type loginResponse struct {
|
||||||
@@ -28,11 +30,13 @@ func loginHandler(svc *app.AuthService) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := svc.Login(c.Request.Context(), app.LoginInput{
|
result, err := svc.Login(c.Request.Context(), app.LoginInput{
|
||||||
Username: body.Username,
|
Username: body.Username,
|
||||||
Password: body.Password,
|
Password: body.Password,
|
||||||
IP: c.ClientIP(),
|
EncryptedPassword: body.EncryptedPassword,
|
||||||
UserAgent: c.Request.UserAgent(),
|
PasswordKeyID: body.PasswordKeyID,
|
||||||
RequestID: middleware.RequestIDFromGin(c),
|
IP: c.ClientIP(),
|
||||||
|
UserAgent: c.Request.UserAgent(),
|
||||||
|
RequestID: middleware.RequestIDFromGin(c),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
@@ -47,6 +51,17 @@ func loginHandler(svc *app.AuthService) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func passwordPublicKeyHandler(svc *app.AuthService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
key := svc.PasswordPublicKey()
|
||||||
|
if key == nil {
|
||||||
|
response.Error(c, response.ErrNotFound(40420, "password_cipher_disabled", "password cipher is disabled"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func meHandler(accounts *app.AccountService) gin.HandlerFunc {
|
func meHandler(accounts *app.AccountService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
actor := actorFromGin(c)
|
actor := actorFromGin(c)
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/geo-platform/tenant-api/internal/ops/app"
|
"github.com/geo-platform/tenant-api/internal/ops/app"
|
||||||
|
sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
Engine *gin.Engine
|
Engine *gin.Engine
|
||||||
|
Server sharedconfig.ServerConfig
|
||||||
Logger *zap.Logger
|
Logger *zap.Logger
|
||||||
DB *pgxpool.Pool
|
DB *pgxpool.Pool
|
||||||
MonitoringDB *pgxpool.Pool
|
MonitoringDB *pgxpool.Pool
|
||||||
@@ -26,13 +28,27 @@ type Deps struct {
|
|||||||
Compliance *app.ComplianceService
|
Compliance *app.ComplianceService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d Deps) ServerAllowedOrigins() []string {
|
||||||
|
return d.Server.AllowedOrigins
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Deps) ServerSecurityHeadersEnabled() bool {
|
||||||
|
return d.Server.SecurityHeaders.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
func RegisterRoutes(d Deps) {
|
func RegisterRoutes(d Deps) {
|
||||||
d.Engine.Use(
|
d.Engine.Use(
|
||||||
middleware.Recovery(d.Logger),
|
middleware.Recovery(d.Logger),
|
||||||
middleware.RequestID(),
|
middleware.RequestID(),
|
||||||
middleware.Logger(d.Logger),
|
middleware.Logger(d.Logger),
|
||||||
middleware.CORS(),
|
|
||||||
)
|
)
|
||||||
|
if d.ServerSecurityHeadersEnabled() {
|
||||||
|
d.Engine.Use(middleware.SecurityHeaders())
|
||||||
|
}
|
||||||
|
d.Engine.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||||
|
AllowedOrigins: d.ServerAllowedOrigins(),
|
||||||
|
AllowAll: len(d.ServerAllowedOrigins()) == 0,
|
||||||
|
}))
|
||||||
|
|
||||||
d.Engine.GET("/api/health/live", func(c *gin.Context) {
|
d.Engine.GET("/api/health/live", func(c *gin.Context) {
|
||||||
response.Success(c, gin.H{"status": "alive"})
|
response.Success(c, gin.H{"status": "alive"})
|
||||||
@@ -53,6 +69,7 @@ func RegisterRoutes(d Deps) {
|
|||||||
|
|
||||||
api := d.Engine.Group("/api/ops")
|
api := d.Engine.Group("/api/ops")
|
||||||
{
|
{
|
||||||
|
api.GET("/auth/password-key", passwordPublicKeyHandler(d.Auth))
|
||||||
api.POST("/auth/login", loginHandler(d.Auth))
|
api.POST("/auth/login", loginHandler(d.Auth))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultPasswordCipherKeyID = "login-password-rsa-oaep-v1"
|
||||||
|
|
||||||
|
type PasswordCipher struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
privateKey *rsa.PrivateKey
|
||||||
|
keyID string
|
||||||
|
publicPEM string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PasswordCipherPublicKey struct {
|
||||||
|
KeyID string `json:"key_id"`
|
||||||
|
Algorithm string `json:"algorithm"`
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPasswordCipher(privateKeyPEM, keyID string) (*PasswordCipher, error) {
|
||||||
|
privateKey, err := parseRSAPrivateKeyPEM(privateKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return newPasswordCipherFromKey(privateKey, keyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEphemeralPasswordCipher(keyID string) (*PasswordCipher, error) {
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate ephemeral rsa private key: %w", err)
|
||||||
|
}
|
||||||
|
return newPasswordCipherFromKey(privateKey, keyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPasswordCipherFromKey(privateKey *rsa.PrivateKey, keyID string) (*PasswordCipher, error) {
|
||||||
|
if strings.TrimSpace(keyID) == "" {
|
||||||
|
keyID = DefaultPasswordCipherKeyID
|
||||||
|
}
|
||||||
|
publicDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal rsa public key: %w", err)
|
||||||
|
}
|
||||||
|
publicPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER}))
|
||||||
|
return &PasswordCipher{
|
||||||
|
privateKey: privateKey,
|
||||||
|
keyID: keyID,
|
||||||
|
publicPEM: publicPEM,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PasswordCipher) PublicKey() PasswordCipherPublicKey {
|
||||||
|
if c == nil {
|
||||||
|
return PasswordCipherPublicKey{}
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return PasswordCipherPublicKey{
|
||||||
|
KeyID: c.keyID,
|
||||||
|
Algorithm: "RSA-OAEP-256",
|
||||||
|
PublicKey: c.publicPEM,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PasswordCipher) Decrypt(ciphertextB64, keyID string) (string, error) {
|
||||||
|
if c == nil {
|
||||||
|
return "", fmt.Errorf("password cipher is not configured")
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
privateKey := c.privateKey
|
||||||
|
expectedKeyID := c.keyID
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
if strings.TrimSpace(keyID) != "" && strings.TrimSpace(keyID) != expectedKeyID {
|
||||||
|
return "", fmt.Errorf("password cipher key id mismatch")
|
||||||
|
}
|
||||||
|
ciphertext, err := base64.StdEncoding.DecodeString(strings.TrimSpace(ciphertextB64))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("decode encrypted password: %w", err)
|
||||||
|
}
|
||||||
|
plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("decrypt encrypted password: %w", err)
|
||||||
|
}
|
||||||
|
return string(plaintext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRSAPrivateKeyPEM(value string) (*rsa.PrivateKey, error) {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, fmt.Errorf("password cipher private key is required")
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode([]byte(trimmed))
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("password cipher private key is not PEM encoded")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch block.Type {
|
||||||
|
case "RSA PRIVATE KEY":
|
||||||
|
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse pkcs1 rsa private key: %w", err)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
case "PRIVATE KEY":
|
||||||
|
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse pkcs8 private key: %w", err)
|
||||||
|
}
|
||||||
|
rsaKey, ok := key.(*rsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("private key is not RSA")
|
||||||
|
}
|
||||||
|
return rsaKey, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported private key PEM type %q", block.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPasswordCipherDecryptsRSAOAEP256(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
privatePEM := string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "RSA PRIVATE KEY",
|
||||||
|
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||||
|
}))
|
||||||
|
|
||||||
|
cipher, err := NewPasswordCipher(privatePEM, "test-key")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
encrypted, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &key.PublicKey, []byte("Admin@123"), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := cipher.Decrypt(base64.StdEncoding.EncodeToString(encrypted), "test-key")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Admin@123", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordCipherRejectsWrongKeyID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
privatePEM := string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "RSA PRIVATE KEY",
|
||||||
|
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||||
|
}))
|
||||||
|
|
||||||
|
cipher, err := NewPasswordCipher(privatePEM, "expected")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = cipher.Decrypt("bad", "other")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "key id mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewEphemeralPasswordCipherExposesPublicKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cipher, err := NewEphemeralPasswordCipher("ephemeral")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
publicKey := cipher.PublicKey()
|
||||||
|
require.Equal(t, "ephemeral", publicKey.KeyID)
|
||||||
|
require.Equal(t, "RSA-OAEP-256", publicKey.Algorithm)
|
||||||
|
require.Contains(t, publicKey.PublicKey, "BEGIN PUBLIC KEY")
|
||||||
|
}
|
||||||
@@ -44,9 +44,15 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Port int `mapstructure:"port"`
|
Port int `mapstructure:"port"`
|
||||||
Mode string `mapstructure:"mode"`
|
Mode string `mapstructure:"mode"`
|
||||||
TrustedProxies []string `mapstructure:"trusted_proxies"`
|
TrustedProxies []string `mapstructure:"trusted_proxies"`
|
||||||
|
AllowedOrigins []string `mapstructure:"allowed_origins"`
|
||||||
|
SecurityHeaders SecurityHeaders `mapstructure:"security_headers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SecurityHeaders struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
@@ -296,13 +302,20 @@ type JWTConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
LoginGuard LoginGuardConfig `mapstructure:"login_guard"`
|
LoginGuard LoginGuardConfig `mapstructure:"login_guard"`
|
||||||
|
PasswordCipher PasswordCipherConfig `mapstructure:"password_cipher"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginGuardConfig struct {
|
type LoginGuardConfig struct {
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PasswordCipherConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
KeyID string `mapstructure:"key_id"`
|
||||||
|
PrivateKeyPEM string `mapstructure:"private_key_pem"`
|
||||||
|
}
|
||||||
|
|
||||||
type LogConfig struct {
|
type LogConfig struct {
|
||||||
Level string `mapstructure:"level"`
|
Level string `mapstructure:"level"`
|
||||||
Format string `mapstructure:"format"`
|
Format string `mapstructure:"format"`
|
||||||
@@ -498,6 +511,11 @@ func decodeResolvedConfig(configFile, localConfigFile string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyConfigDefaults(settings map[string]any) {
|
func applyConfigDefaults(settings map[string]any) {
|
||||||
|
serverSettings := ensureMapSetting(settings, "server")
|
||||||
|
securityHeadersSettings := ensureMapSetting(serverSettings, "security_headers")
|
||||||
|
if _, ok := securityHeadersSettings["enabled"]; !ok {
|
||||||
|
securityHeadersSettings["enabled"] = true
|
||||||
|
}
|
||||||
cacheSettings := ensureMapSetting(settings, "cache")
|
cacheSettings := ensureMapSetting(settings, "cache")
|
||||||
if _, ok := cacheSettings["metrics_enabled"]; !ok {
|
if _, ok := cacheSettings["metrics_enabled"]; !ok {
|
||||||
cacheSettings["metrics_enabled"] = true
|
cacheSettings["metrics_enabled"] = true
|
||||||
@@ -540,11 +558,12 @@ func NormalizeServerConfig(cfg *ServerConfig) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cfg.Mode = strings.TrimSpace(cfg.Mode)
|
cfg.Mode = strings.TrimSpace(cfg.Mode)
|
||||||
|
cfg.AllowedOrigins = compactStringSlice(cfg.AllowedOrigins)
|
||||||
if cfg.TrustedProxies == nil {
|
if cfg.TrustedProxies == nil {
|
||||||
cfg.TrustedProxies = DefaultTrustedProxies()
|
cfg.TrustedProxies = DefaultTrustedProxies()
|
||||||
return
|
} else {
|
||||||
|
cfg.TrustedProxies = compactStringSlice(cfg.TrustedProxies)
|
||||||
}
|
}
|
||||||
cfg.TrustedProxies = compactStringSlice(cfg.TrustedProxies)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func compactStringSlice(values []string) []string {
|
func compactStringSlice(values []string) []string {
|
||||||
@@ -964,6 +983,21 @@ func applyEnvOverrides(cfg *Config) {
|
|||||||
if trustedProxies, ok := lookupNonEmptyEnv("SERVER_TRUSTED_PROXIES"); ok {
|
if trustedProxies, ok := lookupNonEmptyEnv("SERVER_TRUSTED_PROXIES"); ok {
|
||||||
cfg.Server.TrustedProxies = strings.Split(trustedProxies, ",")
|
cfg.Server.TrustedProxies = strings.Split(trustedProxies, ",")
|
||||||
}
|
}
|
||||||
|
if origins, ok := lookupNonEmptyEnv("SERVER_ALLOWED_ORIGINS"); ok {
|
||||||
|
cfg.Server.AllowedOrigins = strings.Split(origins, ",")
|
||||||
|
}
|
||||||
|
if enabled, ok := lookupBoolEnv("SERVER_SECURITY_HEADERS_ENABLED"); ok {
|
||||||
|
cfg.Server.SecurityHeaders.Enabled = enabled
|
||||||
|
}
|
||||||
|
if enabled, ok := lookupBoolEnv("AUTH_PASSWORD_CIPHER_ENABLED"); ok {
|
||||||
|
cfg.Auth.PasswordCipher.Enabled = enabled
|
||||||
|
}
|
||||||
|
if keyID, ok := lookupNonEmptyEnv("AUTH_PASSWORD_CIPHER_KEY_ID"); ok {
|
||||||
|
cfg.Auth.PasswordCipher.KeyID = keyID
|
||||||
|
}
|
||||||
|
if privateKey, ok := lookupNonEmptyEnv("AUTH_PASSWORD_CIPHER_PRIVATE_KEY_PEM"); ok {
|
||||||
|
cfg.Auth.PasswordCipher.PrivateKeyPEM = privateKey
|
||||||
|
}
|
||||||
if path, ok := lookupNonEmptyEnv("IP_REGION_V4_XDB_PATH"); ok {
|
if path, ok := lookupNonEmptyEnv("IP_REGION_V4_XDB_PATH"); ok {
|
||||||
cfg.IPRegion.V4XDBPath = path
|
cfg.IPRegion.V4XDBPath = path
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,32 @@ server:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadAppliesServerSecurityDefaultsAndOriginOverrides(t *testing.T) {
|
||||||
|
t.Setenv("SERVER_ALLOWED_ORIGINS", "https://admin.example.com, https://ops.example.com")
|
||||||
|
|
||||||
|
configPath := writeTestConfig(t, `
|
||||||
|
server: {}
|
||||||
|
`)
|
||||||
|
|
||||||
|
cfg, err := Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.Server.SecurityHeaders.Enabled {
|
||||||
|
t.Fatalf("expected security headers enabled by default")
|
||||||
|
}
|
||||||
|
wantOrigins := []string{"https://admin.example.com", "https://ops.example.com"}
|
||||||
|
if len(cfg.Server.AllowedOrigins) != len(wantOrigins) {
|
||||||
|
t.Fatalf("allowed origins = %#v, want %#v", cfg.Server.AllowedOrigins, wantOrigins)
|
||||||
|
}
|
||||||
|
for i := range wantOrigins {
|
||||||
|
if cfg.Server.AllowedOrigins[i] != wantOrigins[i] {
|
||||||
|
t.Fatalf("allowed origins = %#v, want %#v", cfg.Server.AllowedOrigins, wantOrigins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadAppliesSchedulerHTTPDefaultsAndDisableSentinel(t *testing.T) {
|
func TestLoadAppliesSchedulerHTTPDefaultsAndDisableSentinel(t *testing.T) {
|
||||||
defaultConfigPath := writeTestConfig(t, `
|
defaultConfigPath := writeTestConfig(t, `
|
||||||
scheduler: {}
|
scheduler: {}
|
||||||
@@ -358,6 +384,34 @@ jwt:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadPrefersEnvPasswordCipherSettings(t *testing.T) {
|
||||||
|
t.Setenv("AUTH_PASSWORD_CIPHER_ENABLED", "true")
|
||||||
|
t.Setenv("AUTH_PASSWORD_CIPHER_KEY_ID", "env-key")
|
||||||
|
t.Setenv("AUTH_PASSWORD_CIPHER_PRIVATE_KEY_PEM", "pem-value")
|
||||||
|
|
||||||
|
configPath := writeTestConfig(t, `
|
||||||
|
auth:
|
||||||
|
password_cipher:
|
||||||
|
enabled: false
|
||||||
|
key_id: config-key
|
||||||
|
private_key_pem: config-pem
|
||||||
|
`)
|
||||||
|
|
||||||
|
cfg, err := Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if !cfg.Auth.PasswordCipher.Enabled {
|
||||||
|
t.Fatalf("expected env password cipher enabled")
|
||||||
|
}
|
||||||
|
if cfg.Auth.PasswordCipher.KeyID != "env-key" {
|
||||||
|
t.Fatalf("expected env key id, got %q", cfg.Auth.PasswordCipher.KeyID)
|
||||||
|
}
|
||||||
|
if cfg.Auth.PasswordCipher.PrivateKeyPEM != "pem-value" {
|
||||||
|
t.Fatalf("expected env private key pem, got %q", cfg.Auth.PasswordCipher.PrivateKeyPEM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadAppliesMembershipDefaults(t *testing.T) {
|
func TestLoadAppliesMembershipDefaults(t *testing.T) {
|
||||||
configPath := writeTestConfig(t, `
|
configPath := writeTestConfig(t, `
|
||||||
membership: {}
|
membership: {}
|
||||||
|
|||||||
@@ -5,17 +5,5 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func CORS() gin.HandlerFunc {
|
func CORS() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return CORSWithConfig(CORSConfig{AllowAll: true})
|
||||||
c.Header("Access-Control-Allow-Origin", "*")
|
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
||||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-Request-ID")
|
|
||||||
c.Header("Access-Control-Expose-Headers", "X-Request-ID")
|
|
||||||
c.Header("Access-Control-Max-Age", "86400")
|
|
||||||
|
|
||||||
if c.Request.Method == "OPTIONS" {
|
|
||||||
c.AbortWithStatus(204)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -27,7 +30,7 @@ func Logger(logger *zap.Logger) gin.HandlerFunc {
|
|||||||
fields = append(fields, zap.String("route", route))
|
fields = append(fields, zap.String("route", route))
|
||||||
}
|
}
|
||||||
if rawQuery := c.Request.URL.RawQuery; rawQuery != "" {
|
if rawQuery := c.Request.URL.RawQuery; rawQuery != "" {
|
||||||
fields = append(fields, zap.String("query", rawQuery))
|
fields = append(fields, zap.String("query", redactRawQuery(rawQuery)))
|
||||||
}
|
}
|
||||||
if userAgent := c.Request.UserAgent(); userAgent != "" {
|
if userAgent := c.Request.UserAgent(); userAgent != "" {
|
||||||
fields = append(fields, zap.String("user_agent", userAgent))
|
fields = append(fields, zap.String("user_agent", userAgent))
|
||||||
@@ -86,3 +89,29 @@ func Logger(logger *zap.Logger) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sensitiveQueryKeyPattern = regexp.MustCompile(`(?i)(password|passwd|pwd|token|secret|authorization|credential|session|cookie|key|signature|sig)`)
|
||||||
|
|
||||||
|
func redactRawQuery(raw string) string {
|
||||||
|
values, err := url.ParseQuery(raw)
|
||||||
|
if err != nil {
|
||||||
|
return redactQueryFallback(raw)
|
||||||
|
}
|
||||||
|
for key := range values {
|
||||||
|
if sensitiveQueryKeyPattern.MatchString(key) {
|
||||||
|
values[key] = []string{"[REDACTED]"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactQueryFallback(raw string) string {
|
||||||
|
parts := strings.Split(raw, "&")
|
||||||
|
for i, part := range parts {
|
||||||
|
key, _, ok := strings.Cut(part, "=")
|
||||||
|
if ok && sensitiveQueryKeyPattern.MatchString(key) {
|
||||||
|
parts[i] = key + "=[REDACTED]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "&")
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,3 +105,36 @@ func TestLoggerUsesForwardedClientIPFromTrustedProxy(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusOK, resp.Code)
|
require.Equal(t, http.StatusOK, resp.Code)
|
||||||
require.Contains(t, logBuffer.String(), `"client_ip":"106.14.89.27"`)
|
require.Contains(t, logBuffer.String(), `"client_ip":"106.14.89.27"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoggerRedactsSensitiveQueryValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
var logBuffer bytes.Buffer
|
||||||
|
encoderConfig := zap.NewProductionEncoderConfig()
|
||||||
|
encoderConfig.TimeKey = ""
|
||||||
|
logger := zap.New(zapcore.NewCore(
|
||||||
|
zapcore.NewJSONEncoder(encoderConfig),
|
||||||
|
zapcore.AddSync(&logBuffer),
|
||||||
|
zapcore.DebugLevel,
|
||||||
|
))
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(RequestID())
|
||||||
|
router.Use(Logger(logger))
|
||||||
|
router.GET("/test", func(c *gin.Context) {
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test?password=secret&tab=cards&token=abc", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
logLine := logBuffer.String()
|
||||||
|
require.Contains(t, logLine, `"query":"password=%5BREDACTED%5D&tab=cards&token=%5BREDACTED%5D"`)
|
||||||
|
require.NotContains(t, logLine, "secret")
|
||||||
|
require.NotContains(t, logLine, "token=abc")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SecurityHeaders() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
h := c.Writer.Header()
|
||||||
|
h.Set("X-Content-Type-Options", "nosniff")
|
||||||
|
h.Set("X-Frame-Options", "DENY")
|
||||||
|
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
h.Set("X-Permitted-Cross-Domain-Policies", "none")
|
||||||
|
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||||
|
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
|
||||||
|
if isHTTPSRequest(c) {
|
||||||
|
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||||
|
}
|
||||||
|
if isSensitivePath(c.Request.URL.Path) {
|
||||||
|
h.Set("Cache-Control", "no-store")
|
||||||
|
h.Set("Pragma", "no-cache")
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHTTPSRequest(c *gin.Context) bool {
|
||||||
|
if c == nil || c.Request == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c.Request.TLS != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSensitivePath(path string) bool {
|
||||||
|
switch strings.TrimSpace(path) {
|
||||||
|
case "/api/auth/login",
|
||||||
|
"/api/auth/refresh",
|
||||||
|
"/api/auth/me",
|
||||||
|
"/api/auth/logout",
|
||||||
|
"/api/auth/password-key",
|
||||||
|
"/api/ops/auth/login",
|
||||||
|
"/api/ops/auth/me",
|
||||||
|
"/api/ops/auth/password",
|
||||||
|
"/api/ops/auth/password-key":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CORSConfig struct {
|
||||||
|
AllowedOrigins []string
|
||||||
|
AllowAll bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func CORSWithConfig(cfg CORSConfig) gin.HandlerFunc {
|
||||||
|
allowedOrigins := compactOrigins(cfg.AllowedOrigins)
|
||||||
|
allowedSet := make(map[string]struct{}, len(allowedOrigins))
|
||||||
|
for _, origin := range allowedOrigins {
|
||||||
|
allowedSet[origin] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
origin := strings.TrimSpace(c.GetHeader("Origin"))
|
||||||
|
allowedOrigin := ""
|
||||||
|
switch {
|
||||||
|
case cfg.AllowAll:
|
||||||
|
allowedOrigin = "*"
|
||||||
|
case origin != "":
|
||||||
|
if _, ok := allowedSet[origin]; ok {
|
||||||
|
allowedOrigin = origin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if allowedOrigin != "" {
|
||||||
|
c.Header("Access-Control-Allow-Origin", allowedOrigin)
|
||||||
|
if allowedOrigin != "*" {
|
||||||
|
c.Header("Vary", "Origin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-Request-ID, X-Internal-Metrics-Token")
|
||||||
|
c.Header("Access-Control-Expose-Headers", "X-Request-ID")
|
||||||
|
c.Header("Access-Control-Max-Age", "86400")
|
||||||
|
|
||||||
|
if c.Request.Method == http.MethodOptions {
|
||||||
|
if origin != "" && allowedOrigin == "" {
|
||||||
|
c.AbortWithStatus(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compactOrigins(values []string) []string {
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[value]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSecurityHeadersAddsNoStoreForSensitiveAuthPaths(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(SecurityHeaders())
|
||||||
|
router.POST("/api/auth/login", func(c *gin.Context) {
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
|
||||||
|
req.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
require.Equal(t, "nosniff", resp.Header().Get("X-Content-Type-Options"))
|
||||||
|
require.Equal(t, "DENY", resp.Header().Get("X-Frame-Options"))
|
||||||
|
require.Equal(t, "no-store", resp.Header().Get("Cache-Control"))
|
||||||
|
require.Equal(t, "no-cache", resp.Header().Get("Pragma"))
|
||||||
|
require.Equal(t, "max-age=31536000; includeSubDomains", resp.Header().Get("Strict-Transport-Security"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORSWithConfigRejectsUnknownPreflightOrigin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(CORSWithConfig(CORSConfig{AllowedOrigins: []string{"https://admin.example.com"}}))
|
||||||
|
router.POST("/api/auth/login", func(c *gin.Context) {
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
|
||||||
|
req.Header.Set("Origin", "https://evil.example.com")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusForbidden, resp.Code)
|
||||||
|
require.Empty(t, resp.Header().Get("Access-Control-Allow-Origin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORSWithConfigAllowsConfiguredOrigin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(CORSWithConfig(CORSConfig{AllowedOrigins: []string{"https://admin.example.com"}}))
|
||||||
|
router.POST("/api/auth/login", func(c *gin.Context) {
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
|
||||||
|
req.Header.Set("Origin", "https://admin.example.com")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusNoContent, resp.Code)
|
||||||
|
require.Equal(t, "https://admin.example.com", resp.Header().Get("Access-Control-Allow-Origin"))
|
||||||
|
require.Equal(t, "Origin", resp.Header().Get("Vary"))
|
||||||
|
}
|
||||||
@@ -19,10 +19,11 @@ var routeDocs = map[string]routeDoc{
|
|||||||
"GET /api/health/ready": {"就绪探针", "Kubernetes readiness 探针,依赖(数据库等)就绪后返回 200。"},
|
"GET /api/health/ready": {"就绪探针", "Kubernetes readiness 探针,依赖(数据库等)就绪后返回 200。"},
|
||||||
|
|
||||||
// --- Auth ---
|
// --- Auth ---
|
||||||
"POST /api/auth/login": {"账号密码登录", "客户后台账号密码登录,返回 access/refresh token。"},
|
"GET /api/auth/password-key": {"登录密码加密公钥", "返回登录密码前端加密使用的临时公钥与 key_id。"},
|
||||||
"POST /api/auth/refresh": {"刷新访问令牌", "用 refresh token 换取新的 access token。"},
|
"POST /api/auth/login": {"账号密码登录", "客户后台账号密码登录,返回 access/refresh token。"},
|
||||||
"GET /api/auth/me": {"当前登录用户", "返回当前 JWT 对应的用户、租户、Workspace 等会话信息。"},
|
"POST /api/auth/refresh": {"刷新访问令牌", "用 refresh token 换取新的 access token。"},
|
||||||
"POST /api/auth/logout": {"退出登录", "吊销当前会话,使其 access/refresh token 立即失效。"},
|
"GET /api/auth/me": {"当前登录用户", "返回当前 JWT 对应的用户、租户、Workspace 等会话信息。"},
|
||||||
|
"POST /api/auth/logout": {"退出登录", "吊销当前会话,使其 access/refresh token 立即失效。"},
|
||||||
|
|
||||||
// --- Public 资源 ---
|
// --- Public 资源 ---
|
||||||
"GET /api/public/assets/:token": {"对外访问签名资源", "通过签名 token 直接访问对象存储中的图片/附件,无需登录。"},
|
"GET /api/public/assets/:token": {"对外访问签名资源", "通过签名 token 直接访问对象存储中的图片/附件,无需登录。"},
|
||||||
|
|||||||
@@ -498,7 +498,8 @@ func errorResponse(description string) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func securityForPath(path string) []map[string][]string {
|
func securityForPath(path string) []map[string][]string {
|
||||||
if path == "/api/auth/login" || path == "/api/auth/refresh" ||
|
if path == "/api/auth/password-key" ||
|
||||||
|
path == "/api/auth/login" || path == "/api/auth/refresh" ||
|
||||||
strings.HasPrefix(path, "/api/health/") ||
|
strings.HasPrefix(path, "/api/health/") ||
|
||||||
strings.HasPrefix(path, "/api/public/") {
|
strings.HasPrefix(path, "/api/public/") {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type AuthService struct {
|
|||||||
jwt *auth.Manager
|
jwt *auth.Manager
|
||||||
sessions *auth.SessionStore
|
sessions *auth.SessionStore
|
||||||
loginGuard *auth.LoginGuard
|
loginGuard *auth.LoginGuard
|
||||||
|
cipher *auth.PasswordCipher
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthService(
|
func NewAuthService(
|
||||||
@@ -45,11 +46,18 @@ func NewAuthService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) WithPasswordCipher(cipher *auth.PasswordCipher) *AuthService {
|
||||||
|
s.cipher = cipher
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
type LoginRequest struct {
|
type LoginRequest struct {
|
||||||
Identifier string `json:"identifier"`
|
Identifier string `json:"identifier"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password" binding:"required"`
|
Password string `json:"password"`
|
||||||
IP string `json:"-"`
|
EncryptedPassword string `json:"encrypted_password"`
|
||||||
|
PasswordKeyID string `json:"password_key_id"`
|
||||||
|
IP string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginResponse struct {
|
type LoginResponse struct {
|
||||||
@@ -107,6 +115,10 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
|
|||||||
if identifier == "" {
|
if identifier == "" {
|
||||||
return nil, response.ErrBadRequest(40001, "invalid_params", "login identifier is required")
|
return nil, response.ErrBadRequest(40001, "invalid_params", "login identifier is required")
|
||||||
}
|
}
|
||||||
|
password, err := s.loginPassword(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
permit, err := s.acquireLoginPermit(ctx, identifier, req.IP)
|
permit, err := s.acquireLoginPermit(ctx, identifier, req.IP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -114,6 +126,9 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
|
|||||||
}
|
}
|
||||||
credentialsVerified := false
|
credentialsVerified := false
|
||||||
defer func() {
|
defer func() {
|
||||||
|
if permit == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
if credentialsVerified {
|
if credentialsVerified {
|
||||||
permit.RecordSuccess(ctx)
|
permit.RecordSuccess(ctx)
|
||||||
return
|
return
|
||||||
@@ -127,7 +142,7 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
|
|||||||
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
|
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||||
permit.RecordFailure(ctx)
|
permit.RecordFailure(ctx)
|
||||||
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
|
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
|
||||||
}
|
}
|
||||||
@@ -178,6 +193,34 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) PasswordPublicKey() *auth.PasswordCipherPublicKey {
|
||||||
|
if s == nil || s.cipher == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
publicKey := s.cipher.PublicKey()
|
||||||
|
return &publicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) loginPassword(req LoginRequest) (string, error) {
|
||||||
|
if strings.TrimSpace(req.EncryptedPassword) != "" {
|
||||||
|
if s.cipher == nil {
|
||||||
|
return "", response.ErrBadRequest(40002, "password_cipher_unavailable", "encrypted password is not supported by this server")
|
||||||
|
}
|
||||||
|
password, err := s.cipher.Decrypt(req.EncryptedPassword, req.PasswordKeyID)
|
||||||
|
if err != nil {
|
||||||
|
return "", response.ErrBadRequest(40003, "invalid_encrypted_password", "encrypted password is invalid")
|
||||||
|
}
|
||||||
|
if password == "" {
|
||||||
|
return "", response.ErrBadRequest(40001, "invalid_params", "password is required")
|
||||||
|
}
|
||||||
|
return password, nil
|
||||||
|
}
|
||||||
|
if req.Password == "" {
|
||||||
|
return "", response.ErrBadRequest(40001, "invalid_params", "password is required")
|
||||||
|
}
|
||||||
|
return req.Password, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AuthService) acquireLoginPermit(ctx context.Context, identifier, ip string) (*auth.LoginPermit, error) {
|
func (s *AuthService) acquireLoginPermit(ctx context.Context, identifier, ip string) (*auth.LoginPermit, error) {
|
||||||
if s.loginGuard == nil {
|
if s.loginGuard == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthServiceLoginPasswordDecryptsEncryptedPassword(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
privatePEM := string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "RSA PRIVATE KEY",
|
||||||
|
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||||
|
}))
|
||||||
|
cipher, err := auth.NewPasswordCipher(privatePEM, "tenant-test-key")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
encrypted, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &key.PublicKey, []byte("Test@123"), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
svc := (&AuthService{}).WithPasswordCipher(cipher)
|
||||||
|
got, err := svc.loginPassword(LoginRequest{
|
||||||
|
EncryptedPassword: base64.StdEncoding.EncodeToString(encrypted),
|
||||||
|
PasswordKeyID: "tenant-test-key",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Test@123", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthServiceLoginPasswordAllowsPlainPasswordCompatibility(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got, err := (&AuthService{}).loginPassword(LoginRequest{Password: "Test@123"})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Test@123", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthServiceLoginPasswordRejectsEncryptedWhenCipherMissing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := (&AuthService{}).loginPassword(LoginRequest{EncryptedPassword: "abc"})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*response.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "password_cipher_unavailable", appErr.Message)
|
||||||
|
}
|
||||||
@@ -25,10 +25,19 @@ func NewAuthHandler(a *bootstrap.App) *AuthHandler {
|
|||||||
a.JWT,
|
a.JWT,
|
||||||
a.Sessions,
|
a.Sessions,
|
||||||
a.LoginGuard,
|
a.LoginGuard,
|
||||||
),
|
).WithPasswordCipher(a.PasswordCipher),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) PasswordPublicKey(c *gin.Context) {
|
||||||
|
key := h.svc.PasswordPublicKey()
|
||||||
|
if key == nil {
|
||||||
|
response.Error(c, response.ErrNotFound(40420, "password_cipher_disabled", "password cipher is disabled"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, key)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) Login(c *gin.Context) {
|
func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
var req app.LoginRequest
|
var req app.LoginRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
|||||||
|
|
||||||
authAPI := app.Engine.Group("/api/auth")
|
authAPI := app.Engine.Group("/api/auth")
|
||||||
authHandler := NewAuthHandler(app)
|
authHandler := NewAuthHandler(app)
|
||||||
|
authAPI.GET("/password-key", authHandler.PasswordPublicKey)
|
||||||
authAPI.POST("/login", authHandler.Login)
|
authAPI.POST("/login", authHandler.Login)
|
||||||
authAPI.POST("/refresh", authHandler.Refresh)
|
authAPI.POST("/refresh", authHandler.Refresh)
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ func TestPublicRoutes_NoAuth(t *testing.T) {
|
|||||||
method string
|
method string
|
||||||
path string
|
path string
|
||||||
}{
|
}{
|
||||||
|
{http.MethodGet, "/api/auth/password-key"},
|
||||||
{http.MethodPost, "/api/auth/login"},
|
{http.MethodPost, "/api/auth/login"},
|
||||||
{http.MethodPost, "/api/auth/refresh"},
|
{http.MethodPost, "/api/auth/refresh"},
|
||||||
{http.MethodGet, "/api/health/live"},
|
{http.MethodGet, "/api/health/live"},
|
||||||
|
|||||||
Reference in New Issue
Block a user