diff --git a/.githooks/pre-commit b/.githooks/pre-commit deleted file mode 100755 index b06bebc4..00000000 --- a/.githooks/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh - -# lint & format visualizer -cd frontend && pnpm exec lint-staged --allow-empty diff --git a/api/.env.example b/api/.env.example index 1a398cbf..9a68457c 100644 --- a/api/.env.example +++ b/api/.env.example @@ -8,3 +8,16 @@ FRONTEND_SECRET=FJIOERJGOERHGOIREHGIREHGROIEHGEORIGHOIOI3HRIO3232 API_SECRET_ENCRYPTION_KEY=c5o43icm5i43jio5j34p5 RABBITMQ_URL=amqp://guest:guest@localhost:5672 K8S_SERVICE_URL=http://localhost:9000 + + +JWT_SECRET=gerg43g3g34g +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_CALLBACK_URL=http://localhost:4000/auth/github/callback +OAUTH_SUCCESS_REDIRECT_URL=http://localhost:3000/auth/sso +42_OAUTH_SUCCESS_REDIRECT_URL=http://localhost:3000/profile +CORS_ORIGIN=http://localhost:3000 + +FORTYTWO_CALLBACK_URL=http://localhost:4000/auth/42/callback +FORTYTWO_CLIENT_ID= +FORTYTWO_CLIENT_SECRET= \ No newline at end of file diff --git a/api/README.md b/api/README.md index 5eb14883..bba65ef8 100644 --- a/api/README.md +++ b/api/README.md @@ -106,3 +106,21 @@ This service runs as both: * **Microservice** - RabbitMQ message consumer for: * `game_results` - Match result processing * `github-service-results` - GitHub operation results + +## Environment Variables + +### GitHub OAuth (Required) + +1. Go to GitHub → Settings → Developer settings → OAuth Apps +2. Register a new OAuth app +3. Set Authorization callback URL: `http://localhost:4000/auth/github/callback` +4. Add Client ID and Secret to `.env.local` + +### 42 School OAuth (Optional) + +For testing account linking functionality: + +1. Go to 42 School → Settings → API → Applications +2. Create a new application +3. Set Redirect URI: `http://localhost:4000/auth/42/callback` +4. Add Client ID and Client Secret to `.env.local` diff --git a/api/package.json b/api/package.json index 7b9db33a..454f3dfa 100644 --- a/api/package.json +++ b/api/package.json @@ -31,6 +31,7 @@ "@nestjs/common": "^11.1.7", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.7", + "@nestjs/jwt": "^11.0.0", "@nestjs/microservices": "^11.1.7", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.7", @@ -45,6 +46,8 @@ "crypto-js": "^4.2.0", "passport": "^0.7.0", "passport-github2": "^0.1.12", + "passport-jwt": "^4.0.1", + "passport-oauth2": "^1.8.0", "pg": "^8.16.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", diff --git a/api/pnpm-lock.yaml b/api/pnpm-lock.yaml index 20aac125..94d0bfc1 100644 --- a/api/pnpm-lock.yaml +++ b/api/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@nestjs/core': specifier: ^11.1.7 version: 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.7)(@nestjs/platform-express@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': + specifier: ^11.0.0 + version: 11.0.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@nestjs/microservices': specifier: ^11.1.7 version: 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -62,6 +65,12 @@ importers: passport-github2: specifier: ^0.1.12 version: 0.1.12 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 + passport-oauth2: + specifier: ^1.8.0 + version: 1.8.0 pg: specifier: ^8.16.3 version: 8.16.3 @@ -853,6 +862,11 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/jwt@11.0.1': + resolution: {integrity: sha512-HXSsc7SAnCnjA98TsZqrE7trGtHDnYXWp4Ffy6LwSmck1QvbGYdMzBquXofX5l6tIRpeY4Qidl2Ti2CVG77Pdw==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/mapped-types@2.1.0': resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==} peerDependencies: @@ -1183,6 +1197,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/luxon@3.7.1': resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} @@ -1192,6 +1209,9 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@24.9.1': resolution: {integrity: sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==} @@ -1710,6 +1730,9 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2013,6 +2036,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + edmonds-blossom-fixed@1.0.1: resolution: {integrity: sha512-wtpraSt4yJeUpNU8RGC4q2JBxsJbHFxI7/htm/mS4FgxSN90WBwmgE7QZwpcY70KJRqmfLCNxU49UIc+DfzLKQ==} @@ -2722,6 +2748,16 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2759,12 +2795,33 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -3013,6 +3070,9 @@ packages: resolution: {integrity: sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw==} engines: {node: '>= 0.8.0'} + passport-jwt@4.0.1: + resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} + passport-oauth2@1.8.0: resolution: {integrity: sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==} engines: {node: '>= 0.4.0'} @@ -4707,6 +4767,12 @@ snapshots: '@nestjs/microservices': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7) + '@nestjs/jwt@11.0.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.2 + '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)': dependencies: '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -5022,12 +5088,19 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.9.1 + '@types/luxon@3.7.1': {} '@types/methods@1.1.4': {} '@types/mime@1.3.5': {} + '@types/ms@2.1.0': {} + '@types/node@24.9.1': dependencies: undici-types: 7.16.0 @@ -5650,6 +5723,8 @@ snapshots: buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer-more-ints@1.0.0: {} @@ -5905,6 +5980,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + edmonds-blossom-fixed@1.0.1: {} ee-first@1.1.1: {} @@ -6833,6 +6912,30 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -6862,10 +6965,24 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash@4.17.21: {} log-symbols@4.1.0: @@ -7078,6 +7195,11 @@ snapshots: dependencies: passport-oauth2: 1.8.0 + passport-jwt@4.0.1: + dependencies: + jsonwebtoken: 9.0.2 + passport-strategy: 1.0.0 + passport-oauth2@1.8.0: dependencies: base64url: 3.0.1 diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index 438edde2..5cb51701 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -1,4 +1,126 @@ -import { Controller } from "@nestjs/common"; +import { + BadRequestException, + Body, + Controller, + Get, + Post, + Query, + Req, + Res, + UseGuards, +} from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; +import { Request, Response } from "express"; +import { AuthService } from "./auth.service"; +import { JwtAuthGuard } from "./jwt-auth.guard"; +import { ConfigService } from "@nestjs/config"; +import { UserService } from "../user/user.service"; +import { UserId } from "../guards/UserGuard"; +import * as CryptoJS from "crypto-js"; +import { SocialAccountService } from "../user/social-account.service"; +import { SocialPlatform } from "../user/entities/social-account.entity"; @Controller("auth") -export class AuthController {} +export class AuthController { + constructor( + private auth: AuthService, + private configService: ConfigService, + private userService: UserService, + private socialAccountService: SocialAccountService, + ) {} + + @Get("/github/callback") + @UseGuards(AuthGuard("github")) + githubCallback(@Req() req: Request, @Res() res: Response) { + const user: any = (req as any).user; + const token = this.auth.signToken(user); + const redirectUrl = this.configService.getOrThrow( + "OAUTH_SUCCESS_REDIRECT_URL", + ); + if (redirectUrl) { + res.cookie("token", token, { + httpOnly: true, + secure: true, + sameSite: "none", + maxAge: 30 * 24 * 60 * 60 * 1000, + }); + return res.redirect(redirectUrl); + } + return res.json({ token }); + } + + @Get("/42/getUrl") + @UseGuards(JwtAuthGuard) + getFortyTwoAuthUrl(@UserId() userId: string) { + const encryptedUserId = CryptoJS.AES.encrypt( + userId, + this.configService.getOrThrow("API_SECRET_ENCRYPTION_KEY"), + ).toString(); + + const base64EncodedEncryptedUserId = + Buffer.from(encryptedUserId).toString("base64"); + + return `https://api.intra.42.fr/oauth/authorize?client_id=${this.configService.getOrThrow("FORTYTWO_CLIENT_ID")}&redirect_uri=${encodeURIComponent(this.configService.getOrThrow("FORTYTWO_CALLBACK_URL"))}&response_type=code&state=${base64EncodedEncryptedUserId}`; + } + + @Get("/42/callback") + @UseGuards(AuthGuard("42")) + async fortyTwoCallback( + @Req() + request: Request & { + user: { + fortyTwoAccount: { + platformUserId: string; + username: string; + email: string; + }; + }; + }, + @Res() res: Response, + @Query("state") encryptedUserId: string, + ) { + try { + const base64DecodedEncryptedUserId = Buffer.from( + encryptedUserId, + "base64", + ).toString("utf-8"); + + const userId = CryptoJS.AES.decrypt( + base64DecodedEncryptedUserId, + this.configService.getOrThrow("API_SECRET_ENCRYPTION_KEY"), + ).toString(CryptoJS.enc.Utf8); + if (!userId) throw new BadRequestException("Invalid state parameter."); + + await this.socialAccountService.upsertSocialAccountForUser({ + userId, + platform: SocialPlatform.FORTYTWO, + platformUserId: request.user.fortyTwoAccount.platformUserId, + username: request.user.fortyTwoAccount.username, + }); + + const redirectUrl = this.configService.getOrThrow( + "42_OAUTH_SUCCESS_REDIRECT_URL", + ); + + return res.redirect(redirectUrl); + } catch (e) { + // Use a more detailed log, and preserve specific error messages for BadRequestException + console.error("Error in FortyTwo callback:", e); + if (e instanceof BadRequestException) { + throw e; + } + throw new BadRequestException( + e && typeof e.message === "string" + ? `Invalid state parameter: ${e.message}` + : "Invalid state parameter." + ); + } + } + + @Get("/me") + @UseGuards(JwtAuthGuard) + me(@Req() req: Request) { + const user: any = (req as any).user; + return this.userService.getUserById(user.id); + } +} diff --git a/api/src/auth/auth.module.ts b/api/src/auth/auth.module.ts index a7eac273..9dc7effc 100644 --- a/api/src/auth/auth.module.ts +++ b/api/src/auth/auth.module.ts @@ -1,9 +1,37 @@ import { Module } from "@nestjs/common"; +import { PassportModule } from "@nestjs/passport"; +import { JwtModule } from "@nestjs/jwt"; +import { ConfigModule, ConfigService } from "@nestjs/config"; import { AuthService } from "./auth.service"; import { AuthController } from "./auth.controller"; +import { JwtStrategy } from "./jwt.strategy"; +import { GithubOAuthStrategy } from "./github.strategy"; +import { FortyTwoOAuthStrategy } from "./fortytwo.strategy"; +import { UserModule } from "../user/user.module"; @Module({ - providers: [AuthService], + imports: [ + ConfigModule, + UserModule, + PassportModule.register({ session: false }), + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.getOrThrow("JWT_SECRET"), + signOptions: { + expiresIn: "30d", + }, + }), + }), + ], + providers: [ + AuthService, + JwtStrategy, + GithubOAuthStrategy, + FortyTwoOAuthStrategy, + ], controllers: [AuthController], + exports: [PassportModule, JwtModule], }) export class AuthModule {} diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts index 58b57505..63097206 100644 --- a/api/src/auth/auth.service.ts +++ b/api/src/auth/auth.service.ts @@ -1,4 +1,17 @@ import { Injectable } from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import { UserEntity } from "../user/entities/user.entity"; @Injectable() -export class AuthService {} +export class AuthService { + constructor(private jwt: JwtService) {} + + signToken(user: UserEntity) { + const payload = { + sub: user.id, + email: user.email, + username: user.username, + }; + return this.jwt.sign(payload); + } +} diff --git a/api/src/auth/fortytwo.strategy.ts b/api/src/auth/fortytwo.strategy.ts new file mode 100644 index 00000000..76bbc58d --- /dev/null +++ b/api/src/auth/fortytwo.strategy.ts @@ -0,0 +1,64 @@ +import { Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { ConfigService } from "@nestjs/config"; +import { UserService } from "../user/user.service"; +import { SocialAccountService } from "../user/social-account.service"; +import { Strategy } from "passport-oauth2"; + +interface FortyTwoProfile { + id: number; + login: string; + email: string | null; + image_url: string | null; + displayname: string | null; +} + +@Injectable() +export class FortyTwoOAuthStrategy extends PassportStrategy(Strategy, "42") { + constructor( + config: ConfigService, + private readonly users: UserService, + private readonly socialAccounts: SocialAccountService, + ) { + super({ + authorizationURL: "https://api.intra.42.fr/oauth/authorize", + tokenURL: "https://api.intra.42.fr/oauth/token", + clientID: config.getOrThrow("FORTYTWO_CLIENT_ID"), + clientSecret: config.getOrThrow("FORTYTWO_CLIENT_SECRET"), + callbackURL: config.getOrThrow("FORTYTWO_CALLBACK_URL"), + }); + } + + async validate( + accessToken: string, + refreshToken: string, + _params: any, + done: (err: any, user?: any) => void, + ) { + try { + const res = await fetch("https://api.intra.42.fr/v2/me", { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!res.ok) { + console.error("42 API error:", res.status, await res.text()); + return done(new Error(`42 API error: ${res.status}`)); + } + const data = (await res.json()) as FortyTwoProfile; + + const platformUserId = String(data.id); + const username = data.login; + const email = data.email ?? undefined; + + done(null, { + fortyTwoAccount: { + platformUserId, + username, + email, + }, + }); + } catch (err) { + done(err, undefined); + } + } +} diff --git a/api/src/auth/github.strategy.ts b/api/src/auth/github.strategy.ts new file mode 100644 index 00000000..30531c3c --- /dev/null +++ b/api/src/auth/github.strategy.ts @@ -0,0 +1,68 @@ +import { Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { Strategy as GitHubStrategy, Profile } from "passport-github2"; +import { ConfigService } from "@nestjs/config"; +import { UserService } from "../user/user.service"; + +@Injectable() +export class GithubOAuthStrategy extends PassportStrategy( + GitHubStrategy, + "github", +) { + constructor( + config: ConfigService, + private users: UserService, + ) { + super({ + clientID: config.getOrThrow("GITHUB_CLIENT_ID"), + clientSecret: config.getOrThrow("GITHUB_CLIENT_SECRET"), + callbackURL: config.getOrThrow("GITHUB_CALLBACK_URL"), + scope: ["user:email"], + }); + } + + async validate( + accessToken: string, + refreshToken: string, + profile: Profile, + done: (err: any, user?: any) => void, + ) { + try { + const githubId = profile.id; + const email = profile.emails && profile.emails[0]?.value; + const username = + profile.username || profile.displayName || email || githubId; + const name = profile.displayName || username; + const profilePicture = profile.photos && profile.photos[0]?.value; + + let user = await this.users.getUserByGithubId(githubId); + + if (!user) { + user = await this.users.createUser( + email || `${githubId}@users.noreply.github.com`, + username, + name, + profilePicture, + githubId, + accessToken, + false, + ); + } else { + await this.users.updateUser( + user.id, + email || user.email, + username || user.username, + name || user.name, + profilePicture || user.profilePicture, + githubId, + accessToken, + ); + user = await this.users.getUserById(user.id); + } + + done(null, user); + } catch (err) { + done(err, undefined); + } + } +} diff --git a/api/src/auth/jwt-auth.guard.ts b/api/src/auth/jwt-auth.guard.ts new file mode 100644 index 00000000..2e81dba6 --- /dev/null +++ b/api/src/auth/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; + +@Injectable() +export class JwtAuthGuard extends AuthGuard("jwt") {} diff --git a/api/src/auth/jwt.strategy.ts b/api/src/auth/jwt.strategy.ts new file mode 100644 index 00000000..250dd2ba --- /dev/null +++ b/api/src/auth/jwt.strategy.ts @@ -0,0 +1,34 @@ +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { ExtractJwt, Strategy } from "passport-jwt"; +import { ConfigService } from "@nestjs/config"; +import { UserService } from "../user/user.service"; + +export interface JwtPayload { + sub: string; // user id + email?: string; + username?: string; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor( + private config: ConfigService, + private users: UserService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: config.getOrThrow("JWT_SECRET"), + }); + } + + async validate(payload: JwtPayload) { + const userId = payload.sub; + if (!userId) throw new UnauthorizedException(); + // Load current user; if missing, reject token + const user = await this.users.getUserById(userId).catch(() => null); + if (!user) throw new UnauthorizedException(); + return user; // attaches to request.user + } +} diff --git a/api/src/event/event.controller.ts b/api/src/event/event.controller.ts index 8f56ffe7..3ae01cfd 100644 --- a/api/src/event/event.controller.ts +++ b/api/src/event/event.controller.ts @@ -15,7 +15,8 @@ import { TeamService } from "../team/team.service"; import { UserService } from "../user/user.service"; import { CreateEventDto } from "./dtos/createEventDto"; import { SetLockTeamsDateDto } from "./dtos/setLockTeamsDateDto"; -import { UserGuard, UserId } from "../guards/UserGuard"; +import { UserId } from "../guards/UserGuard"; +import { JwtAuthGuard } from "../auth/jwt-auth.guard"; @Controller("event") export class EventController { @@ -25,7 +26,7 @@ export class EventController { private readonly userService: UserService, ) {} - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Get("my") async getMyEvents(@UserId() userId: string) { return this.eventService.getEventsForUser(userId); @@ -51,7 +52,7 @@ export class EventController { return await this.eventService.getCurrentLiveEvent(); } - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Post() createEvent( @UserId() userId: string, @@ -94,7 +95,7 @@ export class EventController { return this.userService.getUserCountOfEvent(eventId); } - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Get(":id/isUserRegistered") getEventByUserId( @Param("id", new ParseUUIDPipe()) eventId: string, @@ -103,7 +104,7 @@ export class EventController { return this.eventService.isUserRegisteredForEvent(eventId, userId); } - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Get(":id/isEventAdmin") isEventAdmin( @Param("id", new ParseUUIDPipe()) eventId: string, @@ -112,7 +113,7 @@ export class EventController { return this.eventService.isEventAdmin(eventId, userId); } - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Put(":id/join") async joinEvent( @Param("id", new ParseUUIDPipe()) eventId: string, @@ -136,7 +137,7 @@ export class EventController { return this.userService.joinEvent(userId, eventId); } - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Put(":id/lock") async lockEvent( @Param("id", new ParseUUIDPipe()) eventId: string, @@ -150,7 +151,7 @@ export class EventController { return this.eventService.lockEvent(eventId); } - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Put(":id/lockTeamsDate") async lockTeamsDate( @Param("id", new ParseUUIDPipe()) eventId: string, diff --git a/api/src/guards/GuardConstants.ts b/api/src/guards/GuardConstants.ts index bf52bf6c..72e55cc9 100644 --- a/api/src/guards/GuardConstants.ts +++ b/api/src/guards/GuardConstants.ts @@ -1,3 +1,2 @@ -export const USER_ID_KEY = "userid"; export const EVENT_ID_PARAM = "eventId"; export const TEAM_ID_PARAM = "teamId"; diff --git a/api/src/guards/TeamGuard.ts b/api/src/guards/TeamGuard.ts index 01e40656..1db0d5d5 100644 --- a/api/src/guards/TeamGuard.ts +++ b/api/src/guards/TeamGuard.ts @@ -9,7 +9,7 @@ import { ParseUUIDPipe, } from "@nestjs/common"; import { TeamService } from "../team/team.service"; -import { EVENT_ID_PARAM, TEAM_ID_PARAM, USER_ID_KEY } from "./GuardConstants"; +import { EVENT_ID_PARAM, TEAM_ID_PARAM } from "./GuardConstants"; import { TeamEntity } from "../team/entities/team.entity"; export const TeamId = Param(TEAM_ID_PARAM, new ParseUUIDPipe()); @@ -40,7 +40,7 @@ export class MyTeamGuards implements CanActivate { async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const eventId: string | undefined = request.params[EVENT_ID_PARAM]; - const userId: string | undefined = request.headers[USER_ID_KEY]; + const userId: string | undefined = request.user?.id; if (!eventId || !userId) return false; diff --git a/api/src/guards/UserGuard.ts b/api/src/guards/UserGuard.ts index 04542ad2..00ea4d20 100644 --- a/api/src/guards/UserGuard.ts +++ b/api/src/guards/UserGuard.ts @@ -1,42 +1,8 @@ -import { - CanActivate, - createParamDecorator, - ExecutionContext, - Injectable, - UnauthorizedException, -} from "@nestjs/common"; -import { Observable } from "rxjs"; -import { ConfigService } from "@nestjs/config"; -import { USER_ID_KEY } from "./GuardConstants"; +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; export const UserId = createParamDecorator( (data: unknown, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); - return request.headers[USER_ID_KEY]; + return request.user?.id; }, ); - -@Injectable() -export class UserGuard implements CanActivate { - FRONTEND_SECRET: string; - - constructor(config: ConfigService) { - this.FRONTEND_SECRET = config.getOrThrow("FRONTEND_SECRET"); - } - - canActivate( - context: ExecutionContext, - ): boolean | Promise | Observable { - const request = context.switchToHttp().getRequest(); - - const authorization = request.headers.authorization; - if (authorization !== this.FRONTEND_SECRET) - throw new UnauthorizedException(); - - const userId = request.headers[USER_ID_KEY]; - if (!userId) throw new UnauthorizedException("User ID is required"); - request.userId = userId; - - return true; - } -} diff --git a/api/src/main.ts b/api/src/main.ts index a7bd8d36..99ec37e3 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -26,12 +26,17 @@ export const getRabbitmqConfig: any = ( async function bootstrap() { const app = await NestFactory.create(AppModule); + const configService = app.get(ConfigService); app.useGlobalPipes(new ValidationPipe()); app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); app.useGlobalFilters(new TypeormExceptionFilter()); - app.enableCors(); - - const configService = app.get(ConfigService); + app.enableCors({ + origin: configService.getOrThrow("CORS_ORIGIN"), + methods: "GET,HEAD,PUT,PATCH,POST,DELETE", + credentials: true, + allowedHeaders: + "Content-Type, Accept, Authorization, X-Requested-With, X-HTTP-Method-Override, X-Auth-Token, X-Refresh-Token", + }); app.connectMicroservice( getRabbitmqConfig(configService, "game_results"), diff --git a/api/src/match/match.controller.ts b/api/src/match/match.controller.ts index 98ac8971..e4b11be5 100644 --- a/api/src/match/match.controller.ts +++ b/api/src/match/match.controller.ts @@ -13,8 +13,9 @@ import { import { MatchService } from "./match.service"; import { EventService } from "../event/event.service"; import { EventState } from "../event/entities/event.entity"; -import { UserGuard, UserId } from "../guards/UserGuard"; +import { UserId } from "../guards/UserGuard"; import { MatchEntity } from "./entites/match.entity"; +import { JwtAuthGuard } from "../auth/jwt-auth.guard"; @Controller("match") export class MatchController { @@ -25,7 +26,7 @@ export class MatchController { private logger = new Logger("MatchController"); - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Get("swiss/:eventId") getSwissMatches( @Param("eventId", ParseUUIDPipe) eventId: string, @@ -39,7 +40,7 @@ export class MatchController { ); } - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Put("swiss/:eventId") async startSwissMatches( @Param("eventId", ParseUUIDPipe) eventId: string, @@ -57,7 +58,7 @@ export class MatchController { return await this.matchService.createNextSwissMatches(eventId); } - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Put("tournament/:eventId") async startTournamentMatches( @Param("eventId", ParseUUIDPipe) eventId: string, @@ -75,7 +76,7 @@ export class MatchController { return this.matchService.getTournamentTeamCount(eventId); } - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Get("tournament/:eventId") getTournamentMatches( @Param("eventId", ParseUUIDPipe) eventId: string, @@ -89,7 +90,7 @@ export class MatchController { ); } - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Get("queue/:eventId") async getQueueMatches( @Param("eventId", ParseUUIDPipe) eventId: string, @@ -98,7 +99,7 @@ export class MatchController { return await this.matchService.getQueueMatches(eventId, userId); } - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Get("queue/:eventId/admin") async getAllQueueMatches( @Param("eventId", ParseUUIDPipe) eventId: string, @@ -111,7 +112,7 @@ export class MatchController { return await this.matchService.getAllQueueMatches(eventId); } - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Get("logs/:matchId") async getMatchLogs( @Param("matchId", ParseUUIDPipe) matchId: string, @@ -125,7 +126,7 @@ export class MatchController { return logs; } - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Put("reveal/:matchId") async revealMatch( @Param("matchId", ParseUUIDPipe) matchId: string, @@ -155,7 +156,7 @@ export class MatchController { return await this.matchService.getMatchById(matchId); } - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Get("queue/:eventId/timeseries") async getQueueMatchesTimeSeries( @Param("eventId") eventId: string, diff --git a/api/src/team/team.controller.ts b/api/src/team/team.controller.ts index 430945ed..b91ad9c3 100644 --- a/api/src/team/team.controller.ts +++ b/api/src/team/team.controller.ts @@ -17,7 +17,6 @@ import { CreateTeamDto } from "./dtos/createTeamDto"; import { InviteUserDto } from "./dtos/inviteUserDto"; import { UserService } from "../user/user.service"; import { EventService } from "../event/event.service"; -import { UserGuard } from "../guards/UserGuard"; import { PermissionRole } from "../user/entities/user.entity"; import { EventId, @@ -28,6 +27,7 @@ import { } from "../guards/TeamGuard"; import { EVENT_ID_PARAM, TEAM_ID_PARAM } from "../guards/GuardConstants"; import { TeamEntity } from "./entities/team.entity"; +import { JwtAuthGuard } from "../auth/jwt-auth.guard"; @Controller("team") export class TeamController { @@ -57,13 +57,13 @@ export class TeamController { ); } - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Get(`event/:${EVENT_ID_PARAM}/my`) getMyTeamForEvent(@EventId eventId: string, @UserId("id") userId: string) { return this.teamService.getTeamOfUserForEvent(eventId, userId); } - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Post(`event/:${EVENT_ID_PARAM}/create`) async createTeam( @UserId() userId: string, @@ -87,7 +87,7 @@ export class TeamController { return this.teamService.createTeam(createTeamDto.name, userId, eventId); } - @UseGuards(UserGuard, MyTeamGuards, TeamNotLockedGuard) + @UseGuards(JwtAuthGuard, MyTeamGuards, TeamNotLockedGuard) @Put(`event/:${EVENT_ID_PARAM}/leave`) async leaveTeam(@UserId() userId: string, @Team() team: TeamEntity) { return this.teamService.leaveTeam(team.id, userId); @@ -123,7 +123,7 @@ export class TeamController { }); } - @UseGuards(UserGuard, MyTeamGuards, TeamNotLockedGuard) + @UseGuards(JwtAuthGuard, MyTeamGuards, TeamNotLockedGuard) @Post(`event/:${EVENT_ID_PARAM}/sendInvite`) async sendInviteToTeam( @EventId eventId: string, @@ -156,7 +156,7 @@ export class TeamController { ); } - @UseGuards(UserGuard, MyTeamGuards, TeamNotLockedGuard) + @UseGuards(JwtAuthGuard, MyTeamGuards, TeamNotLockedGuard) @Get(`event/:${EVENT_ID_PARAM}/searchInviteUsers/:searchQuery`) async searchUsersForInvite( @EventId eventId: string, @@ -166,7 +166,7 @@ export class TeamController { return this.userService.searchUsersForInvite(eventId, searchQuery, team.id); } - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Get(`event/:${EVENT_ID_PARAM}/pending`) async getUserPendingInvites( @UserId() userId: string, @@ -175,7 +175,7 @@ export class TeamController { return this.teamService.getTeamsUserIsInvitedTo(userId, eventId); } - @UseGuards(UserGuard, TeamNotLockedGuard) + @UseGuards(JwtAuthGuard, TeamNotLockedGuard) @Put(`event/:${EVENT_ID_PARAM}/acceptInvite/:${TEAM_ID_PARAM}`) async acceptTeamInvite( @UserId() userId: string, @@ -193,7 +193,7 @@ export class TeamController { } // TODO: eventId is not used here, should be removed - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Delete(`event/:${EVENT_ID_PARAM}/declineInvite/:${TEAM_ID_PARAM}`) async declineTeamInvite(@UserId() userId: string, @TeamId teamId: string) { if (!(await this.teamService.isUserInvitedToTeam(userId, teamId))) @@ -202,7 +202,7 @@ export class TeamController { return this.teamService.declineTeamInvite(userId, teamId); } - @UseGuards(UserGuard, MyTeamGuards, TeamNotLockedGuard) + @UseGuards(JwtAuthGuard, MyTeamGuards, TeamNotLockedGuard) @Put(`event/:${EVENT_ID_PARAM}/queue/join`) async joinQueue(@Team() team: TeamEntity) { if (team.inQueue) @@ -211,7 +211,7 @@ export class TeamController { return this.teamService.joinQueue(team.id); } - @UseGuards(UserGuard, MyTeamGuards) + @UseGuards(JwtAuthGuard, MyTeamGuards) @Get(`event/:${EVENT_ID_PARAM}/queue/state`) async getQueueState(@Team() team: TeamEntity) { return this.teamService.getQueueState(team.id); diff --git a/api/src/types/passport-oauth2.d.ts b/api/src/types/passport-oauth2.d.ts new file mode 100644 index 00000000..a1a5892f --- /dev/null +++ b/api/src/types/passport-oauth2.d.ts @@ -0,0 +1,15 @@ +declare module "passport-oauth2" { + import { Strategy as PassportStrategy } from "passport"; + + export interface StrategyOptions { + authorizationURL: string; + tokenURL: string; + clientID: string; + clientSecret: string; + callbackURL: string; + } + + export class Strategy extends PassportStrategy { + constructor(options: StrategyOptions, verify?: any); + } +} diff --git a/api/src/user/social-account.controller.ts b/api/src/user/social-account.controller.ts index fa847319..95bdbb35 100644 --- a/api/src/user/social-account.controller.ts +++ b/api/src/user/social-account.controller.ts @@ -15,7 +15,8 @@ import { SocialAccountEntity, SocialPlatform, } from "./entities/social-account.entity"; -import { UserGuard, UserId } from "../guards/UserGuard"; +import { UserId } from "../guards/UserGuard"; +import { JwtAuthGuard } from "../auth/jwt-auth.guard"; class LinkSocialAccountDto { platform: SocialPlatform; @@ -23,34 +24,12 @@ class LinkSocialAccountDto { platformUserId: string; } -@UseGuards(UserGuard) +@UseGuards(JwtAuthGuard) @ApiTags("social-accounts") @Controller("social-accounts") export class SocialAccountController { constructor(private readonly socialAccountService: SocialAccountService) {} - @Post("link") - @ApiOperation({ summary: "Link a social account to the authenticated user" }) - @ApiResponse({ - status: 200, - description: "Social account linked successfully", - }) - @ApiResponse({ - status: 409, - description: "Social account already linked to another user", - }) - async linkSocialAccount( - @UserId() userId: string, - @Body() linkDto: LinkSocialAccountDto, - ): Promise { - return await this.socialAccountService.linkSocialAccount( - userId, - linkDto.platform, - linkDto.username, - linkDto.platformUserId, - ); - } - @Delete(":platform") @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ diff --git a/api/src/user/social-account.service.ts b/api/src/user/social-account.service.ts index 25b65fec..37313e62 100644 --- a/api/src/user/social-account.service.ts +++ b/api/src/user/social-account.service.ts @@ -1,6 +1,5 @@ import { Injectable, - ConflictException, NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; @@ -20,52 +19,6 @@ export class SocialAccountService { private userRepository: Repository, ) {} - async linkSocialAccount( - userId: string, - platform: SocialPlatform, - username: string, - platformUserId: string, - ): Promise { - // Check if user exists - const user = await this.userRepository.findOne({ where: { id: userId } }); - if (!user) { - throw new NotFoundException("User not found"); - } - - // Check if this platform account is already linked to another user - const existingAccount = await this.socialAccountRepository.findOne({ - where: { platform, platformUserId }, - }); - - if (existingAccount && existingAccount.userId !== userId) { - throw new ConflictException( - "This social account is already linked to another user", - ); - } - - // Check if user already has an account for this platform - const existingUserAccount = await this.socialAccountRepository.findOne({ - where: { userId, platform }, - }); - - if (existingUserAccount) { - // Update existing account - existingUserAccount.username = username; - existingUserAccount.platformUserId = platformUserId; - return await this.socialAccountRepository.save(existingUserAccount); - } - - // Create new social account link - const socialAccount = this.socialAccountRepository.create({ - userId, - platform, - username, - platformUserId, - }); - - return await this.socialAccountRepository.save(socialAccount); - } - async unlinkSocialAccount( userId: string, platform: SocialPlatform, @@ -95,4 +48,29 @@ export class SocialAccountService { where: { userId, platform }, }); } + + async upsertSocialAccountForUser(params: { + userId: string; + platform: SocialPlatform; + username: string; + platformUserId: string; + }): Promise { + const existing = await this.socialAccountRepository.findOne({ + where: { userId: params.userId, platform: params.platform }, + }); + + if (existing) { + existing.username = params.username; + existing.platformUserId = params.platformUserId; + return this.socialAccountRepository.save(existing); + } + + const entity = this.socialAccountRepository.create({ + userId: params.userId, + platform: params.platform, + username: params.username, + platformUserId: params.platformUserId, + }); + return this.socialAccountRepository.save(entity); + } } diff --git a/api/src/user/user.controller.ts b/api/src/user/user.controller.ts index 97b05c02..13ec89fd 100644 --- a/api/src/user/user.controller.ts +++ b/api/src/user/user.controller.ts @@ -1,50 +1,18 @@ import { - Body, Controller, Get, Param, - ParseUUIDPipe, - Post, - Put, UseGuards, } from "@nestjs/common"; import { UserService } from "./user.service"; -import { CreateUserDto } from "./dtos/user.dto"; -import { UserGuard, UserId } from "../guards/UserGuard"; +import { UserId } from "../guards/UserGuard"; +import { JwtAuthGuard } from "../auth/jwt-auth.guard"; @Controller("user") export class UserController { constructor(private readonly userService: UserService) {} - @Post() - async createUser(@Body() user: CreateUserDto) { - return this.userService.createUser( - user.email, - user.username, - user.name, - user.profilePicture, - user.githubId, - user.githubAccessToken, - ); - } - - @Put(":id") - async updateUser( - @Body() user: CreateUserDto, - @Param("id", new ParseUUIDPipe()) id: string, - ) { - return this.userService.updateUser( - id, - user.email, - user.username, - user.name, - user.profilePicture, - user.githubId, - user.githubAccessToken, - ); - } - - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Get("canCreateEvent") async canCreateEvent(@UserId() id: string) { return this.userService.canCreateEvent(id); diff --git a/frontend/.env.example b/frontend/.env.example index 156a54a4..0419ea47 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,17 +1,8 @@ # Authentication Settings -# GitHub Authentication (Required for website sign-ins) -# These are used for user login with NextAuth.js -CLIENT_ID_GITHUB=xxx -CLIENT_SECRET_GITHUB=xxx NEXTAUTH_SECRET=ci5u439cnu59843uc53894 NEXTAUTH_URL=http://localhost:3000 -# 42 Intra Authentication (Optional for testing 42 account linking) -# Only needed if you want to enable 42 school login -NEXT_PUBLIC_FORTY_TWO_CLIENT_ID=xx -FORTY_TWO_CLIENT_SECRET=xxx - # Backend Connection diff --git a/frontend/README.md b/frontend/README.md index 263a416f..d4d00ae5 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -44,21 +44,3 @@ brew install pnpm - **Build:** `pnpm build` (auto-updates wiki and changelog) - **Start:** `pnpm start` - -## Environment Variables - -### GitHub OAuth (Required) - -1. Go to GitHub → Settings → Developer settings → OAuth Apps -2. Register a new OAuth app -3. Set Authorization callback URL: `http://localhost:3000/api/auth/callback` -4. Add Client ID and Secret to `.env.local` - -### 42 School OAuth (Optional) - -For testing account linking functionality: - -1. Go to 42 School → Settings → API → Applications -2. Create a new application -3. Set Redirect URI: `http://localhost:3000/auth/callback/42` -4. Add Client ID and Client Secret to `.env.local` diff --git a/frontend/app/about/aboutPage.tsx b/frontend/app/about/aboutPage.tsx index c3a01b56..73fea7b5 100644 --- a/frontend/app/about/aboutPage.tsx +++ b/frontend/app/about/aboutPage.tsx @@ -34,9 +34,9 @@ const team: TeamMember[] = [ }, { name: "Emil Ebert", - role: "Website", + role: "Website System", imgSrc: "/team/eebert.png", - linkUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + linkUrl: "https://www.linkedin.com/in/emil-ebert/", linkType: "linkedin", }, { diff --git a/frontend/app/actions/axios.ts b/frontend/app/actions/axios.ts index a34cbfd7..d1582ed5 100644 --- a/frontend/app/actions/axios.ts +++ b/frontend/app/actions/axios.ts @@ -1,33 +1,46 @@ import type { AxiosResponse } from "axios"; import type { ServerActionResponse } from "@/app/actions/errors"; import axios from "axios"; -import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/app/utils/authOptions"; const axiosInstance = axios.create({ - baseURL: process.env.BACKEND_URL, + baseURL: + process.env.NEXT_PUBLIC_BACKEND_PUBLIC_URL || process.env.BACKEND_URL, headers: { "Content-Type": "application/json", - "Authorization": `${process.env.BACKEND_SECRET}`, }, }); +function getTokenFromCookieString(cookieString: string): string | null { + const parts = cookieString.split(";"); + for (const part of parts) { + const [k, ...rest] = part.trim().split("="); + if (k === "token") + return decodeURIComponent(rest.join("=")); + } + return null; +} + axiosInstance.interceptors.request.use( async (config) => { - if (!process.env.BACKEND_SECRET) { - config.baseURL = process.env.NEXT_PUBLIC_BACKEND_PUBLIC_URL; + if (process.env.BACKEND_URL) { + // eslint-disable-next-line ts/no-require-imports + const cookieData = await require("next/headers").cookies(); + const token = cookieData.get("token"); + if (token) + config.headers.Authorization = `Bearer ${token.value}`; + + config.baseURL = process.env.BACKEND_URL; return config; } - if (config.url && config.url.startsWith("user/email/")) { - return config; + config.baseURL = process.env.NEXT_PUBLIC_BACKEND_PUBLIC_URL; + const token = getTokenFromCookieString(document.cookie || ""); + if (token) { + config.headers.Authorization = `Bearer ${token}`; } - const session = await getServerSession(authOptions); - config.headers.userId = session?.user?.id || ""; return config; }, (error) => { - // Handle request errors return Promise.reject(error); }, ); diff --git a/frontend/app/actions/social-accounts.ts b/frontend/app/actions/social-accounts.ts index a5ae1411..f24d9270 100644 --- a/frontend/app/actions/social-accounts.ts +++ b/frontend/app/actions/social-accounts.ts @@ -60,3 +60,16 @@ export async function getSocialAccountByPlatform( ); } } + +export async function getFortyTwoAuthUrl(): Promise { + try { + const response = await axiosInstance.get("/auth/42/getUrl"); + return response.data; + } + catch (error: any) { + console.error("Error fetching 42 auth URL:", error); + throw new Error( + error.response?.data?.message || "Failed to fetch 42 auth URL", + ); + } +} diff --git a/frontend/app/auth/sso/page.tsx b/frontend/app/auth/sso/page.tsx new file mode 100644 index 00000000..e86b653f --- /dev/null +++ b/frontend/app/auth/sso/page.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { signIn } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function SsoPage() { + const router = useRouter(); + useEffect(() => { + let cancelled = false; + (async () => { + try { + await signIn("backend", { redirect: false }); + if (!cancelled) { + router.replace("/"); + router.refresh(); + } + } + catch (e) { + console.error("Failed to finalize SSO:", e); + if (!cancelled) + router.replace("/auth/error"); + } + })(); + return () => { + cancelled = true; + }; + }, [router]); + return ( +
+
+
+

Finishing sign-in...

+
+
+ ); +} diff --git a/frontend/app/events/[id]/dashboard/dashboard.tsx b/frontend/app/events/[id]/dashboard/dashboard.tsx index 5cb57c64..a547d119 100644 --- a/frontend/app/events/[id]/dashboard/dashboard.tsx +++ b/frontend/app/events/[id]/dashboard/dashboard.tsx @@ -221,9 +221,11 @@ export function DashboardPage({ eventId }: DashboardPageProps) { setStartingGroupPhase(true); startSwissMatches(eventId) .then(() => { + // eslint-disable-next-line no-alert alert("started group phase"); }) .catch(() => { + // eslint-disable-next-line no-alert alert("error occurred"); setStartingGroupPhase(false); }) @@ -243,9 +245,11 @@ export function DashboardPage({ eventId }: DashboardPageProps) { setStartingTournament(true); startTournamentMatches(eventId) .then(() => { + // eslint-disable-next-line no-alert alert("started tournament phase"); }) .catch(() => { + // eslint-disable-next-line no-alert alert("error occurred"); setStartingTournament(false); }) @@ -274,6 +278,7 @@ export function DashboardPage({ eventId }: DashboardPageProps) { eventId, new Date(teamAutoLockTime).getTime(), ).then(() => { + // eslint-disable-next-line no-alert alert("set team auto lock date"); })} > @@ -283,6 +288,7 @@ export function DashboardPage({ eventId }: DashboardPageProps) { variant="secondary" onClick={() => { setEventTeamsLockDate(eventId, null).then(() => { + // eslint-disable-next-line no-alert alert("reset team auto lock date"); setTeamAutoLockTime(""); }); diff --git a/frontend/app/events/page.tsx b/frontend/app/events/page.tsx index 298e7797..74203e5d 100644 --- a/frontend/app/events/page.tsx +++ b/frontend/app/events/page.tsx @@ -9,7 +9,6 @@ import { import EventsTabs from "@/app/events/EventsTabs"; import { authOptions } from "@/app/utils/authOptions"; import { title } from "@/components/primitives"; -import { Button } from "@/components/ui/button"; export const metadata: Metadata = { title: "Events", @@ -50,8 +49,8 @@ export default async function EventsPage() { Discover and join upcoming coding competitions

{canCreate && ( - - + + Create Event )} diff --git a/frontend/app/profile/ProfileClient.tsx b/frontend/app/profile/ProfileClient.tsx index bf375bb3..f914c561 100644 --- a/frontend/app/profile/ProfileClient.tsx +++ b/frontend/app/profile/ProfileClient.tsx @@ -47,7 +47,7 @@ function ProfileContent() { name={session!.user?.name} description={session!.user?.email} avatarProps={{ - src: session!.user?.image || "/placeholder-avatar.png", + src: session!.user?.profilePicture || "/placeholder-avatar.png", size: "lg", isBordered: true, }} diff --git a/frontend/app/utils/authOptions.ts b/frontend/app/utils/authOptions.ts index 2cd56aa3..f7339a7a 100644 --- a/frontend/app/utils/authOptions.ts +++ b/frontend/app/utils/authOptions.ts @@ -1,73 +1,76 @@ import type { NextAuthOptions } from "next-auth"; -import GithubProvider from "next-auth/providers/github"; +import CredentialsProvider from "next-auth/providers/credentials"; import axiosInstance from "@/app/actions/axios"; +const BACKEND_BASE_URL + = process.env.BACKEND_URL || process.env.NEXT_PUBLIC_BACKEND_PUBLIC_URL; + export const authOptions: NextAuthOptions = { + session: { + strategy: "jwt", + }, providers: [ - GithubProvider({ - clientId: process.env.CLIENT_ID_GITHUB!, - clientSecret: process.env.CLIENT_SECRET_GITHUB!, - authorization: { - params: { - scope: "read:user user:email repo:invite", - }, - }, - }), - ], - callbacks: { - async signIn({ user, account, profile }) { - if (account?.provider === "github") { - const githubProfile = profile as any; - + CredentialsProvider({ + id: "backend", + name: "Backend", + credentials: {}, + async authorize(_credentials, _) { try { - const existingUser = ( - await axiosInstance.get(`user/github/${account.providerAccountId}`) - ).data; - if (!existingUser) { - if (!account?.access_token) { - throw new Error("No access token found"); - } - - await axiosInstance.post(`user/`, { - email: user.email!, - username: githubProfile?.login || user.name!, - name: user.name! || githubProfile?.name, - profilePicture: user.image! || githubProfile?.avatar_url, - githubId: account.providerAccountId, - githubAccessToken: account.access_token, - }); - } - else { - await axiosInstance.put(`user/${existingUser.id}`, { - email: user.email!, - username: githubProfile?.login || existingUser.username, - name: githubProfile?.name || existingUser.name, - profilePicture: - githubProfile?.avatar_url || existingUser.profilePicture, - githubId: account.providerAccountId, - githubAccessToken: account.access_token, - }); + if (!BACKEND_BASE_URL) { + console.error("Missing BACKEND URL env"); + return null; } + + const res = await axiosInstance.get<{ + id: string; + username: string; + email: string; + profilePicture: string; + }>(`/auth/me`); + + return { + id: res.data.id, + name: res.data.username, + email: res.data.email, + profilePicture: res.data.profilePicture, + }; } - catch (e: any) { - console.error("Error during sign in:", e); - console.error("response:", e?.response); - return false; + catch (e) { + console.error("Authorize failed:", e); + return null; } - } - + }, + }), + ], + callbacks: { + async signIn() { return true; }, - async session({ session }) { - if (!session.user?.email) { - throw new Error("User email is not available in session"); + async jwt({ token, user }) { + if (user) { + token.sub = (user as any).id || token.sub; + token.name = user.name || token.name; + token.email = user.email || token.email; } - const dbUser = ( - await axiosInstance.get(`user/email/${session.user?.email}`) - ).data; + return token; + }, + async session({ session }) { + try { + const res = await axiosInstance.get<{ + id: string; + username: string; + email: string; + profilePicture: string; + }>(`/auth/me`); - if (dbUser) - session.user.id = dbUser.id; + session.user.id = res.data.id; + session.user.email = res.data.email; + session.user.name = res.data.username; + session.user.profilePicture = res.data.profilePicture; + } + catch { + session.user.id = ""; + } return session; }, diff --git a/frontend/components/github.tsx b/frontend/components/github.tsx index e9b9ee79..482dfb27 100644 --- a/frontend/components/github.tsx +++ b/frontend/components/github.tsx @@ -1,14 +1,15 @@ -import { signIn } from "next-auth/react"; import { Button } from "@/components/ui/button"; import { GithubIcon } from "./icons"; export default function GithubLoginButton() { async function githubLogin() { try { - await signIn("github"); + const base + = process.env.NEXT_PUBLIC_BACKEND_PUBLIC_URL; + window.location.href = `${base?.replace(/\/$/, "")}/auth/github/callback`; } catch (error) { - console.error("error while logging in:", error); + console.error("error while redirecting to login:", error); } } diff --git a/frontend/components/social-accounts-display.tsx b/frontend/components/social-accounts-display.tsx index 67d8398e..2f447f1e 100644 --- a/frontend/components/social-accounts-display.tsx +++ b/frontend/components/social-accounts-display.tsx @@ -71,6 +71,7 @@ export default function SocialAccountsDisplay() { }); if ( !session?.user?.id + // eslint-disable-next-line no-alert || !confirm("Are you sure you want to unlink this account?") ) { return; @@ -85,6 +86,7 @@ export default function SocialAccountsDisplay() { } catch (error) { console.error("Error unlinking account:", error); + // eslint-disable-next-line no-alert alert("Failed to unlink account. Please try again."); } finally { diff --git a/frontend/components/team/TeamInviteModal.tsx b/frontend/components/team/TeamInviteModal.tsx index eb3a3546..60c54a7f 100644 --- a/frontend/components/team/TeamInviteModal.tsx +++ b/frontend/components/team/TeamInviteModal.tsx @@ -3,7 +3,7 @@ import type { } from "@/app/actions/team"; import { usePlausible } from "next-plausible"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { searchUsersForInvite, sendTeamInvite, @@ -21,7 +21,6 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; -import { useEffect } from "react"; interface TeamInviteModalProps { isOpen: boolean; @@ -71,7 +70,7 @@ export function TeamInviteModal({ ); } catch (error: any) { - // You can customize this error message as needed + // eslint-disable-next-line no-alert alert( error?.response?.data?.message || error?.message diff --git a/frontend/hooks/use42Linking.ts b/frontend/hooks/use42Linking.ts index c64c1020..91b70704 100644 --- a/frontend/hooks/use42Linking.ts +++ b/frontend/hooks/use42Linking.ts @@ -1,10 +1,7 @@ import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; -import { - OAUTH_CONFIG, - OAUTH_PROVIDERS, - OAUTH_URLS, -} from "@/lib/constants/oauth"; +import { getFortyTwoAuthUrl } from "@/app/actions/social-accounts"; +import { OAUTH_CONFIG } from "@/lib/constants/oauth"; /** * Simplified hook for handling 42 School OAuth integration @@ -43,48 +40,32 @@ export function use42Linking(onSuccess?: () => void): Use42LinkingReturn { }, []); const initiate42OAuth = useCallback(() => { - // Clear any previous messages and show immediate loading feedback setMessage(null); setIsInitiating(true); processedRef.current = null; - // Check if environment variable is available - const clientId = process.env.NEXT_PUBLIC_FORTY_TWO_CLIENT_ID; - if (!clientId) { - console.error("NEXT_PUBLIC_FORTY_TWO_CLIENT_ID is not set"); - setMessage({ - type: "error", - text: "OAuth configuration is missing. Please contact support.", - }); - setIsInitiating(false); - return; - } - // Small delay to show the loading state before redirect - setTimeout(() => { - // Generate a cryptographically secure random state string - const array = new Uint8Array(OAUTH_CONFIG.STATE_LENGTH); - window.crypto.getRandomValues(array); - const state = Array.from(array, b => - b.toString(16).padStart(2, "0")).join(""); - const authUrl = new URL(OAUTH_URLS.FORTY_TWO_AUTHORIZE); - - authUrl.searchParams.set("client_id", clientId); - authUrl.searchParams.set( - "redirect_uri", - `${window.location.origin}/auth/callback/${OAUTH_PROVIDERS.FORTY_TWO}`, - ); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("scope", "public"); - authUrl.searchParams.set("state", state); + setTimeout(async () => { + try { + // Ask backend for the 42 auth URL (which includes the encrypted user id in state) + const authUrl = await getFortyTwoAuthUrl(); - // Store state in sessionStorage for verification - sessionStorage.setItem( - OAUTH_CONFIG.SESSION_STORAGE_KEYS.OAUTH_STATE, - state, - ); + if (!authUrl) { + throw new Error("No auth URL returned from backend"); + } - window.location.href = authUrl.toString(); + window.location.href = authUrl; + } + catch (err: any) { + console.error("Failed to initiate 42 OAuth via backend:", err); + setMessage({ + type: "error", + text: + err?.message + || "Failed to start 42 authentication. Please try again later.", + }); + setIsInitiating(false); + } }, OAUTH_CONFIG.LOADING_DELAY); }, []); diff --git a/frontend/layouts/basic-navbar.tsx b/frontend/layouts/basic-navbar.tsx index 32b94f35..7ba79289 100644 --- a/frontend/layouts/basic-navbar.tsx +++ b/frontend/layouts/basic-navbar.tsx @@ -190,7 +190,7 @@ export const Navbar = forwardRef(