Feature: Secret Key User System & Inline Qwen OAuth

Phase 1: User Authentication System
- Added user-data.js: Secret code generation, user creation, session management
- Added UserAuth.tsx: LoginGate, onboarding wizard, secret code reveal dialog
- Users get isolated environments (projects, chats, API keys)

Phase 2: Inline Qwen OAuth (No External CLI)
- Added qwen-oauth.js: Device Authorization Grant with PKCE
- Added QwenAuthDialog.tsx: Full inline auth flow with user code display
- Tokens saved per-user with legacy fallback

Phase 3: Integration
- Updated main.js with IPC handlers for user auth and Qwen OAuth
- Updated preload.js with electron.user and electron.qwenAuth bridges
- Wrapped App.tsx with LoginGate for authentication enforcement

Based on analysis of qwen-code repository OAuth implementation.
This commit is contained in:
Gemini AI
2025-12-20 18:31:50 +04:00
Unverified
parent f35f7bd6c5
commit b6f2c68243
9 changed files with 2863 additions and 5 deletions

View File

@@ -0,0 +1,474 @@
/**
* User Data Service for Goose Ultra
*
* Manages user authentication, session, and data isolation.
* Each user has their own isolated environment with separate:
* - Projects
* - Chat history
* - API keys (Qwen, Ollama)
* - Custom personas
* - Settings
*/
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { app } from 'electron';
// ===== USER DATA STRUCTURE =====
/**
* @typedef {Object} GooseUser
* @property {string} userId - UUID
* @property {string} displayName - User's chosen display name
* @property {string} secretCodeHash - SHA256 hash of the secret code
* @property {string} secretQuestionId - ID of the secret question used
* @property {number} createdAt - Timestamp
* @property {number} lastLoginAt - Timestamp
*/
/**
* @typedef {Object} UserSession
* @property {string} userId
* @property {string} displayName
* @property {number} loginAt
*/
// ===== FILE PATHS =====
const getSystemDir = () => path.join(app.getPath('userData'), 'system');
const getUsersFile = () => path.join(getSystemDir(), 'users.json');
const getSessionFile = () => path.join(getSystemDir(), 'current_session.json');
const getUserDataDir = () => path.join(app.getPath('userData'), 'user_data');
// ===== SECRET QUESTIONS =====
export const SECRET_QUESTIONS = [
{ id: 'mother_maiden', question: "What is your mother's maiden name?" },
{ id: 'first_pet', question: "What was your first pet's name?" },
{ id: 'favorite_teacher', question: "What was your favorite teacher's name?" },
{ id: 'birth_city', question: "In what city were you born?" },
{ id: 'first_car', question: "What was the make of your first car?" },
{ id: 'childhood_nickname', question: "What was your childhood nickname?" },
{ id: 'custom', question: "Custom question (user-defined)" }
];
// ===== SECRET CODE GENERATION =====
/**
* Generate a unique secret code for a new user
* Format: GU-XXXX-XXXX-XXXX (16 alphanumeric chars)
*
* @param {string} displayName
* @param {string} questionId
* @param {string} answer
* @returns {string} The secret code
*/
export function generateSecretCode(displayName, questionId, answer) {
const timestamp = Date.now().toString();
const salt = crypto.randomBytes(16).toString('hex');
const raw = `${displayName}|${questionId}|${answer}|${timestamp}|${salt}`;
// Create a hash and take 12 bytes
const hash = crypto.createHash('sha256').update(raw).digest();
const encoded = hash.slice(0, 12).toString('base64url').toUpperCase();
// Format as GU-XXXX-XXXX-XXXX
const formatted = `GU-${encoded.slice(0, 4)}-${encoded.slice(4, 8)}-${encoded.slice(8, 12)}`;
return formatted;
}
/**
* Hash a secret code for secure storage
* @param {string} secretCode
* @returns {string} SHA256 hash
*/
export function hashSecretCode(secretCode) {
// Normalize the code (remove dashes, uppercase)
const normalized = secretCode.replace(/-/g, '').toUpperCase();
return crypto.createHash('sha256').update(normalized).digest('hex');
}
// ===== USER MANAGEMENT =====
/**
* Ensure system directories exist
*/
function ensureSystemDirs() {
const systemDir = getSystemDir();
const userDataDir = getUserDataDir();
if (!fs.existsSync(systemDir)) {
fs.mkdirSync(systemDir, { recursive: true });
}
if (!fs.existsSync(userDataDir)) {
fs.mkdirSync(userDataDir, { recursive: true });
}
}
/**
* Load all registered users
* @returns {GooseUser[]}
*/
export function loadUsers() {
ensureSystemDirs();
try {
if (fs.existsSync(getUsersFile())) {
return JSON.parse(fs.readFileSync(getUsersFile(), 'utf8'));
}
} catch (e) {
console.error('[UserData] Failed to load users:', e.message);
}
return [];
}
/**
* Save users list
* @param {GooseUser[]} users
*/
function saveUsers(users) {
ensureSystemDirs();
fs.writeFileSync(getUsersFile(), JSON.stringify(users, null, 2));
}
/**
* Create a new user account
*
* @param {string} displayName
* @param {string} questionId
* @param {string} answer
* @returns {{ user: GooseUser, secretCode: string }}
*/
export function createUser(displayName, questionId, answer) {
ensureSystemDirs();
const userId = crypto.randomUUID();
const secretCode = generateSecretCode(displayName, questionId, answer);
const secretCodeHash = hashSecretCode(secretCode);
const now = Date.now();
const user = {
userId,
displayName,
secretCodeHash,
secretQuestionId: questionId,
createdAt: now,
lastLoginAt: now
};
// Add to users list
const users = loadUsers();
users.push(user);
saveUsers(users);
// Create user's data directory
const userDir = path.join(getUserDataDir(), userId);
fs.mkdirSync(userDir, { recursive: true });
// Create subdirectories
fs.mkdirSync(path.join(userDir, 'projects'), { recursive: true });
fs.mkdirSync(path.join(userDir, 'chats'), { recursive: true });
fs.mkdirSync(path.join(userDir, 'vault'), { recursive: true });
// Initialize settings
const defaultSettings = {
preferredFramework: null,
chatPersona: 'assistant',
theme: 'dark',
createdAt: now
};
fs.writeFileSync(
path.join(userDir, 'settings.json'),
JSON.stringify(defaultSettings, null, 2)
);
console.log('[UserData] Created new user:', userId, displayName);
return { user, secretCode };
}
/**
* Authenticate a user with their secret code
*
* @param {string} secretCode
* @returns {GooseUser | null}
*/
export function authenticateUser(secretCode) {
const hash = hashSecretCode(secretCode);
const users = loadUsers();
const user = users.find(u => u.secretCodeHash === hash);
if (user) {
// Update last login
user.lastLoginAt = Date.now();
saveUsers(users);
console.log('[UserData] User authenticated:', user.userId);
return user;
}
console.log('[UserData] Authentication failed: invalid secret code');
return null;
}
// ===== SESSION MANAGEMENT =====
/**
* Start a user session
* @param {GooseUser} user
*/
export function startSession(user) {
const session = {
userId: user.userId,
displayName: user.displayName,
loginAt: Date.now()
};
ensureSystemDirs();
fs.writeFileSync(getSessionFile(), JSON.stringify(session, null, 2));
console.log('[UserData] Session started for:', user.displayName);
return session;
}
/**
* Get the current active session
* @returns {UserSession | null}
*/
export function getCurrentSession() {
try {
if (fs.existsSync(getSessionFile())) {
return JSON.parse(fs.readFileSync(getSessionFile(), 'utf8'));
}
} catch (e) {
console.error('[UserData] Failed to load session:', e.message);
}
return null;
}
/**
* End the current session (logout)
*/
export function endSession() {
try {
if (fs.existsSync(getSessionFile())) {
fs.unlinkSync(getSessionFile());
console.log('[UserData] Session ended');
}
} catch (e) {
console.error('[UserData] Failed to end session:', e.message);
}
}
// ===== USER DATA PATHS =====
/**
* Get the data directory for a specific user
* @param {string} userId
*/
export function getUserDirectory(userId) {
return path.join(getUserDataDir(), userId);
}
/**
* Get the projects directory for a user
* @param {string} userId
*/
export function getUserProjectsDir(userId) {
return path.join(getUserDirectory(userId), 'projects');
}
/**
* Get the chats directory for a user
* @param {string} userId
*/
export function getUserChatsDir(userId) {
return path.join(getUserDirectory(userId), 'chats');
}
/**
* Get the vault directory for a user
* @param {string} userId
*/
export function getUserVaultDir(userId) {
return path.join(getUserDirectory(userId), 'vault');
}
/**
* Get user settings path
* @param {string} userId
*/
export function getUserSettingsPath(userId) {
return path.join(getUserDirectory(userId), 'settings.json');
}
// ===== DATA CLEANUP =====
/**
* Clean all data for a specific user
* This removes:
* - All projects
* - All chats
* - All saved credentials
* - Custom personas
* - Settings
*
* Note: The user account itself remains intact
*
* @param {string} userId
*/
export function cleanUserData(userId) {
const userDir = getUserDirectory(userId);
if (!fs.existsSync(userDir)) {
console.log('[UserData] No data to clean for user:', userId);
return;
}
// Remove all contents but keep the directory structure
const removeContents = (dir) => {
if (!fs.existsSync(dir)) return;
const items = fs.readdirSync(dir);
for (const item of items) {
const itemPath = path.join(dir, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
fs.rmSync(itemPath, { recursive: true, force: true });
} else {
fs.unlinkSync(itemPath);
}
}
};
// Clean each subdirectory
removeContents(path.join(userDir, 'projects'));
removeContents(path.join(userDir, 'chats'));
removeContents(path.join(userDir, 'vault'));
// Reset settings to default
const defaultSettings = {
preferredFramework: null,
chatPersona: 'assistant',
theme: 'dark',
createdAt: Date.now(),
cleanedAt: Date.now()
};
try {
fs.writeFileSync(
path.join(userDir, 'settings.json'),
JSON.stringify(defaultSettings, null, 2)
);
} catch (e) {
console.error('[UserData] Failed to reset settings:', e.message);
}
// Remove Qwen tokens
const tokenPath = path.join(userDir, 'qwen_tokens.json');
if (fs.existsSync(tokenPath)) {
fs.unlinkSync(tokenPath);
}
console.log('[UserData] Cleaned all data for user:', userId);
}
/**
* Delete a user account completely
* @param {string} userId
*/
export function deleteUser(userId) {
// Remove user data
const userDir = getUserDirectory(userId);
if (fs.existsSync(userDir)) {
fs.rmSync(userDir, { recursive: true, force: true });
}
// Remove from users list
let users = loadUsers();
users = users.filter(u => u.userId !== userId);
saveUsers(users);
console.log('[UserData] Deleted user:', userId);
}
// ===== MIGRATION =====
/**
* Migrate legacy global data to a user's isolated environment
* This is called when:
* 1. First user is created and old data exists
* 2. Explicitly requested by user
*
* @param {string} userId
* @param {string} legacyProjectsDir - Old global projects directory
*/
export function migrateGlobalDataToUser(userId, legacyProjectsDir) {
const userProjectsDir = getUserProjectsDir(userId);
if (!fs.existsSync(legacyProjectsDir)) {
console.log('[UserData] No legacy data to migrate');
return;
}
// Copy all projects
const projects = fs.readdirSync(legacyProjectsDir);
for (const project of projects) {
const src = path.join(legacyProjectsDir, project);
const dest = path.join(userProjectsDir, project);
if (fs.statSync(src).isDirectory()) {
fs.cpSync(src, dest, { recursive: true });
}
}
console.log('[UserData] Migrated', projects.length, 'projects to user:', userId);
}
// ===== STATISTICS =====
/**
* Get statistics about a user's data
* @param {string} userId
*/
export function getUserStats(userId) {
const userDir = getUserDirectory(userId);
const countItems = (dir) => {
try {
return fs.existsSync(dir) ? fs.readdirSync(dir).length : 0;
} catch {
return 0;
}
};
const getDirSize = (dir) => {
if (!fs.existsSync(dir)) return 0;
let size = 0;
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const item of items) {
const itemPath = path.join(dir, item.name);
if (item.isDirectory()) {
size += getDirSize(itemPath);
} else {
size += fs.statSync(itemPath).size;
}
}
return size;
};
return {
projectCount: countItems(getUserProjectsDir(userId)),
chatCount: countItems(getUserChatsDir(userId)),
totalSizeBytes: getDirSize(userDir),
hasQwenTokens: fs.existsSync(path.join(userDir, 'qwen_tokens.json'))
};
}