new feature for auth
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user