diff --git a/generators/service/index.js b/generators/service/index.js index 7aceeee..eff719c 100644 --- a/generators/service/index.js +++ b/generators/service/index.js @@ -22,15 +22,7 @@ exports.serviceGenerator = { type: 'list', name: 'tag', message: 'tag', - choices: [ - 'users', - 'guilds', - 'channels', - 'invitations', - 'messages', - 'members', - 'uploads' - ] + choices: ['users', 'guilds', 'channels', 'messages', 'members', 'uploads'] }, { type: 'confirm', diff --git a/src/services/users/current/get.ts b/src/services/users/current/get.ts index 676dbfa..7faff32 100644 --- a/src/services/users/current/get.ts +++ b/src/services/users/current/get.ts @@ -33,24 +33,25 @@ export const getCurrentUser: FastifyPluginAsync = async (fastify) => { if (request.user == null) { throw fastify.httpErrors.forbidden() } + const { user } = request const settings = await prisma.userSetting.findFirst({ - where: { userId: request.user.current.id } + where: { userId: user.current.id } }) const OAuths = await prisma.oAuth.findMany({ - where: { userId: request.user.current.id } + where: { userId: user.current.id } }) const strategies = OAuths.map((oauth) => { return oauth.provider }) - if (request.user.current.password != null) { + if (user.current.password != null) { strategies.push('local') } reply.statusCode = 200 return { user: { - ...request.user.current, + ...user.current, settings, - currentStrategy: request.user.currentStrategy, + currentStrategy: user.currentStrategy, strategies } } diff --git a/src/services/users/current/put.ts b/src/services/users/current/put.ts index ceacfdb..f8e080c 100644 --- a/src/services/users/current/put.ts +++ b/src/services/users/current/put.ts @@ -65,7 +65,10 @@ export const putCurrentUser: FastifyPluginAsync = async (fastify) => { const { redirectURI } = request.params const userValidation = await prisma.user.findFirst({ where: { - OR: [{ email }, { name }], + OR: [ + ...(email != null ? [{ email }] : [{}]), + ...(name != null ? [{ name }] : [{}]) + ], AND: [{ id: { not: request.user.current.id } }] } }) diff --git a/src/services/users/index.ts b/src/services/users/index.ts index 6665384..6f6c339 100644 --- a/src/services/users/index.ts +++ b/src/services/users/index.ts @@ -13,6 +13,13 @@ import { putCurrentUser } from './current/put.js' import { putCurrentUserSettings } from './current/settings/put.js' import { getUserById } from './[userId]/get.js' import { putCurrentUserLogo } from './current/logo/put.js' +import { getSigninDiscordOAuth2Service } from './oauth2/discord/signin/get.js' +import { getCallbackDiscordOAuth2Service } from './oauth2/discord/callback/get.js' +import { getSigninGoogleOAuth2Service } from './oauth2/google/signin/get.js' +import { getCallbackGoogleOAuth2Service } from './oauth2/google/callback/get.js' +import { getSigninGitHubOAuth2Service } from './oauth2/github/signin/get.js' +import { getCallbackGitHubOAuth2Service } from './oauth2/github/callback/get.js' +import { deleteProviderService } from './oauth2/[provider]/delete.js' export const usersService: FastifyPluginAsync = async (fastify) => { await fastify.register(postSignupUser) @@ -28,4 +35,15 @@ export const usersService: FastifyPluginAsync = async (fastify) => { await fastify.register(putCurrentUserSettings) await fastify.register(putCurrentUserLogo) await fastify.register(getUserById) + + await fastify.register(getSigninDiscordOAuth2Service) + await fastify.register(getCallbackDiscordOAuth2Service) + + await fastify.register(getSigninGoogleOAuth2Service) + await fastify.register(getCallbackGoogleOAuth2Service) + + await fastify.register(getSigninGitHubOAuth2Service) + await fastify.register(getCallbackGitHubOAuth2Service) + + await fastify.register(deleteProviderService) } diff --git a/src/services/users/oauth2/[provider]/delete.ts b/src/services/users/oauth2/[provider]/delete.ts new file mode 100644 index 0000000..3b32d44 --- /dev/null +++ b/src/services/users/oauth2/[provider]/delete.ts @@ -0,0 +1,75 @@ +import { Static, Type } from '@sinclair/typebox' +import { FastifyPluginAsync, FastifySchema } from 'fastify' + +import prisma from '../../../../tools/database/prisma.js' +import { fastifyErrors } from '../../../../models/utils.js' +import authenticateUser from '../../../../tools/plugins/authenticateUser.js' +import { oauthSchema } from '../../../../models/OAuth.js' + +const parametersSchema = Type.Object({ + provider: oauthSchema.provider +}) + +type Parameters = Static + +const deleteServiceSchema: FastifySchema = { + description: 'DELETE a provider to authenticate with for a user.', + tags: ['users'] as string[], + security: [ + { + bearerAuth: [] + } + ] as Array<{ [key: string]: [] }>, + params: parametersSchema, + response: { + 200: Type.Object(oauthSchema), + 400: fastifyErrors[400], + 401: fastifyErrors[401], + 403: fastifyErrors[403], + 404: fastifyErrors[404], + 500: fastifyErrors[500] + } +} as const + +export const deleteProviderService: FastifyPluginAsync = async (fastify) => { + await fastify.register(authenticateUser) + + fastify.route<{ + Params: Parameters + }>({ + method: 'DELETE', + url: '/users/oauth2/:provider', + schema: deleteServiceSchema, + handler: async (request, reply) => { + if (request.user == null) { + throw fastify.httpErrors.forbidden() + } + const { user } = request + const { provider } = request.params + const OAuths = await prisma.oAuth.findMany({ + where: { userId: user.current.id } + }) + const strategies = OAuths.map((oauth) => { + return oauth.provider + }) + if (user.current.password != null) { + strategies.push('local') + } + const oauthProvider = OAuths.find((oauth) => oauth.provider === provider) + if (oauthProvider == null) { + throw fastify.httpErrors.notFound('You are not using this provider') + } + const hasOthersWayToAuthenticate = strategies.length >= 2 + if (!hasOthersWayToAuthenticate) { + throw fastify.httpErrors.badRequest( + "You can't delete your only way to authenticate" + ) + } + const oauthProviderDelete = await prisma.oAuth.delete({ + where: { id: oauthProvider.id } + }) + reply.statusCode = 200 + return oauthProviderDelete + } + }) +} diff --git a/src/services/users/oauth2/discord/__utils__/utils.ts b/src/services/users/oauth2/discord/__utils__/utils.ts new file mode 100644 index 0000000..dbeb502 --- /dev/null +++ b/src/services/users/oauth2/discord/__utils__/utils.ts @@ -0,0 +1,60 @@ +import querystring from 'node:querystring' + +import axios from 'axios' + +import { OAuthStrategy } from '../../../../../tools/utils/OAuthStrategy.js' + +export const DISCORD_PROVIDER = 'discord' +export const DISCORD_BASE_URL = 'https://discord.com/api/v6' +export const DISCORD_CLIENT_ID = + process.env.DISCORD_CLIENT_ID ?? 'DISCORD_CLIENT_ID' +export const DISCORD_CLIENT_SECRET = + process.env.DISCORD_CLIENT_SECRET ?? 'DISCORD_CLIENT_SECRET' +export const discordStrategy = new OAuthStrategy(DISCORD_PROVIDER) + +export interface DiscordUser { + id: string + username: string + discriminator: string + avatar?: string + locale?: string +} + +export interface DiscordTokens { + access_token: string + token_type: string + expires_in: number + refresh_token: string + scope: string +} + +export const getDiscordUserData = async ( + code: string, + redirectURI: string +): Promise => { + const { data: tokens } = await axios.post( + `${DISCORD_BASE_URL}/oauth2/token`, + querystring.stringify({ + client_id: DISCORD_CLIENT_ID, + client_secret: DISCORD_CLIENT_SECRET, + grant_type: 'authorization_code', + code, + redirect_uri: redirectURI, + scope: 'identify' + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ) + const { data: discordUser } = await axios.get( + `${DISCORD_BASE_URL}/users/@me`, + { + headers: { + Authorization: `${tokens.token_type} ${tokens.access_token}` + } + } + ) + return discordUser +} diff --git a/src/services/users/oauth2/discord/callback/get.ts b/src/services/users/oauth2/discord/callback/get.ts new file mode 100644 index 0000000..c35869d --- /dev/null +++ b/src/services/users/oauth2/discord/callback/get.ts @@ -0,0 +1,49 @@ +import { Static, Type } from '@sinclair/typebox' +import { FastifyPluginAsync, FastifySchema } from 'fastify' + +import { HOST, PORT } from '../../../../../tools/configurations/index.js' +import { fastifyErrors } from '../../../../../models/utils.js' +import { discordStrategy, getDiscordUserData } from '../__utils__/utils.js' +import { buildQueryURL } from '../../../../../tools/utils/buildQueryURL.js' + +const querySchema = Type.Object({ + code: Type.String(), + redirectURI: Type.String({ format: 'uri-reference' }) +}) + +type QuerySchemaType = Static + +const getServiceSchema: FastifySchema = { + description: 'Discord OAuth2 - callback', + tags: ['users'] as string[], + querystring: querySchema, + response: { + 200: Type.String(), + 400: fastifyErrors[400], + 500: fastifyErrors[500] + } +} as const + +export const getCallbackDiscordOAuth2Service: FastifyPluginAsync = async ( + fastify +) => { + await fastify.route<{ + Querystring: QuerySchemaType + }>({ + method: 'GET', + url: '/users/oauth2/discord/callback', + schema: getServiceSchema, + handler: async (request, reply) => { + const { redirectURI, code } = request.query + const discordUser = await getDiscordUserData( + code, + `${request.protocol}://${HOST}:${PORT}/users/oauth2/discord/callback?redirectURI=${redirectURI}` + ) + const responseJWT = await discordStrategy.callbackSignin({ + name: discordUser.username, + id: discordUser.id + }) + return await reply.redirect(buildQueryURL(redirectURI, responseJWT)) + } + }) +} diff --git a/src/services/users/oauth2/discord/signin/get.ts b/src/services/users/oauth2/discord/signin/get.ts new file mode 100644 index 0000000..8d04a52 --- /dev/null +++ b/src/services/users/oauth2/discord/signin/get.ts @@ -0,0 +1,42 @@ +import { Static, Type } from '@sinclair/typebox' +import { FastifyPluginAsync, FastifySchema } from 'fastify' + +import { HOST, PORT } from '../../../../../tools/configurations/index.js' +import { fastifyErrors } from '../../../../../models/utils.js' +import { DISCORD_BASE_URL, DISCORD_CLIENT_ID } from '../__utils__/utils.js' + +const querySchema = Type.Object({ + redirectURI: Type.String({ format: 'uri-reference' }) +}) + +type QuerySchemaType = Static + +const getServiceSchema: FastifySchema = { + description: 'Discord OAuth2 - signin', + tags: ['users'] as string[], + querystring: querySchema, + response: { + 200: Type.String(), + 400: fastifyErrors[400], + 500: fastifyErrors[500] + } +} as const + +export const getSigninDiscordOAuth2Service: FastifyPluginAsync = async ( + fastify +) => { + await fastify.route<{ + Querystring: QuerySchemaType + }>({ + method: 'GET', + url: '/users/oauth2/discord/signin', + schema: getServiceSchema, + handler: async (request, reply) => { + const { redirectURI } = request.query + const redirectCallback = `${request.protocol}://${HOST}:${PORT}/users/oauth2/discord/callback?redirectURI=${redirectURI}` + const url = `${DISCORD_BASE_URL}/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&scope=identify&response_type=code&redirect_uri=${redirectCallback}` + reply.statusCode = 200 + return url + } + }) +} diff --git a/src/services/users/oauth2/github/__utils__/utils.ts b/src/services/users/oauth2/github/__utils__/utils.ts new file mode 100644 index 0000000..8564cfa --- /dev/null +++ b/src/services/users/oauth2/github/__utils__/utils.ts @@ -0,0 +1,57 @@ +import querystring from 'node:querystring' + +import axios from 'axios' + +import { OAuthStrategy } from '../../../../../tools/utils/OAuthStrategy.js' + +export const GITHUB_PROVIDER = 'github' +export const GITHUB_BASE_URL = 'https://github.com' +export const GITHUB_API_BASE_URL = 'https://api.github.com' +export const GITHUB_CLIENT_ID = + process.env.GITHUB_CLIENT_ID ?? 'GITHUB_CLIENT_ID' +export const GITHUB_CLIENT_SECRET = + process.env.GITHUB_CLIENT_SECRET ?? 'GITHUB_CLIENT_SECRET' +export const githubStrategy = new OAuthStrategy(GITHUB_PROVIDER) + +export interface GitHubUser { + login: string + id: number + name: string + avatar_url: string +} + +export interface GitHubTokens { + access_token: string + scope: string + token_type: string +} + +export const getGitHubUserData = async ( + code: string, + redirectURI: string +): Promise => { + const { data: token } = await axios.post( + `${GITHUB_BASE_URL}/login/oauth/access_token`, + querystring.stringify({ + client_id: GITHUB_CLIENT_ID, + client_secret: GITHUB_CLIENT_SECRET, + code, + redirect_uri: redirectURI + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json' + } + } + ) + const { data: githubUser } = await axios.get( + `${GITHUB_API_BASE_URL}/user`, + { + headers: { + Authorization: `token ${token.access_token}` + } + } + ) + return githubUser +} diff --git a/src/services/users/oauth2/github/callback/get.ts b/src/services/users/oauth2/github/callback/get.ts new file mode 100644 index 0000000..4c08e61 --- /dev/null +++ b/src/services/users/oauth2/github/callback/get.ts @@ -0,0 +1,49 @@ +import { Static, Type } from '@sinclair/typebox' +import { FastifyPluginAsync, FastifySchema } from 'fastify' + +import { HOST, PORT } from '../../../../../tools/configurations/index.js' +import { fastifyErrors } from '../../../../../models/utils.js' +import { githubStrategy, getGitHubUserData } from '../__utils__/utils.js' +import { buildQueryURL } from '../../../../../tools/utils/buildQueryURL.js' + +const querySchema = Type.Object({ + code: Type.String(), + redirectURI: Type.String({ format: 'uri-reference' }) +}) + +type QuerySchemaType = Static + +const getServiceSchema: FastifySchema = { + description: 'GitHub OAuth2 - callback', + tags: ['users'] as string[], + querystring: querySchema, + response: { + 200: Type.String(), + 400: fastifyErrors[400], + 500: fastifyErrors[500] + } +} as const + +export const getCallbackGitHubOAuth2Service: FastifyPluginAsync = async ( + fastify +) => { + await fastify.route<{ + Querystring: QuerySchemaType + }>({ + method: 'GET', + url: '/users/oauth2/github/callback', + schema: getServiceSchema, + handler: async (request, reply) => { + const { redirectURI, code } = request.query + const githubUser = await getGitHubUserData( + code, + `${request.protocol}://${HOST}:${PORT}/users/oauth2/github/callback?redirectURI=${redirectURI}` + ) + const responseJWT = await githubStrategy.callbackSignin({ + name: githubUser.name, + id: githubUser.id + }) + return await reply.redirect(buildQueryURL(redirectURI, responseJWT)) + } + }) +} diff --git a/src/services/users/oauth2/github/signin/get.ts b/src/services/users/oauth2/github/signin/get.ts new file mode 100644 index 0000000..974501f --- /dev/null +++ b/src/services/users/oauth2/github/signin/get.ts @@ -0,0 +1,42 @@ +import { Static, Type } from '@sinclair/typebox' +import { FastifyPluginAsync, FastifySchema } from 'fastify' + +import { HOST, PORT } from '../../../../../tools/configurations/index.js' +import { fastifyErrors } from '../../../../../models/utils.js' +import { GITHUB_BASE_URL, GITHUB_CLIENT_ID } from '../__utils__/utils.js' + +const querySchema = Type.Object({ + redirectURI: Type.String({ format: 'uri-reference' }) +}) + +type QuerySchemaType = Static + +const getServiceSchema: FastifySchema = { + description: 'GitHub OAuth2 - signin', + tags: ['users'] as string[], + querystring: querySchema, + response: { + 200: Type.String(), + 400: fastifyErrors[400], + 500: fastifyErrors[500] + } +} as const + +export const getSigninGitHubOAuth2Service: FastifyPluginAsync = async ( + fastify +) => { + await fastify.route<{ + Querystring: QuerySchemaType + }>({ + method: 'GET', + url: '/users/oauth2/github/signin', + schema: getServiceSchema, + handler: async (request, reply) => { + const { redirectURI } = request.query + const redirectCallback = `${request.protocol}://${HOST}:${PORT}/users/oauth2/github/callback?redirectURI=${redirectURI}` + const url = `${GITHUB_BASE_URL}/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_uri=${redirectCallback}` + reply.statusCode = 200 + return url + } + }) +} diff --git a/src/services/users/oauth2/google/__utils__/utils.ts b/src/services/users/oauth2/google/__utils__/utils.ts new file mode 100644 index 0000000..1c7b450 --- /dev/null +++ b/src/services/users/oauth2/google/__utils__/utils.ts @@ -0,0 +1,59 @@ +import querystring from 'node:querystring' + +import axios from 'axios' + +import { OAuthStrategy } from '../../../../../tools/utils/OAuthStrategy.js' + +export const GOOGLE_PROVIDER = 'google' +export const GOOGLE_BASE_URL = 'https://accounts.google.com/o/oauth2/v2/auth' +export const GOOGLE_OAUTH2_TOKEN = 'https://oauth2.googleapis.com/token' +export const GOOGLE_USERINFO = + 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json' +export const GOOGLE_CLIENT_ID = + process.env.GOOGLE_CLIENT_ID ?? 'GOOGLE_CLIENT_ID' +export const GOOGLE_CLIENT_SECRET = + process.env.GOOGLE_CLIENT_SECRET ?? 'GOOGLE_CLIENT_SECRET' +export const googleStrategy = new OAuthStrategy(GOOGLE_PROVIDER) + +export interface GoogleUser { + id: string + name: string + given_name: string + link: string + picture: string + locale: string +} + +export interface GoogleTokens { + access_token: string + expires_in: number + token_type: string + scope: string + refresh_token?: string +} + +export const getGoogleUserData = async ( + code: string, + redirectURI: string +): Promise => { + const { data: token } = await axios.post( + GOOGLE_OAUTH2_TOKEN, + querystring.stringify({ + client_id: GOOGLE_CLIENT_ID, + client_secret: GOOGLE_CLIENT_SECRET, + code, + redirect_uri: redirectURI, + grant_type: 'authorization_code' + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json' + } + } + ) + const { data: googleUser } = await axios.get( + `${GOOGLE_USERINFO}&access_token=${token.access_token}` + ) + return googleUser +} diff --git a/src/services/users/oauth2/google/callback/get.ts b/src/services/users/oauth2/google/callback/get.ts new file mode 100644 index 0000000..367d4d3 --- /dev/null +++ b/src/services/users/oauth2/google/callback/get.ts @@ -0,0 +1,49 @@ +import { Static, Type } from '@sinclair/typebox' +import { FastifyPluginAsync, FastifySchema } from 'fastify' + +import { HOST, PORT } from '../../../../../tools/configurations/index.js' +import { fastifyErrors } from '../../../../../models/utils.js' +import { googleStrategy, getGoogleUserData } from '../__utils__/utils.js' +import { buildQueryURL } from '../../../../../tools/utils/buildQueryURL.js' + +const querySchema = Type.Object({ + code: Type.String(), + redirectURI: Type.String({ format: 'uri-reference' }) +}) + +type QuerySchemaType = Static + +const getServiceSchema: FastifySchema = { + description: 'Google OAuth2 - callback', + tags: ['users'] as string[], + querystring: querySchema, + response: { + 200: Type.String(), + 400: fastifyErrors[400], + 500: fastifyErrors[500] + } +} as const + +export const getCallbackGoogleOAuth2Service: FastifyPluginAsync = async ( + fastify +) => { + await fastify.route<{ + Querystring: QuerySchemaType + }>({ + method: 'GET', + url: '/users/oauth2/google/callback', + schema: getServiceSchema, + handler: async (request, reply) => { + const { redirectURI, code } = request.query + const googleUser = await getGoogleUserData( + code, + `${request.protocol}://${HOST}:${PORT}/users/oauth2/google/callback?redirectURI=${redirectURI}` + ) + const responseJWT = await googleStrategy.callbackSignin({ + name: googleUser.name, + id: googleUser.id + }) + return await reply.redirect(buildQueryURL(redirectURI, responseJWT)) + } + }) +} diff --git a/src/services/users/oauth2/google/signin/get.ts b/src/services/users/oauth2/google/signin/get.ts new file mode 100644 index 0000000..bad14eb --- /dev/null +++ b/src/services/users/oauth2/google/signin/get.ts @@ -0,0 +1,42 @@ +import { Static, Type } from '@sinclair/typebox' +import { FastifyPluginAsync, FastifySchema } from 'fastify' + +import { HOST, PORT } from '../../../../../tools/configurations/index.js' +import { fastifyErrors } from '../../../../../models/utils.js' +import { GOOGLE_BASE_URL, GOOGLE_CLIENT_ID } from '../__utils__/utils.js' + +const querySchema = Type.Object({ + redirectURI: Type.String({ format: 'uri-reference' }) +}) + +type QuerySchemaType = Static + +const getServiceSchema: FastifySchema = { + description: 'Google OAuth2 - signin', + tags: ['users'] as string[], + querystring: querySchema, + response: { + 200: Type.String(), + 400: fastifyErrors[400], + 500: fastifyErrors[500] + } +} as const + +export const getSigninGoogleOAuth2Service: FastifyPluginAsync = async ( + fastify +) => { + await fastify.route<{ + Querystring: QuerySchemaType + }>({ + method: 'GET', + url: '/users/oauth2/google/signin', + schema: getServiceSchema, + handler: async (request, reply) => { + const { redirectURI } = request.query + const redirectCallback = `${request.protocol}://${HOST}:${PORT}/users/oauth2/google/callback?redirectURI=${redirectURI}` + const url = `${GOOGLE_BASE_URL}?client_id=${GOOGLE_CLIENT_ID}&redirect_uri=${redirectCallback}&response_type=code&scope=profile&access_type=online` + reply.statusCode = 200 + return url + } + }) +} diff --git a/src/services/users/refresh-token/post.ts b/src/services/users/refresh-token/post.ts index 338753a..7f7d972 100644 --- a/src/services/users/refresh-token/post.ts +++ b/src/services/users/refresh-token/post.ts @@ -56,7 +56,7 @@ export const postRefreshTokenUser: FastifyPluginAsync = async (fastify) => { ) as UserJWT const accessToken = generateAccessToken({ id: userJWT.id, - currentStrategy: 'local' + currentStrategy: userJWT.currentStrategy }) reply.statusCode = 200 return { diff --git a/src/tools/configurations/swaggerOptions.ts b/src/tools/configurations/swaggerOptions.ts index 4bedd98..690f0fb 100644 --- a/src/tools/configurations/swaggerOptions.ts +++ b/src/tools/configurations/swaggerOptions.ts @@ -19,7 +19,8 @@ export const swaggerOptions: FastifyDynamicSwaggerOptions = { { name: 'guilds' }, { name: 'channels' }, { name: 'messages' }, - { name: 'members' } + { name: 'members' }, + { name: 'uploads' } ], components: { securitySchemes: {