feat: add OAuth2 authentication (Google/GitHub/Discord)

This commit is contained in:
Divlo 2022-03-06 15:41:30 +00:00
parent 9e6bf25c83
commit 91a0e2a76f
No known key found for this signature in database
GPG Key ID: 8F9478F220CE65E9
16 changed files with 556 additions and 17 deletions

View File

@ -22,15 +22,7 @@ exports.serviceGenerator = {
type: 'list', type: 'list',
name: 'tag', name: 'tag',
message: 'tag', message: 'tag',
choices: [ choices: ['users', 'guilds', 'channels', 'messages', 'members', 'uploads']
'users',
'guilds',
'channels',
'invitations',
'messages',
'members',
'uploads'
]
}, },
{ {
type: 'confirm', type: 'confirm',

View File

@ -33,24 +33,25 @@ export const getCurrentUser: FastifyPluginAsync = async (fastify) => {
if (request.user == null) { if (request.user == null) {
throw fastify.httpErrors.forbidden() throw fastify.httpErrors.forbidden()
} }
const { user } = request
const settings = await prisma.userSetting.findFirst({ const settings = await prisma.userSetting.findFirst({
where: { userId: request.user.current.id } where: { userId: user.current.id }
}) })
const OAuths = await prisma.oAuth.findMany({ const OAuths = await prisma.oAuth.findMany({
where: { userId: request.user.current.id } where: { userId: user.current.id }
}) })
const strategies = OAuths.map((oauth) => { const strategies = OAuths.map((oauth) => {
return oauth.provider return oauth.provider
}) })
if (request.user.current.password != null) { if (user.current.password != null) {
strategies.push('local') strategies.push('local')
} }
reply.statusCode = 200 reply.statusCode = 200
return { return {
user: { user: {
...request.user.current, ...user.current,
settings, settings,
currentStrategy: request.user.currentStrategy, currentStrategy: user.currentStrategy,
strategies strategies
} }
} }

View File

@ -65,7 +65,10 @@ export const putCurrentUser: FastifyPluginAsync = async (fastify) => {
const { redirectURI } = request.params const { redirectURI } = request.params
const userValidation = await prisma.user.findFirst({ const userValidation = await prisma.user.findFirst({
where: { where: {
OR: [{ email }, { name }], OR: [
...(email != null ? [{ email }] : [{}]),
...(name != null ? [{ name }] : [{}])
],
AND: [{ id: { not: request.user.current.id } }] AND: [{ id: { not: request.user.current.id } }]
} }
}) })

View File

@ -13,6 +13,13 @@ import { putCurrentUser } from './current/put.js'
import { putCurrentUserSettings } from './current/settings/put.js' import { putCurrentUserSettings } from './current/settings/put.js'
import { getUserById } from './[userId]/get.js' import { getUserById } from './[userId]/get.js'
import { putCurrentUserLogo } from './current/logo/put.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) => { export const usersService: FastifyPluginAsync = async (fastify) => {
await fastify.register(postSignupUser) await fastify.register(postSignupUser)
@ -28,4 +35,15 @@ export const usersService: FastifyPluginAsync = async (fastify) => {
await fastify.register(putCurrentUserSettings) await fastify.register(putCurrentUserSettings)
await fastify.register(putCurrentUserLogo) await fastify.register(putCurrentUserLogo)
await fastify.register(getUserById) 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)
} }

View File

@ -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<typeof parametersSchema>
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
}
})
}

View File

@ -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<DiscordUser> => {
const { data: tokens } = await axios.post<DiscordTokens>(
`${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<DiscordUser>(
`${DISCORD_BASE_URL}/users/@me`,
{
headers: {
Authorization: `${tokens.token_type} ${tokens.access_token}`
}
}
)
return discordUser
}

View File

@ -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<typeof querySchema>
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))
}
})
}

View File

@ -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<typeof querySchema>
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
}
})
}

View File

@ -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<GitHubUser> => {
const { data: token } = await axios.post<GitHubTokens>(
`${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<GitHubUser>(
`${GITHUB_API_BASE_URL}/user`,
{
headers: {
Authorization: `token ${token.access_token}`
}
}
)
return githubUser
}

View File

@ -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<typeof querySchema>
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))
}
})
}

View File

@ -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<typeof querySchema>
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
}
})
}

View File

@ -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<GoogleUser> => {
const { data: token } = await axios.post<GoogleTokens>(
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<GoogleUser>(
`${GOOGLE_USERINFO}&access_token=${token.access_token}`
)
return googleUser
}

View File

@ -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<typeof querySchema>
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))
}
})
}

View File

@ -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<typeof querySchema>
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
}
})
}

View File

@ -56,7 +56,7 @@ export const postRefreshTokenUser: FastifyPluginAsync = async (fastify) => {
) as UserJWT ) as UserJWT
const accessToken = generateAccessToken({ const accessToken = generateAccessToken({
id: userJWT.id, id: userJWT.id,
currentStrategy: 'local' currentStrategy: userJWT.currentStrategy
}) })
reply.statusCode = 200 reply.statusCode = 200
return { return {

View File

@ -19,7 +19,8 @@ export const swaggerOptions: FastifyDynamicSwaggerOptions = {
{ name: 'guilds' }, { name: 'guilds' },
{ name: 'channels' }, { name: 'channels' },
{ name: 'messages' }, { name: 'messages' },
{ name: 'members' } { name: 'members' },
{ name: 'uploads' }
], ],
components: { components: {
securitySchemes: { securitySchemes: {