diff --git a/.env.example b/.env.example index 01563200..5bc2cce9 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,14 @@ DATABASE_URL=postgresql://user:password@localhost:5432/stellaiverse # JWT JWT_SECRET=your_jwt_secret_key_here JWT_EXPIRATION=24h +JWT_ACCESS_TOKEN_EXPIRY=15m +JWT_REFRESH_TOKEN_EXPIRY_DAYS=7 + +# Authentication Security +AUTH_MAX_LOGIN_ATTEMPTS=5 +AUTH_LOCKOUT_DURATION_MINUTES=15 +AUTH_RATE_LIMIT_TTL_MS=60000 +AUTH_RATE_LIMIT_MAX_ATTEMPTS=5 # AI Services OPENAI_API_KEY=your_openai_key diff --git a/docs/JWT_AUTHENTICATION.md b/docs/JWT_AUTHENTICATION.md new file mode 100644 index 00000000..28822d05 --- /dev/null +++ b/docs/JWT_AUTHENTICATION.md @@ -0,0 +1,426 @@ +# JWT Authentication Module Documentation + +## Overview + +The JWT Authentication module provides secure token-based authentication with refresh tokens, account lockout, and rate limiting. It implements industry best practices for password hashing, token management, and security controls. + +## Features + +- **JWT Access Tokens**: Short-lived tokens (configurable, default 15 minutes) +- **Refresh Tokens**: Long-lived tokens (configurable, default 7 days) with revocation support +- **Account Lockout**: Automatic account lockout after configurable failed login attempts +- **Rate Limiting**: Per-IP and per-user rate limiting for login attempts +- **Login Attempt Tracking**: Comprehensive logging of all authentication attempts +- **Password Security**: Bcrypt hashing with per-user salt (12 rounds) +- **Token Revocation**: Support for revoking individual or all refresh tokens +- **2FA Support**: Two-factor authentication integration (TOTP and backup codes) + +## Configuration + +Add the following environment variables to your `.env` file: + +```bash +# JWT Configuration +JWT_SECRET=your_jwt_secret_key_here +JWT_ACCESS_TOKEN_EXPIRY=15m +JWT_REFRESH_TOKEN_EXPIRY_DAYS=7 + +# Authentication Security +AUTH_MAX_LOGIN_ATTEMPTS=5 +AUTH_LOCKOUT_DURATION_MINUTES=15 +AUTH_RATE_LIMIT_TTL_MS=60000 +AUTH_RATE_LIMIT_MAX_ATTEMPTS=5 +``` + +### Configuration Options + +| Variable | Description | Default | +|----------|-------------|---------| +| `JWT_SECRET` | Secret key for signing JWT tokens | Required | +| `JWT_ACCESS_TOKEN_EXPIRY` | Access token expiry time | `15m` | +| `JWT_REFRESH_TOKEN_EXPIRY_DAYS` | Refresh token expiry in days | `7` | +| `AUTH_MAX_LOGIN_ATTEMPTS` | Maximum failed login attempts before lockout | `5` | +| `AUTH_LOCKOUT_DURATION_MINUTES` | Account lockout duration in minutes | `15` | +| `AUTH_RATE_LIMIT_TTL_MS` | Rate limit time window in milliseconds | `60000` | +| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | Maximum attempts within rate limit window | `5` | + +## API Endpoints + +### Register User + +**Endpoint:** `POST /auth/jwt/register` + +**Description:** Creates a new user account with email/password authentication. + +**Request Body:** +```json +{ + "email": "user@example.com", + "password": "SecurePassword123!", + "username": "johndoe" +} +``` + +**Response (201 Created):** +```json +{ + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "a1b2c3d4e5f6...", + "user": { + "id": "uuid", + "email": "user@example.com", + "username": "johndoe", + "role": "user", + "kycStatus": "unverified" + }, + "requiresTwoFactor": false +} +``` + +**Error Responses:** +- `409 Conflict`: Email or username already exists +- `400 Bad Request`: Invalid input data + +--- + +### Login + +**Endpoint:** `POST /auth/jwt/login` + +**Description:** Authenticates a user with email/password. Implements account lockout after configurable failed attempts. + +**Request Body:** +```json +{ + "email": "user@example.com", + "password": "SecurePassword123!" +} +``` + +**Response (200 OK):** +```json +{ + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "a1b2c3d4e5f6...", + "user": { + "id": "uuid", + "email": "user@example.com", + "username": "johndoe", + "role": "user", + "kycStatus": "unverified" + }, + "requiresTwoFactor": false +} +``` + +**Error Responses:** +- `401 Unauthorized`: Invalid credentials or account locked +- `400 Bad Request`: Account uses wallet authentication +- `429 Too Many Requests`: Rate limit exceeded + +**Security Features:** +- Rate limited: 5 attempts per minute per IP +- Account lockout after 5 failed attempts (configurable) +- Lockout duration: 15 minutes (configurable) +- All attempts logged for audit trail + +--- + +### Refresh Token + +**Endpoint:** `POST /auth/jwt/refresh` + +**Description:** Uses a valid refresh token to issue a new access token. The old refresh token is revoked and replaced with a new one. + +**Request Body:** +```json +{ + "refreshToken": "a1b2c3d4e5f6..." +} +``` + +**Response (200 OK):** +```json +{ + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "newtoken123..." +} +``` + +**Error Responses:** +- `401 Unauthorized`: Invalid or expired refresh token + +**Security Features:** +- Old refresh token is automatically revoked +- New refresh token issued with fresh expiry +- Rate limited: 10 attempts per minute per IP + +--- + +### Logout (All Sessions) + +**Endpoint:** `POST /auth/jwt/logout` + +**Description:** Revokes all refresh tokens for the authenticated user, effectively logging them out from all devices. + +**Headers:** +``` +Authorization: Bearer +``` + +**Response (200 OK):** +```json +{ + "message": "Logged out successfully" +} +``` + +**Error Responses:** +- `401 Unauthorized`: Invalid or expired access token + +**Security Features:** +- Revokes all refresh tokens for the user +- Invalidates all active sessions +- Requires valid access token + +--- + +### Logout (Current Session) + +**Endpoint:** `POST /auth/jwt/logout/current` + +**Description:** Revokes only the current refresh token, allowing other sessions to remain active. + +**Headers:** +``` +Authorization: Bearer +``` + +**Request Body:** +```json +{ + "refreshToken": "a1b2c3d4e5f6..." +} +``` + +**Response (200 OK):** +```json +{ + "message": "Current session logged out successfully" +} +``` + +**Error Responses:** +- `401 Unauthorized`: Invalid or expired access token + +--- + +### Verify Two-Factor Authentication + +**Endpoint:** `POST /auth/jwt/2fa/verify` + +**Description:** Completes the login process by verifying the 2FA code or backup code. Returns final access and refresh tokens. + +**Request Body:** +```json +{ + "userId": "uuid", + "code": "123456" +} +``` + +or with backup code: +```json +{ + "userId": "uuid", + "backupCode": "ABCD1234" +} +``` + +**Response (200 OK):** +```json +{ + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "a1b2c3d4e5f6..." +} +``` + +**Error Responses:** +- `401 Unauthorized`: Invalid 2FA code +- `400 Bad Request`: 2FA not enabled for user + +## Security Considerations + +### Password Security + +- **Hashing Algorithm**: Bcrypt with 12 salt rounds +- **Per-User Salt**: Each password has a unique salt +- **Minimum Password Length**: 8 characters (enforced by validation) +- **Password Storage**: Never store plaintext passwords + +### Token Security + +- **Access Tokens**: Short-lived (15 minutes default) +- **Refresh Tokens**: Long-lived (7 days default) with revocation support +- **Token Storage**: Store refresh tokens in HTTP-only cookies or secure storage +- **Token Rotation**: Refresh tokens are rotated on each refresh +- **Token Revocation**: Support for individual and bulk token revocation + +### Account Lockout + +- **Failed Attempt Tracking**: Tracks failed attempts per user +- **Configurable Threshold**: Default 5 failed attempts +- **Configurable Duration**: Default 15 minutes lockout +- **Automatic Reset**: Counter resets on successful login +- **Audit Logging**: All lockout events are logged + +### Rate Limiting + +- **Per-IP Limits**: 5 login attempts per minute per IP +- **Per-User Limits**: Additional rate limiting by user +- **Sensitive Rate Limiting**: Auth endpoints use stricter rate limits +- **Configurable**: TTL and limits are configurable via environment variables + +### Login Attempt Tracking + +- **Comprehensive Logging**: All login attempts are logged +- **Failure Reasons**: Detailed reasons for failed attempts +- **IP and User Agent**: Captures client information +- **Audit Trail**: Maintains security audit trail +- **Automatic Cleanup**: Old attempts are periodically cleaned up + +## Database Schema + +### Users Table + +Added columns for account lockout: +- `failedLoginAttempts` (integer, default: 0) +- `lockedUntil` (timestamp, nullable) + +### Refresh Tokens Table + +- `id` (UUID, primary key) +- `userId` (UUID, foreign key) +- `token` (string, unique) +- `expiresAt` (timestamp) +- `revoked` (boolean, default: false) +- `revokedAt` (timestamp, nullable) +- `replacedByToken` (string, nullable) +- `ipAddress` (string) +- `userAgent` (string, nullable) +- `createdAt` (timestamp) +- `updatedAt` (timestamp) + +### Login Attempts Table + +- `id` (UUID, primary key) +- `userId` (UUID, foreign key, nullable) +- `email` (string, nullable) +- `success` (boolean) +- `failureReason` (string, nullable) +- `ipAddress` (string) +- `userAgent` (string, nullable) +- `createdAt` (timestamp) + +## Testing + +### Unit Tests + +Unit tests are provided for the `LoginAttemptService`: + +```bash +npm test -- login-attempt.service.spec.ts +``` + +### Integration Tests + +End-to-end tests cover all authentication endpoints: + +```bash +npm run test:e2e -- auth-jwt.e2e-spec.ts +``` + +Test coverage includes: +- User registration +- Successful login +- Failed login attempts +- Account lockout +- Token refresh +- Token revocation +- Logout functionality + +## Migration Guide + +### From Legacy AuthService + +The legacy `AuthService` is deprecated. Migrate to `EnhancedAuthService`: + +**Old:** +```typescript +const result = await authService.login(loginDto); +``` + +**New:** +```typescript +const result = await enhancedAuthService.login( + loginDto, + ipAddress, + userAgent, +); +``` + +### Database Migration + +Run the following migration to add the new columns: + +```bash +npm run migration:generate -- -d src/config/typeorm.config.ts src/migrations/AddAuthSecurityFields +npm run migration:run +``` + +## Best Practices + +### For Developers + +1. **Always use HTTPS** in production +2. **Store refresh tokens securely** (HTTP-only cookies recommended) +3. **Implement proper error handling** for authentication failures +4. **Log security events** for monitoring and auditing +5. **Use environment variables** for sensitive configuration +6. **Rotate JWT secrets** periodically in production +7. **Monitor login attempts** for suspicious activity + +### For Users + +1. **Use strong passwords** with mixed characters +2. **Enable 2FA** when available +3. **Report suspicious activity** immediately +4. **Use different passwords** for different services +5. **Log out from shared devices** after use + +## Troubleshooting + +### Common Issues + +**Account Locked** +- Wait for the lockout period to expire +- Contact administrator if lockout persists +- Check for suspicious activity on the account + +**Invalid Refresh Token** +- Token may have expired +- Token may have been revoked +- Try logging in again to get a new token + +**Rate Limit Exceeded** +- Wait for the rate limit window to expire +- Check for automated scripts or bots +- Contact administrator if legitimate traffic is blocked + +## Support + +For issues or questions about the authentication module: +- Check the documentation first +- Review the test files for examples +- Contact the development team +- Open an issue on the project repository diff --git a/package-lock.json b/package-lock.json index 6b560b2d..d0340765 100644 --- a/package-lock.json +++ b/package-lock.json @@ -90,7 +90,7 @@ "@types/bull": "^3.15.9", "@types/express": "^4.17.25", "@types/jest": "^29.5.14", - "@types/node": "^20.19.30", + "@types/node": "^20.19.43", "@types/nodemailer": "^6.4.14", "@types/passport-jwt": "^3.0.8", "@types/socket.io": "^3.0.1", @@ -2145,6 +2145,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true, "optional": true }, "node_modules/@grpc/grpc-js": { @@ -3842,6 +3843,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "dev": true, "optional": true, "dependencies": { "@gar/promisify": "^1.0.1", @@ -3853,6 +3855,7 @@ "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, "optional": true, "dependencies": { "mkdirp": "^1.0.4", @@ -3866,6 +3869,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "optional": true, "dependencies": { "balanced-match": "^1.0.0", @@ -3877,6 +3881,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, "optional": true, "dependencies": { "fs.realpath": "^1.0.0", @@ -3897,6 +3902,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "optional": true, "dependencies": { "brace-expansion": "^1.1.7" @@ -3909,6 +3915,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, "optional": true, "bin": { "mkdirp": "bin/cmd.js" @@ -3922,6 +3929,7 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, "optional": true, "dependencies": { "glob": "^7.1.3" @@ -6859,6 +6867,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "dev": true, "optional": true, "engines": { "node": ">= 6" @@ -7182,9 +7191,9 @@ } }, "node_modules/@types/node": { - "version": "20.19.30", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", - "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -7797,6 +7806,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, "optional": true }, "node_modules/accepts": { @@ -7875,6 +7885,7 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dev": true, "optional": true, "dependencies": { "humanize-ms": "^1.2.1" @@ -7887,6 +7898,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, "optional": true, "dependencies": { "clean-stack": "^2.0.0", @@ -8098,6 +8110,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "dev": true, "optional": true }, "node_modules/are-we-there-yet": { @@ -8105,6 +8118,7 @@ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", "deprecated": "This package is no longer supported.", + "dev": true, "optional": true, "dependencies": { "delegates": "^1.0.0", @@ -8810,6 +8824,7 @@ "version": "15.3.0", "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "dev": true, "optional": true, "dependencies": { "@npmcli/fs": "^1.0.0", @@ -8839,6 +8854,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "optional": true, "dependencies": { "balanced-match": "^1.0.0", @@ -8850,6 +8866,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, "optional": true, "dependencies": { "fs.realpath": "^1.0.0", @@ -8870,6 +8887,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -8882,6 +8900,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "optional": true, "dependencies": { "brace-expansion": "^1.1.7" @@ -8894,6 +8913,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -8906,6 +8926,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, "optional": true, "bin": { "mkdirp": "bin/cmd.js" @@ -8919,6 +8940,7 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, "optional": true, "dependencies": { "glob": "^7.1.3" @@ -8934,6 +8956,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "optional": true }, "node_modules/call-bind": { @@ -9160,6 +9183,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, "optional": true, "engines": { "node": ">=6" @@ -9382,6 +9406,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, "optional": true, "bin": { "color-support": "bin.js" @@ -9458,7 +9483,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -9486,6 +9511,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true, "optional": true }, "node_modules/content-disposition": { @@ -9851,6 +9877,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, "optional": true }, "node_modules/denque": { @@ -10203,7 +10230,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "devOptional": true, + "dev": true, "engines": { "node": ">=6" } @@ -10225,6 +10252,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, "optional": true }, "node_modules/error": { @@ -11508,7 +11536,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -11540,6 +11568,7 @@ "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", "deprecated": "This package is no longer supported.", + "dev": true, "optional": true, "dependencies": { "aproba": "^1.0.3 || ^2.0.0", @@ -11559,6 +11588,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, "optional": true }, "node_modules/gaxios": { @@ -11870,7 +11900,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -11974,6 +12004,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true, "optional": true }, "node_modules/hasown": { @@ -12045,6 +12076,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, "optional": true }, "node_modules/http-errors": { @@ -12063,6 +12095,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dev": true, "optional": true, "dependencies": { "@tootallnate/once": "1", @@ -12077,6 +12110,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, "optional": true, "dependencies": { "debug": "4" @@ -12112,6 +12146,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, "optional": true, "dependencies": { "ms": "^2.0.0" @@ -12235,7 +12270,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -12245,6 +12280,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, "optional": true, "engines": { "node": ">=8" @@ -12254,6 +12290,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true, "optional": true }, "node_modules/inflight": { @@ -12261,7 +12298,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -12488,6 +12525,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, "optional": true }, "node_modules/is-number": { @@ -14210,6 +14248,7 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "dev": true, "optional": true, "dependencies": { "agentkeepalive": "^4.1.3", @@ -14237,6 +14276,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, "optional": true, "dependencies": { "debug": "4" @@ -14249,6 +14289,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, "optional": true, "dependencies": { "agent-base": "6", @@ -14262,6 +14303,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -14274,6 +14316,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -14286,6 +14329,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "optional": true }, "node_modules/makeerror": { @@ -14517,6 +14561,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, "optional": true, "dependencies": { "minipass": "^3.0.0" @@ -14529,6 +14574,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -14541,12 +14587,14 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "optional": true }, "node_modules/minipass-fetch": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "dev": true, "optional": true, "dependencies": { "minipass": "^3.1.0", @@ -14564,6 +14612,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -14576,12 +14625,14 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "optional": true }, "node_modules/minipass-flush": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, "optional": true, "dependencies": { "minipass": "^3.0.0" @@ -14594,6 +14645,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -14606,12 +14658,14 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "optional": true }, "node_modules/minipass-pipeline": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, "optional": true, "dependencies": { "minipass": "^3.0.0" @@ -14624,6 +14678,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -14636,12 +14691,14 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "optional": true }, "node_modules/minipass-sized": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, "optional": true, "dependencies": { "minipass": "^3.0.0" @@ -14654,6 +14711,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -14666,6 +14724,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "optional": true }, "node_modules/minizlib": { @@ -14873,6 +14932,7 @@ "version": "8.4.1", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "dev": true, "optional": true, "dependencies": { "env-paths": "^2.2.0", @@ -14923,6 +14983,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "optional": true, "dependencies": { "balanced-match": "^1.0.0", @@ -14934,6 +14995,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, "optional": true, "dependencies": { "fs.realpath": "^1.0.0", @@ -14954,6 +15016,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "optional": true, "dependencies": { "brace-expansion": "^1.1.7" @@ -14967,6 +15030,7 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, "optional": true, "dependencies": { "glob": "^7.1.3" @@ -15080,6 +15144,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "dev": true, "optional": true, "dependencies": { "abbrev": "1" @@ -15119,6 +15184,7 @@ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", "deprecated": "This package is no longer supported.", + "dev": true, "optional": true, "dependencies": { "are-we-there-yet": "^3.0.0", @@ -15324,6 +15390,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -15451,7 +15518,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -15990,12 +16057,14 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, "optional": true }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, "optional": true, "dependencies": { "err-code": "^2.0.2", @@ -16557,6 +16626,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, "optional": true, "engines": { "node": ">= 4" @@ -17128,6 +17198,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, "optional": true, "engines": { "node": ">= 6.0.0", @@ -17216,6 +17287,7 @@ "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, "optional": true, "dependencies": { "ip-address": "^10.0.1", @@ -17230,6 +17302,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "dev": true, "optional": true, "dependencies": { "agent-base": "^6.0.2", @@ -17244,6 +17317,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, "optional": true, "dependencies": { "debug": "4" @@ -17364,6 +17438,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "dev": true, "optional": true, "dependencies": { "minipass": "^3.1.1" @@ -17376,6 +17451,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -17388,6 +17464,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "optional": true }, "node_modules/stack-utils": { @@ -18740,6 +18817,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "dev": true, "optional": true, "dependencies": { "unique-slug": "^2.0.0" @@ -18749,6 +18827,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "dev": true, "optional": true, "dependencies": { "imurmurhash": "^0.1.4" @@ -19154,6 +19233,7 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, "optional": true, "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" diff --git a/package.json b/package.json index dcab9519..0f2cbc9f 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,7 @@ "@types/bull": "^3.15.9", "@types/express": "^4.17.25", "@types/jest": "^29.5.14", - "@types/node": "^20.19.30", + "@types/node": "^20.19.43", "@types/nodemailer": "^6.4.14", "@types/passport-jwt": "^3.0.8", "@types/socket.io": "^3.0.1", diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 1ecb56e4..abd0ff2b 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -21,6 +21,7 @@ import { AuthStrategiesModule } from "./auth-strategies.module"; import { AuthController } from "./auth.controller"; import { EnhancedAuthController } from "./enhanced-auth.controller"; +import { JwtAuthController } from "./jwt-auth.controller"; import { ChallengeService } from "./challenge.service"; import { WalletAuthService } from "./wallet-auth.service"; @@ -30,11 +31,17 @@ import { RecoveryService } from "./recovery.service"; import { SessionRecoveryService } from "./session-recovery.service"; import { DelegationService } from "./delegation.service"; import { EnhancedAuthService } from "./enhanced-auth.service"; +import { LoginAttemptService } from "./login-attempt.service"; import { User } from "src/user/entities/user.entity"; import { EmailVerification } from "./entities/email-verification.entity"; import { Wallet } from "./entities/wallet.entity"; -import { RefreshToken, TwoFactorAuth, PasswordResetToken } from "./entities/auth.entity"; +import { + RefreshToken, + TwoFactorAuth, + PasswordResetToken, + LoginAttempt, +} from "./entities/auth.entity"; @Module({ imports: [ @@ -48,9 +55,10 @@ import { RefreshToken, TwoFactorAuth, PasswordResetToken } from "./entities/auth RefreshToken, TwoFactorAuth, PasswordResetToken, + LoginAttempt, ]), ], - controllers: [AuthController, EnhancedAuthController], + controllers: [AuthController, EnhancedAuthController, JwtAuthController], providers: [ ChallengeService, WalletAuthService, @@ -60,6 +68,7 @@ import { RefreshToken, TwoFactorAuth, PasswordResetToken } from "./entities/auth SessionRecoveryService, DelegationService, EnhancedAuthService, + LoginAttemptService, ], exports: [ // Re-export sub-modules so any module importing AuthModule gets everything. @@ -72,6 +81,7 @@ import { RefreshToken, TwoFactorAuth, PasswordResetToken } from "./entities/auth SessionRecoveryService, DelegationService, EnhancedAuthService, + LoginAttemptService, ], }) export class AuthModule {} diff --git a/src/auth/enhanced-auth.service.ts b/src/auth/enhanced-auth.service.ts index 2211b67d..e5edf4e8 100644 --- a/src/auth/enhanced-auth.service.ts +++ b/src/auth/enhanced-auth.service.ts @@ -14,6 +14,7 @@ import { JwtService } from "@nestjs/jwt"; import * as speakeasy from "speakeasy"; import * as qrcode from "qrcode"; import { EmailService } from "./email.service"; +import { LoginAttemptService } from "./login-attempt.service"; import { User } from "src/user/entities/user.entity"; import { RefreshToken, @@ -32,6 +33,7 @@ import { } from "./dto/auth.dto"; import { TwoFactorSetupDto } from "./dto/kyc.dto"; import { v4 as uuidv4 } from "uuid"; +import { ConfigService } from "@nestjs/config"; @Injectable() export class EnhancedAuthService { @@ -47,6 +49,8 @@ export class EnhancedAuthService { private readonly jwtService: JwtService, @Inject(forwardRef(() => EmailService)) private readonly emailService: EmailService, + private readonly loginAttemptService: LoginAttemptService, + private readonly configService: ConfigService, ) {} async register( @@ -123,47 +127,147 @@ export class EnhancedAuthService { }> { const { email, password } = loginDto; - // Find user by email + // Check if account is locked const user = await this.userRepository.findOne({ where: { email } }); - if (!user) { + if (user && user.lockedUntil && user.lockedUntil > new Date()) { + const remainingTime = Math.ceil( + (user.lockedUntil.getTime() - Date.now()) / 60000, + ); + await this.loginAttemptService.recordLoginAttempt( + user, + email, + false, + ipAddress, + userAgent, + "Account locked", + ); + throw new UnauthorizedException( + `Account is temporarily locked. Try again in ${remainingTime} minutes.`, + ); + } + + // Find user by email + const foundUser = await this.userRepository.findOne({ where: { email } }); + if (!foundUser) { + await this.loginAttemptService.recordLoginAttempt( + null, + email, + false, + ipAddress, + userAgent, + "User not found", + ); throw new UnauthorizedException("Invalid credentials"); } - if (!user.isActive) { + if (!foundUser.isActive) { + await this.loginAttemptService.recordLoginAttempt( + foundUser, + email, + false, + ipAddress, + userAgent, + "Account deactivated", + ); throw new UnauthorizedException("Account is deactivated"); } // Check if user has a password (traditional auth user) - if (!user.password) { + if (!foundUser.password) { + await this.loginAttemptService.recordLoginAttempt( + foundUser, + email, + false, + ipAddress, + userAgent, + "Wallet auth only", + ); throw new BadRequestException( "This account uses wallet authentication. Please use wallet login.", ); } // Verify password - const isPasswordValid = await bcrypt.compare(password, user.password); + const isPasswordValid = await bcrypt.compare(password, foundUser.password); if (!isPasswordValid) { - throw new UnauthorizedException("Invalid credentials"); + // Increment failed attempts + const maxAttempts = + this.configService.get("AUTH_MAX_LOGIN_ATTEMPTS") || 5; + const lockoutDuration = + this.configService.get("AUTH_LOCKOUT_DURATION_MINUTES") || 15; + + const newFailedAttempts = (foundUser.failedLoginAttempts || 0) + 1; + + if (newFailedAttempts >= maxAttempts) { + // Lock the account + const lockedUntil = new Date(Date.now() + lockoutDuration * 60 * 1000); + await this.userRepository.update(foundUser.id, { + failedLoginAttempts: newFailedAttempts, + lockedUntil, + }); + + await this.loginAttemptService.recordLoginAttempt( + foundUser, + email, + false, + ipAddress, + userAgent, + "Account locked due to too many failed attempts", + ); + + throw new UnauthorizedException( + `Too many failed login attempts. Account locked for ${lockoutDuration} minutes.`, + ); + } else { + // Just increment failed attempts + await this.userRepository.update(foundUser.id, { + failedLoginAttempts: newFailedAttempts, + }); + + await this.loginAttemptService.recordLoginAttempt( + foundUser, + email, + false, + ipAddress, + userAgent, + "Invalid password", + ); + + throw new UnauthorizedException("Invalid credentials"); + } } - // Update last login - await this.userRepository.update(user.id, { lastLoginAt: new Date() }); + // Successful login - reset failed attempts + await this.userRepository.update(foundUser.id, { + failedLoginAttempts: 0, + lockedUntil: null, + lastLoginAt: new Date(), + }); + + // Record successful login attempt + await this.loginAttemptService.recordLoginAttempt( + foundUser, + email, + true, + ipAddress, + userAgent, + ); // Generate tokens - const tokens = await this.generateTokens(user, ipAddress, userAgent); + const tokens = await this.generateTokens(foundUser, ipAddress, userAgent); // Check if 2FA is required - const twoFactorEnabled = await this.isTwoFactorEnabled(user.id); + const twoFactorEnabled = await this.isTwoFactorEnabled(foundUser.id); return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, user: { - id: user.id, - email: user.email, - username: user.username, - role: user.role, - kycStatus: user.kycStatus, + id: foundUser.id, + email: foundUser.email, + username: foundUser.username, + role: foundUser.role, + kycStatus: foundUser.kycStatus, }, requiresTwoFactor: twoFactorEnabled, }; @@ -381,21 +485,29 @@ export class EnhancedAuthService { ipAddress: string, userAgent?: string, ): Promise<{ accessToken: string; refreshToken: string }> { - // Generate access token + // Generate access token with configurable expiry + const accessTokenExpiry = + this.configService.get("JWT_ACCESS_TOKEN_EXPIRY") || "15m"; const payload = { sub: user.id, email: user.email, username: user.username, role: user.role, }; - const accessToken = this.jwtService.sign(payload, { expiresIn: "15m" }); + const accessToken = this.jwtService.sign(payload, { + expiresIn: accessTokenExpiry as any, + }); - // Generate refresh token + // Generate refresh token with configurable expiry + const refreshTokenExpiryDays = + this.configService.get("JWT_REFRESH_TOKEN_EXPIRY_DAYS") || 7; const refreshTokenValue = this.generateRefreshToken(); const refreshTokenEntity = this.refreshTokenRepository.create({ userId: user.id, token: refreshTokenValue, - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + expiresAt: new Date( + Date.now() + refreshTokenExpiryDays * 24 * 60 * 60 * 1000, + ), ipAddress, userAgent, }); @@ -488,11 +600,7 @@ export class EnhancedAuthService { relations: ["user"], }); - if ( - !resetToken || - resetToken.expiresAt < new Date() || - resetToken.used - ) { + if (!resetToken || resetToken.expiresAt < new Date() || resetToken.used) { throw new BadRequestException("Invalid or expired reset token"); } diff --git a/src/auth/entities/auth.entity.ts b/src/auth/entities/auth.entity.ts index 6433f141..b8084cf0 100644 --- a/src/auth/entities/auth.entity.ts +++ b/src/auth/entities/auth.entity.ts @@ -132,3 +132,35 @@ export class PasswordResetToken { @CreateDateColumn() createdAt: Date; } + +@Entity("login_attempts") +export class LoginAttempt { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ type: "uuid", nullable: true }) + @Index() + userId: string; + + @ManyToOne(() => User) + @JoinColumn({ name: "userId" }) + user: User; + + @Column({ nullable: true }) + email: string; + + @Column({ default: false }) + success: boolean; + + @Column({ nullable: true }) + failureReason: string; + + @Column() + ipAddress: string; + + @Column({ nullable: true }) + userAgent: string; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/auth/jwt-auth.controller.ts b/src/auth/jwt-auth.controller.ts new file mode 100644 index 00000000..f74f9203 --- /dev/null +++ b/src/auth/jwt-auth.controller.ts @@ -0,0 +1,216 @@ +import { + Controller, + Post, + Body, + UseGuards, + Request, + HttpCode, + HttpStatus, +} from "@nestjs/common"; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiBody, +} from "@nestjs/swagger"; +import { Throttle } from "@nestjs/throttler"; +import { EnhancedAuthService } from "./enhanced-auth.service"; +import { JwtAuthGuard } from "./jwt.guard"; +import { + LoginDto, + RegisterDto, + RefreshTokenDto, + TwoFactorVerifyDto, +} from "./dto/auth.dto"; +import { Public } from "../common/decorators/public.decorator"; +import { SensitiveRateLimit } from "../common/decorators/rate-limit.decorator"; + +@ApiTags("JWT Authentication") +@Controller("auth/jwt") +export class JwtAuthController { + constructor(private readonly enhancedAuthService: EnhancedAuthService) {} + + @Public() + @Post("register") + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: "Register with email and password", + description: + "Creates a new user account with email/password authentication. Returns access and refresh tokens.", + }) + @ApiResponse({ + status: 201, + description: "User registered successfully", + schema: { + type: "object", + properties: { + accessToken: { type: "string" }, + refreshToken: { type: "string" }, + user: { + type: "object", + properties: { + id: { type: "string" }, + email: { type: "string" }, + username: { type: "string" }, + role: { type: "string" }, + kycStatus: { type: "string" }, + }, + }, + requiresTwoFactor: { type: "boolean" }, + }, + }, + }) + @ApiResponse({ status: 409, description: "Email or username already exists" }) + @ApiResponse({ status: 400, description: "Invalid input data" }) + async register( + @Body() registerDto: RegisterDto, + @Request() req, + ) { + return this.enhancedAuthService.register( + registerDto, + req.ip, + req.headers["user-agent"], + ); + } + + @Public() + @SensitiveRateLimit("auth") + @Throttle({ default: { ttl: 60000, limit: 5 } }) + @Post("login") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Login with email and password", + description: + "Authenticates a user with email/password. Returns access and refresh tokens. Implements account lockout after configurable failed attempts.", + }) + @ApiBody({ type: LoginDto }) + @ApiResponse({ + status: 200, + description: "Login successful", + schema: { + type: "object", + properties: { + accessToken: { type: "string" }, + refreshToken: { type: "string" }, + user: { + type: "object", + properties: { + id: { type: "string" }, + email: { type: "string" }, + username: { type: "string" }, + role: { type: "string" }, + kycStatus: { type: "string" }, + }, + }, + requiresTwoFactor: { type: "boolean" }, + }, + }, + }) + @ApiResponse({ status: 401, description: "Invalid credentials or account locked" }) + @ApiResponse({ status: 400, description: "Account uses wallet authentication" }) + @ApiResponse({ status: 429, description: "Too many login attempts" }) + async login(@Body() loginDto: LoginDto, @Request() req) { + return this.enhancedAuthService.login( + loginDto, + req.ip, + req.headers["user-agent"], + ); + } + + @Public() + @Throttle({ default: { ttl: 60000, limit: 10 } }) + @Post("refresh") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Refresh access token", + description: + "Uses a valid refresh token to issue a new access token. The old refresh token is revoked and replaced with a new one.", + }) + @ApiBody({ type: RefreshTokenDto }) + @ApiResponse({ + status: 200, + description: "Token refreshed successfully", + schema: { + type: "object", + properties: { + accessToken: { type: "string" }, + refreshToken: { type: "string" }, + }, + }, + }) + @ApiResponse({ status: 401, description: "Invalid or expired refresh token" }) + async refreshToken(@Body() refreshTokenDto: RefreshTokenDto, @Request() req) { + return this.enhancedAuthService.refreshToken( + refreshTokenDto, + req.ip, + req.headers["user-agent"], + ); + } + + @Post("logout") + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Logout and revoke refresh tokens", + description: + "Revokes all refresh tokens for the authenticated user, effectively logging them out from all devices.", + }) + @ApiResponse({ status: 200, description: "Logout successful" }) + @ApiResponse({ status: 401, description: "Unauthorized" }) + async logout(@Request() req) { + const userId = req.user.sub || req.user.id; + await this.enhancedAuthService.revokeAllRefreshTokens(userId); + return { message: "Logged out successfully" }; + } + + @Post("logout/current") + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Logout current session", + description: + "Revokes only the current refresh token, allowing other sessions to remain active.", + }) + @ApiResponse({ status: 200, description: "Logout successful" }) + @ApiResponse({ status: 401, description: "Unauthorized" }) + async logoutCurrent(@Request() req) { + const refreshToken = req.body.refreshToken; + if (refreshToken) { + await this.enhancedAuthService.revokeRefreshToken(refreshToken); + } + return { message: "Current session logged out successfully" }; + } + + @Public() + @Post("2fa/verify") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Verify two-factor authentication", + description: + "Completes the login process by verifying the 2FA code or backup code. Returns final access and refresh tokens.", + }) + @ApiBody({ type: TwoFactorVerifyDto }) + @ApiResponse({ + status: 200, + description: "2FA verified successfully", + schema: { + type: "object", + properties: { + accessToken: { type: "string" }, + refreshToken: { type: "string" }, + }, + }, + }) + @ApiResponse({ status: 401, description: "Invalid 2FA code" }) + @ApiResponse({ status: 400, description: "2FA not enabled for user" }) + async verifyTwoFactor( + @Body() verifyDto: TwoFactorVerifyDto, + @Request() req, + ) { + const userId = req.body.userId; + return this.enhancedAuthService.verifyTwoFactorLogin(userId, verifyDto); + } +} diff --git a/src/auth/login-attempt.service.spec.ts b/src/auth/login-attempt.service.spec.ts new file mode 100644 index 00000000..2355037f --- /dev/null +++ b/src/auth/login-attempt.service.spec.ts @@ -0,0 +1,238 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { LoginAttemptService } from "./login-attempt.service"; +import { LoginAttempt } from "./entities/auth.entity"; +import { User, UserRole, KycStatus } from "src/user/entities/user.entity"; + +describe("LoginAttemptService", () => { + let service: LoginAttemptService; + let loginAttemptRepository: Repository; + + const mockLoginAttemptRepository = { + create: jest.fn(), + save: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockUser: User = { + id: "user-id-1", + email: "test@example.com", + username: "testuser", + password: "hashedpassword", + isActive: true, + failedLoginAttempts: 0, + walletAddress: "0x123", + role: UserRole.USER, + kycStatus: KycStatus.UNVERIFIED, + emailVerified: false, + displayName: null, + bio: null, + avatar: null, + preferences: {}, + referralCode: null, + referredById: null, + referredBy: null, + referrals: [], + provenanceRecords: [], + wallets: [], + lastLoginAt: null, + lockedUntil: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LoginAttemptService, + { + provide: getRepositoryToken(LoginAttempt), + useValue: mockLoginAttemptRepository, + }, + ], + }).compile(); + + service = module.get(LoginAttemptService); + loginAttemptRepository = module.get>( + getRepositoryToken(LoginAttempt), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("recordLoginAttempt", () => { + it("should record a successful login attempt", async () => { + const mockAttempt = { + userId: mockUser.id, + email: mockUser.email, + success: true, + ipAddress: "127.0.0.1", + userAgent: "Mozilla/5.0", + }; + mockLoginAttemptRepository.create.mockReturnValue(mockAttempt); + mockLoginAttemptRepository.save.mockResolvedValue(mockAttempt); + + await service.recordLoginAttempt( + mockUser, + mockUser.email, + true, + "127.0.0.1", + "Mozilla/5.0", + ); + + expect(loginAttemptRepository.create).toHaveBeenCalledWith({ + userId: mockUser.id, + email: mockUser.email, + success: true, + ipAddress: "127.0.0.1", + userAgent: "Mozilla/5.0", + }); + expect(loginAttemptRepository.save).toHaveBeenCalledWith(mockAttempt); + }); + + it("should record a failed login attempt with failure reason", async () => { + const mockAttempt = { + userId: mockUser.id, + email: mockUser.email, + success: false, + failureReason: "Invalid password", + ipAddress: "127.0.0.1", + userAgent: "Mozilla/5.0", + }; + mockLoginAttemptRepository.create.mockReturnValue(mockAttempt); + mockLoginAttemptRepository.save.mockResolvedValue(mockAttempt); + + await service.recordLoginAttempt( + mockUser, + mockUser.email, + false, + "127.0.0.1", + "Mozilla/5.0", + "Invalid password", + ); + + expect(loginAttemptRepository.create).toHaveBeenCalledWith({ + userId: mockUser.id, + email: mockUser.email, + success: false, + failureReason: "Invalid password", + ipAddress: "127.0.0.1", + userAgent: "Mozilla/5.0", + }); + expect(loginAttemptRepository.save).toHaveBeenCalledWith(mockAttempt); + }); + + it("should record a failed login attempt for non-existent user", async () => { + const mockAttempt = { + userId: null, + email: "nonexistent@example.com", + success: false, + failureReason: "User not found", + ipAddress: "127.0.0.1", + userAgent: "Mozilla/5.0", + }; + mockLoginAttemptRepository.create.mockReturnValue(mockAttempt); + mockLoginAttemptRepository.save.mockResolvedValue(mockAttempt); + + await service.recordLoginAttempt( + null, + "nonexistent@example.com", + false, + "127.0.0.1", + "Mozilla/5.0", + "User not found", + ); + + expect(loginAttemptRepository.create).toHaveBeenCalledWith({ + userId: null, + email: "nonexistent@example.com", + success: false, + failureReason: "User not found", + ipAddress: "127.0.0.1", + userAgent: "Mozilla/5.0", + }); + expect(loginAttemptRepository.save).toHaveBeenCalledWith(mockAttempt); + }); + }); + + describe("getFailedAttemptsCount", () => { + it("should return count of failed attempts for email", async () => { + mockLoginAttemptRepository.count.mockResolvedValue(3); + + const count = await service.getFailedAttemptsCount( + "test@example.com", + 15, + ); + + expect(count).toBe(3); + expect(loginAttemptRepository.count).toHaveBeenCalledWith({ + where: { + email: "test@example.com", + success: false, + createdAt: { $gte: expect.any(Date) } as any, + }, + }); + }); + + it("should use default time window if not specified", async () => { + mockLoginAttemptRepository.count.mockResolvedValue(5); + + const count = await service.getFailedAttemptsCount("test@example.com"); + + expect(count).toBe(5); + }); + }); + + describe("getFailedAttemptsForUser", () => { + it("should return count of failed attempts for user", async () => { + mockLoginAttemptRepository.count.mockResolvedValue(2); + + const count = await service.getFailedAttemptsForUser( + "user-id-1", + 15, + ); + + expect(count).toBe(2); + expect(loginAttemptRepository.count).toHaveBeenCalledWith({ + where: { + userId: "user-id-1", + success: false, + createdAt: { $gte: expect.any(Date) } as any, + }, + }); + }); + }); + + describe("cleanupOldAttempts", () => { + it("should delete old login attempts", async () => { + const mockQueryBuilder = { + delete: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 10 }), + }; + mockLoginAttemptRepository.createQueryBuilder.mockReturnValue( + mockQueryBuilder as any, + ); + + await service.cleanupOldAttempts(30); + + expect(mockLoginAttemptRepository.createQueryBuilder).toHaveBeenCalled(); + expect(mockQueryBuilder.delete).toHaveBeenCalled(); + expect(mockQueryBuilder.where).toHaveBeenCalledWith( + "createdAt < :cutoffDate", + expect.objectContaining({ + cutoffDate: expect.any(Date), + }), + ); + expect(mockQueryBuilder.execute).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/auth/login-attempt.service.ts b/src/auth/login-attempt.service.ts new file mode 100644 index 00000000..ddec4ea7 --- /dev/null +++ b/src/auth/login-attempt.service.ts @@ -0,0 +1,71 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { LoginAttempt } from "./entities/auth.entity"; +import { User } from "src/user/entities/user.entity"; + +@Injectable() +export class LoginAttemptService { + constructor( + @InjectRepository(LoginAttempt) + private readonly loginAttemptRepository: Repository, + ) {} + + async recordLoginAttempt( + user: User | null, + email: string, + success: boolean, + ipAddress: string, + userAgent?: string, + failureReason?: string, + ): Promise { + const attempt = this.loginAttemptRepository.create({ + userId: user?.id, + email, + success, + failureReason, + ipAddress, + userAgent, + }); + await this.loginAttemptRepository.save(attempt); + } + + async getFailedAttemptsCount( + email: string, + sinceMinutes: number = 15, + ): Promise { + const since = new Date(Date.now() - sinceMinutes * 60 * 1000); + return this.loginAttemptRepository.count({ + where: { + email, + success: false, + createdAt: { $gte: since } as any, + }, + }); + } + + async getFailedAttemptsForUser( + userId: string, + sinceMinutes: number = 15, + ): Promise { + const since = new Date(Date.now() - sinceMinutes * 60 * 1000); + return this.loginAttemptRepository.count({ + where: { + userId, + success: false, + createdAt: { $gte: since } as any, + }, + }); + } + + async cleanupOldAttempts(daysToKeep: number = 30): Promise { + const cutoffDate = new Date( + Date.now() - daysToKeep * 24 * 60 * 60 * 1000, + ); + await this.loginAttemptRepository + .createQueryBuilder() + .delete() + .where("createdAt < :cutoffDate", { cutoffDate }) + .execute(); + } +} diff --git a/src/user/entities/user.entity.ts b/src/user/entities/user.entity.ts index a8731d6a..28d53766 100644 --- a/src/user/entities/user.entity.ts +++ b/src/user/entities/user.entity.ts @@ -81,6 +81,12 @@ export class User { @Column({ type: "timestamp", nullable: true }) lastLoginAt: Date; + @Column({ default: 0 }) + failedLoginAttempts: number; + + @Column({ type: "timestamp", nullable: true }) + lockedUntil: Date; + @CreateDateColumn() createdAt: Date; @@ -116,4 +122,4 @@ export class User { @OneToMany(() => User, (user) => user.referredBy) referrals: User[]; -} \ No newline at end of file +} diff --git a/test/auth-jwt.e2e-spec.ts b/test/auth-jwt.e2e-spec.ts new file mode 100644 index 00000000..f796719a --- /dev/null +++ b/test/auth-jwt.e2e-spec.ts @@ -0,0 +1,286 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import * as request from "supertest"; +import { AppModule } from "../src/app.module"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { User } from "../src/user/entities/user.entity"; +import { RefreshToken } from "../src/auth/entities/auth.entity"; +import * as bcrypt from "bcrypt"; + +describe("JWT Authentication (e2e)", () => { + let app: INestApplication; + let userRepository: Repository; + let refreshTokenRepository: Repository; + + const testUser = { + email: "test@example.com", + password: "TestPassword123!", + username: "testuser", + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + AppModule, + TypeOrmModule.forRoot({ + type: "sqlite", + database: ":memory:", + entities: [User, RefreshToken], + synchronize: true, + }), + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe()); + await app.init(); + + userRepository = app.get>(getRepositoryToken(User)); + refreshTokenRepository = app.get>( + getRepositoryToken(RefreshToken), + ); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + // Clean up database before each test + await refreshTokenRepository.delete({}); + await userRepository.delete({}); + }); + + describe("POST /auth/jwt/register", () => { + it("should register a new user and return tokens", () => { + return request(app.getHttpServer()) + .post("/auth/jwt/register") + .send(testUser) + .expect(201) + .expect((res) => { + expect(res.body).toHaveProperty("accessToken"); + expect(res.body).toHaveProperty("refreshToken"); + expect(res.body).toHaveProperty("user"); + expect(res.body.user).toHaveProperty("email", testUser.email); + expect(res.body.user).toHaveProperty("username", testUser.username); + expect(res.body.user).toHaveProperty("id"); + }); + }); + + it("should not allow duplicate email registration", async () => { + // First registration + await request(app.getHttpServer()) + .post("/auth/jwt/register") + .send(testUser) + .expect(201); + + // Duplicate registration + return request(app.getHttpServer()) + .post("/auth/jwt/register") + .send(testUser) + .expect(409); + }); + + it("should validate required fields", () => { + return request(app.getHttpServer()) + .post("/auth/jwt/register") + .send({ email: testUser.email }) + .expect(400); + }); + }); + + describe("POST /auth/jwt/login", () => { + beforeEach(async () => { + // Create a test user + const hashedPassword = await bcrypt.hash(testUser.password, 12); + const user = userRepository.create({ + email: testUser.email, + password: hashedPassword, + username: testUser.username, + walletAddress: `email_${testUser.email}`, + isActive: true, + emailVerified: false, + failedLoginAttempts: 0, + }); + await userRepository.save(user); + }); + + it("should login with valid credentials", () => { + return request(app.getHttpServer()) + .post("/auth/jwt/login") + .send({ + email: testUser.email, + password: testUser.password, + }) + .expect(200) + .expect((res) => { + expect(res.body).toHaveProperty("accessToken"); + expect(res.body).toHaveProperty("refreshToken"); + expect(res.body).toHaveProperty("user"); + expect(res.body.user).toHaveProperty("email", testUser.email); + }); + }); + + it("should reject invalid credentials", () => { + return request(app.getHttpServer()) + .post("/auth/jwt/login") + .send({ + email: testUser.email, + password: "WrongPassword123!", + }) + .expect(401); + }); + + it("should reject non-existent user", () => { + return request(app.getHttpServer()) + .post("/auth/jwt/login") + .send({ + email: "nonexistent@example.com", + password: testUser.password, + }) + .expect(401); + }); + + it("should lock account after too many failed attempts", async () => { + // Attempt login 5 times with wrong password + for (let i = 0; i < 5; i++) { + await request(app.getHttpServer()) + .post("/auth/jwt/login") + .send({ + email: testUser.email, + password: "WrongPassword123!", + }) + .expect(401); + } + + // 6th attempt should be locked + return request(app.getHttpServer()) + .post("/auth/jwt/login") + .send({ + email: testUser.email, + password: testUser.password, + }) + .expect(401) + .expect((res) => { + expect(res.body.message).toContain("locked"); + }); + }); + }); + + describe("POST /auth/jwt/refresh", () => { + let validRefreshToken: string; + + beforeEach(async () => { + // Register a user to get tokens + const response = await request(app.getHttpServer()) + .post("/auth/jwt/register") + .send(testUser); + validRefreshToken = response.body.refreshToken; + }); + + it("should refresh tokens with valid refresh token", () => { + return request(app.getHttpServer()) + .post("/auth/jwt/refresh") + .send({ refreshToken: validRefreshToken }) + .expect(200) + .expect((res) => { + expect(res.body).toHaveProperty("accessToken"); + expect(res.body).toHaveProperty("refreshToken"); + expect(res.body.refreshToken).not.toBe(validRefreshToken); + }); + }); + + it("should reject invalid refresh token", () => { + return request(app.getHttpServer()) + .post("/auth/jwt/refresh") + .send({ refreshToken: "invalid-token" }) + .expect(401); + }); + + it("should revoke old refresh token after refresh", async () => { + // First refresh + const firstRefresh = await request(app.getHttpServer()) + .post("/auth/jwt/refresh") + .send({ refreshToken: validRefreshToken }); + + // Try to use the old token again + return request(app.getHttpServer()) + .post("/auth/jwt/refresh") + .send({ refreshToken: validRefreshToken }) + .expect(401); + }); + }); + + describe("POST /auth/jwt/logout", () => { + let accessToken: string; + let refreshToken: string; + + beforeEach(async () => { + // Register a user to get tokens + const response = await request(app.getHttpServer()) + .post("/auth/jwt/register") + .send(testUser); + accessToken = response.body.accessToken; + refreshToken = response.body.refreshToken; + }); + + it("should logout and revoke all refresh tokens", () => { + return request(app.getHttpServer()) + .post("/auth/jwt/logout") + .set("Authorization", `Bearer ${accessToken}`) + .expect(200) + .expect((res) => { + expect(res.body).toHaveProperty("message", "Logged out successfully"); + }); + }); + + it("should require authentication", () => { + return request(app.getHttpServer()) + .post("/auth/jwt/logout") + .expect(401); + }); + + it("should prevent refresh token usage after logout", async () => { + // Logout + await request(app.getHttpServer()) + .post("/auth/jwt/logout") + .set("Authorization", `Bearer ${accessToken}`); + + // Try to refresh + return request(app.getHttpServer()) + .post("/auth/jwt/refresh") + .send({ refreshToken }) + .expect(401); + }); + }); + + describe("POST /auth/jwt/logout/current", () => { + let accessToken: string; + let refreshToken: string; + + beforeEach(async () => { + // Register a user to get tokens + const response = await request(app.getHttpServer()) + .post("/auth/jwt/register") + .send(testUser); + accessToken = response.body.accessToken; + refreshToken = response.body.refreshToken; + }); + + it("should logout current session only", () => { + return request(app.getHttpServer()) + .post("/auth/jwt/logout/current") + .set("Authorization", `Bearer ${accessToken}`) + .send({ refreshToken }) + .expect(200) + .expect((res) => { + expect(res.body).toHaveProperty( + "message", + "Current session logged out successfully", + ); + }); + }); + }); +});