diff --git a/WEB3_AUTH_GUIDE.md b/WEB3_AUTH_GUIDE.md new file mode 100644 index 00000000..4c52b6d0 --- /dev/null +++ b/WEB3_AUTH_GUIDE.md @@ -0,0 +1,308 @@ +# Web3 Wallet Authentication Guide + +This guide explains the secure Web3 wallet signature authentication system implemented for the Web3 Student Lab platform. + +## Overview + +The Web3 authentication system allows users to log in using their Ethereum wallets instead of traditional email/password credentials. The system uses cryptographic signatures to verify ownership of the wallet address and issues standard JWT tokens for session management. + +## Architecture + +### Backend Components + +1. **AuthNonce Model** - Stores cryptographic nonces for wallet authentication +2. **Web3 Service** - Handles nonce generation and signature verification +3. **Authentication Routes** - Provides REST endpoints for Web3 authentication +4. **Rate Limiting** - Prevents abuse of nonce generation + +### Frontend Components + +1. **Web3 Service** - Manages wallet connection and authentication flow +2. **Web3Login Component** - React component for wallet authentication UI +3. **MetaMask Integration** - Connects to browser wallet providers + +## Security Features + +- **Cryptographic Nonces**: Each authentication request uses a unique, time-limited nonce +- **Signature Verification**: Uses Ethers.js to cryptographically verify wallet signatures +- **Rate Limiting**: Prevents nonce endpoint abuse (10 requests/minute per IP) +- **JWT Tokens**: Standard access and refresh token pattern +- **Nonce Cleanup**: Automatic cleanup of expired nonces + +## API Endpoints + +### GET /api/auth/nonce + +Generates and stores a cryptographic nonce for a wallet address. + +**Query Parameters:** +- `walletAddress` (string): Ethereum wallet address (0x-prefixed, 42 characters) + +**Response:** +```json +{ + "nonce": "ABC123...XYZ", + "expiresAt": "2024-01-01T12:05:00.000Z" +} +``` + +**Rate Limiting:** 10 requests per minute per IP address + +### POST /api/auth/verify + +Verifies a wallet signature and authenticates the user. + +**Request Body:** +```json +{ + "walletAddress": "0x742d35Cc6634C0532925a3b8D4C9db96C4b4Db45", + "signature": "0x4a5b6c7d8e9f0123456789abcdef...", + "nonce": "ABC123...XYZ" +} +``` + +**Response:** +```json +{ + "user": { + "id": "user_123", + "email": "0x742d35Cc6634C0532925a3b8D4C9db96C4b4Db45@wallet.auth", + "name": "Wallet User", + "did": null + }, + "accessToken": "eyJhbGciOiJIUzI1NiIs...", + "refreshToken": "eyJhbGciOiJIUzI1NiIs..." +} +``` + +## Database Schema + +### AuthNonce Table + +```sql +model AuthNonce { + id String @id @default(cuid()) + walletAddress String + nonce String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +### Student Model Updates + +The existing `Student` model includes a `walletAddress` field for Web3 authentication: + +```sql +model Student { + // ... existing fields + walletAddress String? @unique + // ... existing fields +} +``` + +## Authentication Flow + +1. **Client Request**: User clicks "Connect Wallet" +2. **Wallet Connection**: Frontend connects to MetaMask/compatible wallet +3. **Nonce Request**: Frontend requests nonce from `/api/auth/nonce` +4. **Message Signing**: Frontend constructs message and requests user signature +5. **Signature Verification**: Backend verifies signature using Ethers.js +6. **User Creation/Retrieval**: Backend finds or creates user record +7. **Token Issuance**: Backend issues JWT access and refresh tokens +8. **Session Storage**: Frontend stores tokens for authenticated requests + +## Frontend Implementation + +### Web3 Service Usage + +```typescript +import { web3AuthService } from '../services/web3.service'; + +// Authenticate with wallet +try { + const authResponse = await web3AuthService.authenticate(); + console.log('Authenticated:', authResponse.user); +} catch (error) { + console.error('Authentication failed:', error); +} + +// Check if already connected +const user = web3AuthService.getStoredUser(); +if (user) { + console.log('Already authenticated:', user); +} + +// Disconnect wallet +web3AuthService.disconnect(); +``` + +### React Component Integration + +```tsx +import Web3Login from '../components/Web3Login'; + +export default function LoginPage() { + const handleLoginSuccess = (authResponse) => { + // Handle successful authentication + console.log('User logged in:', authResponse.user); + }; + + const handleLoginError = (error) => { + // Handle authentication error + console.error('Login failed:', error); + }; + + return ( + + ); +} +``` + +## Security Considerations + +### Nonce Security +- Nonces are cryptographically random (32 characters) +- Nonces expire after 5 minutes +- Used nonces are immediately deleted +- Expired nonces are periodically cleaned up + +### Signature Verification +- Uses Ethers.js `verifyMessage()` for secure signature recovery +- Validates that recovered address matches claimed wallet address +- Prevents signature replay attacks through nonce usage + +### Rate Limiting +- Sliding window rate limiter using Redis +- 10 nonce requests per minute per IP +- Prevents database spam and abuse + +### Token Security +- Standard JWT tokens with configurable expiration +- Access tokens: 15 minutes (configurable) +- Refresh tokens: 7 days (configurable) +- Token rotation on refresh + +## Testing + +### Backend Testing + +```bash +# Test nonce generation +curl "http://localhost:3000/api/auth/nonce?walletAddress=0x1234567890123456789012345678901234567890" + +# Test signature verification (requires valid signature) +curl -X POST "http://localhost:3000/api/auth/verify" \ + -H "Content-Type: application/json" \ + -d '{ + "walletAddress": "0x1234567890123456789012345678901234567890", + "signature": "0x...", + "nonce": "ABC123...XYZ" + }' +``` + +### Frontend Testing + +1. Install MetaMask browser extension +2. Navigate to the Web3 authentication page +3. Click "Connect Wallet" and approve in MetaMask +4. Sign the authentication message +5. Verify successful authentication and token storage + +## Dependencies + +### Backend +- `ethers`: Ethereum library for signature verification +- `express-rate-limit`: Rate limiting middleware +- `@prisma/client`: Database ORM +- `jsonwebtoken`: JWT token handling + +### Frontend +- `ethers`: Ethereum library for wallet interaction +- `@metamask/detect-provider`: MetaMask detection +- `react`: UI framework +- `lucide-react`: Icon library + +## Configuration + +### Environment Variables + +```env +# JWT Configuration +JWT_SECRET=your-super-secret-jwt-key +JWT_EXPIRES_IN=15m +REFRESH_TOKEN_EXPIRES_IN=7d + +# Database +DATABASE_URL=postgresql://user:password@localhost:5432/web3_student_lab + +# Redis (for rate limiting) +REDIS_URL=redis://localhost:6379 +``` + +### Nonce Configuration + +```typescript +// In web3.service.ts +const NONCE_EXPIRY_MINUTES = 5; // Nonce expiration time +const NONCE_LENGTH = 32; // Nonce string length +``` + +## Troubleshooting + +### Common Issues + +1. **MetaMask Not Installed** + - Error: "MetaMask is not installed" + - Solution: Install MetaMask browser extension + +2. **Invalid Wallet Address** + - Error: "Invalid wallet address format" + - Solution: Ensure address is 0x-prefixed and 42 characters long + +3. **Signature Verification Failed** + - Error: "Invalid signature" + - Solution: Ensure the correct message format was signed + +4. **Nonce Expired** + - Error: "Invalid or expired nonce" + - Solution: Request a fresh nonce and retry + +5. **Rate Limited** + - Error: "Too many requests" + - Solution: Wait for rate limit window to reset + +### Debug Mode + +Enable debug logging by setting: + +```env +NODE_ENV=development +LOG_LEVEL=debug +``` + +## Future Enhancements + +1. **Multi-Wallet Support**: Support for WalletConnect, Coinbase Wallet, etc. +2. **Multi-Chain Support**: Support for other EVM-compatible chains +3. **Biometric Authentication**: Integration with wallet biometric features +4. **Session Management**: Advanced session tracking and management +5. **Audit Logging**: Comprehensive audit trail for Web3 authentication events + +## Contributing + +When contributing to the Web3 authentication system: + +1. Follow the existing code patterns and security practices +2. Add comprehensive tests for new features +3. Update documentation for any API changes +4. Ensure proper error handling and user feedback +5. Test with multiple wallet providers when possible + +## License + +This Web3 authentication implementation is part of the Web3 Student Lab project and follows the same licensing terms. diff --git a/backend/package-lock.json b/backend/package-lock.json index d8f0a3a9..5363dab0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,6 +18,7 @@ "bullmq": "^5.76.1", "cors": "^2.8.6", "dotenv": "^17.3.1", + "ethers": "^6.16.0", "express": "^5.2.1", "express-rate-limit": "^8.3.1", "ioredis": "^5.10.1", @@ -56,6 +57,12 @@ "typescript": "^5.9.3" } }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -87,7 +94,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -654,8 +660,7 @@ "version": "0.3.15", "resolved": "https://registry.npmmirror.com/@electric-sql/pglite/-/pglite-0.3.15.tgz", "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { "version": "0.0.20", @@ -2520,7 +2525,6 @@ "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.5.0.tgz", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2689,7 +2693,6 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -3183,7 +3186,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3214,6 +3216,12 @@ "node": ">=0.4.0" } }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.14.0.tgz", @@ -3635,7 +3643,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4342,7 +4349,8 @@ "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/debug": { "version": "4.4.3", @@ -4834,7 +4842,6 @@ "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -5040,6 +5047,100 @@ "node": ">= 0.6" } }, + "node_modules/ethers": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/ethers/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/eventsource": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", @@ -5113,7 +5214,6 @@ "resolved": "https://registry.npmmirror.com/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5842,7 +5942,6 @@ "resolved": "https://registry.npmmirror.com/hono/-/hono-4.11.4.tgz", "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -6284,7 +6383,6 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -7922,7 +8020,6 @@ "resolved": "https://registry.npmmirror.com/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -8086,7 +8183,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8311,7 +8407,6 @@ "integrity": "sha512-n30qZpWehaYQzigLjmuPisyEsvOzHt7bZeRyg8gZ5DvJo9FGjD+gNaY59Ns3hlLD5/jZH5GBeftIss0jDbUoLg==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "7.5.0", "@prisma/dev": "0.20.0", @@ -8680,7 +8775,8 @@ "version": "0.27.0", "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/semver": { "version": "7.7.4", @@ -9620,7 +9716,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -9755,7 +9850,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10379,7 +10473,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/backend/package.json b/backend/package.json index 1f1fa9ea..43f8e07a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,6 +28,7 @@ "bullmq": "^5.76.1", "cors": "^2.8.6", "dotenv": "^17.3.1", + "ethers": "^6.16.0", "express": "^5.2.1", "express-rate-limit": "^8.3.1", "ioredis": "^5.10.1", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index daaba687..6b2df1c0 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -151,6 +151,19 @@ model AuditLog { @@map("audit_logs") } +model AuthNonce { + id String @id @default(cuid()) + walletAddress String + nonce String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("auth_nonces") + @@index([walletAddress]) + @@index([expiresAt]) +} + model AnalyticsData { id String @id @default(cuid()) metricType String // "USER_STAT", "COURSE_STAT", "ENROLLMENT_STAT" diff --git a/backend/src/auth/types.ts b/backend/src/auth/types.ts index d74c5f52..5c7c37fa 100644 --- a/backend/src/auth/types.ts +++ b/backend/src/auth/types.ts @@ -21,3 +21,24 @@ export interface AuthResponse { user: User; token: string; } + +export interface Web3NonceRequest { + walletAddress: string; +} + +export interface Web3NonceResponse { + nonce: string; + expiresAt: string; +} + +export interface Web3VerifyRequest { + walletAddress: string; + signature: string; + nonce: string; +} + +export interface Web3AuthResponse { + user: User; + accessToken: string; + refreshToken: string; +} diff --git a/backend/src/auth/validation.schemas.ts b/backend/src/auth/validation.schemas.ts index 1298a1d2..c8b6a5a8 100644 --- a/backend/src/auth/validation.schemas.ts +++ b/backend/src/auth/validation.schemas.ts @@ -37,8 +37,40 @@ export const loginSchema = z.object({ password: z.string().min(1, 'Password is required'), }); +/** + * Web3 Nonce Request Schema + * Validates the request body for nonce generation + */ +export const web3NonceSchema = z.object({ + walletAddress: z + .string() + .min(1, 'Wallet address is required') + .regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum wallet address format'), +}); + +/** + * Web3 Verify Request Schema + * Validates the request body for signature verification + */ +export const web3VerifySchema = z.object({ + walletAddress: z + .string() + .min(1, 'Wallet address is required') + .regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum wallet address format'), + signature: z + .string() + .min(1, 'Signature is required') + .regex(/^0x[a-fA-F0-9]{130,132}$/, 'Invalid signature format'), + nonce: z + .string() + .min(1, 'Nonce is required') + .min(32, 'Invalid nonce length'), +}); + /** * Type inference for validated data */ export type RegisterRequest = z.infer; export type LoginRequest = z.infer; +export type Web3NonceRequest = z.infer; +export type Web3VerifyRequest = z.infer; diff --git a/backend/src/auth/web3.service.ts b/backend/src/auth/web3.service.ts new file mode 100644 index 00000000..8158946b --- /dev/null +++ b/backend/src/auth/web3.service.ts @@ -0,0 +1,145 @@ +import { ethers } from 'ethers'; +import prisma from '../db/index.js'; +import { + generateAccessToken, + generateRefreshToken, + TokenPayload +} from './token.service.js'; +import { formatUserResponse } from './auth.service.js'; + +const NONCE_EXPIRY_MINUTES = 5; +const NONCE_LENGTH = 32; + +/** + * Generate a cryptographically secure random nonce + */ +export const generateNonce = (): string => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < NONCE_LENGTH; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +}; + +/** + * Create and store a nonce for a wallet address + */ +export const createNonce = async (walletAddress: string): Promise => { + // Normalize wallet address to checksum format + const normalizedAddress = ethers.getAddress(walletAddress); + + // Clean up any existing nonces for this wallet + await prisma.authNonce.deleteMany({ + where: { + walletAddress: normalizedAddress, + expiresAt: { + lt: new Date() + } + } + }); + + // Generate new nonce + const nonce = generateNonce(); + const expiresAt = new Date(Date.now() + NONCE_EXPIRY_MINUTES * 60 * 1000); + + // Store nonce + await prisma.authNonce.create({ + data: { + walletAddress: normalizedAddress, + nonce, + expiresAt, + } + }); + + return nonce; +}; + +/** + * Verify a cryptographic signature against a stored nonce + */ +export const verifySignature = async ( + walletAddress: string, + signature: string, + nonce: string +): Promise<{ user: any; accessToken: string; refreshToken: string }> => { + // Normalize wallet address + const normalizedAddress = ethers.getAddress(walletAddress); + + // Find and validate nonce + const storedNonce = await prisma.authNonce.findFirst({ + where: { + walletAddress: normalizedAddress, + nonce, + expiresAt: { + gt: new Date() + } + } + }); + + if (!storedNonce) { + throw new Error('Invalid or expired nonce'); + } + + // Construct the message that was signed + const message = `Sign this message to authenticate with Web3 Student Lab. Nonce: ${nonce}`; + + try { + // Recover the signer address from the signature + const recoveredAddress = ethers.verifyMessage(message, signature); + + // Verify the recovered address matches the claimed wallet address + if (recoveredAddress.toLowerCase() !== normalizedAddress.toLowerCase()) { + throw new Error('Signature verification failed'); + } + } catch (error) { + throw new Error('Invalid signature format'); + } + + // Clean up the used nonce + await prisma.authNonce.delete({ + where: { id: storedNonce.id } + }); + + // Find or create user with this wallet address + let student = await prisma.student.findUnique({ + where: { walletAddress: normalizedAddress } + }); + + if (!student) { + // Create new user with wallet address + student = await prisma.student.create({ + data: { + walletAddress: normalizedAddress, + email: `${normalizedAddress}@wallet.auth`, // Placeholder email + firstName: 'Wallet', + lastName: 'User', + password: '', // Empty password for wallet users + } + }); + } + + // Generate JWT tokens + const payload: TokenPayload = { userId: student.id }; + const accessToken = generateAccessToken(payload); + const refreshToken = await generateRefreshToken(payload); + + return { + user: formatUserResponse(student), + accessToken, + refreshToken, + }; +}; + +/** + * Clean up expired nonces (should be run periodically) + */ +export const cleanupExpiredNonces = async (): Promise => { + await prisma.authNonce.deleteMany({ + where: { + expiresAt: { + lt: new Date() + } + } + }); +}; diff --git a/backend/src/routes/auth/auth.routes.ts b/backend/src/routes/auth/auth.routes.ts index 25383105..bc4e1648 100644 --- a/backend/src/routes/auth/auth.routes.ts +++ b/backend/src/routes/auth/auth.routes.ts @@ -1,10 +1,12 @@ import { Request, Response, Router } from 'express'; import { authenticate } from '../../auth/auth.middleware.js'; import { login, register } from '../../auth/auth.service.js'; +import { blacklistAccessToken, rotateRefreshToken } from '../../auth/token.service.js'; import { LoginRequest } from '../../auth/types.js'; -import { loginSchema, registerSchema } from '../../auth/validation.schemas.js'; +import { loginSchema, registerSchema, web3VerifySchema } from '../../auth/validation.schemas.js'; +import { createNonce, verifySignature } from '../../auth/web3.service.js'; +import { slidingWindowRateLimiter } from '../../middleware/rateLimiter.js'; import { validateRequest } from '../../utils/validation.js'; -import { rotateRefreshToken, blacklistAccessToken } from '../../auth/token.service.js'; const router = Router(); @@ -124,4 +126,73 @@ router.post('/logout', authenticate, async (req: Request, res: Response) => { res.json({ message: 'Logged out successfully' }); }); +/** + * @route GET /api/auth/nonce + * @desc Generate a cryptographic nonce for Web3 wallet authentication + * @access Public + */ +router.get('/nonce', + slidingWindowRateLimiter({ + windowMs: 60 * 1000, // 1 minute + limit: 10, // 10 requests per minute per IP + keyPrefix: 'rl:nonce', + }), + async (req: Request, res: Response) => { + try { + const { walletAddress } = req.query; + + if (!walletAddress || typeof walletAddress !== 'string') { + res.status(400).json({ error: 'Wallet address is required' }); + return; + } + + // Validate wallet address format + if (!/^0x[a-fA-F0-9]{40}$/.test(walletAddress)) { + res.status(400).json({ error: 'Invalid wallet address format' }); + return; + } + + const nonce = await createNonce(walletAddress); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes + + res.json({ + nonce, + expiresAt: expiresAt.toISOString(), + }); + } catch (error) { + console.error('Nonce generation error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +/** + * @route POST /api/auth/verify + * @desc Verify Web3 wallet signature and authenticate user + * @access Public + */ +router.post('/verify', validateRequest(web3VerifySchema), async (req: Request, res: Response) => { + try { + const { walletAddress, signature, nonce } = req.body; + + const authResponse = await verifySignature(walletAddress, signature, nonce); + + res.json(authResponse); + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Invalid or expired nonce') { + res.status(401).json({ error: error.message }); + return; + } + if (error.message === 'Signature verification failed' || error.message === 'Invalid signature format') { + res.status(401).json({ error: 'Invalid signature' }); + return; + } + } + + console.error('Signature verification error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + export default router; diff --git a/frontend/package.json b/frontend/package.json index ef1a36f9..b49c8313 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,11 +11,13 @@ "format:check": "prettier --check ." }, "dependencies": { + "@metamask/detect-provider": "^2.0.0", "@monaco-editor/react": "^4.7.0", "@stellar/stellar-sdk": "^14.6.1", "axios": "^1.13.6", "bignumber.js": "^10.0.2", "d3": "^7.9.0", + "ethers": "^6.16.0", "framer-motion": "^12.38.0", "html2canvas": "^1.4.1", "jspdf": "^4.2.1", diff --git a/frontend/src/components/Web3Login.tsx b/frontend/src/components/Web3Login.tsx new file mode 100644 index 00000000..effcc61b --- /dev/null +++ b/frontend/src/components/Web3Login.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { AlertCircle, CheckCircle, Loader2, Wallet } from 'lucide-react'; +import React, { useState } from 'react'; +import { Web3AuthResponse, web3AuthService } from '../services/web3.service'; + +// Add TypeScript declaration for window.ethereum +declare global { + interface Window { + ethereum?: any; + } +} + +interface Web3LoginProps { + onLoginSuccess?: (user: Web3AuthResponse) => void; + onLoginError?: (error: Error) => void; + className?: string; +} + +export const Web3Login: React.FC = ({ + onLoginSuccess, + onLoginError, + className = '', +}) => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [walletAddress, setWalletAddress] = useState(null); + + const handleConnectWallet = async () => { + setIsLoading(true); + setError(null); + + try { + // Check if MetaMask is installed + if (!window.ethereum) { + throw new Error('MetaMask is not installed. Please install MetaMask to continue.'); + } + + // Get wallet address first + const address = await web3AuthService.getWalletAddress(); + if (address) { + setWalletAddress(address); + } + + // Authenticate with Web3 + const authResponse = await web3AuthService.authenticate(); + + setWalletAddress(authResponse.user.email); // Will show wallet address + onLoginSuccess?.(authResponse); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Authentication failed'; + setError(errorMessage); + onLoginError?.(err instanceof Error ? err : new Error(errorMessage)); + } finally { + setIsLoading(false); + } + }; + + const handleDisconnect = () => { + web3AuthService.disconnect(); + setWalletAddress(null); + setError(null); + }; + + // Check if already connected on mount + React.useEffect(() => { + const storedUser = web3AuthService.getStoredUser(); + if (storedUser) { + setWalletAddress(storedUser.email); + } + }, []); + + return ( +
+ {walletAddress ? ( +
+
+ +
+

Wallet Connected

+

+ {walletAddress.slice(0, 6)}...{walletAddress.slice(-4)} +

+
+
+ + +
+ ) : ( +
+ + + {error && ( +
+ +

{error}

+
+ )} + +
+

Connect your Ethereum wallet to sign in securely.

+

Requires MetaMask or compatible wallet.

+
+
+ )} +
+ ); +}; + +export default Web3Login; diff --git a/frontend/src/pages/Web3AuthExample.tsx b/frontend/src/pages/Web3AuthExample.tsx new file mode 100644 index 00000000..3916ef7c --- /dev/null +++ b/frontend/src/pages/Web3AuthExample.tsx @@ -0,0 +1,158 @@ +'use client'; + +import React, { useState } from 'react'; +import Web3Login from '../components/Web3Login'; +import { Web3AuthResponse } from '../services/web3.service'; +import { User, LogOut, Shield, Key } from 'lucide-react'; + +export default function Web3AuthExample() { + const [user, setUser] = useState(null); + const [tokens, setTokens] = useState<{ accessToken: string; refreshToken: string } | null>(null); + + const handleLoginSuccess = (authResponse: Web3AuthResponse) => { + setUser(authResponse.user); + setTokens({ + accessToken: authResponse.accessToken, + refreshToken: authResponse.refreshToken, + }); + }; + + const handleLoginError = (error: Error) => { + console.error('Web3 login failed:', error); + }; + + const handleLogout = () => { + setUser(null); + setTokens(null); + // Clear localStorage + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + }; + + // Check for existing session on mount + React.useEffect(() => { + const storedUser = localStorage.getItem('user'); + const storedAccessToken = localStorage.getItem('accessToken'); + const storedRefreshToken = localStorage.getItem('refreshToken'); + + if (storedUser && storedAccessToken && storedRefreshToken) { + try { + setUser(JSON.parse(storedUser)); + setTokens({ + accessToken: storedAccessToken, + refreshToken: storedRefreshToken, + }); + } catch (error) { + console.error('Failed to parse stored user data:', error); + // Clear corrupted data + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + } + } + }, []); + + return ( +
+
+
+
+ +

Web3 Authentication

+
+

+ Secure login using your Ethereum wallet +

+
+ +
+ {user ? ( +
+
+
+ +
+

+ Welcome, {user.name}! +

+

+ {user.email} +

+
+ +
+

User Information

+
+
+
User ID:
+
{user.id.slice(0, 8)}...
+
+
+
Email:
+
{user.email}
+
+
+
DID:
+
+ {user.did ? `${user.did.slice(0, 8)}...` : 'Not set'} +
+
+
+
+ +
+

+ + Authentication Tokens +

+
+
+

Access Token:

+

+ {tokens?.accessToken.slice(0, 20)}...{tokens?.accessToken.slice(-20)} +

+
+
+

Refresh Token:

+

+ {tokens?.refreshToken.slice(0, 20)}...{tokens?.refreshToken.slice(-20)} +

+
+
+
+ + +
+ ) : ( +
+ +
+ )} +
+ +
+
+

How it works:

+
    +
  1. 1. Click "Connect Wallet" to connect your Ethereum wallet
  2. +
  3. 2. Request a cryptographic nonce from the server
  4. +
  5. 3. Sign the authentication message with your wallet
  6. +
  7. 4. Server verifies the signature and issues JWT tokens
  8. +
  9. 5. You're now authenticated and can access protected resources
  10. +
+
+
+
+
+ ); +} diff --git a/frontend/src/services/web3.service.ts b/frontend/src/services/web3.service.ts new file mode 100644 index 00000000..a12b0b97 --- /dev/null +++ b/frontend/src/services/web3.service.ts @@ -0,0 +1,207 @@ +import { ethers } from 'ethers'; +import detectEthereumProvider from '@metamask/detect-provider'; + +export interface Web3AuthResponse { + user: { + id: string; + email: string; + name: string; + did?: string | null; + }; + accessToken: string; + refreshToken: string; +} + +export interface NonceResponse { + nonce: string; + expiresAt: string; +} + +export class Web3AuthService { + private provider: ethers.BrowserProvider | null = null; + private signer: ethers.JsonRpcSigner | null = null; + + /** + * Initialize Web3 provider and signer + */ + async initialize(): Promise { + try { + const ethereumProvider = await detectEthereumProvider(); + + if (!ethereumProvider) { + throw new Error('MetaMask is not installed'); + } + + this.provider = new ethers.BrowserProvider(ethereumProvider as any); + + // Request account access + await this.provider.send('eth_requestAccounts', []); + + this.signer = await this.provider.getSigner(); + return true; + } catch (error) { + console.error('Failed to initialize Web3:', error); + return false; + } + } + + /** + * Get the current wallet address + */ + async getWalletAddress(): Promise { + try { + if (!this.signer) { + await this.initialize(); + } + + if (!this.signer) { + return null; + } + + return await this.signer.getAddress(); + } catch (error) { + console.error('Failed to get wallet address:', error); + return null; + } + } + + /** + * Request a nonce from the backend + */ + async requestNonce(walletAddress: string): Promise { + const response = await fetch( + `/api/auth/nonce?walletAddress=${encodeURIComponent(walletAddress)}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to get nonce'); + } + + return response.json(); + } + + /** + * Sign a message with the current wallet + */ + async signMessage(message: string): Promise { + try { + if (!this.signer) { + throw new Error('Wallet not connected'); + } + + return await this.signer.signMessage(message); + } catch (error) { + console.error('Failed to sign message:', error); + throw new Error('Failed to sign message'); + } + } + + /** + * Authenticate with Web3 wallet + */ + async authenticate(): Promise { + try { + // Initialize and get wallet address + const isInitialized = await this.initialize(); + if (!isInitialized) { + throw new Error('Failed to initialize wallet'); + } + + const walletAddress = await this.getWalletAddress(); + if (!walletAddress) { + throw new Error('No wallet address found'); + } + + // Request nonce + const { nonce } = await this.requestNonce(walletAddress); + + // Construct message to sign + const message = `Sign this message to authenticate with Web3 Student Lab. Nonce: ${nonce}`; + + // Sign the message + const signature = await this.signMessage(message); + + // Verify signature with backend + const response = await fetch('/api/auth/verify', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + walletAddress, + signature, + nonce, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Authentication failed'); + } + + const authResponse = await response.json(); + + // Store tokens in localStorage + localStorage.setItem('accessToken', authResponse.accessToken); + localStorage.setItem('refreshToken', authResponse.refreshToken); + localStorage.setItem('user', JSON.stringify(authResponse.user)); + + return authResponse; + } catch (error) { + console.error('Web3 authentication failed:', error); + throw error; + } + } + + /** + * Disconnect wallet + */ + disconnect(): void { + this.provider = null; + this.signer = null; + + // Clear stored tokens + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + } + + /** + * Check if wallet is connected + */ + isConnected(): boolean { + return this.provider !== null && this.signer !== null; + } + + /** + * Get stored user data + */ + getStoredUser(): any { + const userStr = localStorage.getItem('user'); + return userStr ? JSON.parse(userStr) : null; + } + + /** + * Get stored access token + */ + getStoredAccessToken(): string | null { + return localStorage.getItem('accessToken'); + } + + /** + * Get stored refresh token + */ + getStoredRefreshToken(): string | null { + return localStorage.getItem('refreshToken'); + } +} + +// Export singleton instance +export const web3AuthService = new Web3AuthService();