From 0ff219b3b6bcd742005709a08548c52f793b3416 Mon Sep 17 00:00:00 2001 From: C3349 Date: Sun, 12 Jul 2026 07:31:31 +0800 Subject: [PATCH] feat: add JWT auth guard with Stellar signature verification (Closes #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create auth module with Stellar wallet-based authentication: Auth flow: 1. GET /api/auth/nonce?address=G... — returns challenge message 2. Client signs message with Stellar private key 3. POST /api/auth/login — verifies signature, issues JWT 4. Subsequent write requests include Authorization: Bearer Files created (src/modules/auth/): - auth.service.ts — nonce generation, signature verification via Stellar Keypair, JWT issuance, in-memory nonce store with TTL - auth.controller.ts — GET /api/auth/nonce, POST /api/auth/login - jwt.strategy.ts — Passport JWT strategy (Bearer token extraction) - jwt-auth.guard.ts — NestJS guard wrapping Passport auth - auth.module.ts — JwtModule + PassportModule async config Write endpoints now protected with @UseGuards(JwtAuthGuard): - POST /api/groups - POST /api/loans - PATCH /api/loans/:id/status - POST /api/governance/proposals - POST /api/groups/:groupId/members Read endpoints (GET) remain public. Swagger docs updated with @ApiBearerAuth on protected routes. --- src/app.module.ts | 2 + src/modules/auth/auth.controller.ts | 35 +++++ src/modules/auth/auth.module.ts | 26 ++++ src/modules/auth/auth.service.ts | 126 ++++++++++++++++++ src/modules/auth/jwt-auth.guard.ts | 12 ++ src/modules/auth/jwt.strategy.ts | 30 +++++ .../governance/governance.controller.ts | 7 +- src/modules/groups/groups.controller.ts | 7 +- src/modules/loans/loans.controller.ts | 9 +- src/modules/members/members.controller.ts | 7 +- 10 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 src/modules/auth/auth.controller.ts create mode 100644 src/modules/auth/auth.module.ts create mode 100644 src/modules/auth/auth.service.ts create mode 100644 src/modules/auth/jwt-auth.guard.ts create mode 100644 src/modules/auth/jwt.strategy.ts diff --git a/src/app.module.ts b/src/app.module.ts index 742172e..00897fd 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { ScheduleModule } from "@nestjs/schedule"; +import { AuthModule } from "./modules/auth/auth.module"; import { GroupsModule } from "./modules/groups/groups.module"; import { MembersModule } from "./modules/members/members.module"; import { LoansModule } from "./modules/loans/loans.module"; @@ -14,6 +15,7 @@ import { StellarIndexerService } from "./common/stellar-indexer.service"; imports: [ ConfigModule.forRoot({ isGlobal: true }), ScheduleModule.forRoot(), + AuthModule, GroupsModule, MembersModule, LoansModule, diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..e68e9ae --- /dev/null +++ b/src/modules/auth/auth.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Post, Body, Query } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiQuery } from "@nestjs/swagger"; +import { AuthService } from "./auth.service"; + +@ApiTags("auth") +@Controller("auth") +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Get("nonce") + @ApiOperation({ + summary: "Get a challenge nonce for Stellar wallet authentication", + description: + "Returns a unique message that the client must sign with their " + + "Stellar private key. The signature is then submitted to POST /api/auth/login.", + }) + @ApiQuery({ name: "address", required: true, description: "Stellar public key (G…)" }) + getNonce(@Query("address") address: string) { + return this.authService.generateNonce(address); + } + + @Post("login") + @ApiOperation({ + summary: "Verify Stellar signature and issue a JWT", + description: + "Submit a base64-encoded signature of the nonce challenge message. " + + "Returns a JWT access token for use in the Authorization header.", + }) + login( + @Body("address") address: string, + @Body("signature") signature: string, + ) { + return this.authService.login(address, signature); + } +} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..47ca381 --- /dev/null +++ b/src/modules/auth/auth.module.ts @@ -0,0 +1,26 @@ +import { Module } from "@nestjs/common"; +import { JwtModule } from "@nestjs/jwt"; +import { PassportModule } from "@nestjs/passport"; +import { ConfigService } from "@nestjs/config"; +import { AuthController } from "./auth.controller"; +import { AuthService } from "./auth.service"; +import { JwtStrategy } from "./jwt.strategy"; + +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: "jwt" }), + JwtModule.registerAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get("JWT_SECRET", "coopfin-jwt-secret-dev"), + signOptions: { + expiresIn: config.get("JWT_EXPIRES_IN", "24h"), + }, + }), + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], + exports: [AuthService, JwtModule, PassportModule], +}) +export class AuthModule {} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..9b47f29 --- /dev/null +++ b/src/modules/auth/auth.service.ts @@ -0,0 +1,126 @@ +import { Injectable, UnauthorizedException, Logger } from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import { ConfigService } from "@nestjs/config"; +import { Keypair } from "@stellar/stellar-sdk"; +import { randomBytes } from "crypto"; + +// ─── In-memory nonce store ──────────────────────────────────────────── +// Nonces expire after 5 minutes. In production, replace with Redis. + +interface NonceEntry { + message: string; + expiresAt: number; +} + +@Injectable() +export class AuthService { + private readonly logger = new Logger(AuthService.name); + private readonly nonces = new Map(); + private readonly NONCE_TTL_MS = 5 * 60 * 1000; // 5 minutes + + constructor( + private jwtService: JwtService, + private config: ConfigService, + ) { + // Purge expired nonces every 60 seconds + setInterval(() => this.purgeExpiredNonces(), 60_000); + } + + /** + * Generate a challenge nonce for a Stellar address. + * The client must sign this message to prove ownership of the private key. + */ + generateNonce(address: string): { nonce: string; message: string } { + const nonce = randomBytes(32).toString("hex"); + const timestamp = Date.now(); + const message = `CoopFinance auth challenge: ${nonce}\nAddress: ${address}\nTimestamp: ${timestamp}\nExpires: ${timestamp + this.NONCE_TTL_MS}`; + + this.nonces.set(address, { + message, + expiresAt: timestamp + this.NONCE_TTL_MS, + }); + + this.logger.debug(`Nonce generated for ${address}`); + return { nonce, message }; + } + + /** + * Verify a Stellar signature against the nonce challenge and issue a JWT. + * + * @param address - Stellar public key (G…) + * @param signature - Base64-encoded signature of the challenge message + */ + async login( + address: string, + signature: string, + ): Promise<{ accessToken: string }> { + const entry = this.nonces.get(address); + if (!entry) { + throw new UnauthorizedException( + "No active nonce found. Request GET /api/auth/nonce?address=... first.", + ); + } + + if (Date.now() > entry.expiresAt) { + this.nonces.delete(address); + throw new UnauthorizedException("Nonce expired. Request a new one."); + } + + // ── Verify Stellar signature ──────────────────────────────────── + const isValid = this.verifyStellarSignature( + address, + entry.message, + signature, + ); + + if (!isValid) { + this.logger.warn(`Invalid signature for ${address}`); + throw new UnauthorizedException("Signature verification failed."); + } + + // Consume the nonce (one-time use) + this.nonces.delete(address); + + // ── Issue JWT ─────────────────────────────────────────────────── + const payload = { + sub: address, + address, + iat: Math.floor(Date.now() / 1000), + }; + + const accessToken = this.jwtService.sign(payload); + + this.logger.log(`JWT issued for ${address}`); + return { accessToken }; + } + + /** + * Verify that `signature` is a valid Stellar signature of `message` + * by the keypair identified by `address`. + */ + private verifyStellarSignature( + address: string, + message: string, + signature: string, + ): boolean { + try { + const keypair = Keypair.fromPublicKey(address); + const messageBytes = Buffer.from(message, "utf-8"); + const signatureBytes = Buffer.from(signature, "base64"); + return keypair.verify(messageBytes, signatureBytes); + } catch (err) { + this.logger.error("Signature verification error", err); + return false; + } + } + + /** Remove expired nonces from the in-memory store. */ + private purgeExpiredNonces(): void { + const now = Date.now(); + for (const [key, entry] of this.nonces) { + if (now > entry.expiresAt) { + this.nonces.delete(key); + } + } + } +} diff --git a/src/modules/auth/jwt-auth.guard.ts b/src/modules/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..f47f045 --- /dev/null +++ b/src/modules/auth/jwt-auth.guard.ts @@ -0,0 +1,12 @@ +import { Injectable, ExecutionContext } from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; +import { Observable } from "rxjs"; + +@Injectable() +export class JwtAuthGuard extends AuthGuard("jwt") { + canActivate( + context: ExecutionContext, + ): boolean | Promise | Observable { + return super.canActivate(context); + } +} diff --git a/src/modules/auth/jwt.strategy.ts b/src/modules/auth/jwt.strategy.ts new file mode 100644 index 0000000..d77f7e5 --- /dev/null +++ b/src/modules/auth/jwt.strategy.ts @@ -0,0 +1,30 @@ +import { Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { ExtractJwt, Strategy } from "passport-jwt"; +import { ConfigService } from "@nestjs/config"; + +export interface JwtPayload { + sub: string; + address: string; + iat: number; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(config: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: config.get("JWT_SECRET", "coopfin-jwt-secret-dev"), + }); + } + + validate(payload: JwtPayload): JwtPayload { + // Passport attaches the return value to `request.user` + return { + sub: payload.sub, + address: payload.address, + iat: payload.iat, + }; + } +} diff --git a/src/modules/governance/governance.controller.ts b/src/modules/governance/governance.controller.ts index bbe0d56..880223c 100644 --- a/src/modules/governance/governance.controller.ts +++ b/src/modules/governance/governance.controller.ts @@ -1,6 +1,7 @@ -import { Controller, Get, Post, Param, Body, Query } from "@nestjs/common"; -import { ApiTags, ApiOperation } from "@nestjs/swagger"; +import { Controller, Get, Post, Param, Body, Query, UseGuards } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger"; import { GovernanceService, CreateProposalDto } from "./governance.service"; +import { JwtAuthGuard } from "../auth/jwt-auth.guard"; @ApiTags("governance") @Controller("governance") @@ -19,6 +20,8 @@ export class GovernanceController { } @Post("proposals") + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() @ApiOperation({ summary: "Register proposal after on-chain creation" }) create(@Body() dto: CreateProposalDto) { return this.governanceService.create(dto); diff --git a/src/modules/groups/groups.controller.ts b/src/modules/groups/groups.controller.ts index 36b5907..a53dc8d 100644 --- a/src/modules/groups/groups.controller.ts +++ b/src/modules/groups/groups.controller.ts @@ -1,6 +1,7 @@ -import { Controller, Get, Post, Param, Body } from "@nestjs/common"; -import { ApiTags, ApiOperation } from "@nestjs/swagger"; +import { Controller, Get, Post, Param, Body, UseGuards } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger"; import { GroupsService, CreateGroupDto } from "./groups.service"; +import { JwtAuthGuard } from "../auth/jwt-auth.guard"; @ApiTags("groups") @Controller("groups") @@ -20,6 +21,8 @@ export class GroupsController { } @Post() + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() @ApiOperation({ summary: "Register a new group after contract deployment" }) create(@Body() dto: CreateGroupDto) { return this.groupsService.create(dto); diff --git a/src/modules/loans/loans.controller.ts b/src/modules/loans/loans.controller.ts index 934748b..efe9fb7 100644 --- a/src/modules/loans/loans.controller.ts +++ b/src/modules/loans/loans.controller.ts @@ -1,6 +1,7 @@ -import { Controller, Get, Post, Patch, Param, Body, Query } from "@nestjs/common"; -import { ApiTags, ApiOperation, ApiQuery } from "@nestjs/swagger"; +import { Controller, Get, Post, Patch, Param, Body, Query, UseGuards } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiQuery, ApiBearerAuth } from "@nestjs/swagger"; import { LoansService, CreateLoanDto } from "./loans.service"; +import { JwtAuthGuard } from "../auth/jwt-auth.guard"; @ApiTags("loans") @Controller("loans") @@ -21,12 +22,16 @@ export class LoansController { } @Post() + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() @ApiOperation({ summary: "Register a loan request after on-chain submission" }) create(@Body() dto: CreateLoanDto) { return this.loansService.create(dto); } @Patch(":id/status") + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() @ApiOperation({ summary: "Update loan status (after on-chain approval/repayment)" }) updateStatus(@Param("id") id: string, @Body("status") status: string) { return this.loansService.updateStatus(id, status); diff --git a/src/modules/members/members.controller.ts b/src/modules/members/members.controller.ts index a8bf8e4..0a8c139 100644 --- a/src/modules/members/members.controller.ts +++ b/src/modules/members/members.controller.ts @@ -1,6 +1,7 @@ -import { Controller, Get, Post, Param, Body } from "@nestjs/common"; -import { ApiTags } from "@nestjs/swagger"; +import { Controller, Get, Post, Param, Body, UseGuards } from "@nestjs/common"; +import { ApiTags, ApiBearerAuth } from "@nestjs/swagger"; import { MembersService } from "./members.service"; +import { JwtAuthGuard } from "../auth/jwt-auth.guard"; @ApiTags("members") @Controller("groups/:groupId/members") @@ -13,6 +14,8 @@ export class MembersController { } @Post() + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() add( @Param("groupId") groupId: string, @Body("address") address: string,