fix: harden login for MLPS requirements
This commit is contained in:
@@ -99,7 +99,7 @@ const enUS = {
|
||||
email: 'Email',
|
||||
loginIdentifier: 'Phone / Email',
|
||||
password: 'Password',
|
||||
rememberPassword: 'Remember password',
|
||||
rememberAccount: 'Remember account',
|
||||
forgotPassword: 'Forgot password',
|
||||
},
|
||||
membershipBlocked: {
|
||||
|
||||
@@ -101,7 +101,7 @@ const zhCN = {
|
||||
email: "邮箱",
|
||||
loginIdentifier: "手机号 / 邮箱",
|
||||
password: "密码",
|
||||
rememberPassword: "记住密码",
|
||||
rememberAccount: "记住账号",
|
||||
forgotPassword: "忘记密码",
|
||||
},
|
||||
membershipBlocked: {
|
||||
|
||||
@@ -80,6 +80,7 @@ import type {
|
||||
MonitoringDashboardCompositeResponse,
|
||||
MonitoringQuestionDetailResponse,
|
||||
PlatformAccount,
|
||||
PasswordPublicKeyResponse,
|
||||
PromptRule,
|
||||
PromptRuleGroup,
|
||||
PromptRuleGroupRequest,
|
||||
@@ -336,6 +337,9 @@ apiClient.raw.interceptors.response.use(
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
passwordPublicKey() {
|
||||
return publicClient.get<PasswordPublicKeyResponse>('/api/auth/password-key')
|
||||
},
|
||||
login(payload: LoginRequest) {
|
||||
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)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { ApiClientError } from '@geo/http-client'
|
||||
import type { LoginRequest, UserInfo } from '@geo/shared-types'
|
||||
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
refreshStoredAuthSession,
|
||||
shouldRefreshAccessToken,
|
||||
} from '@/lib/api'
|
||||
import { browserSupportsPasswordCipher, encryptPasswordForLogin } from '@/lib/password-cipher'
|
||||
import {
|
||||
clearStoredSession,
|
||||
readStoredSession,
|
||||
@@ -147,7 +149,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
return loginPromise
|
||||
}
|
||||
|
||||
const loginPayload = { ...payload }
|
||||
const loginPayload = await buildSecureLoginPayload(payload)
|
||||
loginPromise = authApi
|
||||
.login(loginPayload)
|
||||
.then((response) => {
|
||||
@@ -166,6 +168,26 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
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> {
|
||||
try {
|
||||
if (accessToken.value) {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
v-model="formState.identifier"
|
||||
type="text"
|
||||
placeholder="账户"
|
||||
autocomplete="off"
|
||||
autocomplete="username"
|
||||
required
|
||||
@focus="isTyping = true"
|
||||
@blur="isTyping = false"
|
||||
@@ -68,7 +68,7 @@
|
||||
<div class="options">
|
||||
<label class="remember">
|
||||
<input v-model="remember" type="checkbox" />
|
||||
{{ t('auth.rememberPassword') }}
|
||||
{{ t('auth.rememberAccount') }}
|
||||
</label>
|
||||
<a href="#" class="forgot">{{ t('auth.forgotPassword') }}</a>
|
||||
</div>
|
||||
@@ -127,9 +127,8 @@ onMounted(() => {
|
||||
return
|
||||
}
|
||||
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.password = parsed.password
|
||||
remember.value = true
|
||||
}
|
||||
} catch {
|
||||
@@ -144,7 +143,7 @@ function persistRememberedCredentials(): void {
|
||||
if (remember.value) {
|
||||
window.localStorage.setItem(
|
||||
REMEMBER_STORAGE_KEY,
|
||||
JSON.stringify({ identifier: formState.identifier, password: formState.password }),
|
||||
JSON.stringify({ identifier: formState.identifier }),
|
||||
)
|
||||
} else {
|
||||
window.localStorage.removeItem(REMEMBER_STORAGE_KEY)
|
||||
|
||||
@@ -10,11 +10,13 @@ import type {
|
||||
DesktopRuntimeSessionSyncRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
PasswordPublicKeyResponse,
|
||||
RefreshResponse,
|
||||
UserInfo,
|
||||
} from '@geo/shared-types'
|
||||
|
||||
import { normalizeDesktopApiBaseURL } from '../../shared/api-base-url'
|
||||
import { browserSupportsPasswordCipher, encryptPasswordForLogin } from '../lib/password-cipher'
|
||||
|
||||
type SessionMode = 'authenticated' | 'preview'
|
||||
|
||||
@@ -596,9 +598,10 @@ async function performLogin(payload: LoginRequest): Promise<void> {
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const loginPayload = await buildSecureLoginPayload(payload)
|
||||
const authData = await createPublicClient().post<LoginResponse, LoginRequest>(
|
||||
'/api/auth/login',
|
||||
payload,
|
||||
loginPayload,
|
||||
)
|
||||
|
||||
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> {
|
||||
if (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 showServerDialog = 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 =
|
||||
localStorage.getItem('geo_rankly_saved_identifier') ??
|
||||
localStorage.getItem('geo_rankly_saved_email') ??
|
||||
@@ -16,7 +16,7 @@ const savedIdentifier =
|
||||
const form = reactive({
|
||||
apiBaseURL: apiBaseURL.value,
|
||||
identifier: savedIdentifier,
|
||||
password: localStorage.getItem('geo_rankly_saved_password') || '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
@@ -26,7 +26,7 @@ function toggleMenu() {
|
||||
}
|
||||
|
||||
function onRememberChange(event: Event) {
|
||||
rememberPassword.value = (event.target as HTMLInputElement).checked
|
||||
rememberAccount.value = (event.target as HTMLInputElement).checked
|
||||
}
|
||||
|
||||
function handleOutsideClick(event: MouseEvent) {
|
||||
@@ -53,14 +53,13 @@ function saveServerSettings() {
|
||||
}
|
||||
|
||||
async function submitLogin() {
|
||||
if (rememberPassword.value) {
|
||||
localStorage.removeItem('geo_rankly_saved_password')
|
||||
if (rememberAccount.value) {
|
||||
localStorage.setItem('geo_rankly_saved_identifier', form.identifier)
|
||||
localStorage.removeItem('geo_rankly_saved_email')
|
||||
localStorage.setItem('geo_rankly_saved_password', form.password)
|
||||
} else {
|
||||
localStorage.removeItem('geo_rankly_saved_identifier')
|
||||
localStorage.removeItem('geo_rankly_saved_email')
|
||||
localStorage.removeItem('geo_rankly_saved_password')
|
||||
}
|
||||
|
||||
await login({
|
||||
@@ -179,8 +178,8 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="options-row">
|
||||
<label class="check-label">
|
||||
<input type="checkbox" :checked="rememberPassword" @change="onRememberChange" />
|
||||
<span>记住密码</span>
|
||||
<input type="checkbox" :checked="rememberAccount" @change="onRememberChange" />
|
||||
<span>记住账号</span>
|
||||
</label>
|
||||
</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 { 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 {
|
||||
type OperatorView,
|
||||
clearStoredSession,
|
||||
@@ -17,6 +18,12 @@ interface LoginResponse {
|
||||
operator: OperatorView
|
||||
}
|
||||
|
||||
interface PasswordPublicKeyResponse {
|
||||
key_id: string
|
||||
algorithm: 'RSA-OAEP-256'
|
||||
public_key: string
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('ops-auth', () => {
|
||||
const accessToken = ref<string | null>(null)
|
||||
const expiresAt = ref<number | null>(null)
|
||||
@@ -41,11 +48,9 @@ export const useAuthStore = defineStore('ops-auth', () => {
|
||||
}
|
||||
|
||||
const payload = { username, password, remember }
|
||||
const loginPayload = await buildSecureLoginPayload(username, password)
|
||||
loginPromise = http
|
||||
.post<LoginResponse>('/auth/login', {
|
||||
username: payload.username,
|
||||
password: payload.password,
|
||||
})
|
||||
.post<LoginResponse>('/auth/login', loginPayload)
|
||||
.then((result) => {
|
||||
resetUnauthorizedHandling()
|
||||
accessToken.value = result.access_token
|
||||
@@ -69,6 +74,25 @@ export const useAuthStore = defineStore('ops-auth', () => {
|
||||
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> {
|
||||
if (!accessToken.value) return
|
||||
try {
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
v-model="formState.username"
|
||||
type="text"
|
||||
placeholder="请输入管理员账号"
|
||||
autocomplete="off"
|
||||
autocomplete="username"
|
||||
required
|
||||
@focus="isTyping = true"
|
||||
@blur="isTyping = false"
|
||||
@@ -58,6 +58,7 @@
|
||||
v-model="formState.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="请输入密码"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
<button type="button" class="eye-btn" @click="showPassword = !showPassword">
|
||||
|
||||
@@ -22,10 +22,18 @@ export interface AuthTokens {
|
||||
|
||||
export interface LoginRequest {
|
||||
identifier: string
|
||||
password: string
|
||||
password?: string
|
||||
encrypted_password?: string
|
||||
password_key_id?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
export interface PasswordPublicKeyResponse {
|
||||
key_id: string
|
||||
algorithm: 'RSA-OAEP-256'
|
||||
public_key: string
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
email: string | null
|
||||
|
||||
@@ -94,8 +94,21 @@ func main() {
|
||||
|
||||
auditSvc := app.NewAuditService(auditsRepo, logger).WithIPRegionResolver(ipRegionResolver)
|
||||
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"))
|
||||
authSvc := app.NewAuthService(accountsRepo, auditSvc, issuer, loginGuard)
|
||||
authSvc := app.NewAuthService(accountsRepo, auditSvc, issuer, loginGuard).WithPasswordCipher(passwordCipher)
|
||||
accountSvc := app.NewAccountService(accountsRepo, auditSvc)
|
||||
tenantLoginGuard := sharedauth.NewLoginGuard(rdb, sharedauth.DefaultLoginGuardConfig("tenant"))
|
||||
adminUserSvc := app.NewAdminUserService(adminUsersRepo, auditSvc, cfg.AdminUsers.DefaultPlanCode).
|
||||
@@ -139,6 +152,7 @@ func main() {
|
||||
|
||||
transport.RegisterRoutes(transport.Deps{
|
||||
Engine: engine,
|
||||
Server: cfg.Server,
|
||||
Logger: logger,
|
||||
DB: pool,
|
||||
MonitoringDB: monitoringPool,
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
server:
|
||||
port: 8080
|
||||
mode: debug
|
||||
# Production should restrict this to real frontend origins.
|
||||
# Empty keeps local development CORS permissive.
|
||||
allowed_origins: []
|
||||
security_headers:
|
||||
enabled: true
|
||||
trusted_proxies:
|
||||
- 127.0.0.1
|
||||
- ::1
|
||||
@@ -162,6 +167,16 @@ jwt:
|
||||
access_ttl: 15m
|
||||
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:
|
||||
provider: ark
|
||||
base_url: https://ark.cn-beijing.volces.com/api/v3
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
server:
|
||||
port: 8080
|
||||
mode: debug
|
||||
# 生产环境请配置为真实前端域名,例如 https://app.example.com。
|
||||
# 留空时沿用开发环境 CORS 放开策略。
|
||||
allowed_origins: []
|
||||
security_headers:
|
||||
enabled: true
|
||||
trusted_proxies:
|
||||
- 127.0.0.1
|
||||
- ::1
|
||||
@@ -171,6 +176,11 @@ jwt:
|
||||
auth:
|
||||
login_guard:
|
||||
enabled: true
|
||||
password_cipher:
|
||||
# 留空 private_key_pem 时会在单进程内生成临时密钥;生产多实例请通过环境变量注入同一把私钥。
|
||||
enabled: true
|
||||
key_id: login-password-rsa-oaep-v1
|
||||
private_key_pem: ""
|
||||
|
||||
log:
|
||||
level: debug,info,warn,error
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
server:
|
||||
port: 8090
|
||||
mode: debug
|
||||
# 生产环境请配置为真实运维后台域名,例如 https://ops.example.com。
|
||||
# 留空时沿用开发环境 CORS 放开策略。
|
||||
allowed_origins: []
|
||||
security_headers:
|
||||
enabled: true
|
||||
trusted_proxies:
|
||||
- 127.0.0.1
|
||||
- ::1
|
||||
@@ -43,6 +48,13 @@ jwt:
|
||||
secret: "15645415841" # 必须通过 OPS_JWT_SECRET 环境变量提供
|
||||
access_ttl: 8h
|
||||
|
||||
auth:
|
||||
password_cipher:
|
||||
# 留空 private_key_pem 时会在单进程内生成临时密钥;生产多实例请通过环境变量注入同一把私钥。
|
||||
enabled: true
|
||||
key_id: ops-password-rsa-oaep-v1
|
||||
private_key_pem: ""
|
||||
|
||||
default_admin:
|
||||
username: admin
|
||||
display_name: 系统管理员
|
||||
|
||||
@@ -43,6 +43,7 @@ type App struct {
|
||||
JWT *auth.Manager
|
||||
Sessions *auth.SessionStore
|
||||
LoginGuard *auth.LoginGuard
|
||||
PasswordCipher *auth.PasswordCipher
|
||||
LLM llm.Client
|
||||
ReloadableLLM *llm.ReloadableClient
|
||||
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)
|
||||
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.Enabled = cfg.Auth.LoginGuard.Enabled
|
||||
if !loginGuardCfg.Enabled {
|
||||
@@ -173,8 +185,14 @@ func New(configPath string) (*App, error) {
|
||||
middleware.Recovery(logger),
|
||||
middleware.RequestID(),
|
||||
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) {
|
||||
response.Success(c, gin.H{"status": "alive"})
|
||||
@@ -210,6 +228,7 @@ func New(configPath string) (*App, error) {
|
||||
JWT: jwtMgr,
|
||||
Sessions: sessions,
|
||||
LoginGuard: loginGuard,
|
||||
PasswordCipher: passwordCipher,
|
||||
LLM: llmClient,
|
||||
ReloadableLLM: llmClient,
|
||||
RetrievalProvider: retrievalProvider,
|
||||
@@ -237,6 +256,16 @@ func New(configPath string) (*App, error) {
|
||||
}, 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 {
|
||||
if a == nil || a.ConfigStore == nil {
|
||||
return nil
|
||||
|
||||
@@ -117,6 +117,7 @@ type AuthService struct {
|
||||
audits *AuditService
|
||||
issuer *TokenIssuer
|
||||
guard *sharedauth.LoginGuard
|
||||
cipher *sharedauth.PasswordCipher
|
||||
}
|
||||
|
||||
func NewAuthService(
|
||||
@@ -132,12 +133,27 @@ func NewAuthService(
|
||||
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 {
|
||||
Username string
|
||||
Password string
|
||||
IP string
|
||||
UserAgent string
|
||||
RequestID string
|
||||
Username string
|
||||
Password string
|
||||
EncryptedPassword string
|
||||
PasswordKeyID string
|
||||
IP string
|
||||
UserAgent string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
type LoginResult struct {
|
||||
@@ -176,6 +192,10 @@ func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, e
|
||||
if in.Username == "" {
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -183,6 +203,9 @@ func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, e
|
||||
}
|
||||
credentialsVerified := false
|
||||
defer func() {
|
||||
if permit == nil {
|
||||
return
|
||||
}
|
||||
if credentialsVerified {
|
||||
permit.RecordSuccess(ctx)
|
||||
return
|
||||
@@ -203,7 +226,7 @@ func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, e
|
||||
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)
|
||||
s.recordLoginFailure(ctx, in, "wrong_password")
|
||||
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "账号或密码错误")
|
||||
@@ -237,6 +260,26 @@ func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, e
|
||||
}, 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) {
|
||||
if s.guard == 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"`
|
||||
Log sharedconfig.LogConfig `mapstructure:"log"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Auth sharedconfig.AuthConfig `mapstructure:"auth"`
|
||||
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
|
||||
AdminUsers AdminUsersConfig `mapstructure:"admin_users"`
|
||||
IPRegion IPRegionConfig `mapstructure:"ip_region"`
|
||||
@@ -245,6 +246,9 @@ func Diff(previous, current *Config) []FieldChange {
|
||||
if previous.JWT != current.JWT {
|
||||
add("jwt", true)
|
||||
}
|
||||
if previous.Auth != current.Auth {
|
||||
add("auth", false)
|
||||
}
|
||||
if previous.AdminUsers != current.AdminUsers {
|
||||
add("admin_users", true)
|
||||
}
|
||||
@@ -310,6 +314,11 @@ func load(path string) (*Config, error) {
|
||||
}
|
||||
|
||||
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")
|
||||
if _, ok := cacheSettings["metrics_enabled"]; !ok {
|
||||
cacheSettings["metrics_enabled"] = true
|
||||
@@ -429,12 +438,46 @@ func applyEnvOverrides(cfg *Config) error {
|
||||
if v := os.Getenv("OPS_SERVER_TRUSTED_PROXIES"); 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 != "" {
|
||||
cfg.RabbitMQ.URL = v
|
||||
}
|
||||
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) {
|
||||
if cfg == nil {
|
||||
return
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
)
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password"`
|
||||
EncryptedPassword string `json:"encrypted_password"`
|
||||
PasswordKeyID string `json:"password_key_id"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
@@ -28,11 +30,13 @@ func loginHandler(svc *app.AuthService) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
result, err := svc.Login(c.Request.Context(), app.LoginInput{
|
||||
Username: body.Username,
|
||||
Password: body.Password,
|
||||
IP: c.ClientIP(),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
RequestID: middleware.RequestIDFromGin(c),
|
||||
Username: body.Username,
|
||||
Password: body.Password,
|
||||
EncryptedPassword: body.EncryptedPassword,
|
||||
PasswordKeyID: body.PasswordKeyID,
|
||||
IP: c.ClientIP(),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
RequestID: middleware.RequestIDFromGin(c),
|
||||
})
|
||||
if err != nil {
|
||||
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 {
|
||||
return func(c *gin.Context) {
|
||||
actor := actorFromGin(c)
|
||||
|
||||
@@ -6,12 +6,14 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"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/response"
|
||||
)
|
||||
|
||||
type Deps struct {
|
||||
Engine *gin.Engine
|
||||
Server sharedconfig.ServerConfig
|
||||
Logger *zap.Logger
|
||||
DB *pgxpool.Pool
|
||||
MonitoringDB *pgxpool.Pool
|
||||
@@ -26,13 +28,27 @@ type Deps struct {
|
||||
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) {
|
||||
d.Engine.Use(
|
||||
middleware.Recovery(d.Logger),
|
||||
middleware.RequestID(),
|
||||
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) {
|
||||
response.Success(c, gin.H{"status": "alive"})
|
||||
@@ -53,6 +69,7 @@ func RegisterRoutes(d Deps) {
|
||||
|
||||
api := d.Engine.Group("/api/ops")
|
||||
{
|
||||
api.GET("/auth/password-key", passwordPublicKeyHandler(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 {
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"`
|
||||
TrustedProxies []string `mapstructure:"trusted_proxies"`
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"`
|
||||
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 {
|
||||
@@ -296,13 +302,20 @@ type JWTConfig struct {
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
LoginGuard LoginGuardConfig `mapstructure:"login_guard"`
|
||||
LoginGuard LoginGuardConfig `mapstructure:"login_guard"`
|
||||
PasswordCipher PasswordCipherConfig `mapstructure:"password_cipher"`
|
||||
}
|
||||
|
||||
type LoginGuardConfig struct {
|
||||
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 {
|
||||
Level string `mapstructure:"level"`
|
||||
Format string `mapstructure:"format"`
|
||||
@@ -498,6 +511,11 @@ func decodeResolvedConfig(configFile, localConfigFile string) (*Config, error) {
|
||||
}
|
||||
|
||||
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")
|
||||
if _, ok := cacheSettings["metrics_enabled"]; !ok {
|
||||
cacheSettings["metrics_enabled"] = true
|
||||
@@ -540,11 +558,12 @@ func NormalizeServerConfig(cfg *ServerConfig) {
|
||||
return
|
||||
}
|
||||
cfg.Mode = strings.TrimSpace(cfg.Mode)
|
||||
cfg.AllowedOrigins = compactStringSlice(cfg.AllowedOrigins)
|
||||
if cfg.TrustedProxies == nil {
|
||||
cfg.TrustedProxies = DefaultTrustedProxies()
|
||||
return
|
||||
} else {
|
||||
cfg.TrustedProxies = compactStringSlice(cfg.TrustedProxies)
|
||||
}
|
||||
cfg.TrustedProxies = compactStringSlice(cfg.TrustedProxies)
|
||||
}
|
||||
|
||||
func compactStringSlice(values []string) []string {
|
||||
@@ -964,6 +983,21 @@ func applyEnvOverrides(cfg *Config) {
|
||||
if trustedProxies, ok := lookupNonEmptyEnv("SERVER_TRUSTED_PROXIES"); ok {
|
||||
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 {
|
||||
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) {
|
||||
defaultConfigPath := writeTestConfig(t, `
|
||||
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) {
|
||||
configPath := writeTestConfig(t, `
|
||||
membership: {}
|
||||
|
||||
@@ -5,17 +5,5 @@ import (
|
||||
)
|
||||
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
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()
|
||||
}
|
||||
return CORSWithConfig(CORSConfig{AllowAll: true})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -27,7 +30,7 @@ func Logger(logger *zap.Logger) gin.HandlerFunc {
|
||||
fields = append(fields, zap.String("route", route))
|
||||
}
|
||||
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 != "" {
|
||||
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.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。"},
|
||||
|
||||
// --- Auth ---
|
||||
"POST /api/auth/login": {"账号密码登录", "客户后台账号密码登录,返回 access/refresh token。"},
|
||||
"POST /api/auth/refresh": {"刷新访问令牌", "用 refresh token 换取新的 access token。"},
|
||||
"GET /api/auth/me": {"当前登录用户", "返回当前 JWT 对应的用户、租户、Workspace 等会话信息。"},
|
||||
"POST /api/auth/logout": {"退出登录", "吊销当前会话,使其 access/refresh token 立即失效。"},
|
||||
"GET /api/auth/password-key": {"登录密码加密公钥", "返回登录密码前端加密使用的临时公钥与 key_id。"},
|
||||
"POST /api/auth/login": {"账号密码登录", "客户后台账号密码登录,返回 access/refresh token。"},
|
||||
"POST /api/auth/refresh": {"刷新访问令牌", "用 refresh token 换取新的 access token。"},
|
||||
"GET /api/auth/me": {"当前登录用户", "返回当前 JWT 对应的用户、租户、Workspace 等会话信息。"},
|
||||
"POST /api/auth/logout": {"退出登录", "吊销当前会话,使其 access/refresh token 立即失效。"},
|
||||
|
||||
// --- Public 资源 ---
|
||||
"GET /api/public/assets/:token": {"对外访问签名资源", "通过签名 token 直接访问对象存储中的图片/附件,无需登录。"},
|
||||
|
||||
@@ -498,7 +498,8 @@ func errorResponse(description string) map[string]any {
|
||||
}
|
||||
|
||||
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/public/") {
|
||||
return nil
|
||||
|
||||
@@ -21,6 +21,7 @@ type AuthService struct {
|
||||
jwt *auth.Manager
|
||||
sessions *auth.SessionStore
|
||||
loginGuard *auth.LoginGuard
|
||||
cipher *auth.PasswordCipher
|
||||
}
|
||||
|
||||
func NewAuthService(
|
||||
@@ -45,11 +46,18 @@ func NewAuthService(
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) WithPasswordCipher(cipher *auth.PasswordCipher) *AuthService {
|
||||
s.cipher = cipher
|
||||
return s
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Identifier string `json:"identifier"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
IP string `json:"-"`
|
||||
Identifier string `json:"identifier"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
EncryptedPassword string `json:"encrypted_password"`
|
||||
PasswordKeyID string `json:"password_key_id"`
|
||||
IP string `json:"-"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
@@ -107,6 +115,10 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
|
||||
if identifier == "" {
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -114,6 +126,9 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
|
||||
}
|
||||
credentialsVerified := false
|
||||
defer func() {
|
||||
if permit == nil {
|
||||
return
|
||||
}
|
||||
if credentialsVerified {
|
||||
permit.RecordSuccess(ctx)
|
||||
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")
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
if s.loginGuard == 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.Sessions,
|
||||
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) {
|
||||
var req app.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
@@ -13,6 +13,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
|
||||
authAPI := app.Engine.Group("/api/auth")
|
||||
authHandler := NewAuthHandler(app)
|
||||
authAPI.GET("/password-key", authHandler.PasswordPublicKey)
|
||||
authAPI.POST("/login", authHandler.Login)
|
||||
authAPI.POST("/refresh", authHandler.Refresh)
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ func TestPublicRoutes_NoAuth(t *testing.T) {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "/api/auth/password-key"},
|
||||
{http.MethodPost, "/api/auth/login"},
|
||||
{http.MethodPost, "/api/auth/refresh"},
|
||||
{http.MethodGet, "/api/health/live"},
|
||||
|
||||
Reference in New Issue
Block a user