create-nara 1.0.46 → 1.0.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of create-nara might be problematic. Click here for more details.

package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nara",
3
- "version": "1.0.46",
3
+ "version": "1.0.47",
4
4
  "description": "CLI to scaffold NARA projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,16 +1,18 @@
1
1
  import { BaseController, jsonSuccess, jsonError } from '@nara-web/core';
2
2
  import type { NaraRequest, NaraResponse } from '@nara-web/core';
3
3
  import { UserModel } from '../models/User.js';
4
+ import { SessionModel } from '../models/Session.js';
5
+ import Authenticate from '../services/Authenticate.js';
4
6
  import LoginThrottle from '../services/LoginThrottle.js';
5
7
  import Logger from '../services/Logger.js';
6
- import bcrypt from 'bcrypt';
7
- import jwt from 'jsonwebtoken';
8
+ import { randomUUID } from 'crypto';
8
9
  import { AUTH, ERROR_MESSAGES } from '../config/index.js';
9
10
 
10
- const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
11
- const JWT_EXPIRES_SECONDS = AUTH.JWT_EXPIRY_SECONDS;
11
+ // Session cookie configuration
12
+ const SESSION_COOKIE_NAME = 'auth_id';
13
+ const SESSION_EXPIRY_MS = 1000 * 60 * 60 * 24 * 60; // 60 days
12
14
 
13
- // Cookie options for auth token
15
+ // Cookie options for auth session
14
16
  const COOKIE_OPTIONS = {
15
17
  httpOnly: true,
16
18
  secure: process.env.NODE_ENV === 'production',
@@ -68,7 +70,8 @@ export class AuthController extends BaseController {
68
70
  return res.redirect('/login');
69
71
  }
70
72
 
71
- const passwordMatch = await bcrypt.compare(password, user.password);
73
+ // Verify password using PBKDF2
74
+ const passwordMatch = await Authenticate.compare(password, user.password);
72
75
  if (!passwordMatch) {
73
76
  const throttleResult = LoginThrottle.recordFailedAttempt(email, ip);
74
77
 
@@ -96,17 +99,18 @@ export class AuthController extends BaseController {
96
99
  ip,
97
100
  });
98
101
 
99
- // Generate JWT token with user info
100
- const token = jwt.sign(
101
- { userId: user.id, email: user.email, name: user.name },
102
- JWT_SECRET,
103
- { expiresIn: AUTH.JWT_EXPIRY_SECONDS }
104
- );
102
+ // Create session in database
103
+ const sessionId = randomUUID();
104
+ await SessionModel.create({
105
+ id: sessionId,
106
+ user_id: user.id,
107
+ user_agent: req.headers['user-agent'] || null,
108
+ });
105
109
 
106
- // Set auth cookie for web routes (maxAge in ms)
107
- res.cookie('auth_token', token, JWT_EXPIRES_SECONDS * 1000, COOKIE_OPTIONS);
110
+ // Set session cookie
111
+ res.cookie(SESSION_COOKIE_NAME, sessionId, SESSION_EXPIRY_MS, COOKIE_OPTIONS);
108
112
 
109
- // Redirect to dashboard (Inertia will handle it)
113
+ // Redirect to dashboard
110
114
  return res.redirect('/dashboard');
111
115
  }
112
116
 
@@ -135,12 +139,13 @@ export class AuthController extends BaseController {
135
139
  return res.redirect('/register');
136
140
  }
137
141
 
138
- // Hash password
139
- const hashedPassword = await bcrypt.hash(password, AUTH.BCRYPT_SALT_ROUNDS);
142
+ // Hash password using PBKDF2
143
+ const hashedPassword = await Authenticate.hash(password);
140
144
 
141
145
  try {
142
- // Create user in database
143
- const [userId] = await UserModel.create({ name, email, password: hashedPassword });
146
+ // Create user in database with UUID
147
+ const userId = randomUUID();
148
+ await UserModel.create({ id: userId, name, email, password: hashedPassword });
144
149
 
145
150
  Logger.logAuth('registration_success', {
146
151
  userId,
@@ -148,15 +153,16 @@ export class AuthController extends BaseController {
148
153
  ip: req.ip,
149
154
  });
150
155
 
151
- // Generate JWT token
152
- const token = jwt.sign(
153
- { userId, email, name },
154
- JWT_SECRET,
155
- { expiresIn: AUTH.JWT_EXPIRY_SECONDS }
156
- );
156
+ // Create session
157
+ const sessionId = randomUUID();
158
+ await SessionModel.create({
159
+ id: sessionId,
160
+ user_id: userId,
161
+ user_agent: req.headers['user-agent'] || null,
162
+ });
157
163
 
158
- // Set auth cookie for web routes (maxAge in ms)
159
- res.cookie('auth_token', token, JWT_EXPIRES_SECONDS * 1000, COOKIE_OPTIONS);
164
+ // Set session cookie
165
+ res.cookie(SESSION_COOKIE_NAME, sessionId, SESSION_EXPIRY_MS, COOKIE_OPTIONS);
160
166
 
161
167
  // Redirect to dashboard
162
168
  return res.redirect('/dashboard');
@@ -178,8 +184,14 @@ export class AuthController extends BaseController {
178
184
  }
179
185
 
180
186
  async logout(req: NaraRequest, res: NaraResponse) {
181
- // Clear auth cookie (set maxAge to 0)
182
- res.cookie('auth_token', '', 0, COOKIE_OPTIONS);
187
+ // Delete session from database
188
+ const sessionId = req.cookies?.[SESSION_COOKIE_NAME];
189
+ if (sessionId) {
190
+ await SessionModel.delete(sessionId);
191
+ }
192
+
193
+ // Clear session cookie
194
+ res.cookie(SESSION_COOKIE_NAME, '', 0, COOKIE_OPTIONS);
183
195
 
184
196
  Logger.logAuth('logout', {
185
197
  userId: req.user?.id,
@@ -198,9 +210,8 @@ export class AuthController extends BaseController {
198
210
  return res.redirect('/forgot-password');
199
211
  }
200
212
 
201
- // TODO: Implement actual password reset email sending
213
+ // TODO: Implement password reset (requires email service)
202
214
 
203
- // Set success message and redirect
204
215
  res.cookie('success', 'If an account exists with this email, a reset link has been sent.', AUTH.ERROR_COOKIE_EXPIRY_MS);
205
216
  return res.redirect('/login');
206
217
  }
@@ -213,7 +224,7 @@ export class AuthController extends BaseController {
213
224
  return res.redirect('/forgot-password');
214
225
  }
215
226
 
216
- // TODO: Implement actual password reset
227
+ // TODO: Implement password reset (requires email service)
217
228
 
218
229
  res.cookie('success', 'Password has been reset successfully.', AUTH.ERROR_COOKIE_EXPIRY_MS);
219
230
  return res.redirect('/login');
@@ -1,80 +1,64 @@
1
1
  import type { NaraRequest, NaraResponse } from '@nara-web/core';
2
- import { UserModel } from '../models/User.js';
3
- import jwt from 'jsonwebtoken';
2
+ import { SessionModel } from '../models/Session.js';
4
3
 
5
- const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
4
+ const SESSION_COOKIE_NAME = 'auth_id';
6
5
 
7
6
  /**
8
- * Auth middleware for API routes (Bearer token)
7
+ * Auth middleware for API routes
8
+ * Returns 401 JSON if not authenticated
9
9
  */
10
10
  export function authMiddleware(req: NaraRequest, res: NaraResponse, next: () => void) {
11
- const authHeader = req.headers.authorization;
12
-
13
- if (!authHeader || !authHeader.startsWith('Bearer ')) {
11
+ if (!req.user) {
14
12
  res.status(401).json({ success: false, message: 'Unauthorized' });
15
13
  return;
16
14
  }
17
-
18
- const token = authHeader.substring(7);
19
-
20
- try {
21
- const decoded = jwt.verify(token, JWT_SECRET) as { userId: string; email: string };
22
- req.user = { id: decoded.userId, email: decoded.email, name: '' };
23
- next();
24
- } catch (error) {
25
- res.status(401).json({ success: false, message: 'Invalid token' });
26
- }
15
+ next();
27
16
  }
28
17
 
29
18
  /**
30
- * Auth middleware for web/Inertia routes (cookie-based)
19
+ * Auth middleware for web/Inertia routes (session-based)
31
20
  * Redirects to login if not authenticated
32
21
  */
33
22
  export async function webAuthMiddleware(req: NaraRequest, res: NaraResponse, next: () => void) {
34
- // Check for auth token in cookie
35
- const token = req.cookies?.auth_token;
23
+ const sessionId = req.cookies?.[SESSION_COOKIE_NAME];
36
24
  const isApiRoute = req.path.startsWith('/api/');
37
25
 
38
- if (!token) {
26
+ if (!sessionId) {
39
27
  if (isApiRoute) {
40
- // API routes return JSON
41
28
  res.status(401).json({ success: false, message: 'Unauthorized. Please log in.' });
42
29
  } else if (req.headers['x-inertia']) {
43
- // Inertia requests get redirect header
44
30
  res.status(409).setHeader('X-Inertia-Location', '/login').send('');
45
31
  } else {
46
- // Regular requests get redirected
47
32
  res.redirect('/login');
48
33
  }
49
34
  return;
50
35
  }
51
36
 
52
37
  try {
53
- const decoded = jwt.verify(token, JWT_SECRET) as { userId: string; email: string; name: string };
38
+ // Get user from session using optimized JOIN query
39
+ const user = await SessionModel.getUserBySessionId(sessionId);
54
40
 
55
- // Fetch fresh user data from database to include avatar and other fields
56
- const dbUser = await UserModel.findById(decoded.userId);
57
- if (dbUser) {
58
- (req as any).user = {
59
- id: dbUser.id,
60
- email: dbUser.email,
61
- name: dbUser.name,
62
- phone: dbUser.phone,
63
- avatar: dbUser.avatar,
64
- role: dbUser.role,
65
- is_admin: dbUser.role === 'admin',
66
- is_verified: !!dbUser.email_verified_at
67
- };
68
- } else {
69
- req.user = { id: decoded.userId, email: decoded.email, name: decoded.name };
41
+ if (!user) {
42
+ // Session not found or invalid, clear cookie
43
+ res.cookie(SESSION_COOKIE_NAME, '', 0);
44
+ if (isApiRoute) {
45
+ res.status(401).json({ success: false, message: 'Session expired. Please log in again.' });
46
+ } else if (req.headers['x-inertia']) {
47
+ res.status(409).setHeader('X-Inertia-Location', '/login').send('');
48
+ } else {
49
+ res.redirect('/login');
50
+ }
51
+ return;
70
52
  }
71
53
 
54
+ // Attach user to request
55
+ (req as any).user = user;
72
56
  next();
73
57
  } catch (error) {
74
- // Clear invalid token
75
- res.cookie('auth_token', '', 0);
58
+ // Clear invalid session
59
+ res.cookie(SESSION_COOKIE_NAME, '', 0);
76
60
  if (isApiRoute) {
77
- res.status(401).json({ success: false, message: 'Session expired. Please log in again.' });
61
+ res.status(401).json({ success: false, message: 'Session error. Please log in again.' });
78
62
  } else if (req.headers['x-inertia']) {
79
63
  res.status(409).setHeader('X-Inertia-Location', '/login').send('');
80
64
  } else {
@@ -87,22 +71,24 @@ export async function webAuthMiddleware(req: NaraRequest, res: NaraResponse, nex
87
71
  * Guest middleware - only allow unauthenticated users
88
72
  * Redirects to dashboard if already logged in
89
73
  */
90
- export function guestMiddleware(req: NaraRequest, res: NaraResponse, next: () => void) {
91
- const token = req.cookies?.auth_token;
74
+ export async function guestMiddleware(req: NaraRequest, res: NaraResponse, next: () => void) {
75
+ const sessionId = req.cookies?.[SESSION_COOKIE_NAME];
92
76
 
93
- if (token) {
77
+ if (sessionId) {
94
78
  try {
95
- jwt.verify(token, JWT_SECRET);
96
- // User is authenticated, redirect to dashboard
97
- if (req.headers['x-inertia']) {
98
- res.status(409).setHeader('X-Inertia-Location', '/dashboard').send('');
99
- } else {
100
- res.redirect('/dashboard');
79
+ const user = await SessionModel.getUserBySessionId(sessionId);
80
+ if (user) {
81
+ // User is authenticated, redirect to dashboard
82
+ if (req.headers['x-inertia']) {
83
+ res.status(409).setHeader('X-Inertia-Location', '/dashboard').send('');
84
+ } else {
85
+ res.redirect('/dashboard');
86
+ }
87
+ return;
101
88
  }
102
- return;
103
89
  } catch {
104
- // Invalid token, clear it and continue
105
- res.cookie('auth_token', '', 0);
90
+ // Invalid session, clear it and continue
91
+ res.cookie(SESSION_COOKIE_NAME, '', 0);
106
92
  }
107
93
  }
108
94
 
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Authentication Service
3
+ * Handles user authentication operations including password hashing, verification,
4
+ * session management, and login/logout functionality.
5
+ */
6
+
7
+ import { SessionModel } from '../models/Session.js';
8
+ import type { NaraRequest, NaraResponse } from '@nara-web/core';
9
+ import { randomUUID, pbkdf2Sync, randomBytes, timingSafeEqual } from 'crypto';
10
+
11
+ // PBKDF2 configuration
12
+ const ITERATIONS = 100000;
13
+ const KEYLEN = 64;
14
+ const DIGEST = 'sha512';
15
+ const SALT_SIZE = 16;
16
+
17
+ // Session cookie configuration
18
+ const SESSION_COOKIE_NAME = 'auth_id';
19
+ const SESSION_EXPIRY_MS = 1000 * 60 * 60 * 24 * 60; // 60 days
20
+
21
+ /**
22
+ * Secure cookie options
23
+ */
24
+ const getSecureCookieOptions = () => ({
25
+ httpOnly: true,
26
+ secure: process.env.NODE_ENV === 'production',
27
+ sameSite: 'lax' as const,
28
+ path: '/',
29
+ });
30
+
31
+ class Authenticate {
32
+ /**
33
+ * Hashes a plain text password using PBKDF2
34
+ */
35
+ async hash(password: string) {
36
+ const salt = randomBytes(SALT_SIZE).toString('hex');
37
+ const hash = pbkdf2Sync(password, salt, ITERATIONS, KEYLEN, DIGEST).toString('hex');
38
+ return `${salt}:${hash}`;
39
+ }
40
+
41
+ /**
42
+ * Compares a plain text password with a hashed password
43
+ * Uses timing-safe comparison to prevent timing attacks
44
+ */
45
+ async compare(password: string, storedHash: string) {
46
+ const [salt, hash] = storedHash.split(':');
47
+ if (!salt || !hash) return false;
48
+
49
+ const newHash = pbkdf2Sync(password, salt, ITERATIONS, KEYLEN, DIGEST).toString('hex');
50
+
51
+ // Use timing-safe comparison to prevent timing attacks
52
+ const hashBuffer = Buffer.from(hash, 'hex');
53
+ const newHashBuffer = Buffer.from(newHash, 'hex');
54
+
55
+ if (hashBuffer.length !== newHashBuffer.length) return false;
56
+
57
+ return timingSafeEqual(hashBuffer, newHashBuffer);
58
+ }
59
+
60
+ /**
61
+ * Processes user login by creating a new session
62
+ */
63
+ async process(user: any, request: NaraRequest, response: NaraResponse) {
64
+ const token = randomUUID();
65
+
66
+ await SessionModel.create({
67
+ id: token,
68
+ user_id: user.id,
69
+ user_agent: request.headers['user-agent'] || null,
70
+ });
71
+
72
+ response
73
+ .cookie(SESSION_COOKIE_NAME, token, SESSION_EXPIRY_MS, getSecureCookieOptions())
74
+ .redirect('/dashboard');
75
+ }
76
+
77
+ /**
78
+ * Handles user logout by removing the session
79
+ */
80
+ async logout(request: NaraRequest, response: NaraResponse) {
81
+ await SessionModel.delete(request.cookies[SESSION_COOKIE_NAME]);
82
+
83
+ response
84
+ .cookie(SESSION_COOKIE_NAME, '', 0, getSecureCookieOptions())
85
+ .redirect('/login');
86
+ }
87
+ }
88
+
89
+ export default new Authenticate();
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Session Model
3
+ *
4
+ * Handles session-related database operations.
5
+ */
6
+ import { db } from '../config/database.js';
7
+
8
+ export interface Session {
9
+ id: string;
10
+ user_id: string;
11
+ user_agent: string | null;
12
+ }
13
+
14
+ export class SessionModel {
15
+ static tableName = 'sessions';
16
+
17
+ static async findById(id: string): Promise<Session | undefined> {
18
+ return db(this.tableName).where({ id }).first();
19
+ }
20
+
21
+ static async findByUserId(userId: string): Promise<Session[]> {
22
+ return db(this.tableName).where({ user_id: userId });
23
+ }
24
+
25
+ static async create(data: Partial<Session>): Promise<number[]> {
26
+ return db(this.tableName).insert(data);
27
+ }
28
+
29
+ static async delete(id: string): Promise<number> {
30
+ return db(this.tableName).where({ id }).delete();
31
+ }
32
+
33
+ static async deleteByUserId(userId: string): Promise<number> {
34
+ return db(this.tableName).where({ user_id: userId }).delete();
35
+ }
36
+
37
+ /**
38
+ * Get user by session ID (optimized JOIN query)
39
+ */
40
+ static async getUserBySessionId(sessionId: string): Promise<{
41
+ id: string;
42
+ name: string | null;
43
+ email: string;
44
+ phone: string | null;
45
+ avatar: string | null;
46
+ is_admin: boolean;
47
+ } | undefined> {
48
+ return db('sessions')
49
+ .join('users', 'sessions.user_id', 'users.id')
50
+ .where('sessions.id', sessionId)
51
+ .select([
52
+ 'users.id',
53
+ 'users.name',
54
+ 'users.email',
55
+ 'users.phone',
56
+ 'users.avatar',
57
+ db.raw("CASE WHEN users.role = 'admin' THEN 1 ELSE 0 END as is_admin")
58
+ ])
59
+ .first();
60
+ }
61
+ }
@@ -8,7 +8,6 @@ export interface User {
8
8
  phone?: string;
9
9
  avatar?: string;
10
10
  role: string;
11
- email_verified_at: string | null;
12
11
  created_at: string;
13
12
  updated_at: string;
14
13
  }
@@ -0,0 +1,13 @@
1
+ import type { Knex } from 'knex';
2
+
3
+ export async function up(knex: Knex): Promise<void> {
4
+ await knex.schema.createTable('sessions', (table) => {
5
+ table.string('id').primary();
6
+ table.string('user_id').index();
7
+ table.text('user_agent');
8
+ });
9
+ }
10
+
11
+ export async function down(knex: Knex): Promise<void> {
12
+ await knex.schema.dropTableIfExists('sessions');
13
+ }
@@ -9,7 +9,6 @@ export async function up(knex: Knex): Promise<void> {
9
9
  table.string('phone').nullable();
10
10
  table.string('avatar').nullable();
11
11
  table.string('role').defaultTo('user');
12
- table.datetime('email_verified_at').nullable();
13
12
  table.datetime('created_at').defaultTo(knex.fn.now());
14
13
  table.datetime('updated_at').defaultTo(knex.fn.now());
15
14
  });
@@ -1,77 +0,0 @@
1
- /**
2
- * EmailVerificationToken Model
3
- *
4
- * Handles email verification token database operations.
5
- */
6
- import { db } from '../config/database.js';
7
-
8
- /**
9
- * EmailVerificationToken record interface
10
- */
11
- export interface EmailVerificationTokenRecord {
12
- id: number;
13
- user_id: string;
14
- token: string;
15
- created_at: number;
16
- expires_at: number;
17
- }
18
-
19
- /**
20
- * Data for creating a new email verification token
21
- */
22
- export interface CreateEmailVerificationTokenData {
23
- user_id: string;
24
- token: string;
25
- expires_at: number;
26
- }
27
-
28
- class EmailVerificationTokenModel {
29
- private tableName = 'email_verification_tokens';
30
-
31
- /**
32
- * Find valid token for user (not expired)
33
- */
34
- async findValidToken(userId: string, token: string): Promise<EmailVerificationTokenRecord | undefined> {
35
- return db(this.tableName)
36
- .where('user_id', userId)
37
- .where('token', token)
38
- .where('expires_at', '>', Date.now())
39
- .first();
40
- }
41
-
42
- /**
43
- * Find by user ID
44
- */
45
- async findByUserId(userId: string): Promise<EmailVerificationTokenRecord | undefined> {
46
- return db(this.tableName).where('user_id', userId).first();
47
- }
48
-
49
- /**
50
- * Create a new email verification token
51
- */
52
- async createToken(data: CreateEmailVerificationTokenData): Promise<void> {
53
- await db(this.tableName).insert({
54
- user_id: data.user_id,
55
- token: data.token,
56
- expires_at: data.expires_at,
57
- created_at: Date.now(),
58
- });
59
- }
60
-
61
- /**
62
- * Delete all tokens for a user
63
- */
64
- async deleteByUserId(userId: string): Promise<number> {
65
- return db(this.tableName).where('user_id', userId).delete();
66
- }
67
-
68
- /**
69
- * Delete expired tokens
70
- */
71
- async deleteExpired(): Promise<number> {
72
- return db(this.tableName).where('expires_at', '<', Date.now()).delete();
73
- }
74
- }
75
-
76
- export const EmailVerificationToken = new EmailVerificationTokenModel();
77
- export default EmailVerificationToken;
@@ -1,18 +0,0 @@
1
- import type { Knex } from 'knex';
2
-
3
- export async function up(knex: Knex): Promise<void> {
4
- await knex.schema.createTable('email_verification_tokens', (table) => {
5
- table.increments('id').primary();
6
- table.uuid('user_id').notNullable().index();
7
- table.string('token').notNullable().unique();
8
- table.bigInteger('created_at').notNullable();
9
- table.bigInteger('expires_at').notNullable().index();
10
-
11
- // Foreign key to users table
12
- table.foreign('user_id').references('id').inTable('users').onDelete('CASCADE');
13
- });
14
- }
15
-
16
- export async function down(knex: Knex): Promise<void> {
17
- await knex.schema.dropTableIfExists('email_verification_tokens');
18
- }