From 4661ca4f2a607d4988d705dc67c21b519e157f4e Mon Sep 17 00:00:00 2001 From: Nevo David Date: Sat, 16 Mar 2024 19:06:52 +0700 Subject: [PATCH] feat: github authentication --- .../backend/src/api/routes/auth.controller.ts | 50 ++++++-- .../backend/src/services/auth/auth.service.ts | 40 ++++++- .../src/services/auth/providers.interface.ts | 2 + .../auth/providers/github.provider.ts | 65 ++++++++--- .../app/auth/providers/github.provider.tsx | 33 ++++++ apps/frontend/src/components/auth/login.tsx | 12 +- .../frontend/src/components/auth/register.tsx | 108 ++++++++++++++---- .../src/components/launches/calendar.tsx | 1 - apps/frontend/src/middleware.ts | 7 +- .../src/dtos/auth/create.org.user.dto.ts | 1 + 10 files changed, 268 insertions(+), 51 deletions(-) create mode 100644 apps/frontend/src/app/auth/providers/github.provider.tsx diff --git a/apps/backend/src/api/routes/auth.controller.ts b/apps/backend/src/api/routes/auth.controller.ts index a36563b4..96b5ecae 100644 --- a/apps/backend/src/api/routes/auth.controller.ts +++ b/apps/backend/src/api/routes/auth.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Post, Req, Res } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common'; import { Response, Request } from 'express'; import { CreateOrgUserDto } from '@gitroom/nestjs-libraries/dtos/auth/create.org.user.dto'; @@ -6,8 +6,8 @@ import { LoginUserDto } from '@gitroom/nestjs-libraries/dtos/auth/login.user.dto import { AuthService } from '@gitroom/backend/services/auth/auth.service'; import { ForgotReturnPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/forgot-return.password.dto'; import { ForgotPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/forgot.password.dto'; -import {removeSubdomain} from "@gitroom/helpers/subdomain/subdomain.management"; -import {ApiTags} from "@nestjs/swagger"; +import { removeSubdomain } from '@gitroom/helpers/subdomain/subdomain.management'; +import { ApiTags } from '@nestjs/swagger'; @ApiTags('Auth') @Controller('/auth') @@ -31,7 +31,8 @@ export class AuthController { ); response.cookie('auth', jwt, { - domain: '.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname, + domain: + '.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname, secure: true, httpOnly: true, sameSite: 'none', @@ -40,7 +41,8 @@ export class AuthController { if (typeof addedOrg !== 'boolean' && addedOrg?.organizationId) { response.cookie('showorg', addedOrg.organizationId, { - domain: '.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname, + domain: + '.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname, secure: true, httpOnly: true, sameSite: 'none', @@ -75,7 +77,8 @@ export class AuthController { ); response.cookie('auth', jwt, { - domain: '.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname, + domain: + '.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname, secure: true, httpOnly: true, sameSite: 'none', @@ -84,7 +87,8 @@ export class AuthController { if (typeof addedOrg !== 'boolean' && addedOrg?.organizationId) { response.cookie('showorg', addedOrg.organizationId, { - domain: '.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname, + domain: + '.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname, secure: true, httpOnly: true, sameSite: 'none', @@ -122,4 +126,36 @@ export class AuthController { reset: !!reset, }; } + + @Get('/oauth/:provider') + async oauthLink(@Param('provider') provider: string) { + return this._authService.oauthLink(provider); + } + + @Post('/oauth/:provider/exists') + async oauthExists( + @Body('code') code: string, + @Param('provider') provider: string, + @Res({ passthrough: true }) response: Response + ) { + const { jwt, token } = await this._authService.checkExists(provider, code); + if (token) { + return response.json({ token }); + } + + response.cookie('auth', jwt, { + domain: + '.' + new URL(removeSubdomain(process.env.FRONTEND_URL!)).hostname, + secure: true, + httpOnly: true, + sameSite: 'none', + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), + }); + + response.header('reload', 'true'); + + response.status(200).json({ + login: true, + }); + } } diff --git a/apps/backend/src/services/auth/auth.service.ts b/apps/backend/src/services/auth/auth.service.ts index e3824740..88414005 100644 --- a/apps/backend/src/services/auth/auth.service.ts +++ b/apps/backend/src/services/auth/auth.service.ts @@ -9,7 +9,7 @@ import { ProvidersFactory } from '@gitroom/backend/services/auth/providers/provi import dayjs from 'dayjs'; import { NewsletterService } from '@gitroom/nestjs-libraries/services/newsletter.service'; import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service'; -import {ForgotReturnPasswordDto} from "@gitroom/nestjs-libraries/dtos/auth/forgot-return.password.dto"; +import { ForgotReturnPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/forgot-return.password.dto'; @Injectable() export class AuthService { @@ -96,11 +96,15 @@ export class AuthService { ) { const providerInstance = ProvidersFactory.loadProvider(provider); const providerUser = await providerInstance.getUser(body.providerToken); + if (!providerUser) { throw new Error('Invalid provider token'); } - const user = await this._userService.getUserByProvider(providerUser.id, provider); + const user = await this._userService.getUserByProvider( + providerUser.id, + provider + ); if (user) { return user; } @@ -137,7 +141,10 @@ export class AuthService { } forgotReturn(body: ForgotReturnPasswordDto) { - const user = AuthChecker.verifyJWT(body.token) as {id: string, expires: string}; + const user = AuthChecker.verifyJWT(body.token) as { + id: string; + expires: string; + }; if (dayjs(user.expires).isBefore(dayjs())) { return false; } @@ -145,6 +152,33 @@ export class AuthService { return this._userService.updatePassword(user.id, body.password); } + oauthLink(provider: string) { + const providerInstance = ProvidersFactory.loadProvider( + provider as Provider + ); + return providerInstance.generateLink(); + } + + async checkExists(provider: string, code: string) { + const providerInstance = ProvidersFactory.loadProvider( + provider as Provider + ); + const token = await providerInstance.getToken(code); + const user = await providerInstance.getUser(token); + if (!user) { + throw new Error('Invalid user'); + } + const checkExists = await this._userService.getUserByProvider( + user.id, + provider as Provider + ); + if (checkExists) { + return { jwt: await this.jwt(checkExists) }; + } + + return { token }; + } + private async jwt(user: User) { return AuthChecker.signJWT(user); } diff --git a/apps/backend/src/services/auth/providers.interface.ts b/apps/backend/src/services/auth/providers.interface.ts index d3241fd1..f0b98028 100644 --- a/apps/backend/src/services/auth/providers.interface.ts +++ b/apps/backend/src/services/auth/providers.interface.ts @@ -1,3 +1,5 @@ export interface ProvidersInterface { + generateLink(): string; + getToken(code: string): Promise; getUser(providerToken: string): Promise<{email: string, id: string}> | false; } \ No newline at end of file diff --git a/apps/backend/src/services/auth/providers/github.provider.ts b/apps/backend/src/services/auth/providers/github.provider.ts index 150a6e2b..dcfdb669 100644 --- a/apps/backend/src/services/auth/providers/github.provider.ts +++ b/apps/backend/src/services/auth/providers/github.provider.ts @@ -1,15 +1,54 @@ -import {ProvidersInterface} from "@gitroom/backend/services/auth/providers.interface"; +import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface'; export class GithubProvider implements ProvidersInterface { - async getUser(providerToken: string): Promise<{email: string, id: string}> { - const data = await (await fetch('https://api.github.com/user', { - headers: { - Authorization: `token ${providerToken}` - } - })).json(); - return { - email: data.email, - id: data.id, - }; - } -} \ No newline at end of file + generateLink(): string { + return `https://github.com/login/oauth/authorize?client_id=${ + process.env.GITHUB_CLIENT_ID + }&scope=user:email&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/settings?provider=github` + )}`; + } + + async getToken(code: string): Promise { + const { access_token } = await ( + await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + client_id: process.env.GITHUB_CLIENT_ID, + client_secret: process.env.GITHUB_CLIENT_SECRET, + code, + redirect_uri: `${process.env.FRONTEND_URL}/settings`, + }), + }) + ).json(); + + return access_token; + } + + async getUser(access_token: string): Promise<{ email: string; id: string }> { + const data = await ( + await fetch('https://api.github.com/user', { + headers: { + Authorization: `token ${access_token}`, + }, + }) + ).json(); + + const [{ email }] = await ( + await fetch('https://api.github.com/user/emails', { + headers: { + Authorization: `token ${access_token}`, + }, + }) + ).json(); + + return { + email: email, + id: String(data.id), + }; + } +} diff --git a/apps/frontend/src/app/auth/providers/github.provider.tsx b/apps/frontend/src/app/auth/providers/github.provider.tsx new file mode 100644 index 00000000..9e2fefb8 --- /dev/null +++ b/apps/frontend/src/app/auth/providers/github.provider.tsx @@ -0,0 +1,33 @@ +import { useCallback } from 'react'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; + +export const GithubProvider = () => { + const fetch = useFetch(); + const gotoLogin = useCallback(async () => { + const link = await (await fetch('/auth/oauth/GITHUB')).text(); + window.location.href = link; + }, []); + + return ( +
+
+ + + +
+
Sign in with GitHub
+
+ ); +}; diff --git a/apps/frontend/src/components/auth/login.tsx b/apps/frontend/src/components/auth/login.tsx index cc689acb..e76d5699 100644 --- a/apps/frontend/src/components/auth/login.tsx +++ b/apps/frontend/src/components/auth/login.tsx @@ -8,6 +8,7 @@ import { Input } from '@gitroom/react/form/input'; import { useMemo, useState } from 'react'; import { classValidatorResolver } from '@hookform/resolvers/class-validator'; import { LoginUserDto } from '@gitroom/nestjs-libraries/dtos/auth/login.user.dto'; +import { GithubProvider } from '@gitroom/frontend/app/auth/providers/github.provider'; type Inputs = { email: string; @@ -53,10 +54,17 @@ export function Login() {

- Create An Account + Sign In

-
+ +
+
+
+
OR
+
+
+
{ + if (provider && code) { + load(); + } + }, []); + + const load = useCallback(async () => { + const { token } = await ( + await fetch(`/auth/oauth/${provider?.toUpperCase() || 'LOCAL'}/exists`, { + method: 'POST', + body: JSON.stringify({ code }), + }) + ).json(); + + if (token) { + setCode(token); + setShow(true); + } + }, [provider, code]); + + if (!code && !provider) { + return ; + } + + if (!show) { + return ; + } + + return ; +} + +export function RegisterAfter({ + token, + provider, +}: { + token: string; + provider: string; +}) { const [loading, setLoading] = useState(false); + const getQuery = useSearchParams(); + + const isAfterProvider = useMemo(() => { + return !!token && !!provider; + }, [token, provider]); + const resolver = useMemo(() => { return classValidatorResolver(CreateOrgUserDto); }, []); @@ -26,8 +79,8 @@ export function Register() { const form = useForm({ resolver, defaultValues: { - providerToken: '', - provider: 'LOCAL', + providerToken: token, + provider: provider, }, }); @@ -37,7 +90,7 @@ export function Register() { setLoading(true); const register = await fetchData('/auth/register', { method: 'POST', - body: JSON.stringify({ ...data, provider: 'LOCAL' }), + body: JSON.stringify({ ...data }), }); if (register.status === 400) { form.setError('email', { @@ -53,23 +106,36 @@ export function Register() {

- Create An Account + Sign Up

-
- - + {!isAfterProvider && } + {!isAfterProvider && ( +
+
+
+
OR
+
+
+ )} +
+ {!isAfterProvider && ( + <> + + + + )} { .hour(+hour.split(':')[0] - 1) .minute(0)} /> - {console.log(currentWeek)} {['00', '10', '20', '30', '40', '50'].map((num) => ( -1) { @@ -27,15 +26,15 @@ export async function middleware(request: NextRequest) { } const org = nextUrl.searchParams.get('org'); - const orgUrl = org ? '?org=' + org : ''; + const url = new URL(nextUrl).search; if (nextUrl.href.indexOf('/auth') === -1 && !authCookie) { - return NextResponse.redirect(new URL(`/auth${orgUrl}`, nextUrl.href)); + return NextResponse.redirect(new URL(`/auth${url}`, nextUrl.href)); } // If the url is /auth and the cookie exists, redirect to / if (nextUrl.href.indexOf('/auth') > -1 && authCookie) { - return NextResponse.redirect(new URL(`/${orgUrl}`, nextUrl.href)); + return NextResponse.redirect(new URL(`/${url}`, nextUrl.href)); } if (nextUrl.href.indexOf('/auth') > -1 && !authCookie) { diff --git a/libraries/nestjs-libraries/src/dtos/auth/create.org.user.dto.ts b/libraries/nestjs-libraries/src/dtos/auth/create.org.user.dto.ts index f47dcce7..0505e2b0 100644 --- a/libraries/nestjs-libraries/src/dtos/auth/create.org.user.dto.ts +++ b/libraries/nestjs-libraries/src/dtos/auth/create.org.user.dto.ts @@ -19,6 +19,7 @@ export class CreateOrgUserDto { @IsEmail() @IsDefined() + @ValidateIf(o => !o.providerToken) email: string; @IsString()