From 5408e5a035b43d1d9e481ccd5c2ea7d931607c27 Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Mon, 17 Mar 2025 13:12:08 -0400 Subject: [PATCH 1/9] backend google login --- backend/src/auth/auth.module.ts | 7 +- backend/src/auth/auth.service.ts | 77 +++++++++++++++++++ backend/src/auth/google.controller.ts | 45 +++++++++++ backend/src/auth/oauth/GoogleStrategy.ts | 46 +++++++++++ backend/src/interceptor/LoggingInterceptor.ts | 72 +++++++++++++---- backend/src/user/user.model.ts | 5 +- frontend/src/app/auth/oauth-callback/page.tsx | 67 ++++++++++++++++ 7 files changed, 304 insertions(+), 15 deletions(-) create mode 100644 backend/src/auth/google.controller.ts create mode 100644 backend/src/auth/oauth/GoogleStrategy.ts create mode 100644 frontend/src/app/auth/oauth-callback/page.tsx diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 1065fcf5..e9256c97 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -10,6 +10,8 @@ import { AuthResolver } from './auth.resolver'; import { RefreshToken } from './refresh-token/refresh-token.model'; import { JwtCacheModule } from 'src/jwt-cache/jwt-cache.module'; import { MailModule } from 'src/mail/mail.module'; +import { GoogleStrategy } from './oauth/GoogleStrategy'; +import { GoogleController } from './google.controller'; @Module({ imports: [ @@ -26,7 +28,10 @@ import { MailModule } from 'src/mail/mail.module'; JwtCacheModule, MailModule, ], - providers: [AuthService, AuthResolver], + controllers: [ + GoogleController, + ], + providers: [AuthService, AuthResolver, GoogleStrategy], exports: [AuthService, JwtModule], }) export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index ab543575..c3b1da3d 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -525,4 +525,81 @@ export class AuthService { refreshToken: refreshToken, }; } + + /** + * Handles the Google OAuth callback: find or create the user, then issue JWT(s). + * @param googleProfile The user object attached by the GoogleStrategy validate() method. + * @returns an object containing accessToken & refreshToken (if you use refresh tokens). + */ + async handleGoogleCallback(googleProfile: { + googleId: string; + email: string; + firstName?: string; + lastName?: string; + }): Promise<{ accessToken: string; refreshToken?: string }> { + Logger.log(`handle Google Callback for email: ${googleProfile.email}`); + + // First, try to find user by googleId + let user = await this.userRepository.findOne({ + where: { googleId: googleProfile.googleId }, + }); + + if (!user) { + // If not found by googleId, try to find by email + user = await this.userRepository.findOne({ + where: { email: googleProfile.email }, + }); + + if (user) { + // If found by email but not googleId, update the user with googleId + Logger.log(`Linking existing email account to Google: ${googleProfile.email}`); + user.googleId = googleProfile.googleId; + user.isEmailConfirmed = true; // Ensure email is confirmed since Google verifies emails + + // Update name if it wasn't set before + if (!user.username || user.username === user.email.split('@')[0]) { + const fullName = [googleProfile.firstName, googleProfile.lastName] + .filter(Boolean) + .join(' '); + if (fullName) { + user.username = fullName; + } + } + + user = await this.userRepository.save(user); + } else { + // If user not found at all, create a new one + Logger.log(`Creating new user from Google account: ${googleProfile.email}`); + const fullName = [googleProfile.firstName, googleProfile.lastName] + .filter(Boolean) + .join(' '); + + user = this.userRepository.create({ + googleId: googleProfile.googleId, + email: googleProfile.email, + username: fullName || googleProfile.email.split('@')[0], + isEmailConfirmed: true, // Google has already verified the email + password: null // OAuth users don't need a password + }); + + user = await this.userRepository.save(user); + } + } + + // Generate tokens + const accessToken = this.jwtService.sign( + { userId: user.id, email: user.email }, + { expiresIn: '30m' }, + ); + + const refreshTokenEntity = await this.createRefreshToken(user); + this.jwtCacheService.storeAccessToken(accessToken); + + const refreshToken = refreshTokenEntity.token; + + return { + accessToken, + refreshToken, + }; + } } diff --git a/backend/src/auth/google.controller.ts b/backend/src/auth/google.controller.ts new file mode 100644 index 00000000..e4cdb6ae --- /dev/null +++ b/backend/src/auth/google.controller.ts @@ -0,0 +1,45 @@ +import { Controller, Get, Logger, Req, Res, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ConfigService } from '@nestjs/config'; +import { AuthService } from './auth.service'; + +@Controller('auth') +export class GoogleController { + constructor( + private configService: ConfigService, + private authService: AuthService, + ) {} + + @Get('google') + @UseGuards(AuthGuard('google')) + async googleAuth() { + // This route initiates the Google OAuth flow + // The guard redirects to Google + } + + @Get('google/callback') + @UseGuards(AuthGuard('google')) + async googleAuthCallback(@Req() req, @Res() res) { + Logger.log('Google callback'); + const googleProfile = req.user as { + googleId: string; + email: string; + firstName?: string; + lastName?: string; + }; + + // Call the AuthService method + const { accessToken, refreshToken } = + await this.authService.handleGoogleCallback(googleProfile); + + const frontendUrl = + this.configService.get('FRONTEND_URL') || + 'http://localhost:3000'; + + // TO DO IS UNSAFE + // Redirect to frontend, pass tokens in query params + return res.redirect( + `${frontendUrl}/auth/oauth-callback?accessToken=${accessToken}&refreshToken=${refreshToken}` + ); + } +} \ No newline at end of file diff --git a/backend/src/auth/oauth/GoogleStrategy.ts b/backend/src/auth/oauth/GoogleStrategy.ts new file mode 100644 index 00000000..070f567c --- /dev/null +++ b/backend/src/auth/oauth/GoogleStrategy.ts @@ -0,0 +1,46 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, VerifyCallback } from 'passport-google-oauth20'; +import { ConfigService } from '@nestjs/config'; +import { AuthService } from '../auth.service'; + +@Injectable() +export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { + constructor( + private configService: ConfigService, + private authService: AuthService, + ) { + Logger.log('Google Client ID:', configService.get('GOOGLE_CLIENT_ID')); + Logger.log('Google Secret:', configService.get('GOOGLE_SECRET')); + + super({ + clientID: configService.get('GOOGLE_CLIENT_ID'), + clientSecret: configService.get('GOOGLE_SECRET'), + callbackURL: configService.get('GOOGLE_CALLBACK_URL'), + scope: ['email', 'profile'], + }); + } + + async validate( + accessToken: string, + refreshToken: string, + profile: any, + done: VerifyCallback, + ): Promise { + const { name, emails, photos, id } = profile; + Logger.log(`Google profile ID: ${id}`); + + const user = { + id: id, // Include the profile ID + googleId: id, // Also map to googleId + email: emails[0].value, + firstName: name.givenName, + lastName: name.familyName, + picture: photos[0].value, + accessToken, + refreshToken, + }; + + done(null, user); + } +} \ No newline at end of file diff --git a/backend/src/interceptor/LoggingInterceptor.ts b/backend/src/interceptor/LoggingInterceptor.ts index e05e9a55..98183ad1 100644 --- a/backend/src/interceptor/LoggingInterceptor.ts +++ b/backend/src/interceptor/LoggingInterceptor.ts @@ -6,28 +6,74 @@ import { Logger, } from '@nestjs/common'; import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; import { GqlExecutionContext } from '@nestjs/graphql'; @Injectable() export class LoggingInterceptor implements NestInterceptor { - private readonly logger = new Logger('GraphQLRequest'); + private readonly logger = new Logger(LoggingInterceptor.name); intercept(context: ExecutionContext, next: CallHandler): Observable { - const ctx = GqlExecutionContext.create(context); - const { operation, fieldName } = ctx.getInfo(); - let variables = ''; + const now = Date.now(); + let requestInfo: string; + + // Check if we can create a GraphQL context from this request try { - variables = ctx.getContext().req.body.variables; + // Attempt to create a GraphQL context + const gqlContext = GqlExecutionContext.create(context); + const info = gqlContext.getInfo(); + + if (info && info.operation) { + // This is a GraphQL request + const operation = info.operation.operation; + const fieldName = info.fieldName; + requestInfo = `GraphQL: ${operation} ${fieldName}`; + } else { + // If we got here but can't get operation info, fall back to HTTP request info + const httpContext = context.switchToHttp(); + const request = httpContext.getRequest(); + + if (request) { + const method = request.method; + const url = request.url; + requestInfo = `REST: ${method} ${url}`; + } else { + // Default if neither context is fully available + requestInfo = 'Unknown request type'; + } + } } catch (error) { - variables = ''; + // If creating a GraphQL context fails, it's probably a REST request + try { + const httpContext = context.switchToHttp(); + const request = httpContext.getRequest(); + + if (request) { + const method = request.method; + const url = request.url; + requestInfo = `REST: ${method} ${url}`; + } else { + requestInfo = 'Unknown request type'; + } + } catch (httpError) { + requestInfo = 'Failed to determine request type'; + this.logger.error('Error determining request type', httpError.stack); + } } - this.logger.log( - `${operation.operation.toUpperCase()} \x1B[33m${fieldName}\x1B[39m${ - variables ? ` Variables: ${JSON.stringify(variables)}` : '' - }`, - ); + this.logger.log(`Request started: ${requestInfo}`); - return next.handle(); + return next.handle().pipe( + tap({ + next: () => { + const timeSpent = Date.now() - now; + this.logger.log(`Request completed: ${requestInfo} (${timeSpent}ms)`); + }, + error: (error) => { + const timeSpent = Date.now() - now; + this.logger.error(`Request failed: ${requestInfo} (${timeSpent}ms)`, error.stack); + } + }), + ); } -} +} \ No newline at end of file diff --git a/backend/src/user/user.model.ts b/backend/src/user/user.model.ts index 1655392e..28496b14 100644 --- a/backend/src/user/user.model.ts +++ b/backend/src/user/user.model.ts @@ -21,11 +21,14 @@ export class User extends SystemBaseModel { @PrimaryGeneratedColumn('uuid') id: string; + @Column({ nullable: true }) + googleId: string; + @Field() @Column() username: string; - @Column() + @Column({ nullable: true }) // Made nullable for OAuth users password: string; @Field({ nullable: true }) diff --git a/frontend/src/app/auth/oauth-callback/page.tsx b/frontend/src/app/auth/oauth-callback/page.tsx new file mode 100644 index 00000000..e9e4a79c --- /dev/null +++ b/frontend/src/app/auth/oauth-callback/page.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useAuthContext } from '@/providers/AuthProvider'; +import { toast } from 'sonner'; + +export default function OAuthCallbackPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { login } = useAuthContext(); + + useEffect(() => { + const handleAuth = async () => { + try { + const accessToken = searchParams.get('accessToken'); + const refreshToken = searchParams.get('refreshToken'); + const error = searchParams.get('error'); + + // Handle error cases + if (error) { + console.error('Authentication error:', error); + toast.error('Authentication failed'); + router.push('/login'); + return; + } + + // Check if tokens exist + if (!accessToken || !refreshToken) { + console.error('Missing tokens in callback'); + toast.error('Authentication failed: Missing tokens'); + router.push('/login'); + return; + } + + // Store tokens using the context + login(accessToken, refreshToken); + + // Show success message + toast.success('Logged in successfully!'); + + // Redirect to home or dashboard + router.push('/'); + } catch (error) { + console.error('Error processing authentication:', error); + toast.error('Authentication processing failed'); + router.push('/login'); + } + }; + + handleAuth(); + }, [searchParams, login, router]); + + return ( +
+
+

Completing authentication...

+

+ Please wait while we sign you in. +

+
+
+
+
+
+ ); +} \ No newline at end of file From 7622d255fcfeb7c4e859abd3d486c0d307c8c1d1 Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Mon, 17 Mar 2025 13:53:00 -0400 Subject: [PATCH 2/9] frontend support --- backend/src/auth/oauth/GoogleStrategy.ts | 4 +--- frontend/.env.example | 1 + frontend/src/components/sign-in-modal.tsx | 4 ++++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/src/auth/oauth/GoogleStrategy.ts b/backend/src/auth/oauth/GoogleStrategy.ts index 070f567c..8124fe04 100644 --- a/backend/src/auth/oauth/GoogleStrategy.ts +++ b/backend/src/auth/oauth/GoogleStrategy.ts @@ -10,14 +10,12 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { private configService: ConfigService, private authService: AuthService, ) { - Logger.log('Google Client ID:', configService.get('GOOGLE_CLIENT_ID')); - Logger.log('Google Secret:', configService.get('GOOGLE_SECRET')); - super({ clientID: configService.get('GOOGLE_CLIENT_ID'), clientSecret: configService.get('GOOGLE_SECRET'), callbackURL: configService.get('GOOGLE_CALLBACK_URL'), scope: ['email', 'profile'], + prompt: 'select_account', }); } diff --git a/frontend/.env.example b/frontend/.env.example index a29b0d57..498420a9 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,4 +1,5 @@ NEXT_PUBLIC_GRAPHQL_URL=http://localhost:8080/graphql +NEXT_PUBLIC_BACKEND_GOOGLE_OAUTH=http://localhost:8080/auth/google # TLS OPTION for HTTPS TLS=false diff --git a/frontend/src/components/sign-in-modal.tsx b/frontend/src/components/sign-in-modal.tsx index b9777f80..c02e3f2b 100644 --- a/frontend/src/components/sign-in-modal.tsx +++ b/frontend/src/components/sign-in-modal.tsx @@ -154,6 +154,10 @@ export function SignInModal({ isOpen, onClose }: SignInModalProps) {