diff --git a/apps/frontend/src/plugins/core/langs/en.json b/apps/frontend/src/plugins/core/langs/en.json index 75c9635a6..dc1052e21 100644 --- a/apps/frontend/src/plugins/core/langs/en.json +++ b/apps/frontend/src/plugins/core/langs/en.json @@ -241,6 +241,10 @@ "desc": "Not recommended on shared computers." }, "submit": "Log In", + "sso_first_login": { + "title": "Almost there!", + "desc": "Please complete your registration by entering your name." + }, "error": { "title": "Invalid credentials", "desc": "The email address or password was incorrect. Please try again (make sure your caps lock is off)." diff --git a/packages/backend/src/core/auth/auth.controller.ts b/packages/backend/src/core/auth/auth.controller.ts index 0b1b2b6e7..945c732b0 100644 --- a/packages/backend/src/core/auth/auth.controller.ts +++ b/packages/backend/src/core/auth/auth.controller.ts @@ -29,7 +29,7 @@ import { import { VerifyConfirmEmailAuthService } from './services/confirm_email/verify.service'; import { ShowAuthService } from './services/show.service'; -import { SignInAuthService } from './services/sign_in.service'; +import { SignInAuthService } from './services/sign_in/sign_in.service'; import { SignOutAuthService } from './services/sign_out.service'; import { SignUpAuthService } from './services/sign_up/sign_up.service'; diff --git a/packages/backend/src/core/auth/auth.module.ts b/packages/backend/src/core/auth/auth.module.ts index 5884be293..0836f3181 100644 --- a/packages/backend/src/core/auth/auth.module.ts +++ b/packages/backend/src/core/auth/auth.module.ts @@ -5,7 +5,8 @@ import { AuthCron } from './auth.cron'; import { ClearTokenConfirmEmailAuthCron } from './services/confirm_email/clear_tokens_email.cron'; import { VerifyConfirmEmailAuthService } from './services/confirm_email/verify.service'; import { ShowAuthService } from './services/show.service'; -import { SignInAuthService } from './services/sign_in.service'; +import { HelperSignInAuthService } from './services/sign_in/helper.service'; +import { SignInAuthService } from './services/sign_in/sign_in.service'; import { SignOutAuthService } from './services/sign_out.service'; import { HelperSignUpAuthService } from './services/sign_up/helper.service'; import { SendConfirmEmailAuthService } from './services/sign_up/send.confirm_email.service'; @@ -13,19 +14,31 @@ import { SignUpAuthService } from './services/sign_up/sign_up.service'; import { SettingsAuthModule } from './settings/settings.module'; import { SSOAuthModule } from './sso/sso.module'; +@Module({ + providers: [ + HelperSignInAuthService, + SendConfirmEmailAuthService, + HelperSignUpAuthService, + ], + exports: [ + HelperSignInAuthService, + SendConfirmEmailAuthService, + HelperSignUpAuthService, + ], +}) +export class HelpersAuthModule {} + @Module({ providers: [ ShowAuthService, SignUpAuthService, - HelperSignUpAuthService, AuthCron, ClearTokenConfirmEmailAuthCron, - SendConfirmEmailAuthService, VerifyConfirmEmailAuthService, SignInAuthService, SignOutAuthService, ], controllers: [AuthController], - imports: [SettingsAuthModule, SSOAuthModule], + imports: [SettingsAuthModule, SSOAuthModule, HelpersAuthModule], }) export class AuthModule {} diff --git a/packages/backend/src/core/auth/services/sign_in.service.ts b/packages/backend/src/core/auth/services/sign_in/helper.service.ts similarity index 67% rename from packages/backend/src/core/auth/services/sign_in.service.ts rename to packages/backend/src/core/auth/services/sign_in/helper.service.ts index a03bed9c0..cc5e26b45 100644 --- a/packages/backend/src/core/auth/services/sign_in.service.ts +++ b/packages/backend/src/core/auth/services/sign_in/helper.service.ts @@ -1,21 +1,20 @@ import { core_admin_sessions } from '@/database/schema/admins'; import { core_sessions } from '@/database/schema/sessions'; +import { DeviceAuthService } from '@/helpers/auth/device.service'; import { getConfigFile } from '@/helpers/config'; import { EmailHelperService } from '@/helpers/email/email.service'; import { InternalDatabaseService } from '@/utils/database/internal_database.service'; -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { ForbiddenException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { and, eq } from 'drizzle-orm'; import { Request, Response } from 'express'; -import { SignInAuthBody, SignInAuthObj } from 'vitnode-shared/auth/auth.dto'; +import { SignInAuthBody } from 'vitnode-shared/auth/auth.dto'; -import { DeviceAuthService } from '../../../helpers/auth/device.service'; -import { verifyPassword } from '../helpers/password'; -import { SendConfirmEmailAuthService } from './sign_up/send.confirm_email.service'; +import { SendConfirmEmailAuthService } from '../sign_up/send.confirm_email.service'; @Injectable() -export class SignInAuthService { +export class HelperSignInAuthService { constructor( private readonly databaseService: InternalDatabaseService, private readonly jwtService: JwtService, @@ -25,7 +24,7 @@ export class SignInAuthService { private readonly mailService: EmailHelperService, ) {} - private async createSession({ + async createSession({ req, res, body: { email, remember, admin, user_id, name }, @@ -39,7 +38,7 @@ export class SignInAuthService { const devMode: boolean = this.configService.get('dev_mode') ?? false; const device = await this.deviceService.getDevice({ req, res }); if (device.uagent_os === 'Uagent from tests' && !devMode) { - throw new HttpException('ACCESS_DENIED', HttpStatus.UNAUTHORIZED); + throw new ForbiddenException('ACCESS_DENIED'); } const login_token = this.jwtService.sign( @@ -192,82 +191,4 @@ export class SignInAuthService { return login_token; } - - async singIn({ - req, - res, - body: { admin, email: emailRaw, password, ...rest }, - }: { - body: SignInAuthBody; - req: Request; - res: Response; - }): Promise { - const { settings } = getConfigFile(); - const email = emailRaw.toLowerCase(); - const user = await this.databaseService.db.query.core_users.findFirst({ - where: (table, { eq }) => eq(table.email, email), - with: { - confirm_email: true, - }, - columns: { - id: true, - email_verified: true, - group_id: true, - name: true, - password: true, - language: true, - name_seo: true, - avatar_color: true, - }, - }); - if (!user?.password) { - throw new HttpException('ACCESS_DENIED', HttpStatus.UNAUTHORIZED); - } - - const validPassword = await verifyPassword(password, user.password); - if (!validPassword) { - throw new HttpException('ACCESS_DENIED', HttpStatus.UNAUTHORIZED); - } - - if ( - !user.email_verified && - settings.authorization.require_confirm_email && - this.mailService.checkIfEnable() - ) { - await this.sendConfirmEmailCoreSessionsService.sendConfirmEmail({ - userId: user.id, - }); - - throw new HttpException('EMAIL_NOT_VERIFIED', HttpStatus.UNAUTHORIZED); - } - - // If admin mode is enabled, check if user has access to admin cp - if (admin) { - const accessToAdminCP = - await this.databaseService.db.query.core_admin_permissions.findFirst({ - where: (table, { eq, or }) => - or( - user.group_id ? eq(table.group_id, user.group_id) : undefined, - eq(table.user_id, user.id), - ), - }); - if (!accessToAdminCP) { - throw new HttpException('ACCESS_DENIED', HttpStatus.UNAUTHORIZED); - } - } - - const loginToken = await this.createSession({ - req, - res, - body: { admin, email, user_id: user.id, name: user.name, ...rest }, - }); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { password: _, ...userWithoutPassword } = user; - - return { - login_token: loginToken, - ...userWithoutPassword, - }; - } } diff --git a/packages/backend/src/core/auth/services/sign_in/sign_in.service.ts b/packages/backend/src/core/auth/services/sign_in/sign_in.service.ts new file mode 100644 index 000000000..660b4330e --- /dev/null +++ b/packages/backend/src/core/auth/services/sign_in/sign_in.service.ts @@ -0,0 +1,103 @@ +import { getConfigFile } from '@/helpers/config'; +import { EmailHelperService } from '@/helpers/email/email.service'; +import { InternalDatabaseService } from '@/utils/database/internal_database.service'; +import { + ForbiddenException, + HttpException, + HttpStatus, + Injectable, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { SignInAuthBody, SignInAuthObj } from 'vitnode-shared/auth/auth.dto'; + +import { verifyPassword } from '../../helpers/password'; +import { SendConfirmEmailAuthService } from '../sign_up/send.confirm_email.service'; +import { HelperSignInAuthService } from './helper.service'; + +@Injectable() +export class SignInAuthService { + constructor( + private readonly databaseService: InternalDatabaseService, + private readonly signInHelper: HelperSignInAuthService, + private readonly sendConfirmEmailCoreSessionsService: SendConfirmEmailAuthService, + private readonly mailService: EmailHelperService, + ) {} + + async singIn({ + req, + res, + body: { admin, email: emailRaw, password, ...rest }, + }: { + body: SignInAuthBody; + req: Request; + res: Response; + }): Promise { + const { settings } = getConfigFile(); + const email = emailRaw.toLowerCase(); + const user = await this.databaseService.db.query.core_users.findFirst({ + where: (table, { eq }) => eq(table.email, email), + with: { + confirm_email: true, + }, + columns: { + id: true, + email_verified: true, + group_id: true, + name: true, + password: true, + language: true, + name_seo: true, + avatar_color: true, + }, + }); + if (!user?.password) { + throw new ForbiddenException('ACCESS_DENIED'); + } + + const validPassword = await verifyPassword(password, user.password); + if (!validPassword) { + throw new ForbiddenException('ACCESS_DENIED'); + } + + if ( + !user.email_verified && + settings.authorization.require_confirm_email && + this.mailService.checkIfEnable() + ) { + await this.sendConfirmEmailCoreSessionsService.sendConfirmEmail({ + userId: user.id, + }); + + throw new HttpException('EMAIL_NOT_VERIFIED', HttpStatus.UNAUTHORIZED); + } + + // If admin mode is enabled, check if user has access to admin cp + if (admin) { + const accessToAdminCP = + await this.databaseService.db.query.core_admin_permissions.findFirst({ + where: (table, { eq, or }) => + or( + user.group_id ? eq(table.group_id, user.group_id) : undefined, + eq(table.user_id, user.id), + ), + }); + if (!accessToAdminCP) { + throw new ForbiddenException('ACCESS_DENIED'); + } + } + + const loginToken = await this.signInHelper.createSession({ + req, + res, + body: { admin, email, user_id: user.id, name: user.name, ...rest }, + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password: _, ...userWithoutPassword } = user; + + return { + login_token: loginToken, + ...userWithoutPassword, + }; + } +} diff --git a/packages/backend/src/core/auth/services/sign_up/helper.service.ts b/packages/backend/src/core/auth/services/sign_up/helper.service.ts index 1cae24f37..55abf8ec8 100644 --- a/packages/backend/src/core/auth/services/sign_up/helper.service.ts +++ b/packages/backend/src/core/auth/services/sign_up/helper.service.ts @@ -20,7 +20,7 @@ export class HelperSignUpAuthService { private readonly configService: ConfigService, ) {} - private readonly getDefaultData = async (): Promise<{ + readonly getDefaultData = async (): Promise<{ email_verified: boolean; group_id: number; }> => { @@ -62,7 +62,7 @@ export class HelperSignUpAuthService { }; }; - private readonly getLanguage = async (req: Request): Promise => { + readonly getLanguage = async (req: Request): Promise => { const languageToSet: string = req.cookies[this.configService.get('cookies.lang') ?? 'NEXT_LOCALE']; @@ -102,7 +102,7 @@ export class HelperSignUpAuthService { const convertToNameSEO = removeSpecialCharacters(name); const checkNameSEO = await this.databaseService.db.query.core_users.findFirst({ - where: (table, { ilike }) => ilike(table.name_seo, convertToNameSEO), + where: (table, { eq }) => eq(table.name_seo, convertToNameSEO), }); if (checkNameSEO) { diff --git a/packages/backend/src/core/auth/sso/services/callback.service.ts b/packages/backend/src/core/auth/sso/services/callback.service.ts new file mode 100644 index 000000000..e82bd2234 --- /dev/null +++ b/packages/backend/src/core/auth/sso/services/callback.service.ts @@ -0,0 +1,143 @@ +import type { Request, Response } from 'express'; + +import { core_users_sso_tokens } from '@/database/schema/users'; +import { SSOAuthHelper } from '@/helpers/auth/sso.service'; +import { InternalDatabaseService } from '@/utils/database/internal_database.service'; +import { ForbiddenException, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SSOCallbackAuthObj } from 'vitnode-shared/auth/sso.dto'; + +import { HelperSignInAuthService } from '../../services/sign_in/helper.service'; + +@Injectable() +export class CallbackSSOAuthService { + constructor( + private readonly ssoHelper: SSOAuthHelper, + private readonly configService: ConfigService, + private readonly databaseService: InternalDatabaseService, + private readonly signInHelper: HelperSignInAuthService, + ) {} + + async callbackSSO({ + provider, + code, + req, + res, + }: { + code: string; + provider: string; + req: Request; + res: Response; + }): Promise { + const frontendUrl: string = this.configService.getOrThrow('frontend_url'); + const redirectUri = (code: string) => + `${frontendUrl}/login/sso/${code}/callback`; + const ssoProvider = this.ssoHelper.getSSO(provider); + if (!ssoProvider.enabled) { + throw new ForbiddenException('SSO provider not enabled'); + } + + const { access_token } = await ssoProvider.callback({ + client_id: + '1067408430287-igio7a4koou4i26n8vvmqo4eqtcp9gka.apps.googleusercontent.com', + client_secret: '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', + redirect_uri: redirectUri(provider), + code, + }); + const data = await ssoProvider.registerCallback({ access_token }); + + if (!data.verified_email) { + throw new ForbiddenException('Email not verified'); + } + + const sso = + await this.databaseService.db.query.core_users_sso_tokens.findFirst({ + where: (table, { eq, and }) => + and(eq(table.provider, provider), eq(table.provider_id, data.id)), + with: { + user: { + columns: { + id: true, + email: true, + name: true, + language: true, + name_seo: true, + avatar_color: true, + }, + }, + }, + }); + + if (sso) { + const loginToken = await this.signInHelper.createSession({ + req, + res, + body: { + email: sso.user.email, + user_id: sso.user.id, + name: sso.user.name, + remember: true, + }, + }); + + return { + login_token: loginToken, + access_token, + provider, + provider_id: data.id, + ...sso.user, + }; + } + + const user = await this.databaseService.db.query.core_users.findFirst({ + where: (table, { eq }) => eq(table.email, data.email), + columns: { + id: true, + email: true, + name: true, + language: true, + name_seo: true, + avatar_color: true, + }, + }); + // If user exists, create SSO token and sign in + if (user) { + await this.databaseService.db.insert(core_users_sso_tokens).values({ + provider, + provider_id: data.id, + user_id: user.id, + }); + + const loginToken = await this.signInHelper.createSession({ + req, + res, + body: { + email: user.email, + user_id: user.id, + name: user.name, + remember: true, + }, + }); + + return { + access_token, + login_token: loginToken, + provider, + provider_id: data.id, + ...user, + }; + } + + return { + access_token, + login_token: '', + id: 0, + name: data.name, + language: 'en', + name_seo: '', + avatar_color: '', + provider, + provider_id: data.id, + }; + } +} diff --git a/packages/backend/src/core/auth/sso/services/get-url.service.ts b/packages/backend/src/core/auth/sso/services/get-url.service.ts new file mode 100644 index 000000000..107686d07 --- /dev/null +++ b/packages/backend/src/core/auth/sso/services/get-url.service.ts @@ -0,0 +1,24 @@ +import { SSOAuthHelper } from '@/helpers/auth/sso.service'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SSOUrlAuthObj } from 'vitnode-shared/auth/sso.dto'; + +@Injectable() +export class GetUrlSSOAuthService { + constructor( + private readonly ssoHelper: SSOAuthHelper, + private readonly configService: ConfigService, + ) {} + + getUrlSSO(provider: string): SSOUrlAuthObj { + const frontendUrl: string = this.configService.getOrThrow('frontend_url'); + const redirectUri = (code: string) => + `${frontendUrl}/login/sso/${code}/callback`; + + return this.ssoHelper.getSSO(provider).getUrl({ + redirect_uri: redirectUri(provider), + client_id: + '1067408430287-igio7a4koou4i26n8vvmqo4eqtcp9gka.apps.googleusercontent.com', + }); + } +} diff --git a/packages/backend/src/core/auth/sso/services/register-callback.service.ts b/packages/backend/src/core/auth/sso/services/register-callback.service.ts new file mode 100644 index 000000000..a59a4768e --- /dev/null +++ b/packages/backend/src/core/auth/sso/services/register-callback.service.ts @@ -0,0 +1,97 @@ +import type { Request, Response } from 'express'; + +import { core_users } from '@/database/schema/users'; +import { getUserIp, removeSpecialCharacters } from '@/functions'; +import { SSOAuthHelper } from '@/helpers/auth/sso.service'; +import { InternalDatabaseService } from '@/utils/database/internal_database.service'; +import { ConflictException, Injectable } from '@nestjs/common'; +import { + RegisterSSOCallbackAuthBody, + SSOCallbackAuthObj, +} from 'vitnode-shared/auth/sso.dto'; + +import { generateAvatarColor } from '../../helpers/avatar-color'; +import { HelperSignInAuthService } from '../../services/sign_in/helper.service'; +import { HelperSignUpAuthService } from '../../services/sign_up/helper.service'; + +@Injectable() +export class RegisterCallbackSSOAuthService { + constructor( + private readonly databaseService: InternalDatabaseService, + private readonly ssoAuthHelper: SSOAuthHelper, + private readonly signUpHelper: HelperSignUpAuthService, + private readonly signInHelper: HelperSignInAuthService, + ) {} + + async registerCallbackSSO({ + provider, + body: { name, access_token, provider_id }, + req, + res, + }: { + body: RegisterSSOCallbackAuthBody; + provider: string; + req: Request; + res: Response; + }): Promise { + const sso = this.ssoAuthHelper.getSSO(provider); + const data = await sso.registerCallback({ access_token }); + if (provider_id !== data.id) { + throw new ConflictException('Provider id does not match'); + } + + const email = data.email.toLowerCase(); + const user = await this.databaseService.db.query.core_users.findFirst({ + where: (table, { eq }) => eq(table.email, email), + }); + if (user) { + throw new ConflictException('User already exists'); + } + const convertToNameSEO = removeSpecialCharacters(name); + const checkNameSEO = + await this.databaseService.db.query.core_users.findFirst({ + where: (table, { eq }) => eq(table.name_seo, convertToNameSEO), + }); + + if (checkNameSEO) { + throw new ConflictException('NAME_ALREADY_EXISTS'); + } + + const [createUser] = await this.databaseService.db + .insert(core_users) + .values({ + email, + name, + name_seo: convertToNameSEO, + newsletter: false, + avatar_color: generateAvatarColor(name), + group_id: (await this.signUpHelper.getDefaultData()).group_id, + email_verified: true, + ip_address: getUserIp(req), + language: await this.signUpHelper.getLanguage(req), + }) + .returning(); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password: _, ...rest } = createUser; + + const loginToken = await this.signInHelper.createSession({ + req, + res, + body: { + email: createUser.email, + user_id: createUser.id, + name: createUser.name, + remember: true, + }, + }); + + return { + login_token: loginToken, + access_token, + provider, + provider_id, + ...rest, + }; + } +} diff --git a/packages/backend/src/core/auth/sso/sso.controller.ts b/packages/backend/src/core/auth/sso/sso.controller.ts index dede28157..367bf25a3 100644 --- a/packages/backend/src/core/auth/sso/sso.controller.ts +++ b/packages/backend/src/core/auth/sso/sso.controller.ts @@ -1,66 +1,74 @@ -import { SSOAuthHelper } from '@/helpers/auth/sso.service'; +import type { Request, Response } from 'express'; + import { + Body, Controller, - ForbiddenException, Get, Param, + Post, Query, + Req, + Res, } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { ApiTags } from '@nestjs/swagger'; -import { SSOUrlAuthObj } from 'vitnode-shared/auth/sso.dto'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { + RegisterSSOCallbackAuthBody, + SSOCallbackAuthObj, + SSOUrlAuthObj, +} from 'vitnode-shared/auth/sso.dto'; + +import { CallbackSSOAuthService } from './services/callback.service'; +import { GetUrlSSOAuthService } from './services/get-url.service'; +import { RegisterCallbackSSOAuthService } from './services/register-callback.service'; @ApiTags('Core') @Controller('core/auth/sso') export class SSOAuthController { constructor( - private readonly configService: ConfigService, - private readonly ssoHelper: SSOAuthHelper, + private readonly getUrlSSO: GetUrlSSOAuthService, + private readonly callbackSSO: CallbackSSOAuthService, + private readonly registerCallbackSSO: RegisterCallbackSSOAuthService, ) {} - @Get(':provider/callback') - async callbackSSO( + @Post(':provider/callback') + @ApiOkResponse({ + type: SSOCallbackAuthObj, + description: 'Callback SSO', + }) + async callback( @Param('provider') provider: string, - @Query() query: Record, - ) { - const frontendUrl: string = this.configService.getOrThrow('frontend_url'); - - const body = { - client_id: '', - client_secret: '', - code: query.code, - grant_type: 'authorization_code', - redirect_uri: `${frontendUrl}/login/sso/google/callback`, - }; - - const res = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - body: JSON.stringify(body), - }); - - const tokenData: { - access_token?: string; - } = await res.json(); - if (!tokenData.access_token) { - throw new ForbiddenException('Invalid token'); - } - - const userInfoResponse = await fetch( - 'https://www.googleapis.com/oauth2/v2/userinfo', - { - headers: { - Authorization: `Bearer ${tokenData.access_token}`, - }, - }, - ); - - const userInfo = await userInfoResponse.json(); - - return 'callback'; + @Query('code') code: string, + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ): Promise { + return this.callbackSSO.callbackSSO({ provider, code, req, res }); } @Get(':provider') - getUrlSSO(@Param('provider') provider: string): SSOUrlAuthObj { - return this.ssoHelper.getSSO(provider).getUrl(); + @ApiOkResponse({ + type: SSOUrlAuthObj, + description: 'Get SSO URL', + }) + getUrl(@Param('provider') provider: string): SSOUrlAuthObj { + return this.getUrlSSO.getUrlSSO(provider); + } + + @Post(':provider/register') + @ApiOkResponse({ + type: SSOCallbackAuthObj, + description: 'Register user if not exists', + }) + async register( + @Body() body: RegisterSSOCallbackAuthBody, + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + @Param('provider') provider: string, + ): Promise { + return this.registerCallbackSSO.registerCallbackSSO({ + body, + req, + res, + provider, + }); } } diff --git a/packages/backend/src/core/auth/sso/sso.module.ts b/packages/backend/src/core/auth/sso/sso.module.ts index a86f7ce3b..2a6d09348 100644 --- a/packages/backend/src/core/auth/sso/sso.module.ts +++ b/packages/backend/src/core/auth/sso/sso.module.ts @@ -1,7 +1,18 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; + +import { HelpersAuthModule } from '../auth.module'; +import { CallbackSSOAuthService } from './services/callback.service'; +import { GetUrlSSOAuthService } from './services/get-url.service'; +import { RegisterCallbackSSOAuthService } from './services/register-callback.service'; import { SSOAuthController } from './sso.controller'; @Module({ + providers: [ + GetUrlSSOAuthService, + CallbackSSOAuthService, + RegisterCallbackSSOAuthService, + ], controllers: [SSOAuthController], + imports: [forwardRef(() => HelpersAuthModule)], }) export class SSOAuthModule {} diff --git a/packages/backend/src/core/middleware/services/show.service.ts b/packages/backend/src/core/middleware/services/show.service.ts index ba92af800..da04616fa 100644 --- a/packages/backend/src/core/middleware/services/show.service.ts +++ b/packages/backend/src/core/middleware/services/show.service.ts @@ -1,4 +1,5 @@ import { ABSOLUTE_PATHS } from '@/app.module'; +import { SSOAuthHelper } from '@/helpers/auth/sso.service'; import { getConfigFile } from '@/helpers/config'; import { EmailHelperService } from '@/helpers/email/email.service'; import { InternalDatabaseService } from '@/utils/database/internal_database.service'; @@ -9,7 +10,6 @@ import { ManifestWithLang } from 'vitnode-shared/manifest.dto'; import { ShowMiddlewareObj } from 'vitnode-shared/middleware.dto'; import { NavMiddlewareService } from './nav.service'; -import { SSOAuthHelper } from '@/helpers/auth/sso.service'; @Injectable() export class ShowMiddlewareService { @@ -82,10 +82,13 @@ export class ShowMiddlewareService { }, auth_methods: { password: true, - sso: this.ssoHelper.getSSOs().map(sso => ({ - name: sso.name, - code: sso.code, - })), + sso: this.ssoHelper + .getSSOs() + .filter(item => item.enabled) + .map(sso => ({ + name: sso.name, + code: sso.code, + })), }, plugins: ['admin', 'core', ...plugins.map(plugin => plugin.code)], languages_code_default: langs.find(lang => lang.default)?.code ?? 'en', diff --git a/packages/backend/src/database/schema/users.ts b/packages/backend/src/database/schema/users.ts index 55e8d4aa6..dce3b525a 100644 --- a/packages/backend/src/database/schema/users.ts +++ b/packages/backend/src/database/schema/users.ts @@ -70,13 +70,23 @@ export const core_users_sso_tokens = pgTable( }) .notNull(), provider: t.varchar({ length: 100 }).notNull(), - id_provider: t.varchar({ length: 255 }).notNull(), + provider_id: t.varchar({ length: 255 }).notNull(), created_at: t.timestamp().notNull().defaultNow(), updated_at: t.timestamp().notNull().defaultNow(), }), t => [index('core_users_sso_tokens_user_id_idx').on(t.user_id)], ); +export const core_users_sso_tokens_relations = relations( + core_users_sso_tokens, + ({ one }) => ({ + user: one(core_users, { + fields: [core_users_sso_tokens.user_id], + references: [core_users.id], + }), + }), +); + export const core_files_avatars = pgTable('core_files_avatars', t => ({ id: t.serial().primaryKey(), dir_folder: t.varchar({ length: 255 }).notNull(), diff --git a/packages/backend/src/helpers/auth/sso.service.ts b/packages/backend/src/helpers/auth/sso.service.ts index 162e4e33d..5bba4fd26 100644 --- a/packages/backend/src/helpers/auth/sso.service.ts +++ b/packages/backend/src/helpers/auth/sso.service.ts @@ -1,18 +1,40 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { SSOUrlAuthObj } from 'vitnode-shared/auth/sso.dto'; -export interface SSOAuthItem { +export interface SSOAuthCallbackObj { + email: string; + id: string; name: string; - enabled: boolean; - getUrl: () => SSOUrlAuthObj; + verified_email: boolean; +} + +export interface SSOAuthItem { + callback: (args: { + client_id: string; + client_secret: string; + code: string; + redirect_uri: string; + }) => Promise<{ + access_token: string; + }>; code: string; + enabled: boolean; + getUrl: (args: { + client_id: string; + redirect_uri: string; + }) => Pick; + name: string; + registerCallback: (args: { + access_token: string; + }) => Promise; } @Injectable() export class SSOAuthHelper { - constructor(private readonly configService: ConfigService) {} - getSSO(code: string): SSOAuthItem { const item = this.getSSOs().find(sso => sso.code === code); if (!item) { @@ -23,20 +45,15 @@ export class SSOAuthHelper { } getSSOs(): SSOAuthItem[] { - const frontendUrl: string = this.configService.getOrThrow('frontend_url'); - const redirectUri = (code: string) => - `${frontendUrl}/login/sso/${code}/callback`; - return [ { name: 'Google', code: 'google', enabled: true, - getUrl: () => { + getUrl: ({ redirect_uri, client_id }) => { const params = new URLSearchParams({ - client_id: - '1067408430287-igio7a4koou4i26n8vvmqo4eqtcp9gka.apps.googleusercontent.com', - redirect_uri: redirectUri('google'), + client_id, + redirect_uri, response_type: 'code', scope: 'openid profile email', }); @@ -45,6 +62,56 @@ export class SSOAuthHelper { url: `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`, }; }, + callback: async ({ client_id, client_secret, redirect_uri, code }) => { + const body = { + client_id, + client_secret, + code, + grant_type: 'authorization_code', + redirect_uri, + }; + + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + body: JSON.stringify(body), + }); + + const tokenData: { + access_token?: string; + } = await res.json(); + if (!tokenData.access_token) { + throw new ForbiddenException('Invalid token'); + } + + return { + access_token: tokenData.access_token, + }; + }, + registerCallback: async ({ access_token }) => { + const res = await fetch( + 'https://www.googleapis.com/oauth2/v2/userinfo', + { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }, + ); + + const userInfo: { + email: string; + id: string; + name: string; + verified_email: boolean; + } = await res.json(); + // console.log(userInfo); + + return { + id: userInfo.id, + name: userInfo.name, + email: userInfo.email, + verified_email: userInfo.verified_email, + }; + }, }, ]; } diff --git a/packages/backend/src/helpers/helpers.module.ts b/packages/backend/src/helpers/helpers.module.ts index 7b175d279..b52536e81 100644 --- a/packages/backend/src/helpers/helpers.module.ts +++ b/packages/backend/src/helpers/helpers.module.ts @@ -9,6 +9,7 @@ import { import { DeviceAuthService } from './auth/device.service'; import { InternalAuthAdminService } from './auth/internal_auth_admin.service'; +import { SSOAuthHelper } from './auth/sso.service'; import { CaptchaHelper } from './captcha/captcha.service'; import { EmailHelperService } from './email/email.service'; import { EmailHelpersService } from './email/email-helpers.service'; @@ -19,7 +20,6 @@ import { import { FilesHelperService } from './files/files-helper.service'; import { StringLanguageHelper } from './string_language/helpers.service'; import { UserHelper } from './user.service'; -import { SSOAuthHelper } from './auth/sso.service'; @Global() @Module({}) diff --git a/packages/frontend/src/views/theme/views/auth/sign/in/sign-in-view.tsx b/packages/frontend/src/views/theme/views/auth/sign/in/sign-in-view.tsx index 38ee6030a..6596a491c 100644 --- a/packages/frontend/src/views/theme/views/auth/sign/in/sign-in-view.tsx +++ b/packages/frontend/src/views/theme/views/auth/sign/in/sign-in-view.tsx @@ -1,5 +1,5 @@ import { getMiddlewareData } from '@/api/get-middleware-data'; -import { CardDescription, CardTitle } from '@/components/ui/card'; +import { CardDescription } from '@/components/ui/card'; import { Link } from '@/navigation'; import { Metadata } from 'next'; import { getTranslations } from 'next-intl/server'; @@ -23,8 +23,10 @@ export const SignInView = async () => { return (
-
- {t('title')} +
+

+ {t('title')} +

{t.rich('desc', { link: () => {t('sign_up')}, diff --git a/packages/frontend/src/views/theme/views/auth/sign/sso/callback-sso-auth-view.tsx b/packages/frontend/src/views/theme/views/auth/sign/sso/callback-sso-auth-view.tsx deleted file mode 100644 index ed5ddddbb..000000000 --- a/packages/frontend/src/views/theme/views/auth/sign/sso/callback-sso-auth-view.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Loader } from '@/components/ui/loader'; - -export const CallbackSSOAuthView = ({ provider }: { provider: string }) => { - return ( -
- - {provider} -
- ); -}; diff --git a/packages/frontend/src/views/theme/views/auth/sign/sso/callback/callback-sso-auth-view.tsx b/packages/frontend/src/views/theme/views/auth/sign/sso/callback/callback-sso-auth-view.tsx new file mode 100644 index 000000000..fd1498986 --- /dev/null +++ b/packages/frontend/src/views/theme/views/auth/sign/sso/callback/callback-sso-auth-view.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { fetcherClient } from '@/api/fetcher-client'; +import { Loader } from '@/components/ui/loader'; +import { useRouter } from '@/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { notFound } from 'next/navigation'; +import { SSOCallbackAuthObj } from 'vitnode-shared/auth/sso.dto'; + +import { ErrorView } from '../../../../error/error-view'; +import { revalidateApi } from './hooks/revalidate-api'; +import { NameFormCallbackSSO } from './name-form'; + +export const CallbackSSOAuthView = ({ + provider, + code, +}: { + code: string; + provider: string; +}) => { + const { replace } = useRouter(); + if (!code || !provider) { + notFound(); + } + + const { data, isLoading, isError } = useQuery({ + queryKey: ['core.auth.sso.callback', provider, code], + queryFn: async () => { + const { data } = await fetcherClient({ + method: 'POST', + url: `/core/auth/sso/${provider}/callback?code=${code}`, + }); + await revalidateApi(); + + if (data.login_token) { + replace('/'); + } + + return data; + }, + retry: 0, + }); + + if (isLoading || data?.login_token) { + return ( +
+ +
+ ); + } + + if (isError || !data) { + return ; + } + + return ; +}; diff --git a/packages/frontend/src/views/theme/views/auth/sign/sso/callback/hooks/mutation-api.ts b/packages/frontend/src/views/theme/views/auth/sign/sso/callback/hooks/mutation-api.ts new file mode 100644 index 000000000..9d5d67336 --- /dev/null +++ b/packages/frontend/src/views/theme/views/auth/sign/sso/callback/hooks/mutation-api.ts @@ -0,0 +1,40 @@ +'use server'; + +import { fetcher } from '@/api/fetcher'; +import { revalidateTags } from '@/api/revalidate-tags'; +import { redirect } from '@/navigation'; +import { cookies } from 'next/headers'; +import { + RegisterSSOCallbackAuthBody, + SSOCallbackAuthObj, +} from 'vitnode-shared/auth/sso.dto'; + +export const mutationApi = async ({ + provider, + ...body +}: { + provider: string; +} & RegisterSSOCallbackAuthBody) => { + try { + await fetcher({ + method: 'POST', + url: `/core/auth/sso/${provider}/register`, + body, + }); + + const cookie = await cookies(); + const userIdFromCookie = cookie.get('vitnode-user-id')?.value; + if (userIdFromCookie) { + revalidateTags.session(+userIdFromCookie); + } + await redirect('/'); + } catch (err) { + const { message } = err as Error; + + if (message.includes('NAME_ALREADY_EXISTS')) { + return { message: 'NAME_ALREADY_EXISTS' }; + } + + return { message: 'INTERNAL_SERVER_ERROR' }; + } +}; diff --git a/packages/frontend/src/views/theme/views/auth/sign/sso/callback/hooks/revalidate-api.ts b/packages/frontend/src/views/theme/views/auth/sign/sso/callback/hooks/revalidate-api.ts new file mode 100644 index 000000000..3439ccbfa --- /dev/null +++ b/packages/frontend/src/views/theme/views/auth/sign/sso/callback/hooks/revalidate-api.ts @@ -0,0 +1,12 @@ +'use server'; + +import { revalidateTags } from '@/api/revalidate-tags'; +import { cookies } from 'next/headers'; + +export const revalidateApi = async () => { + const cookie = await cookies(); + const userIdFromCookie = cookie.get('vitnode-user-id')?.value; + if (userIdFromCookie) { + revalidateTags.session(+userIdFromCookie); + } +}; diff --git a/packages/frontend/src/views/theme/views/auth/sign/sso/callback/name-form.tsx b/packages/frontend/src/views/theme/views/auth/sign/sso/callback/name-form.tsx new file mode 100644 index 000000000..0fd113317 --- /dev/null +++ b/packages/frontend/src/views/theme/views/auth/sign/sso/callback/name-form.tsx @@ -0,0 +1,119 @@ +import { AutoForm } from '@/components/form/auto-form'; +import { AutoFormInput } from '@/components/form/fields/input'; +import { Button } from '@/components/ui/button'; +import { CardDescription } from '@/components/ui/card'; +import { removeSpecialCharacters } from '@/helpers/special-characters'; +import { nameRegex } from '@/hooks/sign/up/use-sign-up-view'; +import { LogIn } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { UseFormReturn } from 'react-hook-form'; +import { toast } from 'sonner'; +import { SSOCallbackAuthObj } from 'vitnode-shared/auth/sso.dto'; +import * as z from 'zod'; + +import { mutationApi } from './hooks/mutation-api'; + +export const NameFormCallbackSSO = ({ + access_token, + provider_id, + provider, +}: SSOCallbackAuthObj) => { + const t = useTranslations('core.sign_in.sso_first_login'); + const tSignUp = useTranslations('core.sign_up'); + const tCore = useTranslations('core.global'); + const formSchema = z.object({ + name: z + .string() + .min(3, { + message: tCore('errors.min_length', { length: 3 }), + }) + .max(32, { + message: tCore('errors.max_length', { length: 32 }), + }) + .refine(value => nameRegex.test(value), { + message: tSignUp('name.invalid'), + }) + .default(''), + }); + + const onSubmit = async ( + values: z.infer, + form: UseFormReturn>, + ) => { + const mutation = await mutationApi({ + ...values, + provider_id, + provider, + access_token, + }); + if (!mutation?.message) return; + + if (mutation.message === 'NAME_ALREADY_EXISTS') { + form.setError( + 'name', + { + type: 'manual', + message: tSignUp('name.already_exists'), + }, + { + shouldFocus: true, + }, + ); + + return; + } + + toast.error(tCore('errors.title'), { + description: tCore('errors.internal_server_error'), + }); + }; + + return ( +
+
+

+ {t('title')} +

+ {t('desc')} +
+ + { + const value: string = props.field.value ?? ''; + + return ( + <> + + {value.length > 0 && ( + + {tSignUp.rich('name.your_id', { + id: () => ( + + {removeSpecialCharacters(value)} + + ), + })} + + )} + + ); + }, + label: tSignUp('name.label'), + description: tSignUp('name.desc'), + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButton={props => ( + + )} + /> +
+ ); +}; diff --git a/packages/frontend/src/views/theme/views/auth/sign/up/sign-up-view.tsx b/packages/frontend/src/views/theme/views/auth/sign/up/sign-up-view.tsx index 93ec39cf9..884d562b8 100644 --- a/packages/frontend/src/views/theme/views/auth/sign/up/sign-up-view.tsx +++ b/packages/frontend/src/views/theme/views/auth/sign/up/sign-up-view.tsx @@ -1,5 +1,5 @@ import { getMiddlewareData } from '@/api/get-middleware-data'; -import { CardDescription, CardTitle } from '@/components/ui/card'; +import { CardDescription } from '@/components/ui/card'; import { Link } from '@/navigation'; import { Metadata } from 'next'; import { notFound } from 'next/navigation'; @@ -34,8 +34,10 @@ export const SignUpView = async () => { return (
-
- {t('title')} +
+

+ {t('title')} +

{t.rich('desc', { link: () => {t('sign_in')}, diff --git a/packages/frontend/src/views/theme/views/dynamic-view.tsx b/packages/frontend/src/views/theme/views/dynamic-view.tsx index 9aadffd0f..3b683e6a5 100644 --- a/packages/frontend/src/views/theme/views/dynamic-view.tsx +++ b/packages/frontend/src/views/theme/views/dynamic-view.tsx @@ -7,7 +7,7 @@ import { generateMetadataSignIn, SignInView, } from './auth/sign/in/sign-in-view'; -import { CallbackSSOAuthView } from './auth/sign/sso/callback-sso-auth-view'; +import { CallbackSSOAuthView } from './auth/sign/sso/callback/callback-sso-auth-view'; import { UrlSSOAuthView } from './auth/sign/sso/url-sso-auth-view'; import { ConfirmEmailSignUpView } from './auth/sign/up/confirm-email/confirm-email-sign-up-view'; import { @@ -92,7 +92,15 @@ export const DynamicView = async (props: { if (slug[0] === 'login' && !slug[4]) { if (slug[1] === 'sso' && slug[2]) { if (slug[3] === 'callback') { - return ; + const code = (await props.searchParams).code; + + return ( + + + + ); } if (slug[3]) notFound(); diff --git a/packages/shared/src/auth/sso.dto.ts b/packages/shared/src/auth/sso.dto.ts index 822f2536a..ceee32039 100644 --- a/packages/shared/src/auth/sso.dto.ts +++ b/packages/shared/src/auth/sso.dto.ts @@ -1,6 +1,43 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, PickType } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsEmail, + IsString, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; + +import { TransformString } from '../utils/text-language'; +import { nameRegex, SignInAuthObj } from './auth.dto'; export class SSOUrlAuthObj { @ApiProperty() url: string; } + +export class SSOCallbackAuthObj extends SignInAuthObj { + @ApiProperty() + @IsString() + access_token: string; + + @ApiProperty() + @IsString() + provider: string; + + @ApiProperty() + @IsString() + provider_id: string; +} + +export class RegisterSSOCallbackAuthBody extends PickType(SSOCallbackAuthObj, [ + 'provider_id', + 'access_token', +] as const) { + @Transform(TransformString) + @MinLength(3) + @MaxLength(32) + @Matches(nameRegex) + @ApiProperty({ example: 'aXen' }) + name: string; +} diff --git a/packages/shared/src/middleware.dto.ts b/packages/shared/src/middleware.dto.ts index ba25c80bb..fd52d96a3 100644 --- a/packages/shared/src/middleware.dto.ts +++ b/packages/shared/src/middleware.dto.ts @@ -57,11 +57,11 @@ export class CaptchaSecurityMiddleware { export class SSOAuthMethodMiddleware { @ApiProperty() @IsString() - name: string; + code: string; @ApiProperty() @IsString() - code: string; + name: string; } export class AuthMethodMiddleware { @@ -126,6 +126,9 @@ export class LogosMiddleware { } export class ShowMiddlewareObj extends MainSettingsAdminBody { + @ApiProperty() + auth_methods: AuthMethodMiddleware; + @ApiProperty() authorization: AuthorizationMiddleware; @@ -147,9 +150,6 @@ export class ShowMiddlewareObj extends MainSettingsAdminBody { @ApiProperty() logos: LogosMiddleware; - @ApiProperty() - auth_methods: AuthMethodMiddleware; - @ApiProperty({ type: [ShowNavStyles] }) nav: ShowNavStyles[];