diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 20ffa9b..f904b6a 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS users ( verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, + api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); diff --git a/src/config/limits.ts b/src/config/limits.ts index 8eb4a4c..589d4ce 100644 --- a/src/config/limits.ts +++ b/src/config/limits.ts @@ -24,6 +24,9 @@ // Default PBKDF2 iterations for account creation/prelogin fallback. // 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。 defaultKdfIterations: 600000, + // clientSecret length + // clientSecret 长度 + clientSecretLength: 30, }, rateLimit: { // Max failed login attempts before temporary lock. diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index 9b4204b..e1b4fb2 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -209,6 +209,7 @@ export async function handleRegister(request: Request, env: Env): Promise { + return apiKey(request, env, userId, false); +} + +// POST /api/accounts/rotate-api-key +export async function handleRotateApiKey(request: Request, env: Env, userId: string): Promise { + return apiKey(request, env, userId, true); +} + +async function apiKey(request: Request, env: Env, userId: string, rotate: boolean): Promise { + const storage = new StorageService(env.DB); + const auth = new AuthService(env); + const user = await storage.getUserById(userId); + if (!user) return errorResponse('User not found', 404); + + let body: Record; + try { + const contentType = request.headers.get('content-type') || ''; + if (contentType.includes('application/x-www-form-urlencoded')) { + const formData = await request.formData(); + body = Object.fromEntries(formData.entries()) as Record; + } else { + body = await request.json(); + } + } catch { + return errorResponse('Invalid JSON', 400); + } + + const currentHash = String(body.masterPasswordHash || body.master_password_hash || body.password || '').trim(); + if (!currentHash) return errorResponse('masterPasswordHash is required', 400); + const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email); + if (!valid) return errorResponse('Invalid password', 400); + + if (rotate || user.apiKey === null) { + // Upstream apikeys are 30-character random alphanumeric strings + user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength); + if (rotate) { + user.securityStamp = generateUUID(); + await storage.deleteRefreshTokensByUserId(user.id); + } + user.updatedAt = new Date().toISOString(); + await storage.saveUser(user); + } + + return jsonResponse({ + apiKey: user.apiKey, + revisionDate: user.updatedAt, + object: 'apiKey', + }); +} + +// Generate a random alphanumeric string of the given length using crypto.getRandomValues. +function randomStringAlphanum(length: number): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const array = new Uint8Array(length); + crypto.getRandomValues(array); + + let result = ''; + for (let i = 0; i < length; i++) { + result += chars[array[i] % chars.length]; + } + return result; +} diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index ceb7be1..e8e1bf1 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -48,6 +48,18 @@ function parseCookieValue(request: Request, name: string): string | null { return null; } +function constantTimeEquals(a: string, b: string): boolean { + const encA = new TextEncoder().encode(a); + const encB = new TextEncoder().encode(b); + if (encA.length !== encB.length) return false; + + let diff = 0; + for (let i = 0; i < encA.length; i++) { + diff |= encA[i] ^ encB[i]; + } + return diff === 0; +} + function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string { const isHttps = new URL(request.url).protocol === 'https:'; const parts = [ @@ -361,6 +373,98 @@ export async function handleToken(request: Request, env: Env): Promise ? withWebRefreshCookie(request, baseResponse, refreshToken) : baseResponse; + } else if (grantType === 'client_credentials') { + // Login with client credentials + const clientId = body.client_id; + const clientSecret = body.client_secret; + const scope = body.scope; + const deviceInfo = readAuthRequestDeviceInfo(body, request); + + const loginIdentifier = `${clientIdentifier}:${clientId}`; + const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope); + if (!parmValid) { + return identityErrorResponse('Parameter error', 'invalid_request', 400); + } + + // Check login lockout before user lookup to reduce user-enumeration signal + const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); + if (!loginCheck.allowed) { + return identityErrorResponse( + `Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`, + 'TooManyRequests', + 429 + ); + } + + const uid = clientId.slice(5); + const user = await storage.getUserById(uid); + if (!user) { + await rateLimit.recordFailedLogin(loginIdentifier); + return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400); + } + if (user.status !== 'active') { + await rateLimit.recordFailedLogin(loginIdentifier); + return identityErrorResponse('Account is disabled', 'invalid_grant', 400); + } + + if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) { + await rateLimit.recordFailedLogin(loginIdentifier); + return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400); + } + + // Persist device only after successful client credential verification. + const deviceSession = + deviceInfo.deviceIdentifier + ? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() } + : null; + if (deviceSession) { + await storage.upsertDevice( + user.id, + deviceSession.identifier, + deviceInfo.deviceName, + deviceInfo.deviceType, + deviceSession.sessionStamp + ); + } + + // Successful login - clear failed attempts + await rateLimit.clearLoginAttempts(loginIdentifier); + + const accessToken = await auth.generateAccessToken(user, deviceSession); + const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); + const accountKeys = buildAccountKeys(user); + const userDecryptionOptions = buildUserDecryptionOptions(user); + + const response: TokenResponse = { + access_token: accessToken, + expires_in: LIMITS.auth.accessTokenTtlSeconds, + token_type: 'Bearer', + ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }), + Key: user.key, + PrivateKey: user.privateKey, + AccountKeys: accountKeys, + accountKeys: accountKeys, + Kdf: user.kdfType, + KdfIterations: user.kdfIterations, + KdfMemory: user.kdfMemory, + KdfParallelism: user.kdfParallelism, + ForcePasswordReset: false, + ResetMasterPassword: false, + MasterPasswordPolicy: { + Object: 'masterPasswordPolicy', + }, + ApiUseKeyConnector: false, + scope: 'api offline_access', + unofficialServer: true, + UserDecryptionOptions: userDecryptionOptions, + userDecryptionOptions: userDecryptionOptions, + }; + + const baseResponse = jsonResponse(response); + return shouldUseWebSession(request) + ? withWebRefreshCookie(request, baseResponse, refreshToken) + : baseResponse; + } else if (grantType === 'send_access') { const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute); if (!sendAccessLimit.allowed) { @@ -553,3 +657,16 @@ export async function handleRevocation(request: Request, env: Env): Promise D1PreparedState const USER_SELECT_COLUMNS = 'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' + 'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' + - 'totp_secret, totp_recovery_code, created_at, updated_at'; + 'totp_secret, totp_recovery_code, api_key, created_at, updated_at'; function mapUserRow(row: any): User { return { @@ -26,6 +26,7 @@ function mapUserRow(row: any): User { verifyDevices: row.verify_devices == null ? true : !!row.verify_devices, totpSecret: row.totp_secret ?? null, totpRecoveryCode: row.totp_recovery_code ?? null, + apiKey: row.api_key ?? null, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -64,11 +65,11 @@ export async function getAllUsers(db: D1Database): Promise { export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise { const email = user.email.toLowerCase(); const stmt = db.prepare( - 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' + - 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + + 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' + + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(id) DO UPDATE SET ' + 'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + - 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at' + 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, api_key=excluded.api_key, updated_at=excluded.updated_at' ); await safeBind( stmt, @@ -90,6 +91,7 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): user.verifyDevices ? 1 : 0, user.totpSecret, user.totpRecoveryCode, + user.apiKey, user.createdAt, user.updatedAt ).run(); @@ -102,8 +104,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User) export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise { const email = user.email.toLowerCase(); const stmt = db.prepare( - 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' + - 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + + 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' + + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' ); const result = await safeBind( @@ -126,6 +128,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: user.verifyDevices ? 1 : 0, user.totpSecret, user.totpRecoveryCode, + user.apiKey, user.createdAt, user.updatedAt ).run(); diff --git a/src/services/storage.ts b/src/services/storage.ts index ec32f82..601ab49 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -108,7 +108,7 @@ import { const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; -const STORAGE_SCHEMA_VERSION = '2026-04-18.1'; +const STORAGE_SCHEMA_VERSION = '2026-04-22'; // D1-backed storage. // Contract: diff --git a/src/types/index.ts b/src/types/index.ts index f188431..3803041 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -50,6 +50,7 @@ export interface User { verifyDevices?: boolean; totpSecret: string | null; totpRecoveryCode: string | null; + apiKey: string | null; createdAt: string; updatedAt: string; } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 9b5895d..5fd2867 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1203,6 +1203,8 @@ export default function App() { }, onOpenDisableTotp: () => setDisableTotpOpen(true), onGetRecoveryCode: accountSecurityActions.getRecoveryCode, + onGetApiKey: accountSecurityActions.getApiKey, + onRotateApiKey: accountSecurityActions.rotateApiKey, onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices, onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice, onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index de8b18f..55a985f 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -94,6 +94,8 @@ export interface AppMainRoutesProps { onEnableTotp: (secret: string, token: string) => Promise; onOpenDisableTotp: () => void; onGetRecoveryCode: (masterPassword: string) => Promise; + onGetApiKey: (masterPassword: string) => Promise; + onRotateApiKey: (masterPassword: string) => Promise; onRefreshAuthorizedDevices: () => Promise; onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise; onRevokeDeviceTrust: (device: AuthorizedDevice) => void; @@ -225,6 +227,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { onEnableTotp={props.onEnableTotp} onOpenDisableTotp={props.onOpenDisableTotp} onGetRecoveryCode={props.onGetRecoveryCode} + onGetApiKey={props.onGetApiKey} + onRotateApiKey={props.onRotateApiKey} onNotify={props.onNotify} /> diff --git a/webapp/src/components/SettingsPage.tsx b/webapp/src/components/SettingsPage.tsx index 5464219..b669e57 100644 --- a/webapp/src/components/SettingsPage.tsx +++ b/webapp/src/components/SettingsPage.tsx @@ -14,6 +14,8 @@ interface SettingsPageProps { onEnableTotp: (secret: string, token: string) => Promise; onOpenDisableTotp: () => void; onGetRecoveryCode: (masterPassword: string) => Promise; + onGetApiKey: (masterPassword: string) => Promise; + onRotateApiKey: (masterPassword: string) => Promise; onNotify?: (type: 'success' | 'error', text: string) => void; } @@ -48,6 +50,10 @@ export default function SettingsPage(props: SettingsPageProps) { const [totpLocked, setTotpLocked] = useState(props.totpEnabled); const [recoveryMasterPassword, setRecoveryMasterPassword] = useState(''); const [recoveryCode, setRecoveryCode] = useState(''); + const [apiKeyMasterPassword, setApiKeyMasterPassword] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false); + const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false); useEffect(() => { if (!props.totpEnabled) { @@ -87,6 +93,27 @@ export default function SettingsPage(props: SettingsPageProps) { props.onNotify?.('success', t('txt_recovery_code_loaded')); } + async function loadApiKey(): Promise { + try { + const key = await props.onGetApiKey(apiKeyMasterPassword); + setApiKey(key); + setApiKeyDialogOpen(true); + } catch (error) { + props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty')); + } + } + + async function doRotateApiKey(): Promise { + try { + const key = await props.onRotateApiKey(apiKeyMasterPassword); + setApiKey(key); + setApiKeyDialogOpen(true); + props.onNotify?.('success', t('txt_api_key_rotated')); + } catch (error) { + props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty')); + } + } + function formatDateTime(value: string | null | undefined): string { if (!value) return t('txt_dash'); const parsed = new Date(value); @@ -235,8 +262,105 @@ export default function SettingsPage(props: SettingsPageProps) { )} + +
+

{t('txt_api_key')}

+ +
+ + +
+
+ setApiKeyDialogOpen(false)} + onCancel={() => setApiKeyDialogOpen(false)} + > +
+
{t('txt_warning')}
+
{t('txt_api_key_warning_body')}
+
+ +
+
+ + {t('txt_oauth_client_credentials')} +
+ {([ + [t('txt_client_id'), `user.${props.profile.id}`], + [t('txt_client_secret'), apiKey], + [t('txt_scope'), 'api'], + [t('txt_grant_type'), 'client_credentials'], + ] as [string, string][]).map(([label, value]) => ( + + ))} +
+
+ { + setRotateApiKeyConfirmOpen(false); + void doRotateApiKey(); + }} + onCancel={() => setRotateApiKeyConfirmOpen(false)} + /> ); } diff --git a/webapp/src/hooks/useAccountSecurityActions.ts b/webapp/src/hooks/useAccountSecurityActions.ts index 6b46ca4..797f6fb 100644 --- a/webapp/src/hooks/useAccountSecurityActions.ts +++ b/webapp/src/hooks/useAccountSecurityActions.ts @@ -5,7 +5,9 @@ import { deleteAuthorizedDevice, deriveLoginHash, getCurrentDeviceIdentifier, + getApiKey, getTotpRecoveryCode, + rotateApiKey, revokeAuthorizedDeviceTrust, revokeAllAuthorizedDeviceTrust, setTotp, @@ -148,6 +150,26 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct return code; }, + async getApiKey(masterPassword: string): Promise { + if (!profile) throw new Error(t('txt_profile_unavailable')); + const normalized = String(masterPassword || ''); + if (!normalized) throw new Error(t('txt_master_password_is_required')); + const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations); + const key = await getApiKey(authedFetch, derived.hash); + if (!key) throw new Error(t('txt_api_key_is_empty')); + return key; + }, + + async rotateApiKey(masterPassword: string): Promise { + if (!profile) throw new Error(t('txt_profile_unavailable')); + const normalized = String(masterPassword || ''); + if (!normalized) throw new Error(t('txt_master_password_is_required')); + const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations); + const key = await rotateApiKey(authedFetch, derived.hash); + if (!key) throw new Error(t('txt_api_key_is_empty')); + return key; + }, + async refreshAuthorizedDevices() { await refetchAuthorizedDevices(); }, diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index 2f5e24c..2d8ebce 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -594,3 +594,31 @@ export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Prom const resp = await authedFetch('/api/devices', { method: 'DELETE' }); if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed')); } + +export async function getApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise { + const resp = await authedFetch('/api/accounts/api-key', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ masterPasswordHash }), + }); + if (!resp.ok) { + const body = await parseJson(resp); + throw new Error(body?.error_description || body?.error || 'Failed to get API key'); + } + const body = (await parseJson<{ apiKey?: string }>(resp)) || {}; + return String(body.apiKey || ''); +} + +export async function rotateApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise { + const resp = await authedFetch('/api/accounts/rotate-api-key', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ masterPasswordHash }), + }); + if (!resp.ok) { + const body = await parseJson(resp); + throw new Error(body?.error_description || body?.error || 'Failed to rotate API key'); + } + const body = (await parseJson<{ apiKey?: string }>(resp)) || {}; + return String(body.apiKey || ''); +} diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index bc7a1a0..439a25f 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -601,6 +601,21 @@ const messages: Record> = { txt_recovery_code_copied: "Recovery code copied", txt_recovery_code_is_empty: "Recovery code is empty", txt_recovery_code_loaded: "Recovery code loaded", + txt_api_key: "API Key", + txt_view_api_key: "View API Key", + txt_rotate_api_key: "Rotate API Key", + txt_api_key_copied: "API key copied", + txt_api_key_loaded: "API key loaded", + txt_api_key_rotated: "API key rotated", + txt_rotate_api_key_confirm: "Rotate API key? The current key will stop working immediately.", + txt_api_key_is_empty: "API key is empty", + txt_api_key_dialog_intro: "Your API key can be used to authenticate with the Bitwarden CLI.", + txt_api_key_warning_body: "Your API key is an alternative authentication mechanism. Keep it secret.", + txt_oauth_client_credentials: "OAuth 2.0 Client Credentials", + txt_client_id: "client_id", + txt_client_secret: "client_secret", + txt_scope: "scope", + txt_grant_type: "grant_type", txt_refresh: "Refresh", txt_refresh_in_seconds_s: "Refresh in {seconds}s", txt_regenerate: "Regenerate", @@ -1363,6 +1378,21 @@ const zhCNOverrides: Record = { txt_recovery_code_copied: '恢复代码已复制', txt_recovery_code_is_empty: '恢复代码为空', txt_recovery_code_loaded: '恢复代码已加载', + txt_api_key: 'API 密钥', + txt_view_api_key: '查看 API 密钥', + txt_rotate_api_key: '轮换 API 密钥', + txt_api_key_copied: 'API 密钥已复制', + txt_api_key_loaded: 'API 密钥已加载', + txt_api_key_rotated: 'API 密钥已轮换', + txt_rotate_api_key_confirm: '轮换 API 密钥?当前密钥将立即失效。', + txt_api_key_is_empty: 'API 密钥为空', + txt_api_key_dialog_intro: '您的 API 密钥可用于在 Bitwarden CLI 中进行身份验证。', + txt_api_key_warning_body: '您的 API 密钥是一种替代身份验证机制。请严格保密。', + txt_oauth_client_credentials: 'OAuth 2.0 客户端凭据', + txt_client_id: 'client_id', + txt_client_secret: 'client_secret', + txt_scope: 'scope', + txt_grant_type: 'grant_type', txt_refresh_in_seconds_s: '{seconds} 秒后刷新', txt_registration_succeeded_please_sign_in: '注册成功,请登录', txt_remove_device: '移除设备',