2
0
mirror of https://github.com/QuantumNous/new-api.git synced 2026-08-31 02:41:34 +00:00

feat(auth): encrypt password login transport

Closes #6743
This commit is contained in:
CaIon 2026-08-29 20:11:05 +08:00
parent 98d50d5383
commit b80d633cf5
No known key found for this signature in database
GPG Key ID: DFDCE3CC3CCED6EB
10 changed files with 372 additions and 12 deletions

115
common/password_crypto.go Normal file
View File

@ -0,0 +1,115 @@
package common
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"strings"
"sync"
)
const passwordEncryptionKeyBits = 2048
var ErrPasswordEncryptionInvalid = errors.New("password encryption payload is invalid")
var passwordEncryptionState struct {
sync.RWMutex
privateKey *rsa.PrivateKey
publicKey string
keyID string
}
// GeneratePasswordEncryptionPrivateKey creates the server key used to decrypt
// browser login passwords. The caller is responsible for persisting the PEM.
func GeneratePasswordEncryptionPrivateKey() (string, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, passwordEncryptionKeyBits)
if err != nil {
return "", fmt.Errorf("generate password encryption key: %w", err)
}
privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return "", fmt.Errorf("marshal password encryption key: %w", err)
}
return string(pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: privateKeyDER,
})), nil
}
// LoadPasswordEncryptionPrivateKey validates a persisted key before replacing
// the active in-memory key used by request handlers.
func LoadPasswordEncryptionPrivateKey(privateKeyPEM string) error {
block, rest := pem.Decode([]byte(privateKeyPEM))
if block == nil || block.Type != "PRIVATE KEY" || strings.TrimSpace(string(rest)) != "" {
return errors.New("password encryption key is not valid PKCS#8 PEM")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return fmt.Errorf("parse password encryption key: %w", err)
}
privateKey, ok := parsed.(*rsa.PrivateKey)
if !ok {
return errors.New("password encryption key is not RSA")
}
if privateKey.N == nil || privateKey.N.BitLen() < passwordEncryptionKeyBits {
return fmt.Errorf("password encryption key must be at least %d bits", passwordEncryptionKeyBits)
}
if err := privateKey.Validate(); err != nil {
return fmt.Errorf("validate password encryption key: %w", err)
}
privateKey.Precompute()
publicKeyDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
if err != nil {
return fmt.Errorf("marshal password encryption public key: %w", err)
}
publicKeyPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: publicKeyDER,
}))
keyDigest := sha256.Sum256(publicKeyDER)
keyID := hex.EncodeToString(keyDigest[:16])
passwordEncryptionState.Lock()
defer passwordEncryptionState.Unlock()
passwordEncryptionState.privateKey = privateKey
passwordEncryptionState.publicKey = publicKeyPEM
passwordEncryptionState.keyID = keyID
return nil
}
// PasswordEncryptionPublicKey returns the active key identifier and SPKI PEM
// public key exposed to browser clients.
func PasswordEncryptionPublicKey() (keyID string, publicKeyPEM string) {
passwordEncryptionState.RLock()
defer passwordEncryptionState.RUnlock()
return passwordEncryptionState.keyID, passwordEncryptionState.publicKey
}
// DecryptPassword decrypts a base64 RSA-OAEP/SHA-256 password submitted by a
// browser. All malformed inputs share one error so callers do not expose
// cryptographic details to unauthenticated clients.
func DecryptPassword(ciphertextBase64 string, keyID string) (string, error) {
passwordEncryptionState.RLock()
privateKey := passwordEncryptionState.privateKey
activeKeyID := passwordEncryptionState.keyID
passwordEncryptionState.RUnlock()
if privateKey == nil || keyID == "" || keyID != activeKeyID {
return "", ErrPasswordEncryptionInvalid
}
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64)
if err != nil || len(ciphertext) != privateKey.Size() {
return "", ErrPasswordEncryptionInvalid
}
plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, ciphertext, nil)
if err != nil || len(plaintext) == 0 {
return "", ErrPasswordEncryptionInvalid
}
return string(plaintext), nil
}

View File

@ -28,8 +28,10 @@ import (
)
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Username string `json:"username"`
Password string `json:"password"`
PasswordEncrypted string `json:"password_encrypted"`
EncryptionKeyID string `json:"encryption_key_id"`
}
var (
@ -37,6 +39,18 @@ var (
errOriginalPasswordFail = errors.New("original password is incorrect")
)
func GetPasswordEncryptionKey(c *gin.Context) {
keyID, publicKey := common.PasswordEncryptionPublicKey()
if keyID == "" || publicKey == "" {
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
return
}
common.ApiSuccess(c, gin.H{
"kid": keyID,
"public_key": publicKey,
})
}
func Login(c *gin.Context) {
if !common.PasswordLoginEnabled {
common.ApiErrorI18n(c, i18n.MsgUserPasswordLoginDisabled)
@ -50,6 +64,13 @@ func Login(c *gin.Context) {
}
username := loginRequest.Username
password := loginRequest.Password
if loginRequest.PasswordEncrypted != "" {
password, err = common.DecryptPassword(loginRequest.PasswordEncrypted, loginRequest.EncryptionKeyID)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgUserUsernameOrPasswordError)
return
}
}
if username == "" || password == "" {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return

View File

@ -318,6 +318,10 @@ func InitResources() error {
common.FatalLog("failed to initialize authorization: " + err.Error())
return err
}
if err = model.InitPasswordEncryption(); err != nil {
common.FatalLog("failed to initialize password encryption: " + err.Error())
return err
}
model.CheckSetup()

View File

@ -316,6 +316,7 @@ func migrateDB() error {
&ExternalIdentityClaim{},
&PasskeyCredential{},
&Option{},
&LoginEncryptionKey{},
&Redemption{},
&Ability{},
&Log{},
@ -380,6 +381,7 @@ func migrateDBFast() error {
{&ExternalIdentityClaim{}, "ExternalIdentityClaim"},
{&PasskeyCredential{}, "PasskeyCredential"},
{&Option{}, "Option"},
{&LoginEncryptionKey{}, "LoginEncryptionKey"},
{&Redemption{}, "Redemption"},
{&Ability{}, "Ability"},
{&Log{}, "Log"},

60
model/password_crypto.go Normal file
View File

@ -0,0 +1,60 @@
package model
import (
"errors"
"fmt"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const activeLoginEncryptionKeySlot = "active"
// LoginEncryptionKey stores internal key material used by the browser login
// protocol. It is deliberately separate from administrator-facing options.
type LoginEncryptionKey struct {
ID uint `json:"-" gorm:"primaryKey"`
Slot string `json:"-" gorm:"type:varchar(32);not null;uniqueIndex"`
PrivateKeyPEM string `json:"-" gorm:"type:text;not null"`
}
// InitPasswordEncryption loads the shared login-encryption key from its
// dedicated store. Concurrent replicas converge through the unique slot.
func InitPasswordEncryption() error {
var stored LoginEncryptionKey
queryErr := DB.Where("slot = ?", activeLoginEncryptionKeySlot).First(&stored).Error
if queryErr == nil {
if err := common.LoadPasswordEncryptionPrivateKey(stored.PrivateKeyPEM); err != nil {
return fmt.Errorf("load persisted password encryption key: %w", err)
}
return nil
}
if !errors.Is(queryErr, gorm.ErrRecordNotFound) {
return fmt.Errorf("read password encryption key: %w", queryErr)
}
privateKeyPEM, err := common.GeneratePasswordEncryptionPrivateKey()
if err != nil {
return err
}
candidate := LoginEncryptionKey{
Slot: activeLoginEncryptionKeySlot,
PrivateKeyPEM: privateKeyPEM,
}
if err := DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "slot"}},
DoNothing: true,
}).Create(&candidate).Error; err != nil {
return fmt.Errorf("persist password encryption key: %w", err)
}
if err := DB.Where("slot = ?", activeLoginEncryptionKeySlot).First(&stored).Error; err != nil {
return fmt.Errorf("reload password encryption key: %w", err)
}
if err := common.LoadPasswordEncryptionPrivateKey(stored.PrivateKeyPEM); err != nil {
return fmt.Errorf("load persisted password encryption key: %w", err)
}
return nil
}

View File

@ -71,6 +71,7 @@ func SetApiRouter(router *gin.Engine) {
userRoute.POST("/auth/refresh", middleware.SessionCookieOriginGuard(), middleware.CriticalRateLimit(), middleware.DisableCache(), controller.RefreshAuth)
userRoute.POST("/auth/logout", middleware.SessionCookieOriginGuard(), middleware.CriticalRateLimit(), middleware.DisableCache(), controller.AuthLogout)
userRoute.POST("/register", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Register)
userRoute.GET("/login/encryption-key", middleware.DisableCache(), controller.GetPasswordEncryptionKey)
userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Login)
userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.Verify2FALogin)
userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.PasskeyLoginBegin)

View File

@ -41,6 +41,7 @@
"motion": "^12.42.2",
"nanoid": "^5.1.16",
"next-themes": "^0.4.6",
"node-forge": "^1.4.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.7",
"react-day-picker": "^10.0.1",
@ -76,6 +77,7 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^26.1.0",
"@types/node-forge": "^1.3.14",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "^7.0.0-dev.20260702.3",
@ -992,6 +994,8 @@
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/node-forge": ["@types/node-forge@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw=="],
"@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
@ -1992,6 +1996,8 @@
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="],
"node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],

View File

@ -60,6 +60,7 @@
"motion": "^12.42.2",
"nanoid": "^5.1.16",
"next-themes": "^0.4.6",
"node-forge": "^1.4.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.7",
"react-day-picker": "^10.0.1",
@ -95,6 +96,7 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^26.1.0",
"@types/node-forge": "^1.3.14",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "^7.0.0-dev.20260702.3",

View File

@ -21,6 +21,10 @@ import axios from 'axios'
import { api, refreshAuthentication, type RefreshOutcome } from '@/lib/api'
import { useAuthStore } from '@/stores/auth-store'
import {
clearPasswordEncryptionCache,
encryptPassword,
} from './lib/password-encryption'
import { getAffiliateCode } from './lib/storage'
import type { TelegramAuthorization } from './lib/telegram-login'
import type {
@ -41,17 +45,27 @@ import type {
// ----------------------------------------------------------------------------
// User login with username and password
export async function login(payload: LoginPayload) {
export async function login(payload: LoginPayload): Promise<LoginResponse> {
const turnstile = payload.turnstile ?? ''
const res = await api.post<LoginResponse>(
`/api/user/login?turnstile=${turnstile}`,
{
username: payload.username,
password: payload.password,
},
{ skipAuthRefresh: true }
)
return res.data
try {
const encryptedPassword = await encryptPassword(payload.password)
const res = await api.post<LoginResponse>(
`/api/user/login?turnstile=${turnstile}`,
{
username: payload.username,
password_encrypted: encryptedPassword.password_encrypted,
encryption_key_id: encryptedPassword.encryption_key_id,
},
{ skipAuthRefresh: true }
)
if (!res.data?.success) {
clearPasswordEncryptionCache()
}
return res.data
} catch (error: unknown) {
clearPasswordEncryptionCache()
throw error
}
}
// Two-factor authentication login

View File

@ -0,0 +1,135 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { t } from 'i18next'
import { api } from '@/lib/api'
interface PasswordEncryptionKey {
kid: string
public_key: string
}
export interface EncryptedPassword {
password_encrypted: string
encryption_key_id: string
}
const KEY_CACHE_TTL_MS = 5 * 60_000
let cachedKey: PasswordEncryptionKey | null = null
let cachedAt = 0
export function clearPasswordEncryptionCache(): void {
cachedKey = null
cachedAt = 0
}
export async function encryptPassword(
password: string
): Promise<EncryptedPassword> {
try {
const key = await getPasswordEncryptionKey()
const ciphertext = await rsaOaepEncrypt(password, key.public_key)
return {
password_encrypted: ciphertext,
encryption_key_id: key.kid,
}
} catch (error: unknown) {
clearPasswordEncryptionCache()
throw new Error(t('Login failed'), { cause: error })
}
}
async function getPasswordEncryptionKey(): Promise<PasswordEncryptionKey> {
const now = Date.now()
if (cachedKey && now - cachedAt < KEY_CACHE_TTL_MS) {
return cachedKey
}
const response = await api.get<{
success: boolean
data?: PasswordEncryptionKey
}>('/api/user/login/encryption-key')
const key = response.data?.data
if (!response.data?.success || !key?.kid || !key.public_key) {
throw new Error('Password encryption key is unavailable')
}
cachedKey = key
cachedAt = now
return key
}
async function rsaOaepEncrypt(
password: string,
publicKeyPEM: string
): Promise<string> {
if (typeof globalThis.crypto?.subtle !== 'undefined') {
try {
const publicKey = await globalThis.crypto.subtle.importKey(
'spki',
pemToDER(publicKeyPEM),
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['encrypt']
)
const ciphertext = await globalThis.crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
publicKey,
new TextEncoder().encode(password)
)
return arrayBufferToBase64(ciphertext)
} catch {
// Older implementations may expose SubtleCrypto without supporting the
// required RSA-OAEP parameters; the HTTP-compatible fallback handles it.
}
}
// Web Crypto is restricted to secure contexts in browsers. Lazy-loading
// forge keeps the normal HTTPS bundle small while supporting HTTP intranets.
const forge = await import('node-forge')
const publicKey = forge.pki.publicKeyFromPem(publicKeyPEM)
const ciphertext = publicKey.encrypt(
forge.util.encodeUtf8(password),
'RSA-OAEP',
{ md: forge.md.sha256.create() }
)
return forge.util.encode64(ciphertext)
}
function pemToDER(pem: string): ArrayBuffer {
const body = pem
.replace('-----BEGIN PUBLIC KEY-----', '')
.replace('-----END PUBLIC KEY-----', '')
.replaceAll(/\s+/g, '')
const binary = atob(body)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
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 btoa(binary)
}