new feature for auth

This commit is contained in:
2025-11-30 19:11:02 +08:00
parent 325f090779
commit d067356881
7 changed files with 332 additions and 26 deletions
+1
View File
@@ -199,6 +199,7 @@
"ahooks": "^3.9.6",
"antd": "^5.28.1",
"antd-style": "^3.7.1",
"bcryptjs": "^3.0.3",
"brotli-wasm": "^3.0.1",
"chroma-js": "^3.1.2",
"cmdk": "^1.1.1",
+4
View File
@@ -17,10 +17,14 @@
"@lobechat/const": "workspace:*",
"@lobechat/types": "workspace:*",
"@lobechat/utils": "workspace:*",
"bcryptjs": "^3.0.3",
"random-words": "^2.0.1",
"ts-md5": "^2.0.1",
"ws": "^8.18.3"
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0"
},
"peerDependencies": {
"@electric-sql/pglite": "^0.2.17",
"dayjs": ">=1.11.19",
@@ -2,7 +2,7 @@
import { and, eq } from 'drizzle-orm';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { LobeChatDatabase } from '../../type';import { sleep } from '@/utils/sleep';
import { sleep } from '@/utils/sleep';
import {
NewKnowledgeBase,
@@ -12,9 +12,12 @@ import {
knowledgeBases,
users,
} from '../../schemas';
import { LobeChatDatabase } from '../../type';
import { KnowledgeBaseModel } from '../knowledgeBase';
import { getTestDB } from './_util';
import { sleep } from '@/utils/sleep';
const serverDB: LobeChatDatabase = await getTestDB();
const userId = 'session-group-model-test-user-id';
@@ -228,6 +231,76 @@ describe('KnowledgeBaseModel', () => {
expect(remainingFiles).toHaveLength(1);
expect(remainingFiles[0].fileId).toBe('file2');
});
it('should only remove files that belong to the current user', async () => {
// Create files for user1
await serverDB.insert(globalFiles).values([
{
hashId: 'hash1',
url: 'https://example.com/document.pdf',
size: 1000,
fileType: 'application/pdf',
creator: userId,
},
]);
await serverDB.insert(files).values([
{
id: 'file1',
name: 'document.pdf',
url: 'https://example.com/document.pdf',
fileHash: 'hash1',
size: 1000,
fileType: 'application/pdf',
userId,
},
]);
// Create files for user2
await serverDB.insert(globalFiles).values([
{
hashId: 'hash3',
url: 'https://example.com/user2-doc.pdf',
size: 2000,
fileType: 'application/pdf',
creator: 'user2',
},
]);
await serverDB.insert(files).values([
{
id: 'file3',
name: 'user2-doc.pdf',
url: 'https://example.com/user2-doc.pdf',
fileHash: 'hash3',
size: 2000,
fileType: 'application/pdf',
userId: 'user2',
},
]);
// user1 creates a knowledge base
const { id: knowledgeBaseId } = await knowledgeBaseModel.create({ name: 'User1 KB' });
await knowledgeBaseModel.addFilesToKnowledgeBase(knowledgeBaseId, ['file1']);
// user2 creates a knowledge base with their file
const user2KnowledgeBaseModel = new KnowledgeBaseModel(serverDB, 'user2');
const { id: user2KnowledgeBaseId } = await user2KnowledgeBaseModel.create({
name: 'User2 KB',
});
await user2KnowledgeBaseModel.addFilesToKnowledgeBase(user2KnowledgeBaseId, ['file3']);
// user1 tries to remove files from user2's knowledge base
// This should NOT remove user2's files due to userId check
await knowledgeBaseModel.removeFilesFromKnowledgeBase(user2KnowledgeBaseId, ['file3']);
// Verify that user2's files are still in their knowledge base
const user2Files = await serverDB.query.knowledgeBaseFiles.findMany({
where: eq(knowledgeBaseFiles.knowledgeBaseId, user2KnowledgeBaseId),
});
expect(user2Files).toHaveLength(1);
expect(user2Files[0].fileId).toBe('file3');
});
});
describe('static findById', () => {
@@ -43,13 +43,15 @@ export class KnowledgeBaseModel {
};
removeFilesFromKnowledgeBase = async (knowledgeBaseId: string, ids: string[]) => {
return this.db.delete(knowledgeBaseFiles).where(
and(
eq(knowledgeBaseFiles.knowledgeBaseId, knowledgeBaseId),
inArray(knowledgeBaseFiles.fileId, ids),
// eq(knowledgeBaseFiles.userId, this.userId),
),
);
return this.db
.delete(knowledgeBaseFiles)
.where(
and(
eq(knowledgeBaseFiles.knowledgeBaseId, knowledgeBaseId),
inArray(knowledgeBaseFiles.fileId, ids),
eq(knowledgeBaseFiles.userId, this.userId),
),
);
};
// query
query = async () => {
+72 -18
View File
@@ -1,34 +1,44 @@
/* eslint-disable sort-keys-fix/sort-keys-fix */
import type { CustomPluginParams } from '@lobechat/types';
import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { boolean, jsonb, pgTable, primaryKey, text } from 'drizzle-orm/pg-core';
import { boolean, index, jsonb, pgTable, primaryKey, text } from 'drizzle-orm/pg-core';
import { DEFAULT_PREFERENCE } from '@/const/user';
import { timestamps, timestamptz } from './_helpers';
export const users = pgTable('users', {
id: text('id').primaryKey().notNull(),
username: text('username').unique(),
email: text('email'),
export const users = pgTable(
'users',
{
id: text('id').primaryKey().notNull(),
username: text('username').unique(),
email: text('email'),
avatar: text('avatar'),
phone: text('phone'),
firstName: text('first_name'),
lastName: text('last_name'),
fullName: text('full_name'),
avatar: text('avatar'),
phone: text('phone'),
firstName: text('first_name'),
lastName: text('last_name'),
fullName: text('full_name'),
isOnboarded: boolean('is_onboarded').default(false),
// Time user was created in Clerk
clerkCreatedAt: timestamptz('clerk_created_at'),
isOnboarded: boolean('is_onboarded').default(false),
// Time user was created in Clerk
clerkCreatedAt: timestamptz('clerk_created_at'),
// Required by nextauth, all null allowed
emailVerifiedAt: timestamptz('email_verified_at'),
// Required by nextauth, all null allowed
emailVerifiedAt: timestamptz('email_verified_at'),
preference: jsonb('preference').$defaultFn(() => DEFAULT_PREFERENCE),
// Local authentication - password hash stored using bcrypt/argon2
passwordHash: text('password_hash'),
...timestamps,
});
preference: jsonb('preference').$defaultFn(() => DEFAULT_PREFERENCE),
...timestamps,
},
(table) => ({
// Index for email lookups during local auth
emailIdx: index('users_email_idx').on(table.email),
}),
);
export type NewUser = typeof users.$inferInsert;
export type UserItem = typeof users.$inferSelect;
@@ -72,3 +82,47 @@ export const userInstalledPlugins = pgTable(
export type NewInstalledPlugin = typeof userInstalledPlugins.$inferInsert;
export type InstalledPluginItem = typeof userInstalledPlugins.$inferSelect;
// Password reset tokens - for "forgot password" flow
export const passwordResetTokens = pgTable(
'password_reset_tokens',
{
id: text('id').primaryKey().notNull(),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
token: text('token').notNull().unique(),
expiresAt: timestamptz('expires_at').notNull(),
...timestamps,
},
(table) => ({
userIdIdx: index('password_reset_tokens_user_id_idx').on(table.userId),
tokenIdx: index('password_reset_tokens_token_idx').on(table.token),
}),
);
export type NewPasswordResetToken = typeof passwordResetTokens.$inferInsert;
export type PasswordResetTokenItem = typeof passwordResetTokens.$inferSelect;
// Email verification tokens - for local email registration
export const emailVerificationTokens = pgTable(
'email_verification_tokens',
{
id: text('id').primaryKey().notNull(),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
email: text('email').notNull(),
token: text('token').notNull().unique(),
expiresAt: timestamptz('expires_at').notNull(),
...timestamps,
},
(table) => ({
userIdIdx: index('email_verification_tokens_user_id_idx').on(table.userId),
tokenIdx: index('email_verification_tokens_token_idx').on(table.token),
emailIdx: index('email_verification_tokens_email_idx').on(table.email),
}),
);
export type NewEmailVerificationToken = typeof emailVerificationTokens.$inferInsert;
export type EmailVerificationTokenItem = typeof emailVerificationTokens.$inferSelect;
+106
View File
@@ -0,0 +1,106 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { hashPassword, validatePasswordStrength, verifyPassword } from './password';
describe('Password Utils', () => {
describe('hashPassword', () => {
it('should hash a password', async () => {
const password = 'TestPassword123!';
const hashed = await hashPassword(password);
expect(hashed).toBeDefined();
expect(hashed).not.toBe(password);
expect(hashed.length).toBeGreaterThan(0);
});
it('should generate different hashes for the same password', async () => {
const password = 'TestPassword123!';
const hash1 = await hashPassword(password);
const hash2 = await hashPassword(password);
// Bcrypt uses salts, so hashes should be different
expect(hash1).not.toBe(hash2);
});
});
describe('verifyPassword', () => {
it('should verify correct password', async () => {
const password = 'TestPassword123!';
const hashed = await hashPassword(password);
const isValid = await verifyPassword(password, hashed);
expect(isValid).toBe(true);
});
it('should reject incorrect password', async () => {
const password = 'TestPassword123!';
const wrongPassword = 'WrongPassword123!';
const hashed = await hashPassword(password);
const isValid = await verifyPassword(wrongPassword, hashed);
expect(isValid).toBe(false);
});
it('should be case sensitive', async () => {
const password = 'TestPassword123!';
const hashed = await hashPassword(password);
const isValid = await verifyPassword('testpassword123!', hashed);
expect(isValid).toBe(false);
});
});
describe('validatePasswordStrength', () => {
it('should accept strong password', () => {
const result = validatePasswordStrength('StrongPass123!');
expect(result.valid).toBe(true);
expect(result.message).toBeUndefined();
});
it('should reject password shorter than 8 characters', () => {
const result = validatePasswordStrength('Weak1!');
expect(result.valid).toBe(false);
expect(result.message).toContain('at least 8 characters');
});
it('should reject password longer than 128 characters', () => {
const longPassword = 'A'.repeat(129) + 'a1!';
const result = validatePasswordStrength(longPassword);
expect(result.valid).toBe(false);
expect(result.message).toContain('must not exceed 128 characters');
});
it('should reject password without lowercase letter', () => {
const result = validatePasswordStrength('UPPERCASE123!');
expect(result.valid).toBe(false);
expect(result.message).toContain('lowercase letter');
});
it('should reject password without uppercase letter', () => {
const result = validatePasswordStrength('lowercase123!');
expect(result.valid).toBe(false);
expect(result.message).toContain('uppercase letter');
});
it('should reject password without number', () => {
const result = validatePasswordStrength('NoNumbers!');
expect(result.valid).toBe(false);
expect(result.message).toContain('number');
});
it('should reject password without special character', () => {
const result = validatePasswordStrength('NoSpecial123');
expect(result.valid).toBe(false);
expect(result.message).toContain('special character');
});
it('should accept various special characters', () => {
const specialChars = '!@#$%^&*(),.?":{}|<>';
for (const char of specialChars) {
const result = validatePasswordStrength(`Password1${char}`);
expect(result.valid).toBe(true);
}
});
});
});
+66
View File
@@ -0,0 +1,66 @@
import bcrypt from 'bcryptjs';
/**
* Hash a plain text password using bcrypt
* @param password - Plain text password
* @returns Hashed password
*/
export const hashPassword = async (password: string): Promise<string> => {
const saltRounds = 10;
return bcrypt.hash(password, saltRounds);
};
/**
* Verify a plain text password against a hashed password
* @param password - Plain text password to verify
* @param hashedPassword - Hashed password to compare against
* @returns True if password matches, false otherwise
*/
export const verifyPassword = async (
password: string,
hashedPassword: string,
): Promise<boolean> => {
return bcrypt.compare(password, hashedPassword);
};
/**
* Validate password strength
* @param password - Password to validate
* @returns Object with validation result and message
*/
export const validatePasswordStrength = (
password: string,
): { message?: string, valid: boolean; } => {
if (password.length < 8) {
return { message: 'Password must be at least 8 characters long', valid: false };
}
if (password.length > 128) {
return { message: 'Password must not exceed 128 characters', valid: false };
}
// At least one lowercase letter
if (!/[a-z]/.test(password)) {
return { message: 'Password must contain at least one lowercase letter', valid: false };
}
// At least one uppercase letter
if (!/[A-Z]/.test(password)) {
return { message: 'Password must contain at least one uppercase letter', valid: false };
}
// At least one number
if (!/\d/.test(password)) {
return { message: 'Password must contain at least one number', valid: false };
}
// At least one special character
if (!/[!"#$%&()*,.:<>?@^{|}]/.test(password)) {
return {
message: 'Password must contain at least one special character (!@#$%^&*(),.?":{}|<>)',
valid: false,
};
}
return { valid: true };
};