From 5343f35e5b572fe366a8712b24bd735de30e6170 Mon Sep 17 00:00:00 2001 From: sct Date: Fri, 28 Aug 2020 09:34:15 +0900 Subject: [PATCH] feat(api): initial implementation of the auth system (#30) Adds the auth system but does not add all required features. They will be handled in other tickets --- package.json | 7 ++++ server/api/plextv.ts | 62 ++++++++++++++++++++++++++++ server/entity/Session.ts | 15 +++++++ server/index.ts | 24 ++++++++++- server/middleware/auth.ts | 29 +++++++++++++ server/routes/auth.ts | 65 ++++++++++++++++++++++++++++++ server/routes/index.ts | 10 ++++- server/tsconfig.json | 3 +- server/types/express.d.ts | 21 ++++++++++ yarn.lock | 85 +++++++++++++++++++++++++++++++++++++-- 10 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 server/api/plextv.ts create mode 100644 server/entity/Session.ts create mode 100644 server/middleware/auth.ts create mode 100644 server/routes/auth.ts create mode 100644 server/types/express.d.ts diff --git a/package.json b/package.json index 17aef5445..f0a40b23d 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,12 @@ "dependencies": { "@tailwindcss/ui": "^0.5.0", "axios": "^0.19.2", + "body-parser": "^1.19.0", "bowser": "^2.10.0", + "connect-typeorm": "^1.1.4", + "cookie-parser": "^1.4.5", "express": "^4.17.1", + "express-session": "^1.17.1", "next": "9.5.2", "react": "16.13.1", "react-dom": "16.13.1", @@ -26,7 +30,10 @@ "devDependencies": { "@commitlint/cli": "^9.1.2", "@commitlint/config-conventional": "^9.1.2", + "@types/body-parser": "^1.19.0", + "@types/cookie-parser": "^1.4.2", "@types/express": "^4.17.7", + "@types/express-session": "^1.17.0", "@types/node": "^14.0.27", "@types/react": "^16.9.46", "@types/react-transition-group": "^4.4.0", diff --git a/server/api/plextv.ts b/server/api/plextv.ts new file mode 100644 index 000000000..26f090fb1 --- /dev/null +++ b/server/api/plextv.ts @@ -0,0 +1,62 @@ +import axios, { AxiosInstance } from 'axios'; + +interface PlexAccountResponse { + user: PlexUser; +} + +interface PlexUser { + id: number; + uuid: string; + email: string; + joined_at: string; + username: string; + title: string; + thumb: string; + hasPassword: boolean; + authToken: string; + subscription: { + active: boolean; + status: string; + plan: string; + features: string[]; + }; + roles: { + roles: string[]; + }; + entitlements: string[]; +} + +class PlexTvAPI { + private authToken: string; + private axios: AxiosInstance; + + constructor(authToken: string) { + this.authToken = authToken; + this.axios = axios.create({ + baseURL: 'https://plex.tv', + headers: { + 'X-Plex-Token': this.authToken, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + } + + public async getUser(): Promise { + try { + const account = await this.axios.get( + '/users/account.json' + ); + + return account.data.user; + } catch (e) { + console.error( + 'Something broke when getting account from plex.tv', + e.message + ); + throw new Error('Invalid auth token'); + } + } +} + +export default PlexTvAPI; diff --git a/server/entity/Session.ts b/server/entity/Session.ts new file mode 100644 index 000000000..e7462c195 --- /dev/null +++ b/server/entity/Session.ts @@ -0,0 +1,15 @@ +import { ISession } from 'connect-typeorm'; +import { Index, Column, PrimaryColumn, Entity } from 'typeorm'; + +@Entity() +export class Session implements ISession { + @Index() + @Column('bigint') + public expiredAt = Date.now(); + + @PrimaryColumn('varchar', { length: 255 }) + public id = ''; + + @Column('text') + public json = ''; +} diff --git a/server/index.ts b/server/index.ts index 1a3266ae0..6f59a8125 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,7 +1,12 @@ import express from 'express'; import next from 'next'; -import { createConnection } from 'typeorm'; +import { createConnection, getRepository } from 'typeorm'; import routes from './routes'; +import bodyParser from 'body-parser'; +import cookieParser from 'cookie-parser'; +import session from 'express-session'; +import { TypeormStore } from 'connect-typeorm/out'; +import { Session } from './entity/Session'; const dev = process.env.NODE_ENV !== 'production'; const app = next({ dev }); @@ -13,6 +18,23 @@ app .prepare() .then(() => { const server = express(); + server.use(cookieParser()); + server.use(bodyParser.json()); + server.use(bodyParser.urlencoded({ extended: true })); + + // Setup sessions + const sessionRespository = getRepository(Session); + server.use( + session({ + secret: 'verysecret', + resave: false, + saveUninitialized: false, + store: new TypeormStore({ + cleanupLimit: 2, + ttl: 86400, + }).connect(sessionRespository), + }) + ); server.use('/api', routes); server.get('*', (req, res) => handle(req, res)); diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts new file mode 100644 index 000000000..6330721cc --- /dev/null +++ b/server/middleware/auth.ts @@ -0,0 +1,29 @@ +import { getRepository } from 'typeorm'; +import { User } from '../entity/User'; +import { Middleware } from '../types/express'; + +export const checkUser: Middleware = async (req, _res, next) => { + if (req.session?.userId) { + const userRepository = getRepository(User); + + const user = await userRepository.findOne({ + where: { id: req.session.userId }, + }); + + if (user) { + req.user = user; + } + } + next(); +}; + +export const isAuthenticated: Middleware = async (req, res, next) => { + if (!req.user) { + res.status(403).json({ + status: 403, + error: 'You do not have permisson to access this endpoint', + }); + } else { + next(); + } +}; diff --git a/server/routes/auth.ts b/server/routes/auth.ts new file mode 100644 index 000000000..1ab0ca14d --- /dev/null +++ b/server/routes/auth.ts @@ -0,0 +1,65 @@ +import { Router } from 'express'; +import { getRepository } from 'typeorm'; +import { User } from '../entity/User'; +import PlexTvAPI from '../api/plextv'; + +const authRoutes = Router(); + +authRoutes.post('/login', async (req, res) => { + const userRepository = getRepository(User); + const body = req.body as { authToken?: string }; + + if (!body.authToken) { + return res.status(500).json({ error: 'You must provide an auth token' }); + } + try { + // First we need to use this auth token to get the users email from plex tv + const plextv = new PlexTvAPI(body.authToken); + const account = await plextv.getUser(); + + // Next let's see if the user already exists + let user = await userRepository.findOne({ + where: { email: account.email }, + }); + + if (user) { + // Let's check if their plex token is up to date + if (user.plexToken !== body.authToken) { + user.plexToken = body.authToken; + await userRepository.save(user); + } + } else { + // Here we check if it's the first user. If it is, we create the user with no check + // and give them admin permissions + const totalUsers = await userRepository.count(); + + if (totalUsers === 0) { + user = new User({ + email: account.email, + plexToken: account.authToken, + // TODO: When we add permissions in #52, set admin here + }); + await userRepository.save(user); + } + + // If we get to this point, the user does not already exist so we need to create the + // user _assuming_ they have access to the plex server + // (We cant do this until we finish the settings sytem and actually + // store the user token in ticket #55) + } + + // Set logged in session + if (req.session && user) { + req.session.userId = user.id; + } + + return res.status(200).json({ status: 'ok' }); + } catch (e) { + console.error(e); + res + .status(500) + .json({ error: 'Something went wrong. Is your auth token valid?' }); + } +}); + +export default authRoutes; diff --git a/server/routes/index.ts b/server/routes/index.ts index e30c59d1b..50dedc422 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -1,9 +1,13 @@ import { Router } from 'express'; import user from './user'; +import authRoutes from './auth'; +import { checkUser, isAuthenticated } from '../middleware/auth'; const router = Router(); -router.use('/user', user); +router.use(checkUser); +router.use('/user', isAuthenticated, user); +router.use('/auth', authRoutes); router.get('/', (req, res) => { return res.status(200).json({ @@ -12,4 +16,8 @@ router.get('/', (req, res) => { }); }); +router.all('*', (req, res) => + res.status(404).json({ status: 404, message: '404 Not Found' }) +); + export default router; diff --git a/server/tsconfig.json b/server/tsconfig.json index 773022433..4edf3b656 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -6,7 +6,8 @@ "noEmit": false, "strictPropertyInitialization": false, "experimentalDecorators": true, - "emitDecoratorMetadata": true + "emitDecoratorMetadata": true, + "typeRoots": ["types"] }, "include": ["**/*.ts", "**/*.tsx"] } diff --git a/server/types/express.d.ts b/server/types/express.d.ts new file mode 100644 index 000000000..8b1487c11 --- /dev/null +++ b/server/types/express.d.ts @@ -0,0 +1,21 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type { NextFunction, Request, Response } from 'express'; +import type { User } from '../entity/User'; + +declare global { + namespace Express { + export interface Session { + userId?: number; + } + + export interface Request { + user?: User; + } + } +} + +export type Middleware = ( + req: Request, + res: Response, + next: NextFunction +) => Promise | void | NextFunction; diff --git a/yarn.lock b/yarn.lock index 312428e69..4ce0cf59a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1242,7 +1242,7 @@ hex-rgb "^4.1.0" postcss-selector-parser "^6.0.2" -"@types/body-parser@*": +"@types/body-parser@*", "@types/body-parser@^1.19.0": version "1.19.0" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f" integrity sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ== @@ -1262,6 +1262,18 @@ dependencies: "@types/node" "*" +"@types/cookie-parser@^1.4.2": + version "1.4.2" + resolved "https://registry.yarnpkg.com/@types/cookie-parser/-/cookie-parser-1.4.2.tgz#e4d5c5ffda82b80672a88a4281aaceefb1bd9df5" + integrity sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg== + dependencies: + "@types/express" "*" + +"@types/debug@0.0.31": + version "0.0.31" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-0.0.31.tgz#bac8d8aab6a823e91deb7f79083b2a35fa638f33" + integrity sha512-LS1MCPaQKqspg7FvexuhmDbWUhE2yIJ+4AgVIyObfc06/UKZ8REgxGNjZc82wPLWmbeOm7S+gSsLgo75TanG4A== + "@types/eslint-visitor-keys@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" @@ -1276,7 +1288,15 @@ "@types/qs" "*" "@types/range-parser" "*" -"@types/express@^4.17.7": +"@types/express-session@^1.15.5", "@types/express-session@^1.17.0": + version "1.17.0" + resolved "https://registry.yarnpkg.com/@types/express-session/-/express-session-1.17.0.tgz#770daf81368f6278e3e40dd894e1e52abbdca0cd" + integrity sha512-OQEHeBFE1UhChVIBhRh9qElHUvTp4BzKKHxMDkGHT7WuYk5eL93hPG7D8YAIkoBSbhNEY0RjreF15zn+U0eLjA== + dependencies: + "@types/express" "*" + "@types/node" "*" + +"@types/express@*", "@types/express@^4.17.7": version "4.17.7" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.7.tgz#42045be6475636d9801369cd4418ef65cdb0dd59" integrity sha512-dCOT5lcmV/uC2J9k0rPafATeeyz+99xTt54ReX11/LObZgfzJqZNcW27zGhYyX+9iSEGXGt5qLPwRSvBZcLvtQ== @@ -2092,7 +2112,7 @@ bn.js@^5.1.1: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.1.3.tgz#beca005408f642ebebea80b042b4d18d2ac0ee6b" integrity sha512-GkTiFpjFtUzU9CbMeJ5iazkCzGL3jrhzerzZIuqLABjbwRaFt33I9tUdSNryIptM+RxDet6OKm2WnLXzW51KsQ== -body-parser@1.19.0: +body-parser@1.19.0, body-parser@^1.19.0: version "1.19.0" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw== @@ -2764,6 +2784,16 @@ configstore@^5.0.1: write-file-atomic "^3.0.0" xdg-basedir "^4.0.0" +connect-typeorm@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/connect-typeorm/-/connect-typeorm-1.1.4.tgz#80f4e60dc4eeb6c04de334c30bf3e4bb766a34e5" + integrity sha512-1/0b1aFzip0UBzuaSUkVZE4EW3n4UZzi3x2fmKeCaOb+sB4VZGj79txzLvtWIHLYY6kls/PKNcQHfEurdEjsUw== + dependencies: + "@types/debug" "0.0.31" + "@types/express-session" "^1.15.5" + debug "^4.1.1" + express-session "^1.15.6" + console-browserify@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" @@ -2838,6 +2868,14 @@ convert-source-map@^0.3.3: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-0.3.5.tgz#f1d802950af7dd2631a1febe0596550c86ab3190" integrity sha1-8dgClQr33SYxof6+BZZVDIarMZA= +cookie-parser@^1.4.5: + version "1.4.5" + resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.5.tgz#3e572d4b7c0c80f9c61daf604e4336831b5d1d49" + integrity sha512-f13bPUj/gG/5mDr+xLmSxxDsB9DQiTIfhJS/sqjrmfAWiAN+x2O4i/XguTL9yDZ+/IFDanJ+5x7hC4CXT9Tdzw== + dependencies: + cookie "0.4.0" + cookie-signature "1.0.6" + cookie-signature@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" @@ -3253,6 +3291,11 @@ depd@~1.1.2: resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= +depd@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + des.js@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.1.tgz#5382142e1bdc53f85d86d53e5f4aa7deb91e0843" @@ -3802,6 +3845,20 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" +express-session@^1.15.6, express-session@^1.17.1: + version "1.17.1" + resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.17.1.tgz#36ecbc7034566d38c8509885c044d461c11bf357" + integrity sha512-UbHwgqjxQZJiWRTMyhvWGvjBQduGCSBDhhZXYenziMFjxst5rMV+aJZ6hKPHZnPyHGsrqRICxtX8jtEbm/z36Q== + dependencies: + cookie "0.4.0" + cookie-signature "1.0.6" + debug "2.6.9" + depd "~2.0.0" + on-headers "~1.0.2" + parseurl "~1.3.3" + safe-buffer "5.2.0" + uid-safe "~2.1.5" + express@^4.17.1: version "4.17.1" resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" @@ -6194,6 +6251,11 @@ on-finished@~2.3.0: dependencies: ee-first "1.1.1" +on-headers@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" + integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== + once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -7095,6 +7157,11 @@ quick-lru@^4.0.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== +random-bytes@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" + integrity sha1-T2ih3Arli9P7lYSMMDJNt11kNgs= + randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -7536,6 +7603,11 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== +safe-buffer@5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" + integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== + safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" @@ -8614,6 +8686,13 @@ typescript@^3.9.7: resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.7.tgz#98d600a5ebdc38f40cb277522f12dc800e9e25fa" integrity sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw== +uid-safe@~2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a" + integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA== + dependencies: + random-bytes "~1.0.0" + undefsafe@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.3.tgz#6b166e7094ad46313b2202da7ecc2cd7cc6e7aae"