servcraft 0.1.0
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.
- package/.dockerignore +45 -0
- package/.env.example +46 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +1 -0
- package/.prettierignore +4 -0
- package/.prettierrc +11 -0
- package/Dockerfile +76 -0
- package/Dockerfile.dev +31 -0
- package/README.md +232 -0
- package/commitlint.config.js +24 -0
- package/dist/cli/index.cjs +3968 -0
- package/dist/cli/index.cjs.map +1 -0
- package/dist/cli/index.d.cts +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +3945 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.cjs +2458 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +828 -0
- package/dist/index.d.ts +828 -0
- package/dist/index.js +2332 -0
- package/dist/index.js.map +1 -0
- package/docker-compose.prod.yml +118 -0
- package/docker-compose.yml +147 -0
- package/eslint.config.js +27 -0
- package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
- package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
- package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
- package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
- package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +5 -0
- package/npm-cache/_update-notifier-last-checked +0 -0
- package/package.json +112 -0
- package/prisma/schema.prisma +157 -0
- package/src/cli/commands/add-module.ts +422 -0
- package/src/cli/commands/db.ts +137 -0
- package/src/cli/commands/docs.ts +16 -0
- package/src/cli/commands/generate.ts +459 -0
- package/src/cli/commands/init.ts +640 -0
- package/src/cli/index.ts +32 -0
- package/src/cli/templates/controller.ts +67 -0
- package/src/cli/templates/dynamic-prisma.ts +89 -0
- package/src/cli/templates/dynamic-schemas.ts +232 -0
- package/src/cli/templates/dynamic-types.ts +60 -0
- package/src/cli/templates/module-index.ts +33 -0
- package/src/cli/templates/prisma-model.ts +17 -0
- package/src/cli/templates/repository.ts +104 -0
- package/src/cli/templates/routes.ts +70 -0
- package/src/cli/templates/schemas.ts +26 -0
- package/src/cli/templates/service.ts +58 -0
- package/src/cli/templates/types.ts +27 -0
- package/src/cli/utils/docs-generator.ts +47 -0
- package/src/cli/utils/field-parser.ts +315 -0
- package/src/cli/utils/helpers.ts +89 -0
- package/src/config/env.ts +80 -0
- package/src/config/index.ts +97 -0
- package/src/core/index.ts +5 -0
- package/src/core/logger.ts +43 -0
- package/src/core/server.ts +132 -0
- package/src/database/index.ts +7 -0
- package/src/database/prisma.ts +54 -0
- package/src/database/seed.ts +59 -0
- package/src/index.ts +63 -0
- package/src/middleware/error-handler.ts +73 -0
- package/src/middleware/index.ts +3 -0
- package/src/middleware/security.ts +116 -0
- package/src/modules/audit/audit.service.ts +192 -0
- package/src/modules/audit/index.ts +2 -0
- package/src/modules/audit/types.ts +37 -0
- package/src/modules/auth/auth.controller.ts +182 -0
- package/src/modules/auth/auth.middleware.ts +87 -0
- package/src/modules/auth/auth.routes.ts +123 -0
- package/src/modules/auth/auth.service.ts +142 -0
- package/src/modules/auth/index.ts +49 -0
- package/src/modules/auth/schemas.ts +52 -0
- package/src/modules/auth/types.ts +69 -0
- package/src/modules/email/email.service.ts +212 -0
- package/src/modules/email/index.ts +10 -0
- package/src/modules/email/templates.ts +213 -0
- package/src/modules/email/types.ts +57 -0
- package/src/modules/swagger/index.ts +3 -0
- package/src/modules/swagger/schema-builder.ts +263 -0
- package/src/modules/swagger/swagger.service.ts +169 -0
- package/src/modules/swagger/types.ts +68 -0
- package/src/modules/user/index.ts +30 -0
- package/src/modules/user/schemas.ts +49 -0
- package/src/modules/user/types.ts +78 -0
- package/src/modules/user/user.controller.ts +139 -0
- package/src/modules/user/user.repository.ts +156 -0
- package/src/modules/user/user.routes.ts +199 -0
- package/src/modules/user/user.service.ts +145 -0
- package/src/modules/validation/index.ts +18 -0
- package/src/modules/validation/validator.ts +104 -0
- package/src/types/common.ts +61 -0
- package/src/types/index.ts +10 -0
- package/src/utils/errors.ts +66 -0
- package/src/utils/index.ts +33 -0
- package/src/utils/pagination.ts +38 -0
- package/src/utils/response.ts +63 -0
- package/tests/integration/auth.test.ts +59 -0
- package/tests/setup.ts +17 -0
- package/tests/unit/modules/validation.test.ts +88 -0
- package/tests/unit/utils/errors.test.ts +113 -0
- package/tests/unit/utils/pagination.test.ts +82 -0
- package/tsconfig.json +33 -0
- package/tsup.config.ts +14 -0
- package/vitest.config.ts +34 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { logger } from '../../core/logger.js';
|
|
3
|
+
import type { AuditLogEntry, AuditLogQuery } from './types.js';
|
|
4
|
+
import type { PaginatedResult } from '../../types/index.js';
|
|
5
|
+
import { createPaginatedResult } from '../../utils/pagination.js';
|
|
6
|
+
|
|
7
|
+
// In-memory storage (will be replaced by Prisma in production)
|
|
8
|
+
const auditLogs: Map<string, AuditLogEntry & { id: string; createdAt: Date }> = new Map();
|
|
9
|
+
|
|
10
|
+
export class AuditService {
|
|
11
|
+
async log(entry: AuditLogEntry): Promise<void> {
|
|
12
|
+
const id = randomUUID();
|
|
13
|
+
const auditEntry = {
|
|
14
|
+
...entry,
|
|
15
|
+
id,
|
|
16
|
+
createdAt: new Date(),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
auditLogs.set(id, auditEntry);
|
|
20
|
+
|
|
21
|
+
// Also log to structured logger
|
|
22
|
+
logger.info(
|
|
23
|
+
{
|
|
24
|
+
audit: true,
|
|
25
|
+
userId: entry.userId,
|
|
26
|
+
action: entry.action,
|
|
27
|
+
resource: entry.resource,
|
|
28
|
+
resourceId: entry.resourceId,
|
|
29
|
+
ipAddress: entry.ipAddress,
|
|
30
|
+
},
|
|
31
|
+
`Audit: ${entry.action} on ${entry.resource}`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async query(
|
|
36
|
+
params: AuditLogQuery
|
|
37
|
+
): Promise<PaginatedResult<AuditLogEntry & { id: string; createdAt: Date }>> {
|
|
38
|
+
const { page = 1, limit = 20 } = params;
|
|
39
|
+
let logs = Array.from(auditLogs.values());
|
|
40
|
+
|
|
41
|
+
// Apply filters
|
|
42
|
+
if (params.userId) {
|
|
43
|
+
logs = logs.filter((log) => log.userId === params.userId);
|
|
44
|
+
}
|
|
45
|
+
if (params.action) {
|
|
46
|
+
logs = logs.filter((log) => log.action === params.action);
|
|
47
|
+
}
|
|
48
|
+
if (params.resource) {
|
|
49
|
+
logs = logs.filter((log) => log.resource === params.resource);
|
|
50
|
+
}
|
|
51
|
+
if (params.resourceId) {
|
|
52
|
+
logs = logs.filter((log) => log.resourceId === params.resourceId);
|
|
53
|
+
}
|
|
54
|
+
if (params.startDate) {
|
|
55
|
+
logs = logs.filter((log) => log.createdAt >= params.startDate!);
|
|
56
|
+
}
|
|
57
|
+
if (params.endDate) {
|
|
58
|
+
logs = logs.filter((log) => log.createdAt <= params.endDate!);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Sort by date descending
|
|
62
|
+
logs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
63
|
+
|
|
64
|
+
const total = logs.length;
|
|
65
|
+
const skip = (page - 1) * limit;
|
|
66
|
+
const data = logs.slice(skip, skip + limit);
|
|
67
|
+
|
|
68
|
+
return createPaginatedResult(data, total, { page, limit });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async findByUser(userId: string, limit = 50): Promise<(AuditLogEntry & { id: string; createdAt: Date })[]> {
|
|
72
|
+
const result = await this.query({ userId, limit });
|
|
73
|
+
return result.data;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async findByResource(
|
|
77
|
+
resource: string,
|
|
78
|
+
resourceId: string,
|
|
79
|
+
limit = 50
|
|
80
|
+
): Promise<(AuditLogEntry & { id: string; createdAt: Date })[]> {
|
|
81
|
+
const result = await this.query({ resource, resourceId, limit });
|
|
82
|
+
return result.data;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Shortcut methods for common audit events
|
|
86
|
+
async logCreate(
|
|
87
|
+
resource: string,
|
|
88
|
+
resourceId: string,
|
|
89
|
+
userId?: string,
|
|
90
|
+
newValue?: Record<string, unknown>,
|
|
91
|
+
meta?: { ipAddress?: string; userAgent?: string }
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
await this.log({
|
|
94
|
+
action: 'create',
|
|
95
|
+
resource,
|
|
96
|
+
resourceId,
|
|
97
|
+
userId,
|
|
98
|
+
newValue,
|
|
99
|
+
...meta,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async logUpdate(
|
|
104
|
+
resource: string,
|
|
105
|
+
resourceId: string,
|
|
106
|
+
userId?: string,
|
|
107
|
+
oldValue?: Record<string, unknown>,
|
|
108
|
+
newValue?: Record<string, unknown>,
|
|
109
|
+
meta?: { ipAddress?: string; userAgent?: string }
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
await this.log({
|
|
112
|
+
action: 'update',
|
|
113
|
+
resource,
|
|
114
|
+
resourceId,
|
|
115
|
+
userId,
|
|
116
|
+
oldValue,
|
|
117
|
+
newValue,
|
|
118
|
+
...meta,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async logDelete(
|
|
123
|
+
resource: string,
|
|
124
|
+
resourceId: string,
|
|
125
|
+
userId?: string,
|
|
126
|
+
oldValue?: Record<string, unknown>,
|
|
127
|
+
meta?: { ipAddress?: string; userAgent?: string }
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
await this.log({
|
|
130
|
+
action: 'delete',
|
|
131
|
+
resource,
|
|
132
|
+
resourceId,
|
|
133
|
+
userId,
|
|
134
|
+
oldValue,
|
|
135
|
+
...meta,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async logLogin(
|
|
140
|
+
userId: string,
|
|
141
|
+
meta?: { ipAddress?: string; userAgent?: string }
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
await this.log({
|
|
144
|
+
action: 'login',
|
|
145
|
+
resource: 'auth',
|
|
146
|
+
userId,
|
|
147
|
+
...meta,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async logLogout(
|
|
152
|
+
userId: string,
|
|
153
|
+
meta?: { ipAddress?: string; userAgent?: string }
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
await this.log({
|
|
156
|
+
action: 'logout',
|
|
157
|
+
resource: 'auth',
|
|
158
|
+
userId,
|
|
159
|
+
...meta,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async logPasswordChange(
|
|
164
|
+
userId: string,
|
|
165
|
+
meta?: { ipAddress?: string; userAgent?: string }
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
await this.log({
|
|
168
|
+
action: 'password_change',
|
|
169
|
+
resource: 'auth',
|
|
170
|
+
userId,
|
|
171
|
+
...meta,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Clear all logs (for testing)
|
|
176
|
+
async clear(): Promise<void> {
|
|
177
|
+
auditLogs.clear();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let auditService: AuditService | null = null;
|
|
182
|
+
|
|
183
|
+
export function getAuditService(): AuditService {
|
|
184
|
+
if (!auditService) {
|
|
185
|
+
auditService = new AuditService();
|
|
186
|
+
}
|
|
187
|
+
return auditService;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function createAuditService(): AuditService {
|
|
191
|
+
return new AuditService();
|
|
192
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type AuditAction =
|
|
2
|
+
| 'create'
|
|
3
|
+
| 'read'
|
|
4
|
+
| 'update'
|
|
5
|
+
| 'delete'
|
|
6
|
+
| 'login'
|
|
7
|
+
| 'logout'
|
|
8
|
+
| 'register'
|
|
9
|
+
| 'password_change'
|
|
10
|
+
| 'password_reset'
|
|
11
|
+
| 'email_verify'
|
|
12
|
+
| 'role_change'
|
|
13
|
+
| 'status_change'
|
|
14
|
+
| 'settings_change';
|
|
15
|
+
|
|
16
|
+
export interface AuditLogEntry {
|
|
17
|
+
userId?: string;
|
|
18
|
+
action: AuditAction | string;
|
|
19
|
+
resource: string;
|
|
20
|
+
resourceId?: string;
|
|
21
|
+
oldValue?: Record<string, unknown>;
|
|
22
|
+
newValue?: Record<string, unknown>;
|
|
23
|
+
ipAddress?: string;
|
|
24
|
+
userAgent?: string;
|
|
25
|
+
metadata?: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AuditLogQuery {
|
|
29
|
+
userId?: string;
|
|
30
|
+
action?: string;
|
|
31
|
+
resource?: string;
|
|
32
|
+
resourceId?: string;
|
|
33
|
+
startDate?: Date;
|
|
34
|
+
endDate?: Date;
|
|
35
|
+
page?: number;
|
|
36
|
+
limit?: number;
|
|
37
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
+
import type { AuthService } from './auth.service.js';
|
|
3
|
+
import type { UserService } from '../user/user.service.js';
|
|
4
|
+
import {
|
|
5
|
+
loginSchema,
|
|
6
|
+
registerSchema,
|
|
7
|
+
refreshTokenSchema,
|
|
8
|
+
changePasswordSchema,
|
|
9
|
+
} from './schemas.js';
|
|
10
|
+
import { success, created } from '../../utils/response.js';
|
|
11
|
+
import { BadRequestError, UnauthorizedError } from '../../utils/errors.js';
|
|
12
|
+
import { validateBody } from '../validation/validator.js';
|
|
13
|
+
import type { AuthenticatedRequest } from './types.js';
|
|
14
|
+
|
|
15
|
+
export class AuthController {
|
|
16
|
+
constructor(
|
|
17
|
+
private authService: AuthService,
|
|
18
|
+
private userService: UserService
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
async register(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
|
22
|
+
const data = validateBody(registerSchema, request.body);
|
|
23
|
+
|
|
24
|
+
// Check if user already exists
|
|
25
|
+
const existingUser = await this.userService.findByEmail(data.email);
|
|
26
|
+
if (existingUser) {
|
|
27
|
+
throw new BadRequestError('Email already registered');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Hash password and create user
|
|
31
|
+
const hashedPassword = await this.authService.hashPassword(data.password);
|
|
32
|
+
const user = await this.userService.create({
|
|
33
|
+
email: data.email,
|
|
34
|
+
password: hashedPassword,
|
|
35
|
+
name: data.name,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Generate tokens
|
|
39
|
+
const tokens = this.authService.generateTokenPair({
|
|
40
|
+
id: user.id,
|
|
41
|
+
email: user.email,
|
|
42
|
+
role: user.role,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
created(reply, {
|
|
46
|
+
user: {
|
|
47
|
+
id: user.id,
|
|
48
|
+
email: user.email,
|
|
49
|
+
name: user.name,
|
|
50
|
+
role: user.role,
|
|
51
|
+
},
|
|
52
|
+
...tokens,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async login(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
|
57
|
+
const data = validateBody(loginSchema, request.body);
|
|
58
|
+
|
|
59
|
+
// Find user
|
|
60
|
+
const user = await this.userService.findByEmail(data.email);
|
|
61
|
+
if (!user) {
|
|
62
|
+
throw new UnauthorizedError('Invalid credentials');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check if user is active
|
|
66
|
+
if (user.status !== 'active') {
|
|
67
|
+
throw new UnauthorizedError('Account is not active');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Verify password
|
|
71
|
+
const isValidPassword = await this.authService.verifyPassword(data.password, user.password);
|
|
72
|
+
if (!isValidPassword) {
|
|
73
|
+
throw new UnauthorizedError('Invalid credentials');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Update last login
|
|
77
|
+
await this.userService.updateLastLogin(user.id);
|
|
78
|
+
|
|
79
|
+
// Generate tokens
|
|
80
|
+
const tokens = this.authService.generateTokenPair({
|
|
81
|
+
id: user.id,
|
|
82
|
+
email: user.email,
|
|
83
|
+
role: user.role,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
success(reply, {
|
|
87
|
+
user: {
|
|
88
|
+
id: user.id,
|
|
89
|
+
email: user.email,
|
|
90
|
+
name: user.name,
|
|
91
|
+
role: user.role,
|
|
92
|
+
},
|
|
93
|
+
...tokens,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async refresh(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
|
98
|
+
const data = validateBody(refreshTokenSchema, request.body);
|
|
99
|
+
|
|
100
|
+
// Verify refresh token
|
|
101
|
+
const payload = await this.authService.verifyRefreshToken(data.refreshToken);
|
|
102
|
+
|
|
103
|
+
// Get fresh user data
|
|
104
|
+
const user = await this.userService.findById(payload.sub);
|
|
105
|
+
if (!user || user.status !== 'active') {
|
|
106
|
+
throw new UnauthorizedError('User not found or inactive');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Blacklist old refresh token (token rotation)
|
|
110
|
+
this.authService.blacklistToken(data.refreshToken);
|
|
111
|
+
|
|
112
|
+
// Generate new tokens
|
|
113
|
+
const tokens = this.authService.generateTokenPair({
|
|
114
|
+
id: user.id,
|
|
115
|
+
email: user.email,
|
|
116
|
+
role: user.role,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
success(reply, tokens);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async logout(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
|
123
|
+
const authHeader = request.headers.authorization;
|
|
124
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
125
|
+
const token = authHeader.substring(7);
|
|
126
|
+
this.authService.blacklistToken(token);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
success(reply, { message: 'Logged out successfully' });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async me(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
|
133
|
+
const authRequest = request as AuthenticatedRequest;
|
|
134
|
+
const user = await this.userService.findById(authRequest.user.id);
|
|
135
|
+
|
|
136
|
+
if (!user) {
|
|
137
|
+
throw new UnauthorizedError('User not found');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
success(reply, {
|
|
141
|
+
id: user.id,
|
|
142
|
+
email: user.email,
|
|
143
|
+
name: user.name,
|
|
144
|
+
role: user.role,
|
|
145
|
+
status: user.status,
|
|
146
|
+
createdAt: user.createdAt,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async changePassword(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
|
151
|
+
const authRequest = request as AuthenticatedRequest;
|
|
152
|
+
const data = validateBody(changePasswordSchema, request.body);
|
|
153
|
+
|
|
154
|
+
// Get current user
|
|
155
|
+
const user = await this.userService.findById(authRequest.user.id);
|
|
156
|
+
if (!user) {
|
|
157
|
+
throw new UnauthorizedError('User not found');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Verify current password
|
|
161
|
+
const isValidPassword = await this.authService.verifyPassword(
|
|
162
|
+
data.currentPassword,
|
|
163
|
+
user.password
|
|
164
|
+
);
|
|
165
|
+
if (!isValidPassword) {
|
|
166
|
+
throw new BadRequestError('Current password is incorrect');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Hash and update password
|
|
170
|
+
const hashedPassword = await this.authService.hashPassword(data.newPassword);
|
|
171
|
+
await this.userService.updatePassword(user.id, hashedPassword);
|
|
172
|
+
|
|
173
|
+
success(reply, { message: 'Password changed successfully' });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function createAuthController(
|
|
178
|
+
authService: AuthService,
|
|
179
|
+
userService: UserService
|
|
180
|
+
): AuthController {
|
|
181
|
+
return new AuthController(authService, userService);
|
|
182
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
+
import { UnauthorizedError, ForbiddenError } from '../../utils/errors.js';
|
|
3
|
+
import type { AuthService } from './auth.service.js';
|
|
4
|
+
import type { AuthUser } from './types.js';
|
|
5
|
+
|
|
6
|
+
export function createAuthMiddleware(authService: AuthService) {
|
|
7
|
+
return async function authenticate(
|
|
8
|
+
request: FastifyRequest,
|
|
9
|
+
reply: FastifyReply
|
|
10
|
+
): Promise<void> {
|
|
11
|
+
const authHeader = request.headers.authorization;
|
|
12
|
+
|
|
13
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
14
|
+
throw new UnauthorizedError('Missing or invalid authorization header');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const token = authHeader.substring(7);
|
|
18
|
+
const payload = await authService.verifyAccessToken(token);
|
|
19
|
+
|
|
20
|
+
request.user = {
|
|
21
|
+
id: payload.sub,
|
|
22
|
+
email: payload.email,
|
|
23
|
+
role: payload.role,
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createRoleMiddleware(allowedRoles: string[]) {
|
|
29
|
+
return async function authorize(
|
|
30
|
+
request: FastifyRequest,
|
|
31
|
+
_reply: FastifyReply
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const user = request.user as AuthUser | undefined;
|
|
34
|
+
|
|
35
|
+
if (!user) {
|
|
36
|
+
throw new UnauthorizedError('Authentication required');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!allowedRoles.includes(user.role)) {
|
|
40
|
+
throw new ForbiddenError('Insufficient permissions');
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createPermissionMiddleware(requiredPermissions: string[]) {
|
|
46
|
+
return async function checkPermissions(
|
|
47
|
+
request: FastifyRequest,
|
|
48
|
+
_reply: FastifyReply
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
const user = request.user as AuthUser | undefined;
|
|
51
|
+
|
|
52
|
+
if (!user) {
|
|
53
|
+
throw new UnauthorizedError('Authentication required');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// This would check against a permissions system
|
|
57
|
+
// For now, we'll implement a basic role-based check
|
|
58
|
+
// In a full implementation, you'd query the user's permissions from the database
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Optional authentication - doesn't throw if no token
|
|
63
|
+
export function createOptionalAuthMiddleware(authService: AuthService) {
|
|
64
|
+
return async function optionalAuthenticate(
|
|
65
|
+
request: FastifyRequest,
|
|
66
|
+
_reply: FastifyReply
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
const authHeader = request.headers.authorization;
|
|
69
|
+
|
|
70
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const token = authHeader.substring(7);
|
|
76
|
+
const payload = await authService.verifyAccessToken(token);
|
|
77
|
+
|
|
78
|
+
request.user = {
|
|
79
|
+
id: payload.sub,
|
|
80
|
+
email: payload.email,
|
|
81
|
+
role: payload.role,
|
|
82
|
+
};
|
|
83
|
+
} catch {
|
|
84
|
+
// Silently ignore auth errors for optional auth
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import type { AuthController } from './auth.controller.js';
|
|
3
|
+
import type { AuthService } from './auth.service.js';
|
|
4
|
+
import { createAuthMiddleware } from './auth.middleware.js';
|
|
5
|
+
import { commonResponses } from '../swagger/index.js';
|
|
6
|
+
|
|
7
|
+
const credentialsBody = {
|
|
8
|
+
type: 'object',
|
|
9
|
+
required: ['email', 'password'],
|
|
10
|
+
properties: {
|
|
11
|
+
email: { type: 'string', format: 'email' },
|
|
12
|
+
password: { type: 'string', minLength: 8 },
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const changePasswordBody = {
|
|
17
|
+
type: 'object',
|
|
18
|
+
required: ['currentPassword', 'newPassword'],
|
|
19
|
+
properties: {
|
|
20
|
+
currentPassword: { type: 'string', minLength: 8 },
|
|
21
|
+
newPassword: { type: 'string', minLength: 8 },
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function registerAuthRoutes(
|
|
26
|
+
app: FastifyInstance,
|
|
27
|
+
controller: AuthController,
|
|
28
|
+
authService: AuthService
|
|
29
|
+
): void {
|
|
30
|
+
const authenticate = createAuthMiddleware(authService);
|
|
31
|
+
|
|
32
|
+
// Public routes
|
|
33
|
+
app.post('/auth/register', {
|
|
34
|
+
schema: {
|
|
35
|
+
tags: ['Auth'],
|
|
36
|
+
summary: 'Register a new user',
|
|
37
|
+
body: credentialsBody,
|
|
38
|
+
response: {
|
|
39
|
+
201: commonResponses.success,
|
|
40
|
+
400: commonResponses.error,
|
|
41
|
+
409: commonResponses.error,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
handler: controller.register.bind(controller),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
app.post('/auth/login', {
|
|
48
|
+
schema: {
|
|
49
|
+
tags: ['Auth'],
|
|
50
|
+
summary: 'Login and obtain tokens',
|
|
51
|
+
body: credentialsBody,
|
|
52
|
+
response: {
|
|
53
|
+
200: commonResponses.success,
|
|
54
|
+
400: commonResponses.error,
|
|
55
|
+
401: commonResponses.unauthorized,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
handler: controller.login.bind(controller),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
app.post('/auth/refresh', {
|
|
62
|
+
schema: {
|
|
63
|
+
tags: ['Auth'],
|
|
64
|
+
summary: 'Refresh access token',
|
|
65
|
+
body: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
required: ['refreshToken'],
|
|
68
|
+
properties: {
|
|
69
|
+
refreshToken: { type: 'string' },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
response: {
|
|
73
|
+
200: commonResponses.success,
|
|
74
|
+
401: commonResponses.unauthorized,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
handler: controller.refresh.bind(controller),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Protected routes
|
|
81
|
+
app.post('/auth/logout', {
|
|
82
|
+
preHandler: [authenticate],
|
|
83
|
+
schema: {
|
|
84
|
+
tags: ['Auth'],
|
|
85
|
+
summary: 'Logout current user',
|
|
86
|
+
security: [{ bearerAuth: [] }],
|
|
87
|
+
response: {
|
|
88
|
+
200: commonResponses.success,
|
|
89
|
+
401: commonResponses.unauthorized,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
handler: controller.logout.bind(controller),
|
|
93
|
+
});
|
|
94
|
+
app.get('/auth/me', {
|
|
95
|
+
preHandler: [authenticate],
|
|
96
|
+
schema: {
|
|
97
|
+
tags: ['Auth'],
|
|
98
|
+
summary: 'Get current user profile',
|
|
99
|
+
security: [{ bearerAuth: [] }],
|
|
100
|
+
response: {
|
|
101
|
+
200: commonResponses.success,
|
|
102
|
+
401: commonResponses.unauthorized,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
handler: controller.me.bind(controller),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
app.post('/auth/change-password', {
|
|
109
|
+
preHandler: [authenticate],
|
|
110
|
+
schema: {
|
|
111
|
+
tags: ['Auth'],
|
|
112
|
+
summary: 'Change current user password',
|
|
113
|
+
security: [{ bearerAuth: [] }],
|
|
114
|
+
body: changePasswordBody,
|
|
115
|
+
response: {
|
|
116
|
+
200: commonResponses.success,
|
|
117
|
+
400: commonResponses.error,
|
|
118
|
+
401: commonResponses.unauthorized,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
handler: controller.changePassword.bind(controller),
|
|
122
|
+
});
|
|
123
|
+
}
|