UNPKG

@directus/api

Version:

Directus is a real-time API and App dashboard for managing SQL database content

436 lines (435 loc) 18.2 kB
import { useEnv } from '@directus/env'; import { ErrorCode, ForbiddenError, InvalidPayloadError, isDirectusError } from '@directus/errors'; import argon2 from 'argon2'; import { GraphQLBoolean, GraphQLEnumType, GraphQLID, GraphQLInt, GraphQLNonNull, GraphQLObjectType, GraphQLString, } from 'graphql'; import { SchemaComposer } from 'graphql-compose'; import { clearSystemCache, getCache } from '../../../cache.js'; import { DEFAULT_AUTH_PROVIDER, REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../../constants.js'; import { rateLimiter } from '../../../middleware/rate-limiter-registration.js'; import { createDefaultAccountability } from '../../../permissions/utils/create-default-accountability.js'; import { generateHash } from '../../../utils/generate-hash.js'; import { getIPFromReq } from '../../../utils/get-ip-from-req.js'; import { getSecret } from '../../../utils/get-secret.js'; import isDirectusJWT from '../../../utils/is-directus-jwt.js'; import { verifyAccessJWT } from '../../../utils/jwt.js'; import { AuthenticationService } from '../../authentication.js'; import { RevisionsService } from '../../revisions.js'; import { TFAService } from '../../tfa.js'; import { UsersService } from '../../users.js'; import { UtilsService } from '../../utils.js'; import { GraphQLService } from '../index.js'; import { GraphQLBigInt } from '../types/bigint.js'; import { GraphQLVoid } from '../types/void.js'; const env = useEnv(); /** * Globally available mutations */ export function globalResolvers(gql, schemaComposer) { const AuthTokens = schemaComposer.createObjectTC({ name: 'auth_tokens', fields: { access_token: GraphQLString, expires: GraphQLBigInt, refresh_token: GraphQLString, }, }); const AuthMode = new GraphQLEnumType({ name: 'auth_mode', values: { json: { value: 'json' }, cookie: { value: 'cookie' }, session: { value: 'session' }, }, }); schemaComposer.Mutation.addFields({ auth_login: { type: AuthTokens, args: { email: new GraphQLNonNull(GraphQLString), password: new GraphQLNonNull(GraphQLString), mode: AuthMode, otp: GraphQLString, }, resolve: async (_, args, { req, res }) => { const accountability = createDefaultAccountability(); if (req?.ip) accountability.ip = req.ip; const userAgent = req?.get('user-agent'); if (userAgent) accountability.userAgent = userAgent; const origin = req?.get('origin'); if (origin) accountability.origin = origin; const authenticationService = new AuthenticationService({ accountability: accountability, schema: gql.schema, }); const mode = args['mode'] ?? 'json'; const { accessToken, refreshToken, expires } = await authenticationService.login(DEFAULT_AUTH_PROVIDER, args, { session: mode === 'session', otp: args?.otp, }); const payload = { expires }; if (mode === 'json') { payload.refresh_token = refreshToken; payload.access_token = accessToken; } if (mode === 'cookie') { res?.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS); payload.access_token = accessToken; } if (mode === 'session') { res?.cookie(env['SESSION_COOKIE_NAME'], accessToken, SESSION_COOKIE_OPTIONS); } return payload; }, }, auth_refresh: { type: AuthTokens, args: { refresh_token: GraphQLString, mode: AuthMode, }, resolve: async (_, args, { req, res }) => { const accountability = createDefaultAccountability(); if (req?.ip) accountability.ip = req.ip; const userAgent = req?.get('user-agent'); if (userAgent) accountability.userAgent = userAgent; const origin = req?.get('origin'); if (origin) accountability.origin = origin; const authenticationService = new AuthenticationService({ accountability: accountability, schema: gql.schema, }); const mode = args['mode'] ?? 'json'; let currentRefreshToken; if (mode === 'json') { currentRefreshToken = args['refresh_token']; } else if (mode === 'cookie') { currentRefreshToken = req?.cookies[env['REFRESH_TOKEN_COOKIE_NAME']]; } else if (mode === 'session') { const token = req?.cookies[env['SESSION_COOKIE_NAME']]; if (isDirectusJWT(token)) { const payload = verifyAccessJWT(token, getSecret()); currentRefreshToken = payload.session; } } if (!currentRefreshToken) { throw new InvalidPayloadError({ reason: `The refresh token is required in either the payload or cookie`, }); } const { accessToken, refreshToken, expires } = await authenticationService.refresh(currentRefreshToken, { session: mode === 'session', }); const payload = { expires }; if (mode === 'json') { payload.refresh_token = refreshToken; payload.access_token = accessToken; } if (mode === 'cookie') { res?.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS); payload.access_token = accessToken; } if (mode === 'session') { res?.cookie(env['SESSION_COOKIE_NAME'], accessToken, SESSION_COOKIE_OPTIONS); } return payload; }, }, auth_logout: { type: GraphQLBoolean, args: { refresh_token: GraphQLString, mode: AuthMode, }, resolve: async (_, args, { req, res }) => { const accountability = createDefaultAccountability(); if (req?.ip) accountability.ip = req.ip; const userAgent = req?.get('user-agent'); if (userAgent) accountability.userAgent = userAgent; const origin = req?.get('origin'); if (origin) accountability.origin = origin; const authenticationService = new AuthenticationService({ accountability: accountability, schema: gql.schema, }); const mode = args['mode'] ?? 'json'; let currentRefreshToken; if (mode === 'json') { currentRefreshToken = args['refresh_token']; } else if (mode === 'cookie') { currentRefreshToken = req?.cookies[env['REFRESH_TOKEN_COOKIE_NAME']]; } else if (mode === 'session') { const token = req?.cookies[env['SESSION_COOKIE_NAME']]; if (isDirectusJWT(token)) { const payload = verifyAccessJWT(token, getSecret()); currentRefreshToken = payload.session; } } if (!currentRefreshToken) { throw new InvalidPayloadError({ reason: `The refresh token is required in either the payload or cookie`, }); } await authenticationService.logout(currentRefreshToken); if (req?.cookies[env['REFRESH_TOKEN_COOKIE_NAME']]) { res?.clearCookie(env['REFRESH_TOKEN_COOKIE_NAME'], REFRESH_COOKIE_OPTIONS); } if (req?.cookies[env['SESSION_COOKIE_NAME']]) { res?.clearCookie(env['SESSION_COOKIE_NAME'], SESSION_COOKIE_OPTIONS); } return true; }, }, auth_password_request: { type: GraphQLBoolean, args: { email: new GraphQLNonNull(GraphQLString), reset_url: GraphQLString, }, resolve: async (_, args, { req }) => { const accountability = createDefaultAccountability(); if (req?.ip) accountability.ip = req.ip; const userAgent = req?.get('user-agent'); if (userAgent) accountability.userAgent = userAgent; const origin = req?.get('origin'); if (origin) accountability.origin = origin; const service = new UsersService({ accountability, schema: gql.schema }); try { await service.requestPasswordReset(args['email'], args['reset_url'] || null); } catch (err) { if (isDirectusError(err, ErrorCode.InvalidPayload)) { throw err; } } return true; }, }, auth_password_reset: { type: GraphQLBoolean, args: { token: new GraphQLNonNull(GraphQLString), password: new GraphQLNonNull(GraphQLString), }, resolve: async (_, args, { req }) => { const accountability = createDefaultAccountability(); if (req?.ip) accountability.ip = req.ip; const userAgent = req?.get('user-agent'); if (userAgent) accountability.userAgent = userAgent; const origin = req?.get('origin'); if (origin) accountability.origin = origin; const service = new UsersService({ accountability, schema: gql.schema }); await service.resetPassword(args['token'], args['password']); return true; }, }, users_me_tfa_generate: { type: new GraphQLObjectType({ name: 'users_me_tfa_generate_data', fields: { secret: { type: GraphQLString }, otpauth_url: { type: GraphQLString }, }, }), args: { password: new GraphQLNonNull(GraphQLString), }, resolve: async (_, args) => { if (!gql.accountability?.user) return null; const service = new TFAService({ accountability: gql.accountability, schema: gql.schema, }); const authService = new AuthenticationService({ accountability: gql.accountability, schema: gql.schema, }); await authService.verifyPassword(gql.accountability.user, args['password']); const { url, secret } = await service.generateTFA(gql.accountability.user); return { secret, otpauth_url: url }; }, }, users_me_tfa_enable: { type: GraphQLBoolean, args: { otp: new GraphQLNonNull(GraphQLString), secret: new GraphQLNonNull(GraphQLString), }, resolve: async (_, args) => { if (!gql.accountability?.user) return null; const service = new TFAService({ accountability: gql.accountability, schema: gql.schema, }); await service.enableTFA(gql.accountability.user, args['otp'], args['secret']); return true; }, }, users_me_tfa_disable: { type: GraphQLBoolean, args: { otp: new GraphQLNonNull(GraphQLString), }, resolve: async (_, args) => { if (!gql.accountability?.user) return null; const service = new TFAService({ accountability: gql.accountability, schema: gql.schema, }); const otpValid = await service.verifyOTP(gql.accountability.user, args['otp']); if (otpValid === false) { throw new InvalidPayloadError({ reason: `"otp" is invalid` }); } await service.disableTFA(gql.accountability.user); return true; }, }, utils_random_string: { type: GraphQLString, args: { length: GraphQLInt, }, resolve: async (_, args) => { const { nanoid } = await import('nanoid'); if (args['length'] !== undefined && (args['length'] < 1 || args['length'] > 500)) { throw new InvalidPayloadError({ reason: `"length" must be between 1 and 500` }); } return nanoid(args['length'] ? args['length'] : 32); }, }, utils_hash_generate: { type: GraphQLString, args: { string: new GraphQLNonNull(GraphQLString), }, resolve: async (_, args) => { return await generateHash(args['string']); }, }, utils_hash_verify: { type: GraphQLBoolean, args: { string: new GraphQLNonNull(GraphQLString), hash: new GraphQLNonNull(GraphQLString), }, resolve: async (_, args) => { return await argon2.verify(args['hash'], args['string']); }, }, utils_sort: { type: GraphQLBoolean, args: { collection: new GraphQLNonNull(GraphQLString), item: new GraphQLNonNull(GraphQLID), to: new GraphQLNonNull(GraphQLID), }, resolve: async (_, args) => { const service = new UtilsService({ accountability: gql.accountability, schema: gql.schema, }); const { item, to } = args; await service.sort(args['collection'], { item, to }); return true; }, }, utils_revert: { type: GraphQLBoolean, args: { revision: new GraphQLNonNull(GraphQLID), }, resolve: async (_, args) => { const service = new RevisionsService({ accountability: gql.accountability, schema: gql.schema, }); await service.revert(args['revision']); return true; }, }, utils_cache_clear: { type: GraphQLVoid, resolve: async () => { if (gql.accountability?.admin !== true) { throw new ForbiddenError(); } const { cache } = getCache(); await cache?.clear(); await clearSystemCache(); return; }, }, users_invite_accept: { type: GraphQLBoolean, args: { token: new GraphQLNonNull(GraphQLString), password: new GraphQLNonNull(GraphQLString), }, resolve: async (_, args) => { const service = new UsersService({ accountability: gql.accountability, schema: gql.schema, }); await service.acceptInvite(args['token'], args['password']); return true; }, }, users_register: { type: GraphQLBoolean, args: { email: new GraphQLNonNull(GraphQLString), password: new GraphQLNonNull(GraphQLString), verification_url: GraphQLString, first_name: GraphQLString, last_name: GraphQLString, }, resolve: async (_, args, { req }) => { const service = new UsersService({ accountability: null, schema: gql.schema }); const ip = req ? getIPFromReq(req) : null; if (ip) { await rateLimiter.consume(ip); } await service.registerUser({ email: args.email, password: args.password, verification_url: args.verification_url, first_name: args.first_name, last_name: args.last_name, }); return true; }, }, users_register_verify: { type: GraphQLBoolean, args: { token: new GraphQLNonNull(GraphQLString), }, resolve: async (_, args) => { const service = new UsersService({ accountability: null, schema: gql.schema }); await service.verifyRegistration(args.token); return true; }, }, }); }