@ratim818/allyve-wellness-backend 1.0.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.
@@ -0,0 +1,293 @@
1
+ import bcrypt from "bcryptjs";
2
+ import jwt from "jsonwebtoken";
3
+ import { v4 as uuidv4 } from "uuid";
4
+ import { db } from "../config/database.js";
5
+ import { config } from "../config/index.js";
6
+ import { encrypt, decrypt } from "./encryption.js";
7
+ import { writeAuditLog } from "./audit.js";
8
+ import { logger } from "../utils/logger.js";
9
+
10
+ // ─── HIPAA AUTH REQUIREMENTS ────────────────────────────────
11
+ // §164.312(a)(1) — Unique user identification
12
+ // §164.312(a)(2)(i) — Unique user ID for tracking
13
+ // §164.312(d) — Person or entity authentication
14
+ // §164.312(a)(2)(iii) — Automatic logoff (handled by session middleware)
15
+ // ─────────────────────────────────────────────────────────────
16
+
17
+ const BCRYPT_ROUNDS = 12; // NIST recommends ≥10 for sensitive systems
18
+ const MAX_LOGIN_ATTEMPTS = 5;
19
+ const LOCKOUT_DURATION_MS = 30 * 60 * 1000; // 30 minutes
20
+
21
+ export interface TokenPayload {
22
+ userId: string;
23
+ email: string;
24
+ role: string;
25
+ sessionId: string;
26
+ }
27
+
28
+ export interface AuthTokens {
29
+ accessToken: string;
30
+ refreshToken: string;
31
+ expiresIn: string;
32
+ }
33
+
34
+ /**
35
+ * Register a new user with encrypted PII and hashed password.
36
+ */
37
+ export async function registerUser(data: {
38
+ email: string;
39
+ password: string;
40
+ fullName: string;
41
+ dateOfBirth: string;
42
+ phone?: string;
43
+ }): Promise<{ userId: string }> {
44
+ // Check if email already exists (using HMAC hash for lookup without decrypting all emails)
45
+ const { hmacHash } = await import("./encryption.js");
46
+ const emailHash = hmacHash(data.email.toLowerCase());
47
+
48
+ const existing = await db("users").where("email_hash", emailHash).first();
49
+ if (existing) {
50
+ throw new Error("An account with this email already exists");
51
+ }
52
+
53
+ const userId = uuidv4();
54
+ const passwordHash = await bcrypt.hash(data.password, BCRYPT_ROUNDS);
55
+
56
+ // Encrypt all PII/PHI at rest
57
+ await db("users").insert({
58
+ id: userId,
59
+ email_encrypted: encrypt(data.email.toLowerCase()),
60
+ email_hash: emailHash, // For lookups without decrypting every row
61
+ password_hash: passwordHash,
62
+ full_name_encrypted: encrypt(data.fullName),
63
+ date_of_birth_encrypted: encrypt(data.dateOfBirth),
64
+ phone_encrypted: data.phone ? encrypt(data.phone) : null,
65
+ role: "patient",
66
+ is_active: true,
67
+ login_attempts: 0,
68
+ created_at: new Date(),
69
+ updated_at: new Date(),
70
+ });
71
+
72
+ await writeAuditLog({
73
+ user_id: userId,
74
+ action: "CREATE",
75
+ resource_type: "user",
76
+ resource_id: userId,
77
+ outcome: "success",
78
+ });
79
+
80
+ logger.info(`User registered: ${userId}`);
81
+ return { userId };
82
+ }
83
+
84
+ /**
85
+ * Authenticate a user and return JWT tokens.
86
+ */
87
+ export async function loginUser(
88
+ email: string,
89
+ password: string,
90
+ ipAddress?: string,
91
+ userAgent?: string
92
+ ): Promise<AuthTokens> {
93
+ const { hmacHash } = await import("./encryption.js");
94
+ const emailHash = hmacHash(email.toLowerCase());
95
+
96
+ const user = await db("users").where("email_hash", emailHash).first();
97
+
98
+ if (!user) {
99
+ await writeAuditLog({
100
+ user_id: null,
101
+ action: "LOGIN_FAILED",
102
+ resource_type: "session",
103
+ details: { reason: "user_not_found" },
104
+ ip_address: ipAddress,
105
+ user_agent: userAgent,
106
+ outcome: "failure",
107
+ });
108
+ throw new Error("Invalid email or password");
109
+ }
110
+
111
+ // Check account lockout
112
+ if (user.locked_until && new Date(user.locked_until) > new Date()) {
113
+ await writeAuditLog({
114
+ user_id: user.id,
115
+ action: "LOGIN_FAILED",
116
+ resource_type: "session",
117
+ details: { reason: "account_locked" },
118
+ ip_address: ipAddress,
119
+ user_agent: userAgent,
120
+ outcome: "denied",
121
+ });
122
+ throw new Error("Account is temporarily locked. Please try again later.");
123
+ }
124
+
125
+ // Verify password
126
+ const isValid = await bcrypt.compare(password, user.password_hash);
127
+ if (!isValid) {
128
+ const attempts = (user.login_attempts || 0) + 1;
129
+ const updates: Record<string, unknown> = {
130
+ login_attempts: attempts,
131
+ updated_at: new Date(),
132
+ };
133
+
134
+ // Lock after MAX_LOGIN_ATTEMPTS
135
+ if (attempts >= MAX_LOGIN_ATTEMPTS) {
136
+ updates.locked_until = new Date(Date.now() + LOCKOUT_DURATION_MS);
137
+ logger.warn(`Account locked for user ${user.id} after ${attempts} failed attempts`);
138
+ }
139
+
140
+ await db("users").where("id", user.id).update(updates);
141
+
142
+ await writeAuditLog({
143
+ user_id: user.id,
144
+ action: "LOGIN_FAILED",
145
+ resource_type: "session",
146
+ details: { reason: "invalid_password", attempt: attempts },
147
+ ip_address: ipAddress,
148
+ user_agent: userAgent,
149
+ outcome: "failure",
150
+ });
151
+
152
+ throw new Error("Invalid email or password");
153
+ }
154
+
155
+ // Reset login attempts on success
156
+ await db("users").where("id", user.id).update({
157
+ login_attempts: 0,
158
+ locked_until: null,
159
+ last_login: new Date(),
160
+ updated_at: new Date(),
161
+ });
162
+
163
+ // Create session
164
+ const sessionId = uuidv4();
165
+ await db("sessions").insert({
166
+ id: sessionId,
167
+ user_id: user.id,
168
+ ip_address: ipAddress,
169
+ user_agent: userAgent,
170
+ expires_at: new Date(Date.now() + config.session.maxAge),
171
+ created_at: new Date(),
172
+ });
173
+
174
+ // Generate tokens
175
+ const payload: TokenPayload = {
176
+ userId: user.id,
177
+ email: email.toLowerCase(),
178
+ role: user.role,
179
+ sessionId,
180
+ };
181
+
182
+ const accessToken = jwt.sign(payload, config.jwt.secret, {
183
+ expiresIn: config.jwt.expiresIn,
184
+ issuer: "allyve-wellness",
185
+ audience: "allyve-frontend",
186
+ } as jwt.SignOptions);
187
+
188
+ const refreshToken = jwt.sign(
189
+ { userId: user.id, sessionId, type: "refresh" },
190
+ config.jwt.secret,
191
+ { expiresIn: config.jwt.refreshExpiresIn } as jwt.SignOptions
192
+ );
193
+
194
+ // Store refresh token hash
195
+ const refreshHash = (await import("./encryption.js")).hmacHash(refreshToken);
196
+ await db("refresh_tokens").insert({
197
+ id: uuidv4(),
198
+ user_id: user.id,
199
+ session_id: sessionId,
200
+ token_hash: refreshHash,
201
+ expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
202
+ created_at: new Date(),
203
+ });
204
+
205
+ await writeAuditLog({
206
+ user_id: user.id,
207
+ action: "LOGIN",
208
+ resource_type: "session",
209
+ resource_id: sessionId,
210
+ ip_address: ipAddress,
211
+ user_agent: userAgent,
212
+ outcome: "success",
213
+ });
214
+
215
+ return {
216
+ accessToken,
217
+ refreshToken,
218
+ expiresIn: config.jwt.expiresIn,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Refresh an access token using a valid refresh token.
224
+ */
225
+ export async function refreshAccessToken(refreshToken: string): Promise<AuthTokens> {
226
+ const { hmacHash } = await import("./encryption.js");
227
+
228
+ // Verify the JWT
229
+ let decoded: jwt.JwtPayload;
230
+ try {
231
+ decoded = jwt.verify(refreshToken, config.jwt.secret) as jwt.JwtPayload;
232
+ } catch {
233
+ throw new Error("Invalid refresh token");
234
+ }
235
+
236
+ if (decoded.type !== "refresh") throw new Error("Invalid token type");
237
+
238
+ // Check token exists in DB and isn't revoked
239
+ const tokenHash = hmacHash(refreshToken);
240
+ const storedToken = await db("refresh_tokens")
241
+ .where("token_hash", tokenHash)
242
+ .where("revoked", false)
243
+ .first();
244
+
245
+ if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
246
+ throw new Error("Refresh token expired or revoked");
247
+ }
248
+
249
+ // Get user
250
+ const user = await db("users").where("id", decoded.userId).first();
251
+ if (!user || !user.is_active) throw new Error("User not found or inactive");
252
+
253
+ // Generate new access token
254
+ const payload: TokenPayload = {
255
+ userId: user.id,
256
+ email: decrypt(user.email_encrypted),
257
+ role: user.role,
258
+ sessionId: decoded.sessionId as string,
259
+ };
260
+
261
+ const newAccessToken = jwt.sign(payload, config.jwt.secret, {
262
+ expiresIn: config.jwt.expiresIn,
263
+ issuer: "allyve-wellness",
264
+ audience: "allyve-frontend",
265
+ } as jwt.SignOptions);
266
+
267
+ return {
268
+ accessToken: newAccessToken,
269
+ refreshToken, // Reuse existing refresh token
270
+ expiresIn: config.jwt.expiresIn,
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Logout — revoke session and refresh tokens.
276
+ */
277
+ export async function logoutUser(
278
+ userId: string,
279
+ sessionId: string,
280
+ ipAddress?: string
281
+ ): Promise<void> {
282
+ await db("sessions").where("id", sessionId).update({ revoked: true });
283
+ await db("refresh_tokens").where("session_id", sessionId).update({ revoked: true });
284
+
285
+ await writeAuditLog({
286
+ user_id: userId,
287
+ action: "LOGOUT",
288
+ resource_type: "session",
289
+ resource_id: sessionId,
290
+ ip_address: ipAddress,
291
+ outcome: "success",
292
+ });
293
+ }
@@ -0,0 +1,76 @@
1
+ import crypto from "crypto";
2
+ import { config } from "../config/index.js";
3
+
4
+ // ─── AES-256-GCM ENCRYPTION FOR PHI AT REST ────────────────
5
+ // HIPAA §164.312(a)(2)(iv) — Encryption and decryption
6
+ // Uses AES-256-GCM which provides both confidentiality AND integrity
7
+ // Each field is encrypted with a unique IV (initialization vector)
8
+ // ─────────────────────────────────────────────────────────────
9
+
10
+ const ALGORITHM = "aes-256-gcm";
11
+ const KEY = Buffer.from(config.encryption.key, "hex");
12
+ const IV_LENGTH = config.encryption.ivLength;
13
+ const AUTH_TAG_LENGTH = 16;
14
+
15
+ if (KEY.length !== 32) {
16
+ throw new Error("ENCRYPTION_KEY must be 32 bytes (64 hex chars). Generate with: openssl rand -hex 32");
17
+ }
18
+
19
+ /**
20
+ * Encrypt a plaintext string. Returns: iv:authTag:ciphertext (all hex-encoded)
21
+ * Each encryption uses a random IV, so identical inputs produce different outputs.
22
+ */
23
+ export function encrypt(plaintext: string): string {
24
+ const iv = crypto.randomBytes(IV_LENGTH);
25
+ const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv, { authTagLength: AUTH_TAG_LENGTH });
26
+
27
+ let encrypted = cipher.update(plaintext, "utf8", "hex");
28
+ encrypted += cipher.final("hex");
29
+ const authTag = cipher.getAuthTag().toString("hex");
30
+
31
+ return `${iv.toString("hex")}:${authTag}:${encrypted}`;
32
+ }
33
+
34
+ /**
35
+ * Decrypt a string produced by encrypt(). Verifies integrity via GCM auth tag.
36
+ */
37
+ export function decrypt(encryptedData: string): string {
38
+ const parts = encryptedData.split(":");
39
+ if (parts.length !== 3) {
40
+ throw new Error("Invalid encrypted data format");
41
+ }
42
+
43
+ const [ivHex, authTagHex, ciphertextHex] = parts;
44
+ const iv = Buffer.from(ivHex, "hex");
45
+ const authTag = Buffer.from(authTagHex, "hex");
46
+
47
+ const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv, { authTagLength: AUTH_TAG_LENGTH });
48
+ decipher.setAuthTag(authTag);
49
+
50
+ let decrypted = decipher.update(ciphertextHex, "hex", "utf8");
51
+ decrypted += decipher.final("utf8");
52
+
53
+ return decrypted;
54
+ }
55
+
56
+ /**
57
+ * Encrypt a JSON-serializable object. Used for structured PHI like health metrics.
58
+ */
59
+ export function encryptJSON(data: unknown): string {
60
+ return encrypt(JSON.stringify(data));
61
+ }
62
+
63
+ /**
64
+ * Decrypt and parse a JSON object.
65
+ */
66
+ export function decryptJSON<T = unknown>(encryptedData: string): T {
67
+ return JSON.parse(decrypt(encryptedData)) as T;
68
+ }
69
+
70
+ /**
71
+ * Hash data one-way (for indexing encrypted fields without revealing values).
72
+ * Uses HMAC-SHA256 so the hash is deterministic but requires the key to reproduce.
73
+ */
74
+ export function hmacHash(data: string): string {
75
+ return crypto.createHmac("sha256", KEY).update(data).digest("hex");
76
+ }
@@ -0,0 +1,57 @@
1
+ import winston from "winston";
2
+
3
+ // ─── HIPAA LOGGING RULES ────────────────────────────────────
4
+ // 1. NEVER log PHI (Protected Health Information)
5
+ // 2. NEVER log passwords, tokens, or encryption keys
6
+ // 3. DO log: timestamps, user IDs, action types, resource types
7
+ // 4. Retain audit logs for 6 years minimum (HIPAA §164.530(j))
8
+ // 5. All log files must be on encrypted storage
9
+ // ─────────────────────────────────────────────────────────────
10
+
11
+ const logFormat = winston.format.combine(
12
+ winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSS" }),
13
+ winston.format.errors({ stack: true }),
14
+ winston.format.printf(({ timestamp, level, message, ...meta }) => {
15
+ const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : "";
16
+ return `[${timestamp}] ${level.toUpperCase()}: ${message}${metaStr}`;
17
+ })
18
+ );
19
+
20
+ export const logger = winston.createLogger({
21
+ level: process.env.AUDIT_LOG_LEVEL || "info",
22
+ format: logFormat,
23
+ defaultMeta: { service: "allyve-backend" },
24
+ transports: [
25
+ // Console output (development)
26
+ new winston.transports.Console({
27
+ format: winston.format.combine(winston.format.colorize(), logFormat),
28
+ }),
29
+
30
+ // Application logs — rotated, no PHI
31
+ new winston.transports.File({
32
+ filename: "logs/app.log",
33
+ maxsize: 10 * 1024 * 1024, // 10MB
34
+ maxFiles: 30,
35
+ }),
36
+
37
+ // Error logs — separate file for alerts
38
+ new winston.transports.File({
39
+ filename: "logs/error.log",
40
+ level: "error",
41
+ maxsize: 10 * 1024 * 1024,
42
+ maxFiles: 90,
43
+ }),
44
+
45
+ // HIPAA Audit trail — separate, long-retention
46
+ new winston.transports.File({
47
+ filename: "logs/audit.log",
48
+ level: "info",
49
+ maxsize: 50 * 1024 * 1024,
50
+ maxFiles: 365, // Keep 1 year of files on disk; archive older to S3/GCS
51
+ }),
52
+ ],
53
+ });
54
+
55
+ // Ensure log directory exists
56
+ import { mkdirSync } from "fs";
57
+ try { mkdirSync("logs", { recursive: true }); } catch { /* exists */ }
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022"],
7
+ "outDir": "./dist",
8
+ "rootDir": "./src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "resolveJsonModule": true,
14
+ "declaration": true,
15
+ "declarationMap": true,
16
+ "sourceMap": true,
17
+ "baseUrl": ".",
18
+ "paths": {
19
+ "@/*": ["src/*"]
20
+ }
21
+ },
22
+ "include": ["src/**/*"],
23
+ "exclude": ["node_modules", "dist"]
24
+ }