diff --git a/harvest-finance/backend/src/app.module.ts b/harvest-finance/backend/src/app.module.ts index 9e02da07..cea03f5b 100644 --- a/harvest-finance/backend/src/app.module.ts +++ b/harvest-finance/backend/src/app.module.ts @@ -52,6 +52,7 @@ import { Notification, Order, Reward, + Session, SorobanEvent, Transaction, User, @@ -93,6 +94,7 @@ 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 { CreateSessionsAndOAuthLinks1700000000022 } from './database/migrations/1700000000022-CreateSessionsAndOAuthLinks'; import { AddRefreshTokenRotation1700000000022 } from './database/migrations/1700000000022-AddRefreshTokenRotation'; import { DomainEventsModule } from './domain-events'; import { DomainEventHandlersModule } from './common/events'; @@ -124,6 +126,7 @@ import { CreateCustodialWallets1700000000021 } from './database/migrations/17000 entities: [ User, UserOAuthLink, + Session, Order, Transaction, Verification, @@ -163,6 +166,7 @@ import { CreateCustodialWallets1700000000021 } from './database/migrations/17000 CreateVaultReservations1700000000018, AddDepositorConcentrationThreshold1700000000022, CreateVaultApyHistory1700000000017, + CreateSessionsAndOAuthLinks1700000000022, CreateCustodialWallets1700000000021, ], synchronize: false, diff --git a/harvest-finance/backend/src/auth/auth.controller.ts b/harvest-finance/backend/src/auth/auth.controller.ts index 235160ef..9cb9405f 100644 --- a/harvest-finance/backend/src/auth/auth.controller.ts +++ b/harvest-finance/backend/src/auth/auth.controller.ts @@ -7,6 +7,7 @@ import { HttpCode, HttpStatus, Get, + Query, } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import type { Request } from 'express'; @@ -127,8 +128,13 @@ export class AuthController { status: 500, description: 'Internal server error', }) - async login(@Body() loginDto: LoginDto): Promise { - return this.authService.login(loginDto); + async login(@Body() loginDto: LoginDto, @Req() req: Request): Promise { + const userAgent = req.headers['user-agent']; + const ipAddress = + (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? + req.socket?.remoteAddress ?? + undefined; + return this.authService.login(loginDto, userAgent, ipAddress); } /** @@ -375,7 +381,12 @@ export class AuthController { type: AuthResponseDto, }) async googleAuthRedirect(@Req() req): Promise { - return this.authService.loginWithOAuth(req.user); + const userAgent = req.headers['user-agent']; + const ipAddress = + (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? + req.socket?.remoteAddress ?? + undefined; + return this.authService.loginWithOAuth(req.user, userAgent, ipAddress); } /** @@ -401,7 +412,12 @@ export class AuthController { type: AuthResponseDto, }) async githubAuthRedirect(@Req() req): Promise { - return this.authService.loginWithOAuth(req.user); + const userAgent = req.headers['user-agent']; + const ipAddress = + (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? + req.socket?.remoteAddress ?? + undefined; + return this.authService.loginWithOAuth(req.user, userAgent, ipAddress); } @Get('verify-email') diff --git a/harvest-finance/backend/src/auth/auth.module.ts b/harvest-finance/backend/src/auth/auth.module.ts index a647a604..f94bc910 100644 --- a/harvest-finance/backend/src/auth/auth.module.ts +++ b/harvest-finance/backend/src/auth/auth.module.ts @@ -2,7 +2,9 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { AuthController } from './auth.controller'; +import { SessionsController } from './sessions.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { StellarStrategy } from './strategies/stellar.strategy'; @@ -19,16 +21,39 @@ import { CustodialWalletService } from '../wallets/custodial-wallet.service'; @Module({ imports: [ + TypeOrmModule.forFeature([User, UserOAuthLink, Session]), TypeOrmModule.forFeature([User, UserOAuthLink, CustodialWallet]), PassportModule.register({ defaultStrategy: 'jwt' }), - JwtModule.register({ - secret: 'super_secret_jwt_key', - signOptions: { - expiresIn: '1h', - }, + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: + configService.get('JWT_SECRET') || 'super_secret_jwt_key', + signOptions: { + expiresIn: + configService.get('JWT_EXPIRES_IN') || '1h', + }, + }), }), CommonModule, ], + controllers: [AuthController, SessionsController], + providers: [ + AuthService, + JwtStrategy, + StellarStrategy, + GoogleStrategy, + GithubStrategy, + ], + exports: [ + AuthService, + JwtStrategy, + StellarStrategy, + GoogleStrategy, + GithubStrategy, + PassportModule, + ], controllers: [AuthController], providers: [AuthService, JwtStrategy, StellarStrategy, GoogleStrategy, GithubStrategy, CustodialWalletService], exports: [AuthService, JwtStrategy, StellarStrategy, GoogleStrategy, GithubStrategy, PassportModule, CustodialWalletService], diff --git a/harvest-finance/backend/src/auth/auth.service.ts b/harvest-finance/backend/src/auth/auth.service.ts index 7970e904..b530334a 100644 --- a/harvest-finance/backend/src/auth/auth.service.ts +++ b/harvest-finance/backend/src/auth/auth.service.ts @@ -3,6 +3,7 @@ import { ConflictException, UnauthorizedException, BadRequestException, + NotFoundException, Inject, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; @@ -37,6 +38,12 @@ import { StellarAuthResponseDto, StellarChallengeResponseDto, } from './dto/stellar-auth.dto'; +import { + RevokeSessionResponseDto, + SessionListResponseDto, + SessionResponseDto, +} from './dto/session.dto'; +import { deriveDeviceName } from './utils/device-name.util'; import { CustodialWalletService } from '../wallets/custodial-wallet.service'; @Injectable() @@ -177,7 +184,11 @@ export class AuthService { /** * Login user */ - async login(loginDto: LoginDto): Promise { + async login( + loginDto: LoginDto, + userAgent?: string, + ipAddress?: string, + ): Promise { const { email, password } = loginDto; // Find user with password @@ -245,7 +256,7 @@ export class AuthService { await this.userRepository.update(user.id, { lastLogin: new Date(), lockedUntil: null }); // Generate tokens - const tokens = await this.generateTokens(user); + const tokens = await this.generateTokens(user, userAgent, ipAddress); this.logger.log(`User logged in successfully: ${email}`, 'AuthService'); @@ -294,6 +305,10 @@ export class AuthService { } /** + * Refresh access token. + * + * Validates the refresh token against the stored (hashed) session record and + * updates `lastUsedAt` so the sessions list stays current. * Refresh access token — implements refresh token rotation with family-level * reuse detection. * @@ -313,6 +328,8 @@ export class AuthService { // Step 1 — verify JWT signature & expiry let payload: { sub: string; email: string; role: string; jti?: string }; try { + // Verify refresh token signature and expiry + const payload = await this.jwtService.verifyAsync(refresh_token, { payload = await this.jwtService.verifyAsync(refresh_token, { secret: this.configService.get('JWT_REFRESH_SECRET') || @@ -331,6 +348,51 @@ export class AuthService { throw new UnauthorizedException('Invalid refresh token'); } + // Validate the refresh token against the stored session record. + // The sessionId claim was embedded at token-generation time. + if (payload.sessionId) { + const session = await this.sessionRepository.findOne({ + where: { id: payload.sessionId, user: { id: user.id } }, + select: ['id', 'refreshToken', 'expiresAt'], + }); + + if (!session || session.expiresAt < new Date()) { + throw new UnauthorizedException('Session has expired or been revoked'); + } + + const tokenMatches = await bcrypt.compare( + refresh_token, + session.refreshToken, + ); + if (!tokenMatches) { + throw new UnauthorizedException('Invalid refresh token'); + } + + // Touch lastUsedAt so the sessions list reflects recent activity + await this.sessionRepository.update(session.id, { + lastUsedAt: new Date(), + }); + } + + // Issue a new access token (same session, same refresh token — no rotation) + const accessToken = await this.jwtService.signAsync( + { + sub: user.id, + email: user.email, + role: user.role, + sessionId: payload.sessionId, + }, + { + expiresIn: this.accessTokenExpiry, + secret: + this.configService.get('JWT_SECRET') || + 'super_secret_jwt_key', + }, + ); + + return { access_token: accessToken, token_type: 'Bearer' }; + } catch (error) { + if (error instanceof UnauthorizedException) throw error; // Fetch all non-expired sessions for this user so we can bcrypt-compare const candidateSessions = await this.sessionRepository.find({ where: { user: { id: user.id } }, @@ -620,6 +682,35 @@ export class AuthService { } /** + * Generate access and refresh tokens, persisting a session record enriched + * with the caller's User-Agent and IP address. + * + * @param user - Authenticated user entity + * @param userAgent - Raw User-Agent header (optional) + * @param ipAddress - Client IP address (optional) + */ + async generateTokens( + user: User, + userAgent?: string, + ipAddress?: string, + ): Promise<{ accessToken: string; refreshToken: string }> { + // Save the session first so we can embed its ID in the JWT payload. + const hashedRefreshTokenPlaceholder = ''; // filled in after hashing below + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + const deviceName = deriveDeviceName(userAgent); + + // Create a temporary session to get the UUID before signing tokens. + const session = this.sessionRepository.create({ + user, + refreshToken: hashedRefreshTokenPlaceholder, + userAgent: userAgent ?? null, + ipAddress: ipAddress ?? null, + deviceName, + lastUsedAt: new Date(), + expiresAt, + }); + await this.sessionRepository.save(session); + * Generate access and refresh tokens and persist a new session row. * Each call starts a brand-new token family (used on login/register/OAuth). */ @@ -634,6 +725,7 @@ export class AuthService { sub: user.id, email: user.email, role: user.role, + sessionId: session.id, // allows DELETE /auth/sessions to identify current session }; const [accessToken, refreshToken] = await Promise.all([ @@ -651,10 +743,10 @@ export class AuthService { }), ]); + // Persist hashed refresh token onto the already-saved session row. // Store hashed refresh token with a new family ID const hashedRefreshToken = await bcrypt.hash(refreshToken, this.saltRounds); - const session = this.sessionRepository.create({ - user, + await this.sessionRepository.update(session.id, { refreshToken: hashedRefreshToken, familyId: uuidv4(), // new family for every fresh login isRevoked: false, @@ -664,7 +756,6 @@ export class AuthService { lastUsedAt: new Date(), expiresAt: new Date(Date.now() + this.refreshTokenExpiryMs), }); - await this.sessionRepository.save(session); return { accessToken, refreshToken }; } @@ -747,8 +838,12 @@ export class AuthService { /** * Log in user via OAuth and generate access/refresh tokens. */ - async loginWithOAuth(user: User): Promise { - const tokens = await this.generateTokens(user); + async loginWithOAuth( + user: User, + userAgent?: string, + ipAddress?: string, + ): Promise { + const tokens = await this.generateTokens(user, userAgent, ipAddress); return { access_token: tokens.accessToken, refresh_token: tokens.refreshToken, @@ -799,26 +894,97 @@ export class AuthService { return { success: true }; } - async getSessions(userId: string, page: number, limit: number) { - const [items, total] = await this.sessionRepository.findAndCount({ + /** + * Return paginated active sessions for a user, flagging the caller's own session. + */ + async getSessions( + userId: string, + page: number, + limit: number, + currentSessionId?: string, + ): Promise { + const now = new Date(); + const [sessions, total] = await this.sessionRepository.findAndCount({ where: { user: { id: userId } }, + order: { lastUsedAt: 'DESC' }, skip: (page - 1) * limit, take: limit, + // expiresAt filter is handled in the query below instead }); - return { items, total }; + + // Filter out expired sessions without a separate query + const activeSessions = sessions.filter((s) => s.expiresAt > now); + + const items: SessionResponseDto[] = activeSessions.map((s) => ({ + id: s.id, + deviceName: s.deviceName, + ipAddress: s.ipAddress, + userAgent: s.userAgent, + lastUsedAt: s.lastUsedAt, + createdAt: s.createdAt, + expiresAt: s.expiresAt, + isCurrent: currentSessionId ? s.id === currentSessionId : false, + })); + + return { items, total, page, limit }; } - async revokeSession(userId: string, sessionId: string) { - await this.sessionRepository.delete({ id: sessionId, user: { id: userId } }); - return { success: true }; + /** + * Revoke a single session belonging to the given user. + * Throws NotFoundException if the session does not exist or belongs to another user. + */ + async revokeSession( + userId: string, + sessionId: string, + ): Promise { + const session = await this.sessionRepository.findOne({ + where: { id: sessionId, user: { id: userId } }, + }); + + if (!session) { + throw new NotFoundException('Session not found'); + } + + await this.sessionRepository.delete(session.id); + this.logger.log( + `Session ${sessionId} revoked for user ${userId}`, + 'AuthService', + ); + + return { success: true, message: 'Session revoked successfully' }; } - async revokeAllSessions(userId: string, currentSessionId?: string) { - const query = this.sessionRepository.createQueryBuilder().delete().where('user_id = :userId', { userId }); + /** + * Revoke all sessions for a user except the current one. + * If `currentSessionId` is not provided (e.g. legacy tokens), all sessions are revoked. + */ + async revokeAllSessions( + userId: string, + currentSessionId?: string, + ): Promise { + const qb = this.sessionRepository + .createQueryBuilder('session') + .delete() + .where('session.user_id = :userId', { userId }); + if (currentSessionId) { - query.andWhere('id != :currentSessionId', { currentSessionId }); + qb.andWhere('session.id != :currentSessionId', { currentSessionId }); } - await query.execute(); - return { success: true }; + + const result = await qb.execute(); + const count: number = result.affected ?? 0; + + this.logger.log( + `Revoked ${count} session(s) for user ${userId} (kept: ${currentSessionId ?? 'none'})`, + 'AuthService', + ); + + return { + success: true, + message: + count === 0 + ? 'No other sessions to revoke' + : `${count} session(s) revoked successfully`, + }; } } diff --git a/harvest-finance/backend/src/auth/dto/session.dto.ts b/harvest-finance/backend/src/auth/dto/session.dto.ts new file mode 100644 index 00000000..eb70af62 --- /dev/null +++ b/harvest-finance/backend/src/auth/dto/session.dto.ts @@ -0,0 +1,111 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +// ── Query DTO ──────────────────────────────────────────────────────────────── + +export class SessionPaginationQueryDto { + @ApiPropertyOptional({ + description: 'Page number (1-indexed)', + example: 1, + minimum: 1, + default: 1, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page: number = 1; + + @ApiPropertyOptional({ + description: 'Number of sessions per page (max 50)', + example: 10, + minimum: 1, + maximum: 50, + default: 10, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + limit: number = 10; +} + +// ── Response DTOs ───────────────────────────────────────────────────────────── + +export class SessionResponseDto { + @ApiProperty({ + description: 'Session UUID', + example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }) + id: string; + + @ApiProperty({ + description: 'Human-readable device / browser name', + example: 'Chrome on Windows', + nullable: true, + }) + deviceName: string | null; + + @ApiPropertyOptional({ + description: 'IP address recorded at login', + example: '203.0.113.42', + nullable: true, + }) + ipAddress: string | null; + + @ApiPropertyOptional({ + description: 'Raw User-Agent string', + example: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...', + nullable: true, + }) + userAgent: string | null; + + @ApiProperty({ + description: 'ISO 8601 timestamp of the last token refresh on this session', + example: '2026-06-01T12:00:00.000Z', + }) + lastUsedAt: Date; + + @ApiProperty({ + description: 'ISO 8601 timestamp when the session was first created', + example: '2026-05-25T08:00:00.000Z', + }) + createdAt: Date; + + @ApiProperty({ + description: 'ISO 8601 hard expiry of the refresh token', + example: '2026-07-01T08:00:00.000Z', + }) + expiresAt: Date; + + @ApiProperty({ + description: 'Whether this is the caller\'s own current session', + example: true, + }) + isCurrent: boolean; +} + +export class SessionListResponseDto { + @ApiProperty({ type: [SessionResponseDto] }) + items: SessionResponseDto[]; + + @ApiProperty({ description: 'Total number of active sessions', example: 3 }) + total: number; + + @ApiProperty({ description: 'Current page', example: 1 }) + page: number; + + @ApiProperty({ description: 'Page size', example: 10 }) + limit: number; +} + +export class RevokeSessionResponseDto { + @ApiProperty({ example: true }) + success: boolean; + + @ApiProperty({ example: 'Session revoked successfully' }) + message: string; +} diff --git a/harvest-finance/backend/src/auth/sessions.controller.ts b/harvest-finance/backend/src/auth/sessions.controller.ts index 685885c7..2b4950c4 100644 --- a/harvest-finance/backend/src/auth/sessions.controller.ts +++ b/harvest-finance/backend/src/auth/sessions.controller.ts @@ -1,27 +1,147 @@ -import { Controller, Get, Delete, Param, UseGuards, Request, Query } from '@nestjs/common'; +import { + Controller, + Get, + Delete, + Param, + UseGuards, + Req, + Query, + HttpCode, + HttpStatus, + ParseUUIDPipe, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, + ApiQuery, +} from '@nestjs/swagger'; +import type { Request } from 'express'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { AuthService } from './auth.service'; +import { + RevokeSessionResponseDto, + SessionListResponseDto, + SessionPaginationQueryDto, +} from './dto/session.dto'; -@Controller('auth/sessions') +@ApiTags('Sessions') +@ApiBearerAuth('JWT-auth') +@Controller({ + path: 'auth/sessions', + version: '1', +}) @UseGuards(JwtAuthGuard) export class SessionsController { constructor(private readonly authService: AuthService) {} + /** + * List all active sessions for the authenticated user. + * + * Each item includes device name, IP address, User-Agent, and the + * last-used timestamp so the user can identify unfamiliar devices. + * The caller's own current session is flagged with `isCurrent: true`. + */ @Get() - async getSessions(@Request() req, @Query('page') page: number = 1, @Query('limit') limit: number = 10) { - return this.authService.getSessions(req.user.id, page, limit); + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'List active sessions', + description: + 'Returns all active refresh-token sessions for the current user, ' + + 'paginated. Each entry includes device name, IP address, and last-used ' + + 'timestamp. The caller\'s current session is marked with `isCurrent: true`.', + }) + @ApiQuery({ name: 'page', required: false, example: 1, type: Number }) + @ApiQuery({ name: 'limit', required: false, example: 10, type: Number }) + @ApiResponse({ + status: 200, + description: 'Paginated list of active sessions', + type: SessionListResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getSessions( + @Req() req: Request, + @Query() query: SessionPaginationQueryDto, + ): Promise { + const user = req.user as any; + const currentSessionId: string | undefined = user?.sessionId; + + return this.authService.getSessions( + user.id, + query.page, + query.limit, + currentSessionId, + ); } + /** + * Revoke a specific session by its ID. + * + * A user can only revoke sessions that belong to them. Attempting to revoke + * a session owned by another user returns 404. + */ @Delete(':sessionId') - async revokeSession(@Request() req, @Param('sessionId') sessionId: string) { - return this.authService.revokeSession(req.user.id, sessionId); + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Revoke a specific session', + description: + 'Permanently invalidates the refresh token associated with the given ' + + 'session ID. The affected device will need to log in again.', + }) + @ApiParam({ + name: 'sessionId', + description: 'UUID of the session to revoke', + example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }) + @ApiResponse({ + status: 200, + description: 'Session revoked successfully', + type: RevokeSessionResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Session not found' }) + async revokeSession( + @Req() req: Request, + @Param('sessionId', ParseUUIDPipe) sessionId: string, + ): Promise { + const user = req.user as any; + return this.authService.revokeSession(user.id, sessionId); } + /** + * Revoke all sessions except the current one. + * + * Designed for "sign out all other devices" functionality. The caller + * remains signed in; every other active session is deleted so those + * refresh tokens can no longer be used. + * + * The current session is identified via the `sessionId` claim in the JWT. + * If no `sessionId` claim is present (tokens issued before this feature), + * all sessions are revoked. + */ @Delete() - async revokeAllSessions(@Request() req) { - // Pass current access token or current session ID if available, to exempt it - // For simplicity, assuming req.user has the current sessionId attached - const currentSessionId = req.user.sessionId; - return this.authService.revokeAllSessions(req.user.id, currentSessionId); + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Revoke all other sessions', + description: + 'Invalidates every active refresh-token session for the current user ' + + 'except the one used to make this request. ' + + 'Other devices will need to log in again.', + }) + @ApiResponse({ + status: 200, + description: 'All other sessions revoked', + type: RevokeSessionResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async revokeAllSessions( + @Req() req: Request, + ): Promise { + const user = req.user as any; + // sessionId is embedded in the JWT payload by AuthService.generateTokens + const currentSessionId: string | undefined = user?.sessionId; + return this.authService.revokeAllSessions(user.id, currentSessionId); } } diff --git a/harvest-finance/backend/src/auth/strategies/jwt.strategy.ts b/harvest-finance/backend/src/auth/strategies/jwt.strategy.ts index c301b239..5f92f7a2 100644 --- a/harvest-finance/backend/src/auth/strategies/jwt.strategy.ts +++ b/harvest-finance/backend/src/auth/strategies/jwt.strategy.ts @@ -10,6 +10,7 @@ export interface JwtPayload { sub: string; email: string; role: string; + sessionId?: string; } @Injectable() @@ -30,7 +31,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - async validate(payload: JwtPayload): Promise { + async validate(payload: JwtPayload): Promise { const user = await this.userRepository.findOne({ where: { id: payload.sub, email: payload.email }, }); @@ -39,6 +40,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) { throw new UnauthorizedException('Invalid or expired token'); } - return user; + // Attach sessionId so controllers can identify the current session + (user as any).sessionId = payload.sessionId; + + return user as User & { sessionId?: string }; } } diff --git a/harvest-finance/backend/src/auth/utils/device-name.util.ts b/harvest-finance/backend/src/auth/utils/device-name.util.ts new file mode 100644 index 00000000..01b65088 --- /dev/null +++ b/harvest-finance/backend/src/auth/utils/device-name.util.ts @@ -0,0 +1,70 @@ +/** + * Derives a short, human-readable device name from a raw User-Agent string. + * + * The result follows the pattern " on ", e.g.: + * "Chrome on Windows" + * "Safari on iPhone" + * "Firefox on macOS" + * "Mobile Safari on Android" + * "Postman / curl" (non-browser clients) + * + * This is intentionally a lightweight regex approach — no heavy UA-parser + * library is required for the summary label shown in the sessions list. + */ +export function deriveDeviceName(userAgent: string | undefined | null): string { + if (!userAgent) return 'Unknown device'; + + const ua = userAgent; + + // ── Browser detection ──────────────────────────────────────────────────── + let browser = 'Unknown browser'; + + if (/Edg\//i.test(ua)) { + browser = 'Edge'; + } else if (/OPR\//i.test(ua) || /Opera\//i.test(ua)) { + browser = 'Opera'; + } else if (/SamsungBrowser\//i.test(ua)) { + browser = 'Samsung Browser'; + } else if (/Chrome\//i.test(ua) && !/Chromium\//i.test(ua)) { + browser = 'Chrome'; + } else if (/Chromium\//i.test(ua)) { + browser = 'Chromium'; + } else if (/Firefox\//i.test(ua) || /FxiOS\//i.test(ua)) { + browser = 'Firefox'; + } else if (/Safari\//i.test(ua) && /Mobile/i.test(ua)) { + browser = 'Mobile Safari'; + } else if (/Safari\//i.test(ua)) { + browser = 'Safari'; + } else if (/curl\//i.test(ua)) { + return 'curl'; + } else if (/PostmanRuntime\//i.test(ua)) { + return 'Postman'; + } else if (/python-requests\//i.test(ua)) { + return 'Python requests'; + } else if (/axios\//i.test(ua)) { + return 'axios'; + } + + // ── OS / platform detection ─────────────────────────────────────────────── + let os = 'Unknown OS'; + + if (/iPhone/i.test(ua)) { + os = 'iPhone'; + } else if (/iPad/i.test(ua)) { + os = 'iPad'; + } else if (/Android/i.test(ua)) { + os = 'Android'; + } else if (/Windows Phone/i.test(ua)) { + os = 'Windows Phone'; + } else if (/Windows NT/i.test(ua)) { + os = 'Windows'; + } else if (/Macintosh|Mac OS X/i.test(ua)) { + os = 'macOS'; + } else if (/Linux/i.test(ua)) { + os = 'Linux'; + } else if (/CrOS/i.test(ua)) { + os = 'ChromeOS'; + } + + return `${browser} on ${os}`; +} diff --git a/harvest-finance/backend/src/database/data-source.ts b/harvest-finance/backend/src/database/data-source.ts index 72c34a88..1b03dddc 100644 --- a/harvest-finance/backend/src/database/data-source.ts +++ b/harvest-finance/backend/src/database/data-source.ts @@ -2,6 +2,7 @@ import { DataSource, DataSourceOptions } from 'typeorm'; import { config } from 'dotenv'; import { User } from './entities/user.entity'; import { UserOAuthLink } from './entities/user-oauth-link.entity'; +import { Session } from './entities/session.entity'; import { Order } from './entities/order.entity'; import { Transaction } from './entities/transaction.entity'; import { Verification } from './entities/verification.entity'; @@ -44,6 +45,7 @@ import { CreateYieldAnalytics1700000000012 } from './migrations/1700000000012-Cr import { AddSorobanEventQueryIndexes1700000000013 } from './migrations/1700000000013-AddSorobanEventQueryIndexes'; import { CreateDepositEvents1700000000016 } from './migrations/1700000000016-CreateDepositEvents'; import { CreateVaultReservations1700000000018 } from './migrations/1700000000018-CreateVaultReservations'; +import { CreateSessionsAndOAuthLinks1700000000022 } from './migrations/1700000000022-CreateSessionsAndOAuthLinks'; // Load environment variables explicitly for CLI usage config(); @@ -71,6 +73,7 @@ const options: DataSourceOptions = { entities: [ User, UserOAuthLink, + Session, Order, Transaction, Verification, @@ -114,6 +117,7 @@ const options: DataSourceOptions = { AddSorobanEventQueryIndexes1700000000013, CreateDepositEvents1700000000016, CreateVaultReservations1700000000018, + CreateSessionsAndOAuthLinks1700000000022, ], // synchronize must remain false in all non-test environments. // Use `npm run migration:run` to apply schema changes safely. diff --git a/harvest-finance/backend/src/database/entities/index.ts b/harvest-finance/backend/src/database/entities/index.ts index f24ce032..370775ee 100644 --- a/harvest-finance/backend/src/database/entities/index.ts +++ b/harvest-finance/backend/src/database/entities/index.ts @@ -27,6 +27,7 @@ export { } from './transaction.entity'; export { User, UserRole } from './user.entity'; export { UserOAuthLink } from './user-oauth-link.entity'; +export { Session } from './session.entity'; export { Vault, VaultStatus, VaultType } from './vault.entity'; export { VaultDeposit } from './vault-deposit.entity'; export { Verification, VerificationStatus } from './verification.entity'; diff --git a/harvest-finance/backend/src/database/entities/session.entity.ts b/harvest-finance/backend/src/database/entities/session.entity.ts index 97b5a14e..9a90393b 100644 --- a/harvest-finance/backend/src/database/entities/session.entity.ts +++ b/harvest-finance/backend/src/database/entities/session.entity.ts @@ -1,20 +1,42 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn, Index } from 'typeorm'; import { User } from './user.entity'; +/** + * Persists one active refresh-token session per login event. + * + * Enriched with device metadata so users can identify and revoke sessions + * from the /auth/sessions management endpoints. + */ @Entity('sessions') +@Index('idx_sessions_user_id', ['user']) +@Index('idx_sessions_expires_at', ['expiresAt']) @Index('idx_sessions_family_id', ['familyId']) @Index('idx_sessions_user_id_revoked', ['user', 'isRevoked']) export class Session { @PrimaryGeneratedColumn('uuid') id: string; - @ManyToOne(() => User, user => user.id, { onDelete: 'CASCADE' }) + @ManyToOne(() => User, (user) => user.sessions, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id' }) user: User; - @Column({ name: 'refresh_token' }) + /** bcrypt-hashed refresh token — never returned to clients. */ + @Column({ name: 'refresh_token', select: false }) refreshToken: string; + /** Raw User-Agent header captured at login time. */ + @Column({ name: 'user_agent', type: 'varchar', nullable: true }) + userAgent: string | null; /** * 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. @@ -38,12 +60,22 @@ export class Session { @Column({ name: 'user_agent', nullable: true }) userAgent: string; - @Column({ name: 'ip_address', nullable: true }) - ipAddress: string; + /** Client IP address captured at login time. */ + @Column({ name: 'ip_address', type: 'varchar', nullable: true }) + ipAddress: string | null; + /** + * Human-readable device name derived from the User-Agent string. + * Examples: "Chrome on Windows", "Safari on iPhone", "Firefox on macOS". + */ + @Column({ name: 'device_name', type: 'varchar', nullable: true }) + deviceName: string | null; + + /** Timestamp of the most recent token-refresh using this session. */ @Column({ name: 'last_used_at' }) lastUsedAt: Date; + /** Hard expiry — sessions past this date are considered invalid. */ @Column({ name: 'expires_at' }) expiresAt: Date; diff --git a/harvest-finance/backend/src/database/migrations/1700000000022-CreateSessionsAndOAuthLinks.ts b/harvest-finance/backend/src/database/migrations/1700000000022-CreateSessionsAndOAuthLinks.ts new file mode 100644 index 00000000..fd8ac130 --- /dev/null +++ b/harvest-finance/backend/src/database/migrations/1700000000022-CreateSessionsAndOAuthLinks.ts @@ -0,0 +1,147 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableIndex, +} from 'typeorm'; + +/** + * Creates the `sessions` and `user_oauth_links` tables. + * + * `sessions` persists refresh-token records enriched with device metadata so + * users can view and revoke individual active sessions. + * + * `user_oauth_links` stores per-provider OAuth identity links for a user. + */ +export class CreateSessionsAndOAuthLinks1700000000022 + implements MigrationInterface +{ + name = 'CreateSessionsAndOAuthLinks1700000000022'; + + public async up(queryRunner: QueryRunner): Promise { + // ── user_oauth_links ───────────────────────────────────────────────────── + const oauthTableExists = await queryRunner.hasTable('user_oauth_links'); + if (!oauthTableExists) { + await queryRunner.createTable( + new Table({ + name: 'user_oauth_links', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { name: 'user_id', type: 'uuid' }, + { name: 'oauth_provider', type: 'varchar' }, + { name: 'oauth_id', type: 'varchar' }, + { name: 'created_at', type: 'timestamp', default: 'now()' }, + { name: 'updated_at', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'user_oauth_links', + new TableIndex({ + name: 'idx_user_oauth_links_provider_id', + columnNames: ['oauth_provider', 'oauth_id'], + isUnique: true, + }), + ); + await queryRunner.createIndex( + 'user_oauth_links', + new TableIndex({ + name: 'idx_user_oauth_links_user_id', + columnNames: ['user_id'], + }), + ); + await queryRunner.createForeignKey( + 'user_oauth_links', + new TableForeignKey({ + name: 'fk_oauth_links_user', + columnNames: ['user_id'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + } + + // ── sessions ───────────────────────────────────────────────────────────── + const sessionsTableExists = await queryRunner.hasTable('sessions'); + if (!sessionsTableExists) { + await queryRunner.createTable( + new Table({ + name: 'sessions', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { name: 'user_id', type: 'uuid' }, + { name: 'refresh_token', type: 'varchar' }, + { name: 'user_agent', type: 'varchar', isNullable: true }, + { name: 'ip_address', type: 'varchar', isNullable: true }, + { name: 'device_name', type: 'varchar', isNullable: true }, + { name: 'last_used_at', type: 'timestamp', default: 'now()' }, + { + name: 'expires_at', + type: 'timestamp', + // 7 days from creation by default + default: "now() + interval '7 days'", + }, + { name: 'created_at', type: 'timestamp', default: 'now()' }, + { name: 'updated_at', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'sessions', + new TableIndex({ + name: 'idx_sessions_user_id', + columnNames: ['user_id'], + }), + ); + await queryRunner.createIndex( + 'sessions', + new TableIndex({ + name: 'idx_sessions_expires_at', + columnNames: ['expires_at'], + }), + ); + await queryRunner.createForeignKey( + 'sessions', + new TableForeignKey({ + name: 'fk_sessions_user', + columnNames: ['user_id'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + const sessionsExists = await queryRunner.hasTable('sessions'); + if (sessionsExists) { + await queryRunner.dropForeignKey('sessions', 'fk_sessions_user'); + await queryRunner.dropTable('sessions'); + } + + const oauthExists = await queryRunner.hasTable('user_oauth_links'); + if (oauthExists) { + await queryRunner.dropForeignKey('user_oauth_links', 'fk_oauth_links_user'); + await queryRunner.dropTable('user_oauth_links'); + } + } +}