diff --git a/components/common/Container/Container.module.scss b/components/common/Container/Container.module.scss index 0ed77d4..83706ad 100644 --- a/components/common/Container/Container.module.scss +++ b/components/common/Container/Container.module.scss @@ -2,4 +2,6 @@ max-width: 1080px; margin: 0 auto; padding: 1rem; + background-color: #fff; + min-height: calc(100vh - 54px); } diff --git a/components/common/Header/Header.module.scss b/components/common/Header/Header.module.scss index 6b7d541..b46b7e8 100644 --- a/components/common/Header/Header.module.scss +++ b/components/common/Header/Header.module.scss @@ -24,6 +24,10 @@ &.active { font-weight: bold; + + a { + background-color: #00aaff; + } } a { diff --git a/lib/utils/crypto.ts b/lib/utils/crypto.ts new file mode 100644 index 0000000..8301be7 --- /dev/null +++ b/lib/utils/crypto.ts @@ -0,0 +1,47 @@ +import crypto from 'crypto'; +import { CLIENT_SECRET } from '../constants'; + +const IV_LENGTH = 16; +const ALGORITHM = 'aes-256-cbc'; + +export const generateString = (length: number): string => + crypto.randomBytes(length).toString('hex').slice(0, length); + +// https://stackoverflow.com/q/52212430 +/** + * Symmetric encryption function + * @param value String to encrypt + * @returns Encrypted text + */ +export const encrypt = (value: string) => { + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv( + ALGORITHM, + Buffer.from(CLIENT_SECRET, 'hex'), + iv + ); + let encrypted = cipher.update(value); + encrypted = Buffer.concat([encrypted, cipher.final()]); + return `${iv.toString('hex')}:${encrypted.toString('hex')}`; +}; + +/** + * Symmetric decryption function + * @param text Encrypted string + * @returns Decrypted text + */ +export const decrypt = (text: string) => { + const [iv, encryptedText] = text + .split(':') + .map((part) => Buffer.from(part, 'hex')); + + const decipher = crypto.createDecipheriv( + ALGORITHM, + Buffer.from(CLIENT_SECRET, 'hex'), + iv + ); + + let decrypted = decipher.update(encryptedText); + decrypted = Buffer.concat([decrypted, decipher.final()]); + return decrypted.toString(); +}; diff --git a/pages/_document.tsx b/pages/_document.tsx index 1af2875..8a4efe2 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -1,5 +1,5 @@ import { Head, Html, Main, NextScript } from 'next/document'; -// The stylesheet is for Univia-pro font from Adobe + export default function Document() { return ( diff --git a/pages/api/callback.ts b/pages/api/callback.ts index 826fb5a..069ccf4 100644 --- a/pages/api/callback.ts +++ b/pages/api/callback.ts @@ -2,23 +2,48 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { getAccessToken } from '../../lib/api/remote'; import Cookies from 'cookies'; -import { COOKIE_KEYS } from '../../lib/constants'; +import { COOKIE_KEYS, PUBLIC_URL } from '../../lib/constants'; +import { decrypt } from '../../lib/utils/crypto'; + +const redirect = `${PUBLIC_URL}/api/callback`; export default async function handler( req: NextApiRequest, res: NextApiResponse ) { if (req.query.code) { - // TODO: parse state + if (!req.query.state) { + return res.redirect('/'); + } + const getAuth = await getAccessToken(req.query.code as string); const cookies = new Cookies(req, res, { keys: COOKIE_KEYS }); + if (getAuth) { + const decrypted = decrypt(req.query.state as string); + const stateToken = cookies.get('validation', { signed: true }); + const parsedState = JSON.parse(decrypted); + + if ( + parsedState.state !== stateToken || + parsedState.redirect_uri !== redirect + ) { + return res.redirect('/'); + } + cookies.set('authorization', getAuth.access_token, { expires: new Date(Date.now() + getAuth.expires_in * 1000), secure: process.env.NODE_ENV === 'production', signed: true, }); + + cookies.set('validation', undefined, { + expires: new Date(0), + secure: process.env.NODE_ENV === 'production', + signed: true, + }); } + res.redirect('/'); } } diff --git a/pages/api/hello.ts b/pages/api/hello.ts deleted file mode 100644 index f8bcc7e..0000000 --- a/pages/api/hello.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Next.js API route support: https://nextjs.org/docs/api-routes/introduction -import type { NextApiRequest, NextApiResponse } from 'next' - -type Data = { - name: string -} - -export default function handler( - req: NextApiRequest, - res: NextApiResponse -) { - res.status(200).json({ name: 'John Doe' }) -} diff --git a/pages/api/login.ts b/pages/api/login.ts index 27b85d4..1b0e59d 100644 --- a/pages/api/login.ts +++ b/pages/api/login.ts @@ -1,15 +1,37 @@ +import Cookies from 'cookies'; import { NextApiRequest, NextApiResponse } from 'next'; -import { CLIENT_ID, OAUTH_URL, PUBLIC_URL } from '../../lib/constants'; +import { + CLIENT_ID, + COOKIE_KEYS, + OAUTH_URL, + PUBLIC_URL, +} from '../../lib/constants'; +import { encrypt, generateString } from '../../lib/utils/crypto'; + +const redirect = `${PUBLIC_URL}/api/callback`; export default function handler(req: NextApiRequest, res: NextApiResponse) { + const stateToken = generateString(16); + const state = encrypt( + JSON.stringify({ + redirect_uri: redirect, + state: stateToken, + }) + ); const params = new URLSearchParams({ client_id: CLIENT_ID, response_type: 'code', - redirect_uri: `${PUBLIC_URL}/api/callback`, + redirect_uri: redirect, scope: 'management', + state, }); - // TODO: generate state + const cookies = new Cookies(req, res, { keys: COOKIE_KEYS }); + + cookies.set('validation', stateToken, { + secure: process.env.NODE_ENV === 'production', + signed: true, + }); res.redirect(`${OAUTH_URL}/authorize?${params.toString()}`); } diff --git a/styles/globals.scss b/styles/globals.scss index 4f18421..2ceac67 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -4,6 +4,7 @@ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; + background-color: rgb(231, 231, 231); } a {