diff --git a/.env.example b/.env.example index ee33098..2765b65 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,11 @@ FLY_ACCESS_TOKEN= DATABASE_URL=postgresql://postgres:password@127.0.0.1:5432/culero JWT_SECRET=secret +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_CALLBACK_URL=http://localhost:4200/api/auth/github/callback + + SMTP_HOST= SMTP_PORT= SMTP_EMAIL_ADDRESS= @@ -44,4 +49,6 @@ PROFILES_DIRECTORY= REDIS_URL=redis://localhost:6379 LINKEDIN_PROFILE_FETCHER_API_KEY= -LINKEDIN_PROFILE_FETCHER_HOST= \ No newline at end of file +LINKEDIN_PROFILE_FETCHER_HOST=\ + +SESSION_SECRET= \ No newline at end of file diff --git a/package.json b/package.json index eaa0dbb..2416c4c 100644 --- a/package.json +++ b/package.json @@ -51,11 +51,13 @@ "crypto-js": "^4.2.0", "expo-server-sdk": "^3.10.0", "express": "^4.19.2", + "express-session": "^1.18.0", "install": "^0.13.0", "ioredis": "^5.4.1", "jest-mock-extended": "^3.0.5", "nodemailer": "^6.9.12", "passport-facebook": "^3.0.0", + "passport-github2": "^0.1.12", "passport-google-oauth20": "^2.0.0", "passport-linkedin-oauth2": "^2.0.0", "prisma": "^5.10.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7785f5..f36fc51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: express: specifier: ^4.19.2 version: 4.19.2 + express-session: + specifier: ^1.18.0 + version: 1.18.0 install: specifier: ^0.13.0 version: 0.13.0 @@ -89,6 +92,9 @@ importers: passport-facebook: specifier: ^3.0.0 version: 3.0.0 + passport-github2: + specifier: ^0.1.12 + version: 0.1.12 passport-google-oauth20: specifier: ^2.0.0 version: 2.0.0 @@ -1772,6 +1778,9 @@ packages: cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cookie@0.5.0: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} @@ -2094,6 +2103,10 @@ packages: expo-server-sdk@3.10.0: resolution: {integrity: sha512-isymUVz18Syp9G+TPs2MVZ6WdMoyLw8hDLhpywOd8JqM6iGTka6Dr8Dzq7mjGQ8C8486rxLawZx/W+ps+vkjLQ==} + express-session@1.18.0: + resolution: {integrity: sha512-m93QLWr0ju+rOwApSsyso838LQwgfs44QtOP/WBiwtAgPIo/SAh1a5c6nn2BR6mFNZehTpqKDESzP+fRHVbxwQ==} + engines: {node: '>= 0.8.0'} + express@4.18.2: resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==} engines: {node: '>= 0.10.0'} @@ -3031,6 +3044,10 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + on-headers@1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -3092,6 +3109,10 @@ packages: resolution: {integrity: sha512-K/qNzuFsFISYAyC1Nma4qgY/12V3RSLFdFVsPKXiKZt434wOvthFW1p7zKa1iQihQMRhaWorVE1o3Vi1o+ZgeQ==} engines: {node: '>= 0.4.0'} + passport-github2@0.1.12: + resolution: {integrity: sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw==} + engines: {node: '>= 0.8.0'} + passport-google-oauth20@2.0.0: resolution: {integrity: sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==} engines: {node: '>= 0.4.0'} @@ -3268,6 +3289,10 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + random-bytes@1.0.0: + resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} + engines: {node: '>= 0.8'} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -3767,6 +3792,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + uid-safe@2.1.5: + resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} + engines: {node: '>= 0.8'} + uid2@0.0.4: resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==} @@ -6315,6 +6344,8 @@ snapshots: cookie-signature@1.0.6: {} + cookie-signature@1.0.7: {} + cookie@0.5.0: {} cookie@0.6.0: {} @@ -6630,6 +6661,19 @@ snapshots: transitivePeerDependencies: - encoding + express-session@1.18.0: + dependencies: + cookie: 0.6.0 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + on-headers: 1.0.2 + parseurl: 1.3.3 + safe-buffer: 5.2.1 + uid-safe: 2.1.5 + transitivePeerDependencies: + - supports-color + express@4.18.2: dependencies: accepts: 1.3.8 @@ -7840,6 +7884,8 @@ snapshots: dependencies: ee-first: 1.1.1 + on-headers@1.0.2: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -7915,6 +7961,10 @@ snapshots: dependencies: passport-oauth2: 1.8.0 + passport-github2@0.1.12: + dependencies: + passport-oauth2: 1.8.0 + passport-google-oauth20@2.0.0: dependencies: passport-oauth2: 1.8.0 @@ -8066,6 +8116,8 @@ snapshots: quick-format-unescaped@4.0.4: {} + random-bytes@1.0.0: {} + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -8546,6 +8598,10 @@ snapshots: typescript@5.3.3: {} + uid-safe@2.1.5: + dependencies: + random-bytes: 1.0.0 + uid2@0.0.4: {} uid2@1.0.0: {} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 030fbc3..a3b3df8 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -11,6 +11,8 @@ import { LinkedInStrategy } from '../oauth/strategy/linkedin/linkedin.strategy'; import { AppleOAuthStrategyFactory } from '../oauth/factory/apple/apple-strategy.factory'; import { AppleStrategy } from '../oauth/strategy/apple/apple.strategy'; import { MailService } from '../mail/mail.service'; +import { GithubOAuthStrategyFactory } from '../oauth/factory/github/github-strategy.factory'; +import { GithubStrategy } from '../oauth/strategy/github/github.strategy'; @Module({ imports: [ @@ -64,6 +66,14 @@ import { MailService } from '../mail/mail.service'; }, inject: [AppleOAuthStrategyFactory], }, + GithubOAuthStrategyFactory, + { + provide: GithubStrategy, + useFactory: (githubOAuthFactory: GithubOAuthStrategyFactory) => { + githubOAuthFactory.createOAuthStrategy(); + }, + inject: [GithubOAuthStrategyFactory], + }, ], }) export class AuthModule {} diff --git a/src/auth/controller/auth.controller.spec.ts b/src/auth/controller/auth.controller.spec.ts index 987af42..fe0047b 100644 --- a/src/auth/controller/auth.controller.spec.ts +++ b/src/auth/controller/auth.controller.spec.ts @@ -10,6 +10,7 @@ import { AppleOAuthStrategyFactory } from '../../oauth/factory/apple/apple-strat import { ConfigService } from '@nestjs/config'; import { MailService } from '../../mail/mail.service'; import { mockDeep } from 'jest-mock-extended'; +import { GithubOAuthStrategyFactory } from '../../oauth/factory/github/github-strategy.factory'; describe('AuthController', () => { let controller: AuthController; @@ -25,6 +26,7 @@ describe('AuthController', () => { LinkedInOAuthStrategyFactory, FacebookOAuthStrategyFactory, AppleOAuthStrategyFactory, + GithubOAuthStrategyFactory, ConfigService, MailService, ], diff --git a/src/auth/controller/auth.controller.ts b/src/auth/controller/auth.controller.ts index 13d1480..2e99498 100644 --- a/src/auth/controller/auth.controller.ts +++ b/src/auth/controller/auth.controller.ts @@ -8,6 +8,7 @@ import { Param, Post, Put, + Query, Req, Res, UseGuards, @@ -33,6 +34,7 @@ import { } from '@nestjs/swagger'; import { userProperties } from '../../schemas/user.properties'; import { LowercasePipe } from '../../common/pipes/lowercase.pipe'; +import { GithubOAuthStrategyFactory } from '../../oauth/factory/github/github-strategy.factory'; @Controller('auth') @ApiTags('Auth Controller') @@ -43,6 +45,7 @@ export class AuthController { private facebookOAuthStrategyFactory: FacebookOAuthStrategyFactory, private linkedinOAuthStrategyFactory: LinkedInOAuthStrategyFactory, private appleOAuthStrategyFactory: AppleOAuthStrategyFactory, + private githubOAuthStrategyFactory: GithubOAuthStrategyFactory, ) {} @Public() @@ -51,13 +54,14 @@ export class AuthController { summary: 'Google auth', description: 'Sign in or sign up with Google', }) - async googleOAuthLogin(@Res() res) { + async googleOAuthLogin(@Res() res, @Query() query, @Req() req) { if (!this.googleOAuthStrategyFactory.isOAuthEnabled()) { throw new HttpException( 'Google Auth is not enabled in this environment.', HttpStatus.BAD_REQUEST, ); } + req.session.app_url = query.app_url; res.status(302).redirect('/api/auth/google/callback'); } @@ -65,8 +69,13 @@ export class AuthController { @Public() @Get('google/callback') @UseGuards(AuthGuard('google')) - async googleOAuthCallback(@Req() req) { - return await this.authService.handleGoogleOAuthLogin(req); + async googleOAuthCallback(@Req() req, @Res() res) { + const user = await this.authService.handleGoogleOAuthLogin(req); + const host = req.session.app_url; + + res.send( + ``, + ); } @Public() @@ -75,13 +84,14 @@ export class AuthController { summary: 'Facebook auth', description: 'Sign in or sign up with Facebook', }) - async facebookOAuthLogin(@Res() res) { + async facebookOAuthLogin(@Res() res, @Query() query, @Req() req) { if (!this.facebookOAuthStrategyFactory.isOAuthEnabled()) { throw new HttpException( 'Facebook Auth is not enabled in this environment.', HttpStatus.BAD_REQUEST, ); } + req.session.app_url = query.app_url; res.status(302).redirect('/api/auth/facebook/callback'); } @@ -89,8 +99,14 @@ export class AuthController { @Public() @Get('facebook/callback') @UseGuards(AuthGuard('facebook')) - async facebookOAuthCallback(@Req() req) { - return await this.authService.handleFacebookOAuthLogin(req); + async facebookOAuthCallback(@Req() req, @Res() res) { + const user = await this.authService.handleFacebookOAuthLogin(req); + + const host = req.session.app_url; + + res.send( + ``, + ); } @Public() @@ -99,13 +115,14 @@ export class AuthController { summary: 'LinkedIn auth', description: 'Sign in or sign up with LinkedIn', }) - async linkedinOAuthLogin(@Res() res) { + async linkedinOAuthLogin(@Res() res, @Query() query, @Req() req) { if (!this.linkedinOAuthStrategyFactory.isOAuthEnabled()) { throw new HttpException( 'LinkedIn Auth is not enabled in this environment.', HttpStatus.BAD_REQUEST, ); } + req.session.app_url = query.app_url; res.status(302).redirect('/api/auth/linkedin/callback'); } @@ -113,8 +130,13 @@ export class AuthController { @Public() @Get('linkedin/callback') @UseGuards(AuthGuard('linkedin')) - async linkedinOAuthCallback(@Req() req) { - return await this.authService.handleLinkedInOAuthLogin(req); + async linkedinOAuthCallback(@Req() req, @Res() res) { + const user = await this.authService.handleLinkedInOAuthLogin(req); + const host = req.session.app_url; + + res.send( + ``, + ); } @Public() @@ -123,7 +145,7 @@ export class AuthController { summary: 'Apple auth', description: 'Sign in or sign up with Apple', }) - async appleOAuthLogin(@Res() res) { + async appleOAuthLogin(@Res() res, @Query() query, @Req() req) { if (!this.appleOAuthStrategyFactory.isOAuthEnabled()) { throw new HttpException( 'Apple Auth is not enabled in this environment.', @@ -131,14 +153,52 @@ export class AuthController { ); } + req.session.app_url = query.app_url; + res.status(302).redirect('/api/auth/apple/callback'); } @Public() @Get('apple/callback') @UseGuards(AuthGuard('apple')) - async appleOAuthCallback(@Req() req) { - return await this.authService.handleAppleOAuthLogin(req); + async appleOAuthCallback(@Req() req, @Res() res) { + const user = await this.authService.handleAppleOAuthLogin(req); + const host = req.session.app_url; + + res.send( + ``, + ); + } + + @Public() + @Get('github') + @ApiOperation({ + summary: 'Github auth', + description: 'Sign in or sign up with Github', + }) + async githubOauthLogin(@Res() res, @Query() query, @Req() req) { + if (!this.githubOAuthStrategyFactory.isOAuthEnabled()) { + throw new HttpException( + 'Github Auth is not enabled in this environment.', + HttpStatus.BAD_REQUEST, + ); + } + + req.session.app_url = query.app_url; + + res.status(302).redirect('/api/auth/github/callback'); + } + + @Public() + @Get('github/callback') + @UseGuards(AuthGuard('github')) + async githubOAuthCallback(@Req() req, @Res() res) { + const user = await this.authService.handleGithubOAuthLogin(req); + const host = req.session.app_url; + + res.send( + ``, + ); } @Public() diff --git a/src/auth/service/auth.service.spec.ts b/src/auth/service/auth.service.spec.ts index e8a860f..1aa779d 100644 --- a/src/auth/service/auth.service.spec.ts +++ b/src/auth/service/auth.service.spec.ts @@ -9,6 +9,7 @@ import { FacebookOAuthStrategyFactory } from '../../oauth/factory/facebook/faceb import { AppleOAuthStrategyFactory } from '../../oauth/factory/apple/apple-strategy.factory'; import { ConfigService } from '@nestjs/config'; import { MailService } from '../../mail/mail.service'; +import { GithubOAuthStrategyFactory } from '../..//oauth/factory/github/github-strategy.factory'; describe('AuthService', () => { let service: AuthService; @@ -23,6 +24,7 @@ describe('AuthService', () => { LinkedInOAuthStrategyFactory, FacebookOAuthStrategyFactory, AppleOAuthStrategyFactory, + GithubOAuthStrategyFactory, ConfigService, MailService, ], diff --git a/src/auth/service/auth.service.ts b/src/auth/service/auth.service.ts index c825084..0842425 100644 --- a/src/auth/service/auth.service.ts +++ b/src/auth/service/auth.service.ts @@ -57,9 +57,10 @@ export class AuthService { const user = await this.createUserIfNotExists( email, + AuthType.GOOGLE, name, profilePictureUrl, - AuthType.GOOGLE, + false, ); const token = await this.generateToken(user); @@ -81,6 +82,7 @@ export class AuthService { AuthType.FACEBOOK, displayName, profilePictureUrl, + false, ); const token = await this.generateToken(user); @@ -130,6 +132,23 @@ export class AuthService { }; } + async handleGithubOAuthLogin(req: any) { + const { email, name, login, avatar_url } = req.user._json; + const user = await this.createUserIfNotExists( + email || login, + AuthType.GITHUB, + name, + avatar_url, + ); + + const token = await this.generateToken(user); + + return { + ...user, + token, + }; + } + async resendEmailVerificationCode(email: string) { const user = await this.findUserByEmail(email); if (!user) { @@ -214,6 +233,7 @@ export class AuthService { name: name, profilePictureUrl: profilePictureUrl, authType, + isEmailVerified: authType !== AuthType.EMAIL, settings: { create: {}, }, @@ -232,6 +252,7 @@ export class AuthService { } else if (!user.isEmailVerified) { await this.sendEmailVerificationCode(email); } + return user; } diff --git a/src/main.ts b/src/main.ts index 1c6da3a..7d3e2b8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ import { AppModule } from './app/app.module'; import { ValidationPipe } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { json } from 'express'; +import * as session from 'express-session'; function initializeSwagger(app: any) { const config = new DocumentBuilder() @@ -18,6 +19,8 @@ function initializeSwagger(app: any) { async function bootstrap() { const app = await NestFactory.create(AppModule); const globalPrefix = 'api'; + app.use(session({ secret: process.env.SESSION_SECRET })); + app.setGlobalPrefix(globalPrefix); app.enableCors(); app.useGlobalPipes( diff --git a/src/oauth/factory/github/github-strategy.factory.ts b/src/oauth/factory/github/github-strategy.factory.ts new file mode 100644 index 0000000..2822e97 --- /dev/null +++ b/src/oauth/factory/github/github-strategy.factory.ts @@ -0,0 +1,44 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { OAuthStrategyFactory } from '../oauth-strategy.factory'; +import { GithubStrategy } from '../../strategy/github/github.strategy'; + +@Injectable() +export class GithubOAuthStrategyFactory implements OAuthStrategyFactory { + private readonly clientID: string; + private readonly clientSecret: string; + private readonly callbackURL: string; + + constructor(private readonly configService: ConfigService) { + this.clientID = this.configService.get('GITHUB_CLIENT_ID'); + this.clientSecret = this.configService.get('GITHUB_CLIENT_SECRET'); + this.callbackURL = this.configService.get('GITHUB_CALLBACK_URL'); + } + + public isOAuthEnabled(): boolean { + return Boolean(this.clientID && this.clientSecret && this.callbackURL); + } + + public isSocialAccountLinkEnabled(): boolean { + return false; + } + + public createOAuthStrategy(): GithubStrategy | null { + if (this.isOAuthEnabled()) { + return new GithubStrategy( + this.clientID, + this.clientSecret, + this.callbackURL, + ) as GithubStrategy; + } else { + Logger.warn('Github Auth is not enabled in this environment.'); + return null; + } + } + + public createSocialAccountLinkStrategy< + GithubStrategy, + >(): GithubStrategy | null { + return null; + } +} diff --git a/src/oauth/strategy/github/github.strategy.ts b/src/oauth/strategy/github/github.strategy.ts new file mode 100644 index 0000000..fed8811 --- /dev/null +++ b/src/oauth/strategy/github/github.strategy.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Profile, Strategy } from 'passport-github2'; +@Injectable() +export class GithubStrategy extends PassportStrategy(Strategy, 'github') { + constructor(clientID: string, clientSecret: string, callbackURL: string) { + super({ + clientID, + clientSecret, + callbackURL, + scope: ['public_profile'], + }); + } + + async validate( + accessToken: string, + refreshToken: string, + profile: Profile, + ): Promise { + return profile; + } +} diff --git a/src/prisma/migrations/20240531152547_github_oauth/migration.sql b/src/prisma/migrations/20240531152547_github_oauth/migration.sql new file mode 100644 index 0000000..45f2ad3 --- /dev/null +++ b/src/prisma/migrations/20240531152547_github_oauth/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "AuthType" ADD VALUE 'GITHUB'; diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index f91510f..b8c149d 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -12,6 +12,7 @@ enum AuthType { APPLE FACEBOOK LINKEDIN + GITHUB EMAIL EXTERNAL }