From d8af09ae026714a96cc4da26d6d4a0666e289688 Mon Sep 17 00:00:00 2001 From: Bart van den Aardweg Date: Thu, 15 Apr 2021 22:50:24 +0200 Subject: [PATCH] refactor: improve hoc, logouthandler --- example/src/pages/_app.tsx | 1 + example/src/pages/api/logout.ts | 5 ++ example/src/pages/index.tsx | 10 +++ jest.config.js | 1 + src/api/__tests__/logoutHandler.test.ts | 83 +++++++++++++++++++++++++ src/api/index.ts | 1 + src/api/logoutHandler.ts | 38 +++++++++++ src/hoc/LoginComponent.tsx | 1 + src/hoc/withAuth.tsx | 33 ---------- src/hoc/withPasswordProtect.tsx | 39 +++++++----- 10 files changed, 163 insertions(+), 49 deletions(-) create mode 100644 example/src/pages/api/logout.ts create mode 100644 src/api/__tests__/logoutHandler.test.ts create mode 100644 src/api/logoutHandler.ts delete mode 100644 src/hoc/withAuth.tsx diff --git a/example/src/pages/_app.tsx b/example/src/pages/_app.tsx index ca4a774..ab66752 100644 --- a/example/src/pages/_app.tsx +++ b/example/src/pages/_app.tsx @@ -30,6 +30,7 @@ class MyApp extends App { export default process.env.PASSWORD_PROTECT ? withPasswordProtect(MyApp, { loginComponentProps: { + backUrl: 'https://github.com/storyofams/next-password-protect', logo: 'https://storyofams.com/public/story-of-ams-logo-big@2x.png', }, }) diff --git a/example/src/pages/api/logout.ts b/example/src/pages/api/logout.ts new file mode 100644 index 0000000..5050f77 --- /dev/null +++ b/example/src/pages/api/logout.ts @@ -0,0 +1,5 @@ +import { logoutHandler } from '@storyofams/next-password-protect'; + +export default logoutHandler({ + cookieName: 'authorization', +}); diff --git a/example/src/pages/index.tsx b/example/src/pages/index.tsx index 2bd7e39..8f0469a 100644 --- a/example/src/pages/index.tsx +++ b/example/src/pages/index.tsx @@ -3,6 +3,15 @@ import React from 'react'; import { NextSeo } from 'next-seo'; const Home = () => { + const clearCookie = async () => { + await fetch('/api/logout', { + method: 'post', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + }); + window.location.reload(); + }; + return ( <> { description="Next password protect example app" />

Logged in page

+ ); }; diff --git a/jest.config.js b/jest.config.js index df732f4..911f0b7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,4 +20,5 @@ module.exports = { '@testing-library/jest-dom/extend-expect', './jest.setup.js', ], + collectCoverageFrom: ['src/**/*.{ts,tsx}'], }; diff --git a/src/api/__tests__/logoutHandler.test.ts b/src/api/__tests__/logoutHandler.test.ts new file mode 100644 index 0000000..c4089f6 --- /dev/null +++ b/src/api/__tests__/logoutHandler.test.ts @@ -0,0 +1,83 @@ +import { EventEmitter } from 'events'; +import { createMocks } from 'node-mocks-http'; + +import { logoutHandler } from '../logoutHandler'; + +describe('[api] logoutHandler', () => { + it('should set max age on cookie to now to clear', async () => { + const { req, res } = createMocks( + { method: 'POST', body: { password: 'password' } }, + { eventEmitter: EventEmitter }, + ); + + const maxAge = 0; + const now = Date.now(); + + await logoutHandler()(req as any, res as any); + + expect(res._getStatusCode()).toBe(200); + expect(res._getHeaders()).toHaveProperty( + 'set-cookie', + `next-password-protect=; Max-Age=${maxAge}; Path=/; Expires=${new Date( + now + maxAge, + ).toUTCString()}; HttpOnly`, + ); + + jest.restoreAllMocks(); + }); + + it('should handle secure option', async () => { + const { req, res } = createMocks( + { method: 'POST', body: { password: 'password' } }, + { eventEmitter: EventEmitter }, + ); + + const maxAge = 0; + const now = Date.now(); + + await logoutHandler({ cookieSecure: true })(req as any, res as any); + + expect(res._getStatusCode()).toBe(200); + expect(res._getHeaders()).toHaveProperty( + 'set-cookie', + `next-password-protect=; Max-Age=${maxAge}; Path=/; Expires=${new Date( + now + maxAge, + ).toUTCString()}; HttpOnly; Secure`, + ); + + jest.restoreAllMocks(); + }); + + it('should throw on incorrect method', async () => { + const { req, res } = createMocks( + { method: 'GET' }, + { eventEmitter: EventEmitter }, + ); + + await logoutHandler()(req as any, res as any); + + expect(res._getStatusCode()).toBe(500); + + jest.restoreAllMocks(); + }); + + it('should gracefully error', async () => { + const { req, res } = createMocks( + { method: 'POST', body: { password: 'password' } }, + { eventEmitter: EventEmitter }, + ); + + jest.spyOn(Date, 'now').mockImplementation(() => { + throw new Error(); + }); + + await logoutHandler()(req as any, res as any); + + expect(res._getStatusCode()).toBe(500); + expect(res._getData()).toBe( + JSON.stringify({ message: 'An error has occured.' }), + ); + + jest.restoreAllMocks(); + }); +}); diff --git a/src/api/index.ts b/src/api/index.ts index 1b926ce..d4d74b4 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,2 +1,3 @@ export * from './passwordCheckHandler'; export * from './loginHandler'; +export * from './logoutHandler'; diff --git a/src/api/logoutHandler.ts b/src/api/logoutHandler.ts new file mode 100644 index 0000000..e403fef --- /dev/null +++ b/src/api/logoutHandler.ts @@ -0,0 +1,38 @@ +import { Request, Response } from 'express'; + +import { sendJson } from './sendJson'; +import { setCookie } from './setCookie'; + +interface PasswordProtectHandlerOptions { + /* @default next-password-protect */ + cookieName?: string; + cookieSameSite?: boolean | 'lax' | 'none' | 'strict'; + cookieSecure?: boolean; +} + +export const logoutHandler = ( + options?: PasswordProtectHandlerOptions, +) => async (req: Request, res: Response) => { + res.setHeader('Content-Type', 'application/json'); + + try { + if (req.method !== 'POST') { + throw new Error('Invalid method.'); + } + + setCookie(res, options?.cookieName || 'next-password-protect', '', { + httpOnly: true, + sameSite: options?.cookieSameSite || false, + secure: + options?.cookieSecure !== undefined + ? options?.cookieSecure + : process.env.NODE_ENV === 'production', + path: '/', + maxAge: 0, + }); + + sendJson(res, 200); + } catch (err) { + sendJson(res, 500, { message: err.message || 'An error has occured.' }); + } +}; diff --git a/src/hoc/LoginComponent.tsx b/src/hoc/LoginComponent.tsx index e67299f..8444278 100644 --- a/src/hoc/LoginComponent.tsx +++ b/src/hoc/LoginComponent.tsx @@ -252,6 +252,7 @@ export const LoginComponent = ({ color: buttonColor || '#111', marginTop: '32px', cursor: 'pointer', + textAlign: 'center', }} > {isBusy ? 'Logging in...' : 'Login'} diff --git a/src/hoc/withAuth.tsx b/src/hoc/withAuth.tsx deleted file mode 100644 index fbeb9c4..0000000 --- a/src/hoc/withAuth.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import { useAmp } from 'next/amp'; - -import { LoginComponentProps } from './LoginComponent'; - -export const withAuth = ( - WrappedComponent, - LoginComponent, - pageProps, - isAuthenticated: boolean, - loginComponentProps: LoginComponentProps, -) => { - const Component = (props) => { - const isAmp = useAmp(); - - if (isAuthenticated === undefined) { - return null; - } - - if (isAuthenticated) { - return ; - } - - // AMP is not yet supported - if (isAmp) { - return null; - } - - return ; - }; - - return Component; -}; diff --git a/src/hoc/withPasswordProtect.tsx b/src/hoc/withPasswordProtect.tsx index 0be5edd..d1eb517 100644 --- a/src/hoc/withPasswordProtect.tsx +++ b/src/hoc/withPasswordProtect.tsx @@ -1,18 +1,18 @@ -import React, { ReactNode, useEffect, useState } from 'react'; +import React, { ElementType, useEffect, useState } from 'react'; +import { useAmp } from 'next/amp'; import type { AppProps } from 'next/app'; import { LoginComponent as DefaultLoginComponent, LoginComponentProps, } from './LoginComponent'; -import { withAuth } from './withAuth'; interface PasswordProtectHOCOptions { /* @default /api/passwordCheck */ checkApiUrl?: string; /* @default /api/login */ loginApiUrl?: string; - loginComponent?: ReactNode; + loginComponent?: ElementType; loginComponentProps?: Omit; } @@ -22,6 +22,7 @@ export const withPasswordProtect = ( options?: PasswordProtectHOCOptions, ) => { const ProtectedApp = ({ Component, pageProps, ...props }: AppProps) => { + const isAmp = useAmp(); const [isAuthenticated, setAuthenticated] = useState( undefined, ); @@ -46,20 +47,26 @@ export const withPasswordProtect = ( checkIfLoggedIn(); }, []); + if (isAuthenticated === undefined) { + return null; + } + + if (isAuthenticated) { + return ; + } + + // AMP is not yet supported + if (isAmp) { + return null; + } + + const LoginComponent: ElementType = + options?.loginComponent || DefaultLoginComponent; + return ( - ); };