diff --git a/.github/workflows/deploy-functions.yml b/.github/workflows/deploy-functions.yml index fb87923..fadea6d 100644 --- a/.github/workflows/deploy-functions.yml +++ b/.github/workflows/deploy-functions.yml @@ -29,5 +29,5 @@ jobs: cd .. cd functions npm install - - name: deploy functions + - name: deploy functions and hosting routes run: firebase deploy -f --token ${FIREBASE_TOKEN} \ No newline at end of file diff --git a/.github/workflows/functions-ci.yml b/.github/workflows/functions-ci.yml index 7f515ce..bda9e14 100644 --- a/.github/workflows/functions-ci.yml +++ b/.github/workflows/functions-ci.yml @@ -3,7 +3,7 @@ name: Functions CI on: push: branches: - - '*' + - master pull_request: - '*' paths: diff --git a/README.md b/README.md index 318a1ea..8ada62d 100644 --- a/README.md +++ b/README.md @@ -138,12 +138,72 @@ pm2 start ecosystem.config.js pm2 save ``` +Put behind a NGINX reverse proxy with this config (encryption by LetsEncrypt) + +``` +server { + server_name account.lingdocs.com; + + location / { + proxy_pass http://localhost:4000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Access-Control-Allow-Origin *; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + error_page 500 /500.json; + location /500.json { + return 500 '{"ok":false,"error":"500 Internal Server Error"}'; + } + + error_page 502 /502.json; + location /502.json { + return 502 '{"ok":false,"error":"502 Bad Gateway"}'; + } + + error_page 503 /503.json; + location /503.json { + return 503 '{"ok":false,"error":"503 Service Temporarily Unavailable"}'; + } + + error_page 504 /504.json; + location /504.json { + return 504 '{"ok":false,"error":"504 Gateway Timeout"}'; + } + + listen [::]:443 ssl ipv6only=on; # managed by Certbot + listen 443 ssl; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/account.lingdocs.com/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/account.lingdocs.com/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot + +} +server { + if ($host = account.lingdocs.com) { + return 301 https://$host$request_uri; + } # managed by Certbot + + + server_name account.lingdocs.com; + + listen 80; + listen [::]:80; + return 404; # managed by Certbot + +} +``` + #### CouchDB When a user upgrades their account level to `student` or `editor`: 1. A doc in the `_users` db is created with their Firebase Authentication info, account level, and a password they can use for syncing their personal wordlistdb -2. A user database is created (by the firebase functions - *not* by the couchdb_peruser) which they use to sync their personal wordlist. +2. A user database is created (automatically by `couchdb_peruser`) which they use to sync their personal wordlist. There is also a `review-tasks` database which is used to store all the review tasks for editors and syncs with the review tasks in the app for the editor(s). diff --git a/account/.github/workflows/deploy.yml b/account/.github/workflows/deploy.yml deleted file mode 100644 index d08b417..0000000 --- a/account/.github/workflows/deploy.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: CI -on: - push: - branches: [ master ] -jobs: - deploy: - runs-on: self-hosted - steps: - - uses: actions/checkout@v2 - - run: npm install - - run: pm2 restart "auth.lingdocs.com" \ No newline at end of file diff --git a/account/README.md b/account/README.md deleted file mode 100644 index 5c067da..0000000 --- a/account/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# auth.lingdocs.com - -Auth service for LingDocs (in progress, not usable yet) - diff --git a/account/src/lib/couch-db.ts b/account/src/lib/couch-db.ts index ffd80e0..c44ea53 100644 --- a/account/src/lib/couch-db.ts +++ b/account/src/lib/couch-db.ts @@ -2,25 +2,27 @@ import Nano from "nano"; import { DocumentInsertResponse } from "nano"; import { getTimestamp } from "./time-utils"; import env from "./env-vars"; +import * as T from "../../../website/src/lib/account-types"; const nano = Nano(env.couchDbURL); -const usersDb = nano.db.use("test-users"); +const usersDb = nano.db.use("lingdocs-users"); +const userDbPrefix = "userdb-"; -export function updateLastActive(user: LingdocsUser): LingdocsUser { +export function updateLastActive(user: T.LingdocsUser): T.LingdocsUser { return { ...user, lastActive: getTimestamp(), }; } -export function updateLastLogin(user: LingdocsUser): LingdocsUser { +export function updateLastLogin(user: T.LingdocsUser): T.LingdocsUser { return { ...user, lastLogin: getTimestamp(), }; } -function processAPIResponse(user: LingdocsUser, response: DocumentInsertResponse): LingdocsUser | undefined { +function processAPIResponse(user: T.LingdocsUser, response: DocumentInsertResponse): T.LingdocsUser | undefined { if (response.ok !== true) return undefined; return { ...user, @@ -29,7 +31,7 @@ function processAPIResponse(user: LingdocsUser, response: DocumentInsertResponse }; } -export async function getLingdocsUser(field: "email" | "userId" | "githubId" | "googleId" | "twitterId", value: string): Promise { +export async function getLingdocsUser(field: "email" | "userId" | "githubId" | "googleId" | "twitterId", value: string): Promise { const user = await usersDb.find({ selector: field === "githubId" ? { github: { id: value }} @@ -42,10 +44,15 @@ export async function getLingdocsUser(field: "email" | "userId" | "githubId" | " if (!user.docs.length) { return undefined; } - return user.docs[0] as LingdocsUser; + return user.docs[0] as T.LingdocsUser; } -export async function insertLingdocsUser(user: LingdocsUser): Promise { +export async function getAllLingdocsUsers(): Promise { + const users = await usersDb.find({ selector: { userId: { $exists: true }}}); + return users.docs as T.LingdocsUser[]; +} + +export async function insertLingdocsUser(user: T.LingdocsUser): Promise { const res = await usersDb.insert(user); const newUser = processAPIResponse(user, res); if (!newUser) { @@ -54,34 +61,53 @@ export async function insertLingdocsUser(user: LingdocsUser): Promise { +export async function deleteLingdocsUser(uuid: T.UUID): Promise { const user = await getLingdocsUser("userId", uuid); + await deleteCouchDbAuthUser(uuid); if (!user) return; // TODO: cleanup userdbs etc // TODO: Better type certainty here... obviously there is an _id and _rev here await usersDb.destroy(user._id as string, user._rev as string); } +export async function deleteCouchDbAuthUser(uuid: T.UUID): Promise { + const authUsers = nano.db.use("_users"); + const user = await authUsers.find({ selector: { name: uuid }}); + if (!user.docs.length) return; + const u = user.docs[0]; + await authUsers.destroy(u._id, u._rev); +} + // TODO: TO MAKE THIS SAFER, PASS IN JUST THE UPDATING FIELDS!! // TODO: take out the updated object - do just an ID, and then use the toUpdate safe thing -export async function updateLingdocsUser(uuid: UUID, toUpdate: +export async function updateLingdocsUser(uuid: T.UUID, toUpdate: // TODO: OR USE REDUCER?? { name: string } | - { name?: string, email: string, emailVerified: Hash } | + { name?: string, email: string, emailVerified: T.Hash } | { email: string, emailVerified: true } | - { emailVerified: Hash } | + { emailVerified: T.Hash } | { emailVerified: true } | - { password: Hash } | - { google: GoogleProfile | undefined } | - { github: GitHubProfile | undefined } | - { twitter: TwitterProfile | undefined } | + { password: T.Hash } | + { google: T.GoogleProfile | undefined } | + { github: T.GitHubProfile | undefined } | + { twitter: T.TwitterProfile | undefined } | { passwordReset: { - tokenHash: Hash, - requestedOn: TimeStamp, + tokenHash: T.Hash, + requestedOn: T.TimeStamp, }, - } -): Promise { + } | + { + level: "student", + wordlistDbName: T.WordlistDbName, + couchDbPassword: T.UserDbPassword, + upgradeToStudentRequest: undefined, + } | + { userTextOptionsRecord: T.UserTextOptionsRecord } | + { upgradeToStudentRequest: "waiting" } | + { upgradeToStudentRequest: "denied" } | + { lastActive: T.TimeStamp } +): Promise { const user = await getLingdocsUser("userId", uuid); if (!user) throw new Error("unable to update - user not found " + uuid); if ("password" in toUpdate) { @@ -96,3 +122,74 @@ export async function updateLingdocsUser(uuid: UUID, toUpdate: ...toUpdate, }); } + +export async function addCouchDbAuthUser(uuid: T.UUID): Promise<{ password: T.UserDbPassword, userDbName: T.WordlistDbName }> { + const password = generateWordlistDbPassword(); + const userDbName = getWordlistDbName(uuid); + const usersDb = nano.db.use("_users"); + // TODO: prevent conflict if adding an existing user for some reason + const authUser: T.CouchDbAuthUser = { + _id: `org.couchdb.user:${uuid}`, + type: "user", + roles: [], + name: uuid, + password, + }; + await usersDb.insert(authUser); + return { password, userDbName }; +} + +// Instead of these functions, I'm using couch_peruser +// export async function createWordlistDatabase(uuid: T.UUID, password: T.UserDbPassword): Promise<{ name: T.WordlistDbName, password: T.UserDbPassword }> { +// const name = getWordlistDbName(uuid); +// // create wordlist database for user +// await nano.db.create(name); +// const securityInfo = { +// admins: { +// names: [uuid], +// roles: ["_admin"] +// }, +// members: { +// names: [uuid], +// roles: ["_admin"], +// }, +// }; +// const userDb = nano.db.use(name); +// await userDb.insert(securityInfo as any, "_security"); +// return { password, name }; +// } + +// export async function deleteWordlistDatabase(uuid: T.UUID): Promise { +// const name = getWordlistDbName(uuid); +// try { +// await nano.db.destroy(name); +// } catch (e) { +// // allow the error to pass if we're just trying to delete a database that never existed +// if (e.message !== "Database does not exist.") { +// throw new Error("error deleting database"); +// } +// } +// } + +function generateWordlistDbPassword(): T.UserDbPassword { + function makeChunk(): string { + return Math.random().toString(36).slice(2) + } + const password = new Array(4).fill(0).reduce((acc: string): string => ( + acc + makeChunk() + ), ""); + return password as T.UserDbPassword; +} + +function stringToHex(str: string) { + const arr1 = []; + for (let n = 0, l = str.length; n < l; n ++) { + const hex = Number(str.charCodeAt(n)).toString(16); + arr1.push(hex); + } + return arr1.join(''); +} + +export function getWordlistDbName(uid: T.UUID): T.WordlistDbName { + return `${userDbPrefix}${stringToHex(uid)}` as T.WordlistDbName; +} \ No newline at end of file diff --git a/account/src/lib/env-vars.ts b/account/src/lib/env-vars.ts index b56e93e..4169fe3 100644 --- a/account/src/lib/env-vars.ts +++ b/account/src/lib/env-vars.ts @@ -8,6 +8,7 @@ const names = [ "LINGDOCS_ACCOUNT_TWITTER_CLIENT_SECRET", "LINGDOCS_ACCOUNT_GITHUB_CLIENT_SECRET", "LINGDOCS_ACCOUNT_RECAPTCHA_SECRET", + "LINGDOCS_ACCOUNT_UPGRADE_PASSWORD", ]; const values = names.map((name) => ({ @@ -31,4 +32,5 @@ export default { twitterClientSecret: values[6].value, githubClientSecret: values[7].value, recaptchaSecret: values[8].value, + upgradePassword: values[9].value, }; diff --git a/account/src/lib/mail-utils.ts b/account/src/lib/mail-utils.ts index 5f5033c..82ca548 100644 --- a/account/src/lib/mail-utils.ts +++ b/account/src/lib/mail-utils.ts @@ -1,15 +1,16 @@ import nodemailer from "nodemailer"; import inProd from "./inProd"; import env from "./env-vars"; +import * as T from "../../../website/src/lib/account-types"; type Address = string | { name: string, address: string }; -const from: Address = { +const adminAddress: Address = { name: "LingDocs Admin", address: "admin@lingdocs.com", }; -function getAddress(user: LingdocsUser): Address { +function getAddress(user: T.LingdocsUser): Address { // TODO: Guard against "" if (!user.name) return user.email || ""; return { @@ -30,31 +31,52 @@ const transporter = nodemailer.createTransport({ async function sendEmail(to: Address, subject: string, text: string) { await transporter.sendMail({ - from, + from: adminAddress, to, subject, text, }); } -// TODO: MAKE THIS +// TODO: MAKE THIS A URL ACROSS PROJECT const baseURL = inProd ? "https://account.lingdocs.com" : "http://localhost:4000"; -export async function sendVerificationEmail(user: LingdocsUser, token: URLToken) { +export async function sendVerificationEmail(user: T.LingdocsUser, token: T.URLToken) { + const subject = "Please Verify Your E-mail"; const content = `Hello ${user.name}, Please verify your email by visiting this link: ${baseURL}/email-verification/${user.userId}/${token} LingDocs Admin`; - await sendEmail(getAddress(user), "Please Verify Your E-mail", content); + await sendEmail(getAddress(user), subject, content); } -export async function sendPasswordResetEmail(user: LingdocsUser, token: URLToken) { +export async function sendPasswordResetEmail(user: T.LingdocsUser, token: T.URLToken) { + const subject = "Reset Your Password"; const content = `Hello ${user.name}, Please visit this link to reset your password: ${baseURL}/password-reset/${user.userId}/${token} LingDocs Admin`; - await sendEmail(getAddress(user), "Reset Your Password", content); + await sendEmail(getAddress(user), subject, content); +} + +export async function sendAccountUpgradeMessage(user: T.LingdocsUser) { + const subject = "You're Upgraded to Student"; + const content = `Hello ${user.name}, + +Congratulations on your upgrade to a LingDocs Student account! 👨‍🎓 + +Now you can start using your wordlist in the dictionary. It will automatically sync across any devices you're signed in to. + +LingDocs Admin`; + + await sendEmail(getAddress(user), subject, content); +} + +export async function sendUpgradeRequestToAdmin(userWantingToUpgrade: T.LingdocsUser) { + const subject = "Account Upgrade Request"; + const content = `${userWantingToUpgrade.name} - ${userWantingToUpgrade.email} - ${userWantingToUpgrade.userId} is requesting to upgrade to student.`; + await sendEmail(adminAddress, subject, content); } \ No newline at end of file diff --git a/account/src/lib/password-utils.ts b/account/src/lib/password-utils.ts index beac0b7..4611265 100644 --- a/account/src/lib/password-utils.ts +++ b/account/src/lib/password-utils.ts @@ -1,23 +1,24 @@ import { hash, compare } from "bcryptjs"; import { randomBytes } from "crypto"; import base64url from "base64url"; +import * as T from "../../../website/src/lib/account-types"; const tokenSize = 24; -export async function getHash(p: string): Promise { - return await hash(p, 10) as Hash; +export async function getHash(p: string): Promise { + return await hash(p, 10) as T.Hash; } -export async function getEmailTokenAndHash(): Promise<{ token: URLToken, hash: Hash }> { +export async function getEmailTokenAndHash(): Promise<{ token: T.URLToken, hash: T.Hash }> { const token = getURLToken(); const h = await getHash(token); return { token, hash: h }; } -export function getURLToken(): URLToken { - return base64url(randomBytes(tokenSize)) as URLToken; +export function getURLToken(): T.URLToken { + return base64url(randomBytes(tokenSize)) as T.URLToken; } -export function compareToHash(s: string, hash: Hash): Promise { +export function compareToHash(s: string, hash: T.Hash): Promise { return compare(s, hash); } diff --git a/account/src/lib/time-utils.ts b/account/src/lib/time-utils.ts index 0dbc6a9..c9647d3 100644 --- a/account/src/lib/time-utils.ts +++ b/account/src/lib/time-utils.ts @@ -1,3 +1,5 @@ -export function getTimestamp(): TimeStamp { - return Date.now() as TimeStamp; +import * as T from "../../../website/src/lib/account-types"; + +export function getTimestamp(): T.TimeStamp { + return Date.now() as T.TimeStamp; } \ No newline at end of file diff --git a/account/src/lib/user-utils.ts b/account/src/lib/user-utils.ts index 09f2cca..bd33585 100644 --- a/account/src/lib/user-utils.ts +++ b/account/src/lib/user-utils.ts @@ -1,18 +1,26 @@ import { v4 as uuidv4 } from "uuid"; -import { insertLingdocsUser } from "../lib/couch-db"; +import { + insertLingdocsUser, + addCouchDbAuthUser, + updateLingdocsUser, +} from "../lib/couch-db"; import { getHash, getEmailTokenAndHash, } from "../lib/password-utils"; import { getTimestamp } from "../lib/time-utils"; -import { sendVerificationEmail } from "../lib/mail-utils"; +import { + sendVerificationEmail, + sendAccountUpgradeMessage, +} from "../lib/mail-utils"; import { outsideProviders } from "../middleware/setup-passport"; +import * as T from "../../../website/src/lib/account-types"; -function getUUID(): UUID { - return uuidv4() as UUID; +function getUUID(): T.UUID { + return uuidv4() as T.UUID; } -export function canRemoveOneOutsideProvider(user: LingdocsUser): boolean { +export function canRemoveOneOutsideProvider(user: T.LingdocsUser): boolean { if (user.email && user.password) { return true; } @@ -20,7 +28,7 @@ export function canRemoveOneOutsideProvider(user: LingdocsUser): boolean { return providersPresent.length > 1; } -export function getVerifiedEmail({ emails }: ProviderProfile): string | false { +export function getVerifiedEmail({ emails }: T.ProviderProfile): string | false { return ( emails && emails.length @@ -29,7 +37,7 @@ export function getVerifiedEmail({ emails }: ProviderProfile): string | false { ) ? emails[0].value : false; } -function getEmailFromGoogleProfile(profile: GoogleProfile): { email: string | undefined, verified: boolean } { +function getEmailFromGoogleProfile(profile: T.GoogleProfile): { email: string | undefined, verified: boolean } { if (!profile.emails || profile.emails.length === 0) { return { email: undefined, verified: false }; } @@ -42,6 +50,33 @@ function getEmailFromGoogleProfile(profile: GoogleProfile): { email: string | un }; } +export async function upgradeUser(userId: T.UUID): Promise { + // add user to couchdb authentication db + const { password, userDbName } = await addCouchDbAuthUser(userId); + // // create user db + // update LingdocsUser + const user = await updateLingdocsUser(userId, { + level: "student", + wordlistDbName: userDbName, + couchDbPassword: password, + upgradeToStudentRequest: undefined, + }); + if (user.email) { + sendAccountUpgradeMessage(user).catch(console.error); + } + return { + ok: true, + message: "user upgraded to student", + user, + }; +} + +export async function denyUserUpgradeRequest(userId: T.UUID): Promise { + await updateLingdocsUser(userId, { + upgradeToStudentRequest: "denied", + }); +} + export async function createNewUser(input: { strategy: "local", email: string, @@ -49,20 +84,20 @@ export async function createNewUser(input: { passwordPlainText: string, } | { strategy: "github", - profile: GitHubProfile, + profile: T.GitHubProfile, } | { strategy: "google", - profile: GoogleProfile, + profile: T.GoogleProfile, } | { strategy: "twitter", - profile: TwitterProfile, -}): Promise { + profile: T.TwitterProfile, +}): Promise { const userId = getUUID(); const now = getTimestamp(); if (input.strategy === "local") { const email = await getEmailTokenAndHash(); const password = await getHash(input.passwordPlainText); - const newUser: LingdocsUser = { + const newUser: T.LingdocsUser = { _id: userId, userId, email: input.email, @@ -73,6 +108,7 @@ export async function createNewUser(input: { tests: [], lastLogin: now, lastActive: now, + userTextOptionsRecord: undefined, }; const user = await insertLingdocsUser(newUser); sendVerificationEmail(user, email.token).catch(console.error); @@ -80,7 +116,7 @@ export async function createNewUser(input: { } // GitHub || Twitter if (input.strategy === "github" || input.strategy === "twitter") { - const newUser: LingdocsUser = { + const newUser: T.LingdocsUser = { _id: userId, userId, emailVerified: false, @@ -90,6 +126,7 @@ export async function createNewUser(input: { tests: [], lastLogin: now, lastActive: now, + userTextOptionsRecord: undefined, }; const user = await insertLingdocsUser(newUser); return user; @@ -99,7 +136,7 @@ export async function createNewUser(input: { const { email, verified } = getEmailFromGoogleProfile(input.profile); if (email && !verified) { const em = await getEmailTokenAndHash(); - const newUser: LingdocsUser = { + const newUser: T.LingdocsUser = { _id: userId, userId, email, @@ -110,12 +147,13 @@ export async function createNewUser(input: { tests: [], lastActive: now, level: "basic", + userTextOptionsRecord: undefined, } const user = await insertLingdocsUser(newUser); sendVerificationEmail(user, em.token); return user; } - const newUser: LingdocsUser = { + const newUser: T.LingdocsUser = { _id: userId, userId, email, @@ -126,7 +164,8 @@ export async function createNewUser(input: { tests: [], lastActive: now, level: "basic", + userTextOptionsRecord: undefined, } const user = await insertLingdocsUser(newUser); return user; -} \ No newline at end of file +} diff --git a/account/src/middleware/setup-passport.ts b/account/src/middleware/setup-passport.ts index 7e357ae..2a7a67a 100644 --- a/account/src/middleware/setup-passport.ts +++ b/account/src/middleware/setup-passport.ts @@ -16,6 +16,8 @@ import { getVerifiedEmail, } from "../lib/user-utils"; import env from "../lib/env-vars"; +import * as T from "../../../website/src/lib/account-types"; +import { getTimestamp } from "../lib/time-utils"; export const outsideProviders: ("github" | "google" | "twitter")[] = ["github", "google", "twitter"]; @@ -116,7 +118,7 @@ function setupPassport(passport: PassportStatic) { async function(req: any, accessToken: any, refreshToken: any, profileRaw: any, done: any) { // not getting refresh token const { _json, _raw, ...profile } = profileRaw; - const ghProfile: GitHubProfile = { ...profile, accessToken }; + const ghProfile: T.GitHubProfile = { ...profile, accessToken }; try { if (req.isAuthenticated()) { if (!req.user) done(new Error("user lost")); @@ -142,14 +144,14 @@ function setupPassport(passport: PassportStatic) { cb(null, user.userId); }); - passport.deserializeUser(async (userId: UUID, cb) => { + passport.deserializeUser(async (userId: T.UUID, cb) => { try { const user = await getLingdocsUser("userId", userId); if (!user) { cb(null, false); return; } - const newUser = await insertLingdocsUser(updateLastActive(user)); + const newUser = await updateLingdocsUser(userId, { lastActive: getTimestamp() }); cb(null, newUser); } catch (err) { cb(err, null); diff --git a/account/src/middleware/setup-session.ts b/account/src/middleware/setup-session.ts index 038b84c..f48bd77 100644 --- a/account/src/middleware/setup-session.ts +++ b/account/src/middleware/setup-session.ts @@ -20,8 +20,7 @@ function setupSession(app: Express) { maxAge: 1000 * 60 * 60 * 24 * 7 * 30 * 6, secure: inProd, domain: inProd ? "lingdocs.com" : undefined, - // TODO: TRY TO SET TO TRUE - httpOnly: false, + httpOnly: true, }, store: inProd ? new RedisStore({ client: redis.createClient() }) diff --git a/account/src/routers/api-router.ts b/account/src/routers/api-router.ts index f62af7e..08fbb0c 100644 --- a/account/src/routers/api-router.ts +++ b/account/src/routers/api-router.ts @@ -1,19 +1,28 @@ import express, { Response } from "express"; import { deleteLingdocsUser, + getLingdocsUser, updateLingdocsUser, + deleteCouchDbAuthUser, } from "../lib/couch-db"; import { getHash, - getURLToken, compareToHash, getEmailTokenAndHash, } from "../lib/password-utils"; import { + sendUpgradeRequestToAdmin, sendVerificationEmail, } from "../lib/mail-utils"; +import { + upgradeUser, +} from "../lib/user-utils"; +import * as T from "../../../website/src/lib/account-types"; +import env from "../lib/env-vars"; -function sendResponse(res: Response, payload: APIResponse) { +// TODO: ADD PROPER ERROR HANDLING THAT WILL RETURN JSON ALWAYS + +function sendResponse(res: Response, payload: T.APIResponse) { return res.send(payload); } @@ -24,7 +33,7 @@ apiRouter.use((req, res, next) => { if (req.isAuthenticated()) { return next(); } - const r: APIResponse = { ok: false, error: "401 Unauthorized" }; + const r: T.APIResponse = { ok: false, error: "401 Unauthorized" }; return res.status(401).send(r); }); @@ -87,6 +96,65 @@ apiRouter.put("/email-verification", async (req, res, next) => { } }); +apiRouter.put("/user/userTextOptionsRecord", async (req, res, next) => { + if (!req.user) throw new Error("user not found"); + try { + const { userTextOptionsRecord } = req.body as T.UpdateUserTextOptionsRecordBody; + const user = await updateLingdocsUser(req.user.userId, { userTextOptionsRecord }); + const toSend: T.UpdateUserTextOptionsRecordResponse = { ok: true, message: "updated userTextOptionsRecord", user }; + res.send(toSend); + } catch (e) { + next(e); + } +}); + +apiRouter.put("/user/upgrade", async (req, res, next) => { + if (!req.user) throw new Error("user not found"); + try { + const givenPassword = (req.body.password || "") as string; + const studentPassword = env.upgradePassword; + if (givenPassword.toLowerCase().trim() !== studentPassword.toLowerCase()) { + const wrongPass: T.UpgradeUserResponse = { + ok: false, + error: "incorrect password", + }; + res.send(wrongPass); + return; + } + const { userId } = req.user; + const user = await getLingdocsUser("userId", userId); + if (!user) throw new Error("user lost"); + if (user.level !== "basic") { + const alreadyUpgraded: T.UpgradeUserResponse = { + ok: true, + message: "user already upgraded", + user, + }; + res.send(alreadyUpgraded); + return; + } + const upgraded: T.UpgradeUserResponse = await upgradeUser(userId); + res.send(upgraded); + } catch (e) { + next(e); + } +}); + +apiRouter.post("/user/upgradeToStudentRequest", async (req, res, next) => { + if (!req.user) throw new Error("user not found"); + try { + if (req.user.level === "student" || req.user.level === "editor") { + res.send({ ok: true, message: "user already upgraded" }); + return; + } + sendUpgradeRequestToAdmin(req.user).catch(console.error); + await updateLingdocsUser(req.user.userId, { upgradeToStudentRequest: "waiting" }); + res.send({ ok: true, message: "request for upgrade sent" }); + } catch (e) { + next(e); + } +}); + /** * deletes a users own account */ @@ -94,7 +162,7 @@ apiRouter.delete("/user", async (req, res, next) => { try { if (!req.user) throw new Error("user not found"); await deleteLingdocsUser(req.user.userId); - sendResponse(res, { ok: true, message: "user delted" }); + sendResponse(res, { ok: true, message: "user deleted" }); } catch (e) { next(e); } @@ -103,7 +171,7 @@ apiRouter.delete("/user", async (req, res, next) => { /** * signs out the user signed in */ -apiRouter.post("/sign-out" , (req, res) => { +apiRouter.post("/sign-out", (req, res) => { req.logOut(); sendResponse(res, { ok: true, message: "signed out" }); }); diff --git a/account/src/routers/auth-router.ts b/account/src/routers/auth-router.ts index dcba12d..6b52ebd 100644 --- a/account/src/routers/auth-router.ts +++ b/account/src/routers/auth-router.ts @@ -1,6 +1,8 @@ import { Router } from "express"; import { PassportStatic } from "passport"; import { + deleteLingdocsUser, + getAllLingdocsUsers, getLingdocsUser, updateLingdocsUser, } from "../lib/couch-db"; @@ -11,6 +13,10 @@ import { compareToHash, getEmailTokenAndHash, } from "../lib/password-utils"; +import { + upgradeUser, + denyUserUpgradeRequest, +} from "../lib/user-utils"; import { validateReCaptcha } from "../lib/recaptcha"; import { getTimestamp, @@ -21,6 +27,7 @@ import { } from "../lib/mail-utils"; import { outsideProviders } from "../middleware/setup-passport"; import inProd from "../lib/inProd"; +import * as T from "../../../website/src/lib/account-types"; const authRouter = (passport: PassportStatic) => { const router = Router(); @@ -71,7 +78,7 @@ const authRouter = (passport: PassportStatic) => { return res.render("login", { recaptcha: "fail", inProd }); } } - passport.authenticate("local", (err, user: LingdocsUser | undefined, info) => { + passport.authenticate("local", (err, user: T.LingdocsUser | undefined, info) => { if (err) throw err; if (!user && info.message === "email not found") { return res.send({ ok: false, newSignup: true }); @@ -128,17 +135,64 @@ const authRouter = (passport: PassportStatic) => { try { const { email, password, name } = req.body; const existingUser = await getLingdocsUser("email", email); - if (existingUser) return res.send("Tser Already Exists"); + if (existingUser) return res.send("User Already Exists"); const user = await createNewUser({ strategy: "local", email, passwordPlainText: password, name }); req.logIn(user, (err) => { if (err) return next(err); return res.send({ ok: true, user }); }); } catch(e) { - return next(e); + next(e); } }); + router.get("/admin", async (req, res, next) => { + try { + if (!req.user || !req.user.admin) { + return res.redirect("/"); + } + const users = await getAllLingdocsUsers(); + res.render("admin", { users }); + } catch (e) { + next(e); + } + }); + + /** + * Grant request for upgrade to student + */ + router.post("/admin/upgradeToStudent/:userId/:grantOrDeny", async (req, res, next) => { + try { + if (!req.user || !req.user.admin) { + return res.redirect("/"); + } + const userId = req.params.userId as T.UUID; + const grantOrDeny = req.params.grantOrDeny as "grant" | "deny"; + if (grantOrDeny === "grant") { + await upgradeUser(userId); + } else { + await denyUserUpgradeRequest(userId); + } + res.redirect("/admin"); + } catch (e) { + next(e); + } + }); + + router.delete("/admin/:userId", async (req, res, next) => { + try { + // TODO: MAKE PROPER MIDDLEWARE WITH TYPING + if (!req.user || !req.user.admin) { + return res.redirect("/"); + } + const toDelete = req.params.userId as T.UUID; + await deleteLingdocsUser(toDelete); + res.send({ ok: true, message: "user deleted" }); + } catch (e) { + next(e); + } + }); + router.get("/email-verification/:uuid/:token", async (req, res, next) => { const page = "email-verification"; const { uuid, token } = req.params; diff --git a/account/src/types.d.ts b/account/src/types.d.ts deleted file mode 100644 index 77a8325..0000000 --- a/account/src/types.d.ts +++ /dev/null @@ -1,37 +0,0 @@ -type Hash = string & { __brand: "Hashed String" }; -type UUID = string & { __brand: "Random Unique UID" }; -type TimeStamp = number & { __brand: "UNIX Timestamp in milliseconds" }; -type UserDbPassword = string & { __brand: "password for an individual user couchdb" }; -type URLToken = string & { __brand: "Base 64 URL Token" }; -type EmailVerified = true | Hash | false; -type ActionComplete = { ok: true, message: string }; -type ActionError = { ok: false, error: string }; -type APIResponse = ActionComplete | ActionError | { ok: true, user: LingdocsUser }; - -type WoutRJ = Omit; - -type GoogleProfile = WoutRJ & { refreshToken: string, accessToken: string }; -type GitHubProfile = WoutRJ & { accessToken: string }; -type TwitterProfile = WoutRJ & { token: string, tokenSecret: string }; -type ProviderProfile = GoogleProfile | GitHubProfile | TwitterProfile; -type UserLevel = "basic" | "student" | "editor"; - -// TODO: TYPE GUARDING SO WE NEVER HAVE A USER WITH NO Id or Password -type LingdocsUser = { - userId: UUID, - password?: Hash, - name: string, - email?: string, - emailVerified: EmailVerified, - github?: GitHubProfile, - google?: GoogleProfile, - twitter?: TwitterProfile, - passwordReset?: { - tokenHash: Hash, - requestedOn: TimeStamp, - }, - tests: [], - lastLogin: TimeStamp, - lastActive: TimeStamp, -} & ({ level: "basic"} | { level: "student" | "editor", userDbPassword: UserDbPassword }) -& import("nano").MaybeDocument; diff --git a/account/views/admin.ejs b/account/views/admin.ejs new file mode 100644 index 0000000..dd3fba9 --- /dev/null +++ b/account/views/admin.ejs @@ -0,0 +1,95 @@ + + + + + + + Admin · LingDocs + + + + + +
+

LingDocs Auth Admin

+ + + + + + + + + + + + + <% for(var i=0; i < users.length; i++) { %> + + + + + + + + + <% } %> + +
NameEmailProvidersLevelLast Active
<%= users[i].name %> <% if (users[i].admin) { %> + + <% } %> + <%= users[i].email %> + <% if (users[i].password && users[i].email) { %> + + <% } %> + <% if (users[i].google) { %> + + <% } %> + <% if (users[i].twitter) { %> + + <% } %> + <% if (users[i].github) { %> + + <% } %> + + <% if (users[i].upgradeToStudentRequest === "waiting") { %> +
+
Requested Upgrade
+
+
+ +
+
+
+
+ +
+
+
+ <% } else if (users[i].upgradeToStudentRequest === "waiting"){ %> + Upgrade Denied + <% } else { %> + <%= users[i].level %> + <% } %> +
+ <%= new Date(users[i].lastActive).toUTCString() %> + + +
+
+ + \ No newline at end of file diff --git a/account/views/user.ejs b/account/views/user.ejs index 46bc503..01ac33e 100644 --- a/account/views/user.ejs +++ b/account/views/user.ejs @@ -7,11 +7,17 @@ Account · LingDocs +

LingDocs Account

-

Profile:

+ <% if (user.admin) { %> +
Admin Console
+ <% } %> +

Profile

@@ -44,56 +50,67 @@
- <% if (user.email) { %> -

Password:

- <% } %> -