From 997d0add0ba45c470e2116ed4cae9f9fb11c769a Mon Sep 17 00:00:00 2001 From: Noble-Devbase Date: Mon, 29 Jun 2026 09:19:20 +0100 Subject: [PATCH] refresh token --- .../specs/refresh-token-rotation/.config.kiro | 1 + .../refresh-token-rotation/requirements.md | 107 +++++++++ harvest-finance/backend/src/app.module.ts | 3 + .../backend/src/auth/auth.module.ts | 7 +- .../backend/src/auth/auth.service.ts | 226 +++++++++++++++--- .../entities/security-event.entity.ts | 55 +++++ .../src/database/entities/session.entity.ts | 24 +- .../1700000000022-AddRefreshTokenRotation.ts | 95 ++++++++ 8 files changed, 483 insertions(+), 35 deletions(-) create mode 100644 .kiro/specs/refresh-token-rotation/.config.kiro create mode 100644 .kiro/specs/refresh-token-rotation/requirements.md create mode 100644 harvest-finance/backend/src/database/entities/security-event.entity.ts create mode 100644 harvest-finance/backend/src/database/migrations/1700000000022-AddRefreshTokenRotation.ts diff --git a/.kiro/specs/refresh-token-rotation/.config.kiro b/.kiro/specs/refresh-token-rotation/.config.kiro new file mode 100644 index 00000000..7fc91679 --- /dev/null +++ b/.kiro/specs/refresh-token-rotation/.config.kiro @@ -0,0 +1 @@ +{"specId": "refresh-token-rotation", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/refresh-token-rotation/requirements.md b/.kiro/specs/refresh-token-rotation/requirements.md new file mode 100644 index 00000000..18d7094b --- /dev/null +++ b/.kiro/specs/refresh-token-rotation/requirements.md @@ -0,0 +1,107 @@ +# Requirements Document + +## Introduction + +This feature adds refresh token rotation to the Harvest Finance backend (NestJS). Every call to `POST /auth/refresh` issues a brand-new refresh token and atomically invalidates the one that was just presented. If a token that has already been used (i.e., is marked invalidated/replaced) is presented again, the system treats this as a theft signal and revokes the entire token family — every active token in that lineage — then logs a security event to the audit log and emails the affected user. Token families are tracked in the database via a `familyId` column, allowing the revocation sweep to be a single targeted query. + +## Glossary + +- **RefreshToken**: A database record representing one issued refresh token. Has columns `id` (uuid), `token` (hashed value), `familyId` (uuid), `isRevoked` (boolean), `replacedBy` (uuid | null), `userId` (FK), `expiresAt` (timestamp), `createdAt` (timestamp). +- **TokenFamily**: All `RefreshToken` records sharing the same `familyId`. A family starts when a user first logs in or performs a full re-authentication. Each `POST /auth/refresh` call continues the family by creating a child token. +- **Rotation**: The act of issuing a new `RefreshToken` for a family while setting `isRevoked = true` and `replacedBy = ` on the old one, in a single atomic operation. +- **Reuse Detection**: Detecting that a presented refresh token's `isRevoked` is already `true`, which indicates a previously rotated (now invalid) token is being replayed — a theft signal. +- **Family Revocation**: Setting `isRevoked = true` on every `RefreshToken` row sharing the same `familyId` as the detected replayed token. +- **AuditLog**: A database table (or append-only log store) that records security-relevant events with a timestamp, event type, userId, and contextual metadata (e.g., IP address, user-agent, familyId). +- **AuthService**: The NestJS service at `backend/src/auth/auth.service.ts` that owns all token-lifecycle logic. +- **AuthController**: The NestJS controller exposing `POST /auth/refresh`. +- **Session**: The existing record linking a user to their current refresh token; superseded by `RefreshToken` table but may co-exist during migration. + +--- + +## Requirements + +### Requirement 1: Refresh Token Rotation on Every Use + +**User Story:** As a logged-in user, I want each use of my refresh token to produce a new one, so that my session stays secure even if an old token is captured in transit. + +#### Acceptance Criteria + +1. WHEN a valid, non-revoked refresh token is presented to `POST /auth/refresh`, THE AuthService SHALL issue a new `RefreshToken` record in the same `familyId` and return a new access token and refresh token pair; the old token SHALL be marked `isRevoked = true` with `replacedBy = ` if and only if the new token is successfully issued — no partial state shall be observable by the caller. +2. IF any step in the rotation operation fails, THE AuthService SHALL ensure the original refresh token record is left unchanged (still `isRevoked = false`, `replacedBy = null`) and SHALL return a `500 Internal Server Error` to the caller. +3. WHEN rotation succeeds, THE AuthService SHALL return a new access token signed with `JWT_SECRET` (expiry `1h`) and a new refresh token signed with `JWT_REFRESH_SECRET` (expiry `7d`). +4. WHEN rotation succeeds, THE AuthController SHALL set the new refresh token in an `HttpOnly`, `Secure`, `SameSite=Strict` cookie in addition to returning it in the response body, so that browser clients receive it automatically. +5. WHEN a refresh token's `expiresAt` is in the past, THE AuthService SHALL treat it as invalid, return a `401 Unauthorized` response, and SHALL NOT rotate it. +6. WHEN a refresh token's JWT signature is invalid or the token string cannot be decoded, THE AuthService SHALL return a `401 Unauthorized` response without querying the database. + +--- + +### Requirement 2: Reuse Detection and Family Revocation + +**User Story:** As a security-conscious operator, I want the system to detect when a previously rotated refresh token is replayed and immediately revoke the entire token family, so that a stolen token grants at most one additional use before the attacker is locked out. + +#### Acceptance Criteria + +1. WHEN a refresh token is presented to `POST /auth/refresh` and the corresponding `RefreshToken` record has `isRevoked = true`, THE AuthService SHALL identify the `familyId` of that record and set `isRevoked = true` on every `RefreshToken` row sharing that `familyId` in a single atomic UPDATE. +2. IF the bulk revocation UPDATE fails, THE AuthService SHALL return a `401 Unauthorized` to the caller immediately and SHALL retry the revocation asynchronously up to 3 times within a 60-second window; IF all 3 retries are exhausted, THE AuthService SHALL emit an error-level log entry with the `familyId` and SHALL NOT attempt further retries. +3. WHEN family revocation is triggered, THE AuthService SHALL write an `AuditLog` entry with event type `REFRESH_TOKEN_REUSE_DETECTED`, the affected `userId`, `familyId`, the presented (replayed) token `id`, the request IP address, and the current UTC timestamp. +4. WHEN family revocation is triggered, THE AuthService SHALL send an email to the affected user's registered email address notifying them of suspected token theft and advising them to change their password. +5. IF the theft-alert email send fails, THE AuthService SHALL emit a warning-level log entry containing the `userId` and the failure reason, and SHALL NOT block the family revocation or the `401` response. +6. WHEN family revocation completes, THE AuthController SHALL return a `401 Unauthorized` response; the response body SHALL NOT contain any of the following: the word "reuse", the word "replay", the `familyId`, the `replayedTokenId`, or any description of the detection mechanism. +7. WHEN all `RefreshToken` records for a `familyId` have `isRevoked = true`, any subsequent `POST /auth/refresh` presenting any token from that family SHALL return `401 Unauthorized` without triggering another revocation sweep or dispatching another theft-alert email. + +--- + +### Requirement 3: Token Family Lifecycle + +**User Story:** As a backend developer, I want token families to be created at login and carried through rotations, so that revocation can target an entire lineage rather than individual tokens. + +#### Acceptance Criteria + +1. WHEN a user successfully authenticates (via email/password or OAuth), THE AuthService SHALL generate a new `familyId` (UUID v4) and store the first `RefreshToken` record with `isRevoked = false` and `replacedBy = null`. +2. WHEN a refresh token is rotated, THE AuthService SHALL copy the `familyId` from the old record to the new record and SHALL set the old record to `isRevoked = true` and `replacedBy = `, ensuring the entire rotation chain shares one `familyId` and the old record's state transition is part of the same atomic operation as the new record creation. +3. WHEN a user explicitly calls `POST /auth/logout` and the presented refresh token has `isRevoked = false`, THE AuthService SHALL set `isRevoked = true` on that token only and SHALL NOT write a reuse-detection `AuditLog` entry or send a theft-alert email. +4. WHEN a user explicitly calls `POST /auth/logout` and the presented refresh token already has `isRevoked = true`, THE AuthService SHALL return an error response and SHALL NOT trigger reuse-detection logic, revoke the family, or send any email. +5. THE `refresh_tokens` table SHALL enforce a unique constraint on the `token` column (hashed value) to prevent duplicate token storage. +6. THE `refresh_tokens` table SHALL have a non-unique database index on `familyId` so that a family revocation sweep does not require a full table scan. + +--- + +### Requirement 4: Audit Log + +**User Story:** As a security operator, I want every family revocation to produce an immutable audit log entry, so that I can investigate suspected token theft incidents after the fact. + +#### Acceptance Criteria + +1. WHEN `REFRESH_TOKEN_REUSE_DETECTED` is logged, THE AuditLog entry SHALL contain at minimum: `eventType` (the string `"REFRESH_TOKEN_REUSE_DETECTED"`), `userId`, `familyId`, `replayedTokenId`, `ipAddress` (the string `"UNKNOWN"` if unavailable), `userAgent` (truncated to 512 characters, or the string `"UNKNOWN"` if unavailable), and `occurredAt` (UTC timestamp in ISO 8601 format). +2. No application-layer code path SHALL update or delete existing `AuditLog` rows; the table SHALL only support INSERT operations from application code. +3. IF writing the `AuditLog` entry fails, THE AuthService SHALL emit an error-level log entry to the application logger identifying the failed write and the associated `familyId`. +4. IF writing the `AuditLog` entry fails, THE AuthService SHALL still complete the family revocation and return `401 Unauthorized` to the caller, independently of the logging failure. +5. THE AuditLog table SHALL have a non-unique index on `userId` and a separate non-unique index on `occurredAt` to support incident queries without a full table scan. + +--- + +### Requirement 5: Security Hardening + +**User Story:** As a security engineer, I want the refresh token implementation to follow secure storage and transmission practices, so that token values are never exposed in plaintext in the database or logs. + +#### Acceptance Criteria + +1. THE AuthService SHALL store only the SHA-256 HMAC of the refresh token string (keyed with `JWT_REFRESH_SECRET`) in the `RefreshToken.token` column; the plaintext token string SHALL only appear in the HTTP response. +2. IF the performance profile of SHA-256 HMAC is inadequate, THE AuthService MAY substitute bcrypt hashing, provided the hashing algorithm is applied consistently at both storage and lookup time. +3. WHEN looking up a `RefreshToken` record, THE AuthService SHALL compute the hash of the presented token using the same algorithm used at storage time and query by hash value. +4. IF the presented token's hash does not match any record in the `refresh_tokens` table, THE AuthService SHALL return a `401 Unauthorized` response and SHALL NOT disclose whether the token was not found or was found but invalid. +5. The plaintext refresh token value SHALL NOT appear in any log statement, error message, audit log entry, or serialized object representation logged by the application. +6. WHEN generating a new refresh token string, THE AuthService SHALL use a cryptographically secure random source producing at least 32 bytes of entropy, with the output encoded as a base64url string of at least 43 characters. + +--- + +### Requirement 6: Environment and Module Configuration + +**User Story:** As a backend developer, I want the token rotation feature to be fully configurable via environment variables and properly wired into the NestJS module system. + +#### Acceptance Criteria + +1. THE AuthService SHALL read `JWT_REFRESH_SECRET` and `JWT_REFRESH_EXPIRES_IN` from the environment via `ConfigService`; IF either value is absent or resolves to an empty string at token issuance time, THE AuthService SHALL throw a configuration error and SHALL NOT issue any access or refresh tokens. +2. THE AuthModule SHALL import `TypeOrmModule.forFeature([RefreshToken, AuditLog])` so that both repositories are injectable at application startup. +3. IF the email provider dependency is not bound in the module (i.e., no value is resolvable for the email service injection token), THE AuthModule SHALL emit a warning-level log message indicating that theft-alert emails are disabled and SHALL continue starting up without throwing an exception. +4. THE `refresh_tokens` table SHALL support a configurable maximum family chain length via `MAX_TOKEN_FAMILY_DEPTH`; IF `MAX_TOKEN_FAMILY_DEPTH` is set to an integer value between 2 and 100 (inclusive) and a token family's chain length reaches that value, THE AuthService SHALL revoke the root token (depth 1, the token with no ancestor pointing to it via `replacedBy`) before completing the next rotation; IF `MAX_TOKEN_FAMILY_DEPTH` is set to a value outside the range 2–100 or to a non-integer value, THE AuthService SHALL throw a configuration error at startup and SHALL NOT issue tokens. diff --git a/harvest-finance/backend/src/app.module.ts b/harvest-finance/backend/src/app.module.ts index b79e0ef6..d3afcb79 100644 --- a/harvest-finance/backend/src/app.module.ts +++ b/harvest-finance/backend/src/app.module.ts @@ -88,7 +88,10 @@ import { AddSorobanEventQueryIndexes1700000000013 } from './database/migrations/ import { CreateDepositEvents1700000000016 } from './database/migrations/1700000000016-CreateDepositEvents'; import { CreateVaultReservations1700000000018 } from './database/migrations/1700000000018-CreateVaultReservations'; import { VaultReservation } from './vaults/entities/vault-reservation.entity'; +import { Session } from './database/entities/session.entity'; +import { SecurityEvent } from './database/entities/security-event.entity'; import { CreateVaultApyHistory1700000000017 } from './database/migrations/1700000000017-CreateVaultApyHistory'; +import { AddRefreshTokenRotation1700000000022 } from './database/migrations/1700000000022-AddRefreshTokenRotation'; import { DomainEventsModule } from './domain-events'; import { DomainEventHandlersModule } from './common/events'; import { WebhooksModule } from './webhooks/webhooks.module'; diff --git a/harvest-finance/backend/src/auth/auth.module.ts b/harvest-finance/backend/src/auth/auth.module.ts index ae033501..61dbe3e1 100644 --- a/harvest-finance/backend/src/auth/auth.module.ts +++ b/harvest-finance/backend/src/auth/auth.module.ts @@ -8,13 +8,16 @@ import { JwtStrategy } from './strategies/jwt.strategy'; import { StellarStrategy } from './strategies/stellar.strategy'; import { GoogleStrategy } from './strategies/google.strategy'; import { GithubStrategy } from './strategies/github.strategy'; +import { SessionsController } from './sessions.controller'; import { User } from '../database/entities/user.entity'; import { UserOAuthLink } from '../database/entities/user-oauth-link.entity'; +import { Session } from '../database/entities/session.entity'; +import { SecurityEvent } from '../database/entities/security-event.entity'; import { CommonModule } from '../common/common.module'; @Module({ imports: [ - TypeOrmModule.forFeature([User, UserOAuthLink]), + TypeOrmModule.forFeature([User, UserOAuthLink, Session, SecurityEvent]), PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.register({ secret: 'super_secret_jwt_key', @@ -24,7 +27,7 @@ import { CommonModule } from '../common/common.module'; }), CommonModule, ], - controllers: [AuthController], + controllers: [AuthController, SessionsController], providers: [AuthService, JwtStrategy, StellarStrategy, GoogleStrategy, GithubStrategy], exports: [AuthService, JwtStrategy, StellarStrategy, GoogleStrategy, GithubStrategy, PassportModule], }) diff --git a/harvest-finance/backend/src/auth/auth.service.ts b/harvest-finance/backend/src/auth/auth.service.ts index 3a455204..ce2789fe 100644 --- a/harvest-finance/backend/src/auth/auth.service.ts +++ b/harvest-finance/backend/src/auth/auth.service.ts @@ -13,12 +13,14 @@ import * as bcrypt from 'bcrypt'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import type { Cache } from 'cache-manager'; import { randomBytes } from 'crypto'; +import { v4 as uuidv4 } from 'uuid'; import { CustomLoggerService } from '../logger/custom-logger.service'; import { User, UserRole } from '../database/entities/user.entity'; import { UserOAuthLink } from '../database/entities/user-oauth-link.entity'; const zxcvbn = require('zxcvbn'); import * as crypto from 'crypto'; import { Session } from '../database/entities/session.entity'; +import { SecurityEvent, SecurityEventType } from '../database/entities/security-event.entity'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; @@ -41,6 +43,7 @@ export class AuthService { private readonly saltRounds = 10; private readonly accessTokenExpiry = '1h'; private readonly refreshTokenExpiry = '7d'; + private readonly refreshTokenExpiryMs = 7 * 24 * 60 * 60 * 1000; // 7 days private readonly resetTokenExpiry = 3600000; // 1 hour in milliseconds private get maxLoginAttempts(): number { @@ -66,6 +69,8 @@ export class AuthService { private oauthLinkRepository: Repository, @InjectRepository(Session) private sessionRepository: Repository, + @InjectRepository(SecurityEvent) + private securityEventRepository: Repository, private jwtService: JwtService, private configService: ConfigService, @Inject(CACHE_MANAGER) private cacheManager: Cache, @@ -249,47 +254,197 @@ export class AuthService { } /** - * Refresh access token + * Refresh access token — implements refresh token rotation with family-level + * reuse detection. + * + * Happy path: + * 1. Verify the JWT signature & expiry. + * 2. Look up the matching session row by hashed token. + * 3. If the session is already revoked → the token was replayed after rotation. + * Revoke the entire family and throw 401. + * 4. Mark the current session as revoked + set replacedBy. + * 5. Issue a brand-new access token AND a brand-new refresh token. + * 6. Store the new session in the same family. + * 7. Return both tokens. */ async refresh(refreshTokenDto: RefreshTokenDto): Promise { const { refresh_token } = refreshTokenDto; + // Step 1 — verify JWT signature & expiry + let payload: { sub: string; email: string; role: string; jti?: string }; try { - // Verify refresh token - const payload = await this.jwtService.verifyAsync(refresh_token, { + payload = await this.jwtService.verifyAsync(refresh_token, { secret: this.configService.get('JWT_REFRESH_SECRET') || 'super_secret_refresh_jwt_key', }); + } catch { + throw new UnauthorizedException('Invalid or expired refresh token'); + } - // Find user - const user = await this.userRepository.findOne({ - where: { id: payload.sub }, - }); + // Step 2 — find the matching session by scanning hashed tokens for this user + const user = await this.userRepository.findOne({ + where: { id: payload.sub }, + }); - if (!user || !user.isActive) { - throw new UnauthorizedException('Invalid refresh token'); - } + if (!user || !user.isActive) { + throw new UnauthorizedException('Invalid refresh token'); + } - // Generate new access token - const accessToken = await this.jwtService.signAsync( - { - sub: user.id, - email: user.email, - role: user.role, - }, - { - expiresIn: this.accessTokenExpiry, - secret: - this.configService.get('JWT_SECRET') || - 'super_secret_jwt_key', - }, - ); + // Fetch all non-expired sessions for this user so we can bcrypt-compare + const candidateSessions = await this.sessionRepository.find({ + where: { user: { id: user.id } }, + relations: ['user'], + }); - return { access_token: accessToken, token_type: 'Bearer' }; - } catch (error) { + let matchedSession: Session | null = null; + for (const session of candidateSessions) { + if (await bcrypt.compare(refresh_token, session.refreshToken)) { + matchedSession = session; + break; + } + } + + if (!matchedSession) { + // Token is cryptographically valid but not in the DB — treat as stolen throw new UnauthorizedException('Invalid or expired refresh token'); } + + // Step 3 — reuse detection: token was already consumed + if (matchedSession.isRevoked) { + await this.revokeFamilyAndAlert(matchedSession.familyId, user, refresh_token); + throw new UnauthorizedException( + 'Refresh token reuse detected. All sessions have been revoked for your security.', + ); + } + + // Step 4 — atomically revoke the current session + const newSessionId = uuidv4(); // reserve the ID so we can set replacedBy + await this.sessionRepository.update(matchedSession.id, { + isRevoked: true, + replacedBy: newSessionId, + lastUsedAt: new Date(), + }); + + // Step 5 — generate new token pair + const jwtPayload = { sub: user.id, email: user.email, role: user.role }; + + const [accessToken, newRefreshToken] = await Promise.all([ + this.jwtService.signAsync(jwtPayload, { + expiresIn: this.accessTokenExpiry, + secret: + this.configService.get('JWT_SECRET') || 'super_secret_jwt_key', + }), + this.jwtService.signAsync(jwtPayload, { + expiresIn: this.refreshTokenExpiry, + secret: + this.configService.get('JWT_REFRESH_SECRET') || + 'super_secret_refresh_jwt_key', + }), + ]); + + // Step 6 — store the new session in the SAME family + const hashedNewRefreshToken = await bcrypt.hash(newRefreshToken, this.saltRounds); + const newSession = this.sessionRepository.create({ + id: newSessionId, + user, + refreshToken: hashedNewRefreshToken, + familyId: matchedSession.familyId, + isRevoked: false, + replacedBy: null, + userAgent: matchedSession.userAgent, + ipAddress: matchedSession.ipAddress, + lastUsedAt: new Date(), + expiresAt: new Date(Date.now() + this.refreshTokenExpiryMs), + }); + await this.sessionRepository.save(newSession); + + this.logger.log( + `Refresh token rotated for user ${user.id} (family ${matchedSession.familyId})`, + 'AuthService', + ); + + // Step 7 — return both tokens + return { + access_token: accessToken, + refresh_token: newRefreshToken, + token_type: 'Bearer', + }; + } + + /** + * Revoke every session in a token family and write a security-event audit + * record. Called when a previously consumed (revoked) token is replayed — + * indicating a stolen token. + */ + private async revokeFamilyAndAlert( + familyId: string, + user: User, + replayedToken: string, + ): Promise { + // Mark all sessions in the family as revoked + await this.sessionRepository + .createQueryBuilder() + .update(Session) + .set({ isRevoked: true }) + .where('family_id = :familyId', { familyId }) + .execute(); + + const metadata: Record = { + familyId, + userId: user.id, + email: user.email, + detectedAt: new Date().toISOString(), + }; + + // Write audit log entry + const securityEvent = this.securityEventRepository.create({ + userId: user.id, + type: SecurityEventType.REFRESH_TOKEN_REUSE, + message: `Refresh token reuse detected for family ${familyId}. All sessions in the family have been revoked.`, + metadata, + }); + await this.securityEventRepository.save(securityEvent); + + this.logger.warn( + JSON.stringify({ + event: SecurityEventType.REFRESH_TOKEN_REUSE, + ...metadata, + }), + 'AuthService', + ); + + // Alert the user by email (send via mail service if configured, otherwise log) + await this.sendSecurityAlertEmail(user, familyId); + } + + /** + * Sends a security alert email to the user when their token family is revoked. + * Replace the logger stub with a real mailer (e.g. @nestjs-modules/mailer) + * once an SMTP / SES transport is wired up. + */ + private async sendSecurityAlertEmail(user: User, familyId: string): Promise { + const subject = 'Security Alert: Suspicious Activity Detected on Your Account'; + const body = [ + `Hello ${user.firstName ?? user.email},`, + '', + 'We detected that a previously used refresh token was submitted to your account.', + 'This may indicate that your session token has been stolen.', + '', + 'As a precaution, all active sessions associated with this login have been revoked.', + 'Please log in again and change your password if you did not initiate this request.', + '', + `Event reference: ${familyId}`, + `Time: ${new Date().toISOString()}`, + '', + '— Harvest Finance Security Team', + ].join('\n'); + + // TODO: replace with real mail transport (e.g. nodemailer / @nestjs-modules/mailer) + this.logger.error( + `[EMAIL ALERT] To: ${user.email} | Subject: ${subject}\n${body}`, + 'AuthService', + ); } /** @@ -425,9 +580,13 @@ export class AuthService { } /** - * Generate access and refresh tokens + * Generate access and refresh tokens and persist a new session row. + * Each call starts a brand-new token family (used on login/register/OAuth). */ - private async generateTokens(user: User): Promise<{ + private async generateTokens( + user: User, + context?: { userAgent?: string; ipAddress?: string }, + ): Promise<{ accessToken: string; refreshToken: string; }> { @@ -452,15 +611,18 @@ export class AuthService { }), ]); - // Store refresh token in database (Session) + // Store hashed refresh token with a new family ID const hashedRefreshToken = await bcrypt.hash(refreshToken, this.saltRounds); const session = this.sessionRepository.create({ user, refreshToken: hashedRefreshToken, - userAgent: 'Unknown', // Typically passed from request - ipAddress: 'Unknown', // Typically passed from request + familyId: uuidv4(), // new family for every fresh login + isRevoked: false, + replacedBy: null, + userAgent: context?.userAgent ?? 'Unknown', + ipAddress: context?.ipAddress ?? 'Unknown', lastUsedAt: new Date(), - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + expiresAt: new Date(Date.now() + this.refreshTokenExpiryMs), }); await this.sessionRepository.save(session); diff --git a/harvest-finance/backend/src/database/entities/security-event.entity.ts b/harvest-finance/backend/src/database/entities/security-event.entity.ts new file mode 100644 index 00000000..c57822be --- /dev/null +++ b/harvest-finance/backend/src/database/entities/security-event.entity.ts @@ -0,0 +1,55 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from './user.entity'; + +export enum SecurityEventType { + /** A refresh token family was revoked because a previously consumed token was replayed. */ + REFRESH_TOKEN_REUSE = 'REFRESH_TOKEN_REUSE', + /** A single session was explicitly revoked by the user. */ + SESSION_REVOKED = 'SESSION_REVOKED', + /** All sessions were revoked (e.g., password change, admin action). */ + ALL_SESSIONS_REVOKED = 'ALL_SESSIONS_REVOKED', + /** User account was locked after too many failed login attempts. */ + ACCOUNT_LOCKED = 'ACCOUNT_LOCKED', +} + +/** + * Immutable audit record for security-sensitive events. + * Never update or delete rows from this table. + */ +@Entity('security_events') +@Index('idx_security_events_user_id', ['userId']) +@Index('idx_security_events_type', ['type']) +@Index('idx_security_events_created_at', ['createdAt']) +export class SecurityEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id', nullable: true }) + userId: string | null; + + @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'user_id' }) + user: User | null; + + @Column({ type: 'enum', enum: SecurityEventType }) + type: SecurityEventType; + + /** Human-readable description of the event. */ + @Column('text') + message: string; + + /** Arbitrary JSON metadata (IP address, family ID, session IDs, etc.). */ + @Column({ type: 'jsonb', nullable: true }) + metadata: Record | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/harvest-finance/backend/src/database/entities/session.entity.ts b/harvest-finance/backend/src/database/entities/session.entity.ts index 7d448ea4..97b5a14e 100644 --- a/harvest-finance/backend/src/database/entities/session.entity.ts +++ b/harvest-finance/backend/src/database/entities/session.entity.ts @@ -1,7 +1,9 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn, Index } from 'typeorm'; import { User } from './user.entity'; @Entity('sessions') +@Index('idx_sessions_family_id', ['familyId']) +@Index('idx_sessions_user_id_revoked', ['user', 'isRevoked']) export class Session { @PrimaryGeneratedColumn('uuid') id: string; @@ -13,6 +15,26 @@ export class Session { @Column({ name: 'refresh_token' }) refreshToken: string; + /** + * Groups all tokens issued from a single login event (token rotation chain). + * If any revoked token in the family is replayed, the entire family is revoked. + */ + @Column({ name: 'family_id', type: 'uuid' }) + familyId: string; + + /** + * True once the token has been consumed (rotated) or explicitly revoked. + */ + @Column({ name: 'is_revoked', default: false }) + isRevoked: boolean; + + /** + * UUID of the session record that replaced this one after rotation. + * Null until this token is consumed by a /auth/refresh call. + */ + @Column({ name: 'replaced_by', type: 'uuid', nullable: true }) + replacedBy: string | null; + @Column({ name: 'user_agent', nullable: true }) userAgent: string; diff --git a/harvest-finance/backend/src/database/migrations/1700000000022-AddRefreshTokenRotation.ts b/harvest-finance/backend/src/database/migrations/1700000000022-AddRefreshTokenRotation.ts new file mode 100644 index 00000000..a6d0c013 --- /dev/null +++ b/harvest-finance/backend/src/database/migrations/1700000000022-AddRefreshTokenRotation.ts @@ -0,0 +1,95 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds refresh-token rotation support to the `sessions` table and creates + * the `security_events` audit-log table. + * + * sessions changes: + * - family_id uuid NOT NULL — groups all tokens issued from a single login + * - is_revoked boolean NOT NULL DEFAULT false + * - replaced_by uuid NULL — UUID of the successor session row after rotation + * + * security_events: + * - Immutable append-only audit log for security-sensitive events. + */ +export class AddRefreshTokenRotation1700000000022 implements MigrationInterface { + name = 'AddRefreshTokenRotation1700000000022'; + + public async up(queryRunner: QueryRunner): Promise { + // ── sessions: add rotation columns ─────────────────────────────────────── + await queryRunner.query(` + ALTER TABLE "sessions" + ADD COLUMN IF NOT EXISTS "family_id" uuid NOT NULL DEFAULT gen_random_uuid(), + ADD COLUMN IF NOT EXISTS "is_revoked" boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "replaced_by" uuid NULL + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_sessions_family_id" + ON "sessions" ("family_id") + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_sessions_user_id_revoked" + ON "sessions" ("user_id", "is_revoked") + `); + + // ── security_events: audit log ──────────────────────────────────────────── + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE "security_events_type_enum" AS ENUM ( + 'REFRESH_TOKEN_REUSE', + 'SESSION_REVOKED', + 'ALL_SESSIONS_REVOKED', + 'ACCOUNT_LOCKED' + ); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$ + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "security_events" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "user_id" uuid NULL, + "type" "security_events_type_enum" NOT NULL, + "message" text NOT NULL, + "metadata" jsonb NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_security_events" PRIMARY KEY ("id"), + CONSTRAINT "FK_security_events_user" + FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE SET NULL + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_security_events_user_id" + ON "security_events" ("user_id") + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_security_events_type" + ON "security_events" ("type") + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_security_events_created_at" + ON "security_events" ("created_at") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // security_events + await queryRunner.query(`DROP TABLE IF EXISTS "security_events"`); + await queryRunner.query(`DROP TYPE IF EXISTS "security_events_type_enum"`); + + // sessions rotation columns + await queryRunner.query(`DROP INDEX IF EXISTS "idx_sessions_user_id_revoked"`); + await queryRunner.query(`DROP INDEX IF EXISTS "idx_sessions_family_id"`); + await queryRunner.query(` + ALTER TABLE "sessions" + DROP COLUMN IF EXISTS "replaced_by", + DROP COLUMN IF EXISTS "is_revoked", + DROP COLUMN IF EXISTS "family_id" + `); + } +}