mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 22:49:57 +00:00
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user