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 +1 -1
- package/templates/features/auth/app/controllers/AuthController.ts +43 -32
- package/templates/features/auth/app/middlewares/auth.ts +41 -55
- package/templates/features/auth/app/services/Authenticate.ts +89 -0
- package/templates/features/db/app/models/Session.ts +61 -0
- package/templates/features/db/app/models/User.ts +0 -1
- package/templates/features/db/migrations/20230514062913_sessions.ts +13 -0
- package/templates/features/db/migrations/20240101000001_create_users.ts +0 -1
- package/templates/features/db/app/models/EmailVerificationToken.ts +0 -77
- package/templates/features/db/migrations/20240101000003_create_email_verification_tokens.ts +0 -18
package/package.json
CHANGED
|
@@ -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
|
|
7
|
-
import jwt from 'jsonwebtoken';
|
|
8
|
+
import { randomUUID } from 'crypto';
|
|
8
9
|
import { AUTH, ERROR_MESSAGES } from '../config/index.js';
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
107
|
-
res.cookie(
|
|
110
|
+
// Set session cookie
|
|
111
|
+
res.cookie(SESSION_COOKIE_NAME, sessionId, SESSION_EXPIRY_MS, COOKIE_OPTIONS);
|
|
108
112
|
|
|
109
|
-
// Redirect to dashboard
|
|
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
|
|
142
|
+
// Hash password using PBKDF2
|
|
143
|
+
const hashedPassword = await Authenticate.hash(password);
|
|
140
144
|
|
|
141
145
|
try {
|
|
142
|
-
// Create user in database
|
|
143
|
-
const
|
|
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
|
-
//
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
159
|
-
res.cookie(
|
|
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
|
-
//
|
|
182
|
-
|
|
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
|
|
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
|
|
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 {
|
|
3
|
-
import jwt from 'jsonwebtoken';
|
|
2
|
+
import { SessionModel } from '../models/Session.js';
|
|
4
3
|
|
|
5
|
-
const
|
|
4
|
+
const SESSION_COOKIE_NAME = 'auth_id';
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
|
-
* Auth middleware for API routes
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
38
|
+
// Get user from session using optimized JOIN query
|
|
39
|
+
const user = await SessionModel.getUserBySessionId(sessionId);
|
|
54
40
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
75
|
-
res.cookie(
|
|
58
|
+
// Clear invalid session
|
|
59
|
+
res.cookie(SESSION_COOKIE_NAME, '', 0);
|
|
76
60
|
if (isApiRoute) {
|
|
77
|
-
res.status(401).json({ success: false, message: 'Session
|
|
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
|
|
74
|
+
export async function guestMiddleware(req: NaraRequest, res: NaraResponse, next: () => void) {
|
|
75
|
+
const sessionId = req.cookies?.[SESSION_COOKIE_NAME];
|
|
92
76
|
|
|
93
|
-
if (
|
|
77
|
+
if (sessionId) {
|
|
94
78
|
try {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
105
|
-
res.cookie(
|
|
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
|
+
}
|
|
@@ -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
|
-
}
|