create-nara 1.0.37 → 1.0.40

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.37",
3
+ "version": "1.0.40",
4
4
  "description": "CLI to scaffold NARA projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,11 +1,14 @@
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 LoginThrottle from '../services/LoginThrottle.js';
5
+ import Logger from '../services/Logger.js';
4
6
  import bcrypt from 'bcrypt';
5
7
  import jwt from 'jsonwebtoken';
8
+ import { AUTH, ERROR_MESSAGES } from '../config/index.js';
6
9
 
7
10
  const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
8
- const JWT_EXPIRES_SECONDS = 7 * 24 * 60 * 60; // 7 days in seconds
11
+ const JWT_EXPIRES_SECONDS = AUTH.JWT_EXPIRY_SECONDS;
9
12
 
10
13
  // Cookie options for auth token
11
14
  const COOKIE_OPTIONS = {
@@ -18,25 +21,86 @@ const COOKIE_OPTIONS = {
18
21
  export class AuthController extends BaseController {
19
22
  async login(req: NaraRequest, res: NaraResponse) {
20
23
  const { email, password } = await req.json();
24
+ const ip = req.ip || 'unknown';
21
25
 
22
26
  if (!email || !password) {
23
- // Set error cookie and redirect back (Inertia pattern)
24
- res.cookie('error', 'Email and password are required', 5000);
27
+ res.cookie('error', 'Email and password are required', AUTH.ERROR_COOKIE_EXPIRY_MS);
25
28
  return res.redirect('/login');
26
29
  }
27
30
 
31
+ // Check if locked out due to too many failed attempts
32
+ if (LoginThrottle.isLockedOut(email, ip)) {
33
+ const remainingMs = LoginThrottle.getRemainingLockoutTime(email, ip);
34
+ const remainingMinutes = Math.ceil(remainingMs / 60000);
35
+
36
+ Logger.logSecurity('Login blocked - account locked', {
37
+ email,
38
+ ip,
39
+ remainingMinutes,
40
+ });
41
+
42
+ res.cookie('error', `Terlalu banyak percobaan login. Coba lagi dalam ${remainingMinutes} menit.`, AUTH.ERROR_COOKIE_EXPIRY_MS);
43
+ return res.redirect('/login');
44
+ }
45
+
46
+ Logger.info('Login attempt', {
47
+ email,
48
+ ip,
49
+ userAgent: req.headers['user-agent'],
50
+ });
51
+
28
52
  // Find user by email
29
53
  const user = await UserModel.findByEmail(email);
30
- if (!user || !await bcrypt.compare(password, user.password)) {
31
- res.cookie('error', 'Invalid credentials', 5000);
54
+ if (!user) {
55
+ const throttleResult = LoginThrottle.recordFailedAttempt(email, ip);
56
+
57
+ Logger.logSecurity('Login failed - user not found', {
58
+ email,
59
+ ip,
60
+ remainingAttempts: throttleResult.remainingAttempts,
61
+ });
62
+
63
+ const errorMsg = throttleResult.isLocked
64
+ ? `Terlalu banyak percobaan login. Coba lagi dalam ${Math.ceil(throttleResult.lockoutMs / 60000)} menit.`
65
+ : ERROR_MESSAGES.INVALID_CREDENTIALS;
66
+
67
+ res.cookie('error', errorMsg, AUTH.ERROR_COOKIE_EXPIRY_MS);
68
+ return res.redirect('/login');
69
+ }
70
+
71
+ const passwordMatch = await bcrypt.compare(password, user.password);
72
+ if (!passwordMatch) {
73
+ const throttleResult = LoginThrottle.recordFailedAttempt(email, ip);
74
+
75
+ Logger.logSecurity('Login failed - invalid password', {
76
+ userId: user.id,
77
+ email: user.email,
78
+ ip,
79
+ remainingAttempts: throttleResult.remainingAttempts,
80
+ });
81
+
82
+ const errorMsg = throttleResult.isLocked
83
+ ? `Terlalu banyak percobaan login. Coba lagi dalam ${Math.ceil(throttleResult.lockoutMs / 60000)} menit.`
84
+ : ERROR_MESSAGES.INVALID_CREDENTIALS;
85
+
86
+ res.cookie('error', errorMsg, AUTH.ERROR_COOKIE_EXPIRY_MS);
32
87
  return res.redirect('/login');
33
88
  }
34
89
 
90
+ // Successful login - clear attempts
91
+ LoginThrottle.clearAttempts(email, ip);
92
+
93
+ Logger.logAuth('login_success', {
94
+ userId: user.id,
95
+ email: user.email,
96
+ ip,
97
+ });
98
+
35
99
  // Generate JWT token with user info
36
100
  const token = jwt.sign(
37
101
  { userId: user.id, email: user.email, name: user.name },
38
102
  JWT_SECRET,
39
- { expiresIn: JWT_EXPIRES_SECONDS }
103
+ { expiresIn: AUTH.JWT_EXPIRY_SECONDS }
40
104
  );
41
105
 
42
106
  // Set auth cookie for web routes (maxAge in ms)
@@ -50,42 +114,64 @@ export class AuthController extends BaseController {
50
114
  const { name, email, password } = await req.json();
51
115
 
52
116
  if (!name || !email || !password) {
53
- res.cookie('error', 'Name, email and password are required', 5000);
117
+ res.cookie('error', 'Name, email and password are required', AUTH.ERROR_COOKIE_EXPIRY_MS);
54
118
  return res.redirect('/register');
55
119
  }
56
120
 
121
+ Logger.info('Registration attempt', {
122
+ email,
123
+ name,
124
+ ip: req.ip,
125
+ });
126
+
57
127
  // Check if email already exists
58
128
  const existing = await UserModel.findByEmail(email);
59
129
  if (existing) {
60
- res.cookie('error', 'Email already registered', 5000);
130
+ Logger.logSecurity('Registration failed - duplicate email', {
131
+ email,
132
+ ip: req.ip,
133
+ });
134
+ res.cookie('error', ERROR_MESSAGES.EMAIL_EXISTS, AUTH.ERROR_COOKIE_EXPIRY_MS);
61
135
  return res.redirect('/register');
62
136
  }
63
137
 
64
138
  // Hash password
65
- const hashedPassword = await bcrypt.hash(password, 10);
66
-
67
- // Create user in database
68
- const [userId] = await UserModel.create({ name, email, password: hashedPassword });
69
-
70
- // Generate JWT token
71
- const token = jwt.sign(
72
- { userId, email, name },
73
- JWT_SECRET,
74
- { expiresIn: JWT_EXPIRES_SECONDS }
75
- );
76
-
77
- // Set auth cookie for web routes (maxAge in ms)
78
- res.cookie('auth_token', token, JWT_EXPIRES_SECONDS * 1000, COOKIE_OPTIONS);
79
-
80
- // Redirect to dashboard
81
- return res.redirect('/dashboard');
139
+ const hashedPassword = await bcrypt.hash(password, AUTH.BCRYPT_SALT_ROUNDS);
140
+
141
+ try {
142
+ // Create user in database
143
+ const [userId] = await UserModel.create({ name, email, password: hashedPassword });
144
+
145
+ Logger.logAuth('registration_success', {
146
+ userId,
147
+ email,
148
+ ip: req.ip,
149
+ });
150
+
151
+ // Generate JWT token
152
+ const token = jwt.sign(
153
+ { userId, email, name },
154
+ JWT_SECRET,
155
+ { expiresIn: AUTH.JWT_EXPIRY_SECONDS }
156
+ );
157
+
158
+ // Set auth cookie for web routes (maxAge in ms)
159
+ res.cookie('auth_token', token, JWT_EXPIRES_SECONDS * 1000, COOKIE_OPTIONS);
160
+
161
+ // Redirect to dashboard
162
+ return res.redirect('/dashboard');
163
+ } catch (error: any) {
164
+ Logger.error('Registration failed', error);
165
+ res.cookie('error', ERROR_MESSAGES.INTERNAL_ERROR, AUTH.ERROR_COOKIE_EXPIRY_MS);
166
+ return res.redirect('/register');
167
+ }
82
168
  }
83
169
 
84
170
  async me(req: NaraRequest, res: NaraResponse) {
85
171
  const user = req.user;
86
172
 
87
173
  if (!user) {
88
- return jsonError(res, 'Unauthorized', 401);
174
+ return jsonError(res, ERROR_MESSAGES.UNAUTHORIZED, 401);
89
175
  }
90
176
 
91
177
  return jsonSuccess(res, { user });
@@ -95,6 +181,11 @@ export class AuthController extends BaseController {
95
181
  // Clear auth cookie (set maxAge to 0)
96
182
  res.cookie('auth_token', '', 0, COOKIE_OPTIONS);
97
183
 
184
+ Logger.logAuth('logout', {
185
+ userId: req.user?.id,
186
+ ip: req.ip,
187
+ });
188
+
98
189
  // Redirect to login
99
190
  return res.redirect('/login');
100
191
  }
@@ -103,14 +194,14 @@ export class AuthController extends BaseController {
103
194
  const { email } = await req.json();
104
195
 
105
196
  if (!email) {
106
- res.cookie('error', 'Email is required', 5000);
197
+ res.cookie('error', 'Email is required', AUTH.ERROR_COOKIE_EXPIRY_MS);
107
198
  return res.redirect('/forgot-password');
108
199
  }
109
200
 
110
201
  // TODO: Implement actual password reset email sending
111
202
 
112
203
  // Set success message and redirect
113
- res.cookie('success', 'If an account exists with this email, a reset link has been sent.', 5000);
204
+ res.cookie('success', 'If an account exists with this email, a reset link has been sent.', AUTH.ERROR_COOKIE_EXPIRY_MS);
114
205
  return res.redirect('/login');
115
206
  }
116
207
 
@@ -118,13 +209,15 @@ export class AuthController extends BaseController {
118
209
  const { token, password } = await req.json();
119
210
 
120
211
  if (!token || !password) {
121
- res.cookie('error', 'Reset token and password are required', 5000);
212
+ res.cookie('error', 'Reset token and password are required', AUTH.ERROR_COOKIE_EXPIRY_MS);
122
213
  return res.redirect('/forgot-password');
123
214
  }
124
215
 
125
216
  // TODO: Implement actual password reset
126
217
 
127
- res.cookie('success', 'Password has been reset successfully.', 5000);
218
+ res.cookie('success', 'Password has been reset successfully.', AUTH.ERROR_COOKIE_EXPIRY_MS);
128
219
  return res.redirect('/login');
129
220
  }
130
221
  }
222
+
223
+ export default new AuthController();
@@ -0,0 +1,148 @@
1
+ /**
2
+ * OAuthController
3
+ *
4
+ * Handles OAuth authentication flows:
5
+ * - Google OAuth redirect
6
+ * - Google OAuth callback
7
+ */
8
+ import { BaseController } from '@nara-web/core';
9
+ import type { NaraRequest, NaraResponse } from '@nara-web/core';
10
+ import { UserModel } from '../models/User.js';
11
+ import { redirectParamsURL } from '../services/GoogleAuth.js';
12
+ import bcrypt from 'bcrypt';
13
+ import jwt from 'jsonwebtoken';
14
+ import { randomUUID } from 'crypto';
15
+
16
+ const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
17
+ const JWT_EXPIRES_SECONDS = 7 * 24 * 60 * 60; // 7 days in seconds
18
+
19
+ // Cookie options for auth token
20
+ const COOKIE_OPTIONS = {
21
+ httpOnly: true,
22
+ secure: process.env.NODE_ENV === 'production',
23
+ sameSite: 'lax' as const,
24
+ path: '/',
25
+ };
26
+
27
+ interface GoogleTokenResponse {
28
+ access_token: string;
29
+ expires_in: number;
30
+ token_type: string;
31
+ scope: string;
32
+ refresh_token?: string;
33
+ }
34
+
35
+ interface GoogleUserInfo {
36
+ id: string;
37
+ email: string;
38
+ verified_email: boolean;
39
+ name: string;
40
+ given_name?: string;
41
+ family_name?: string;
42
+ picture?: string;
43
+ }
44
+
45
+ export class OAuthController extends BaseController {
46
+ /**
47
+ * Redirect to Google OAuth
48
+ */
49
+ async googleRedirect(req: NaraRequest, res: NaraResponse) {
50
+ const params = redirectParamsURL();
51
+ const googleLoginUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
52
+ return res.redirect(googleLoginUrl);
53
+ }
54
+
55
+ /**
56
+ * Handle Google OAuth callback
57
+ */
58
+ async googleCallback(req: NaraRequest, res: NaraResponse) {
59
+ const { code } = req.query;
60
+
61
+ if (!code) {
62
+ res.cookie('error', 'Authorization code not provided', 5000);
63
+ return res.redirect('/login');
64
+ }
65
+
66
+ try {
67
+ // Exchange authorization code for access token
68
+ const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
69
+ method: 'POST',
70
+ headers: {
71
+ 'Content-Type': 'application/x-www-form-urlencoded',
72
+ },
73
+ body: new URLSearchParams({
74
+ client_id: process.env.GOOGLE_CLIENT_ID || '',
75
+ client_secret: process.env.GOOGLE_CLIENT_SECRET || '',
76
+ redirect_uri: process.env.GOOGLE_REDIRECT_URI || '',
77
+ grant_type: 'authorization_code',
78
+ code: code as string,
79
+ }),
80
+ });
81
+
82
+ if (!tokenResponse.ok) {
83
+ res.cookie('error', 'Failed to exchange authorization code', 5000);
84
+ return res.redirect('/login');
85
+ }
86
+
87
+ const tokenData: GoogleTokenResponse = await tokenResponse.json();
88
+
89
+ // Get user info from Google
90
+ const userResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
91
+ headers: {
92
+ Authorization: `Bearer ${tokenData.access_token}`,
93
+ },
94
+ });
95
+
96
+ if (!userResponse.ok) {
97
+ res.cookie('error', 'Failed to get user info from Google', 5000);
98
+ return res.redirect('/login');
99
+ }
100
+
101
+ const userData: GoogleUserInfo = await userResponse.json();
102
+ const email = userData.email.toLowerCase();
103
+ const name = userData.name;
104
+
105
+ // Check if user exists
106
+ let user = await UserModel.findByEmail(email);
107
+
108
+ if (!user) {
109
+ // Create new user
110
+ const userId = randomUUID();
111
+ const hashedPassword = await bcrypt.hash(email + Date.now(), 10);
112
+
113
+ await UserModel.create({
114
+ id: userId,
115
+ email,
116
+ password: hashedPassword,
117
+ name,
118
+ role: 'user',
119
+ email_verified_at: userData.verified_email ? new Date().toISOString() : null,
120
+ });
121
+
122
+ user = await UserModel.findById(userId);
123
+ }
124
+
125
+ if (!user) {
126
+ res.cookie('error', 'Failed to create or find user', 5000);
127
+ return res.redirect('/login');
128
+ }
129
+
130
+ // Generate JWT token
131
+ const token = jwt.sign(
132
+ { userId: user.id, email: user.email, name: user.name },
133
+ JWT_SECRET,
134
+ { expiresIn: JWT_EXPIRES_SECONDS }
135
+ );
136
+
137
+ // Set auth cookie
138
+ res.cookie('auth_token', token, JWT_EXPIRES_SECONDS * 1000, COOKIE_OPTIONS);
139
+
140
+ // Redirect to dashboard
141
+ return res.redirect('/dashboard');
142
+ } catch (error) {
143
+ console.error('Google OAuth error:', error);
144
+ res.cookie('error', 'Authentication failed', 5000);
145
+ return res.redirect('/login');
146
+ }
147
+ }
148
+ }
@@ -3,8 +3,30 @@
3
3
  *
4
4
  * In-memory rate limiting middleware using sliding window algorithm.
5
5
  * Tracks requests per key (IP, user, or custom) and returns 429 when limit exceeded.
6
+ *
7
+ * Features:
8
+ * - Configurable max requests and window duration
9
+ * - Custom key generator (default: IP address)
10
+ * - Automatic cleanup of expired entries
11
+ * - Skip function for whitelisting certain requests
12
+ *
13
+ * @example
14
+ * // Basic usage (100 requests per 15 minutes per IP)
15
+ * Route.use(rateLimit());
16
+ *
17
+ * // Custom configuration
18
+ * Route.use(rateLimit({
19
+ * maxRequests: 10,
20
+ * windowMs: 60 * 1000, // 1 minute
21
+ * keyGenerator: (req) => req.user?.id || req.ip,
22
+ * }));
23
+ *
24
+ * // Per-route rate limiting
25
+ * Route.post('/api/upload', rateLimit({ maxRequests: 5, windowMs: 60000 }), uploadHandler);
6
26
  */
7
27
 
28
+ import { RATE_LIMIT } from "../config/index.js";
29
+ import Logger from "../services/Logger.js";
8
30
  import type { NaraRequest, NaraResponse, NaraMiddleware } from '@nara-web/core';
9
31
  import { jsonError } from '@nara-web/core';
10
32
 
@@ -121,14 +143,17 @@ function getResetTime(key: string, windowMs: number): number {
121
143
 
122
144
  /**
123
145
  * Create rate limit middleware
146
+ *
147
+ * @param options - Rate limit configuration
148
+ * @returns Middleware function
124
149
  */
125
150
  export function rateLimit(options: RateLimitOptions = {}): NaraMiddleware {
126
151
  const {
127
- maxRequests = 100,
128
- windowMs = 15 * 60 * 1000, // 15 minutes
152
+ maxRequests = RATE_LIMIT.MAX_REQUESTS,
153
+ windowMs = RATE_LIMIT.WINDOW_MS,
129
154
  keyGenerator = (req: NaraRequest) => req.ip || 'unknown',
130
155
  skip,
131
- message = 'Too many requests, please try again later',
156
+ message = 'Terlalu banyak permintaan, coba lagi nanti',
132
157
  headers = true,
133
158
  name = 'default',
134
159
  } = options;
@@ -156,13 +181,14 @@ export function rateLimit(options: RateLimitOptions = {}): NaraMiddleware {
156
181
 
157
182
  // Check if limit exceeded
158
183
  if (currentCount >= maxRequests) {
159
- console.warn('[RateLimit] Exceeded', {
184
+ Logger.logSecurity('Rate limit exceeded', {
160
185
  key,
161
186
  name,
162
187
  currentCount,
163
188
  maxRequests,
164
189
  ip: req.ip,
165
190
  path: req.path,
191
+ method: req.method,
166
192
  });
167
193
 
168
194
  if (headers) {
@@ -181,11 +207,14 @@ export function rateLimit(options: RateLimitOptions = {}): NaraMiddleware {
181
207
 
182
208
  /**
183
209
  * Create a strict rate limiter for sensitive endpoints
210
+ *
211
+ * @example
212
+ * Route.post('/api/login', strictRateLimit(), AuthController.processLogin);
184
213
  */
185
214
  export function strictRateLimit(options: RateLimitOptions = {}): NaraMiddleware {
186
215
  return rateLimit({
187
- maxRequests: 10,
188
- windowMs: 60 * 1000, // 1 minute
216
+ maxRequests: RATE_LIMIT.STRICT_MAX_REQUESTS,
217
+ windowMs: RATE_LIMIT.STRICT_WINDOW_MS,
189
218
  name: 'strict',
190
219
  ...options,
191
220
  });
@@ -193,11 +222,14 @@ export function strictRateLimit(options: RateLimitOptions = {}): NaraMiddleware
193
222
 
194
223
  /**
195
224
  * Create an API rate limiter
225
+ *
226
+ * @example
227
+ * Route.use('/api', apiRateLimit());
196
228
  */
197
229
  export function apiRateLimit(options: RateLimitOptions = {}): NaraMiddleware {
198
230
  return rateLimit({
199
- maxRequests: 60,
200
- windowMs: 60 * 1000, // 60 requests per minute
231
+ maxRequests: RATE_LIMIT.API_MAX_REQUESTS,
232
+ windowMs: RATE_LIMIT.API_WINDOW_MS,
201
233
  name: 'api',
202
234
  ...options,
203
235
  });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Google OAuth Authentication Service
3
+ * Handles URL parameter generation for Google OAuth authentication flow.
4
+ */
5
+
6
+ /**
7
+ * Generates the URL parameters required for initiating Google OAuth authentication.
8
+ *
9
+ * @returns A URL-encoded string containing all necessary OAuth parameters
10
+ *
11
+ * Parameters included:
12
+ * - client_id: Your application's Google Client ID
13
+ * - redirect_uri: The URL where Google will redirect after authentication
14
+ * - scope: The permissions requested from the user
15
+ * - userinfo.email: Access to user's email
16
+ * - userinfo.profile: Access to user's basic profile info
17
+ * - response_type: Set to 'code' for authorization code flow
18
+ * - access_type: Set to 'offline' to receive a refresh token
19
+ * - prompt: Set to 'consent' to always show the consent screen
20
+ */
21
+ export function redirectParamsURL(): string {
22
+ return new URLSearchParams({
23
+ client_id: process.env.GOOGLE_CLIENT_ID || '',
24
+ redirect_uri: process.env.GOOGLE_REDIRECT_URI || '',
25
+ scope: [
26
+ 'https://www.googleapis.com/auth/userinfo.email',
27
+ 'https://www.googleapis.com/auth/userinfo.profile',
28
+ ].join(' '),
29
+ response_type: 'code',
30
+ access_type: 'offline',
31
+ prompt: 'consent',
32
+ }).toString();
33
+ }