From aeb4ec047276177accf9a69e648ee9656ce3dc9c Mon Sep 17 00:00:00 2001 From: Pyll Gomez <57198612+workatease@users.noreply.github.com> Date: Wed, 24 Feb 2021 10:37:13 -0500 Subject: [PATCH] API hooks for event added for auth.login (#4255) * Rotate JPG image on upload #4206 * fixes #3949 width/height generated for gif and tif * API hooks for event added for auth.login #4079 * updated doc for api hooks for new auth.login event * Style tweaks * Update docs * Tweak docs some more * Spelling error * Allow non-required flags and pass to hook Co-authored-by: rijkvanzanten --- api/src/controllers/auth.ts | 8 +--- api/src/services/authentication.ts | 42 ++++++++++++++++++- docs/guides/api-hooks.md | 67 ++++++++++++++++++------------ 3 files changed, 82 insertions(+), 35 deletions(-) diff --git a/api/src/controllers/auth.ts b/api/src/controllers/auth.ts index 758e49e2ee..28933598c0 100644 --- a/api/src/controllers/auth.ts +++ b/api/src/controllers/auth.ts @@ -21,7 +21,7 @@ const loginSchema = Joi.object({ password: Joi.string().required(), mode: Joi.string().valid('cookie', 'json'), otp: Joi.string(), -}); +}).unknown(); router.post( '/login', @@ -40,19 +40,15 @@ router.post( const { error } = loginSchema.validate(req.body); if (error) throw new InvalidPayloadException(error.message); - const { email, password, otp } = req.body; - const mode = req.body.mode || 'json'; const ip = req.ip; const userAgent = req.get('user-agent'); const { accessToken, refreshToken, expires } = await authenticationService.authenticate({ + ...req.body, ip, userAgent, - email, - password, - otp, }); const payload = { diff --git a/api/src/services/authentication.ts b/api/src/services/authentication.ts index a2a9eec281..cf84d8bd1a 100644 --- a/api/src/services/authentication.ts +++ b/api/src/services/authentication.ts @@ -4,11 +4,13 @@ import argon2 from 'argon2'; import { nanoid } from 'nanoid'; import ms from 'ms'; import { InvalidCredentialsException, InvalidPayloadException, InvalidOTPException } from '../exceptions'; -import { Session, Accountability, AbstractServiceOptions, Action } from '../types'; +import { Session, Accountability, AbstractServiceOptions, Action, SchemaOverview } from '../types'; import Knex from 'knex'; import { ActivityService } from '../services/activity'; import env from '../env'; import { authenticator } from 'otplib'; +import emitter, { emitAsyncSafe } from '../emitter'; +import { omit } from 'lodash'; type AuthenticateOptions = { email: string; @@ -16,17 +18,20 @@ type AuthenticateOptions = { ip?: string | null; userAgent?: string | null; otp?: string; + [key: string]: any; }; export class AuthenticationService { knex: Knex; accountability: Accountability | null; activityService: ActivityService; + schema: SchemaOverview; constructor(options: AbstractServiceOptions) { this.knex = options.knex || database; this.accountability = options.accountability || null; this.activityService = new ActivityService({ knex: this.knex, schema: options.schema }); + this.schema = options.schema; } /** @@ -35,28 +40,58 @@ export class AuthenticationService { * Password is optional to allow usage of this function within the SSO flow and extensions. Make sure * to handle password existence checks elsewhere */ - async authenticate({ email, password, ip, userAgent, otp }: AuthenticateOptions) { + async authenticate(options: AuthenticateOptions) { + const { email, password, ip, userAgent, otp } = options; + + const hookPayload = omit(options, 'password', 'otp'); + const user = await database .select('id', 'password', 'role', 'tfa_secret', 'status') .from('directus_users') .where({ email }) .first(); + await emitter.emitAsync('auth.login.before', hookPayload, { + event: 'auth.login.before', + action: 'login', + schema: this.schema, + payload: hookPayload, + accountability: this.accountability, + status: 'pending', + user: user?.id, + }); + + const emitStatus = (status: 'fail' | 'success') => { + emitAsyncSafe('auth.login', hookPayload, { + event: 'auth.login', + action: 'login', + schema: this.schema, + payload: hookPayload, + accountability: this.accountability, + status, + user: user?.id, + }); + }; + if (!user || user.status !== 'active') { + emitStatus('fail'); throw new InvalidCredentialsException(); } if (password !== undefined) { if (!user.password) { + emitStatus('fail'); throw new InvalidCredentialsException(); } if ((await argon2.verify(user.password, password)) === false) { + emitStatus('fail'); throw new InvalidCredentialsException(); } } if (user.tfa_secret && !otp) { + emitStatus('fail'); throw new InvalidOTPException(`"otp" is required`); } @@ -64,6 +99,7 @@ export class AuthenticationService { const otpValid = await this.verifyOTP(user.id, otp); if (otpValid === false) { + emitStatus('fail'); throw new InvalidOTPException(`"otp" is invalid`); } } @@ -103,6 +139,8 @@ export class AuthenticationService { }); } + emitStatus('success'); + return { accessToken, refreshToken, diff --git a/docs/guides/api-hooks.md b/docs/guides/api-hooks.md index 1f7625afb0..7d0938f9aa 100644 --- a/docs/guides/api-hooks.md +++ b/docs/guides/api-hooks.md @@ -72,31 +72,31 @@ module.exports = function registerHook({ exceptions }) { ### Event Format Options -| Scope | Actions | Before | -| -------------------- | ---------------------------------- | -------- | -| `server` | `start` and `stop` | Optional | -| `init` | | Optional | -| `routes.init` | `before` and `after` | No | -| `routes.custom.init` | `before` and `after` | No | -| `middlewares.init` | `before` and `after` | No | -| `request` | `not_found` | No | -| `response` | | No† | -| `error` | | No | -| `auth` | `success`†, `fail`† and `refresh`† | No | -| `items` | `create`, `update` and `delete` | Optional | -| `activity` | `create`, `update` and `delete` | Optional | -| `collections` | `create`, `update` and `delete` | Optional | -| `fields` | `create`, `update` and `delete` | Optional | -| `files` | `create`, `update` and `delete` | Optional | -| `folders` | `create`, `update` and `delete` | Optional | -| `permissions` | `create`, `update` and `delete` | Optional | -| `presets` | `create`, `update` and `delete` | Optional | -| `relations` | `create`, `update` and `delete` | Optional | -| `revisions` | `create`, `update` and `delete` | Optional | -| `roles` | `create`, `update` and `delete` | Optional | -| `settings` | `create`, `update` and `delete` | Optional | -| `users` | `create`, `update` and `delete` | Optional | -| `webhooks` | `create`, `update` and `delete` | Optional | +| Scope | Actions | Before | +| -------------------- | --------------------------------- | -------- | +| `server` | `start` and `stop` | Optional | +| `init` | | Optional | +| `routes.init` | `before` and `after` | No | +| `routes.custom.init` | `before` and `after` | No | +| `middlewares.init` | `before` and `after` | No | +| `request` | `not_found` | No | +| `response` | | No† | +| `error` | | No | +| `auth` | `login`, `logout`† and `refresh`† | Optional | +| `items` | `create`, `update` and `delete` | Optional | +| `activity` | `create`, `update` and `delete` | Optional | +| `collections` | `create`, `update` and `delete` | Optional | +| `fields` | `create`, `update` and `delete` | Optional | +| `files` | `create`, `update` and `delete` | Optional | +| `folders` | `create`, `update` and `delete` | Optional | +| `permissions` | `create`, `update` and `delete` | Optional | +| `presets` | `create`, `update` and `delete` | Optional | +| `relations` | `create`, `update` and `delete` | Optional | +| `revisions` | `create`, `update` and `delete` | Optional | +| `roles` | `create`, `update` and `delete` | Optional | +| `settings` | `create`, `update` and `delete` | Optional | +| `users` | `create`, `update` and `delete` | Optional | +| `webhooks` | `create`, `update` and `delete` | Optional | † Feature Coming Soon @@ -123,14 +123,14 @@ and the value is the handler function itself. The `registerHook` function receives a context parameter with the following properties: -- `services` — All API interal services +- `services` — All API internal services - `exceptions` — API exception objects that can be used for throwing "proper" errors - `database` — Knex instance that is connected to the current database - `env` — Parsed environment variables ### Event Handler Function -The event handler function (eg: `'items.create': function()`) recieves a context parameter with the following +The event handler function (eg: `'items.create': function()`) receives a context parameter with the following properties: - `event` — Full event string @@ -139,6 +139,19 @@ properties: - `item` — Primary key(s) of the item(s) being modified - `action` — Action that is performed - `payload` — Payload of the request +- `schema` - The current API schema in use + +#### Auth + +The `auth` hooks have the following context properties: + +- `event` — Full event string +- `accountability` — Information about the current user +- `action` — Action that is performed +- `payload` — Payload of the request +- `schema` - The current API schema in use +- `status` - One of `pending`, `success`, `fail` +- `user` - ID of the user that tried logging in/has logged in ## 5. Restart the API