diff --git a/Dockerfile b/Dockerfile index 0ac90087..1b1e53b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:10-alpine +FROM node:12-alpine MAINTAINER Ryan Petschek # Deis wants bash diff --git a/README.md b/README.md index 868eeeaa..96dbfb02 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,15 @@ ## Features -- Seamless OAuth and local signup logins with automatic email verification - - Get users up and running quickly with GitHub, Google, and Facebook OAuth logins right out of the box ([MyMLH](https://my.mlh.io) login planned) - - Full support for local logins as well if users choose +- Seamless OAuth and local signup logins with automatic email verification via [HackGT Ground Truth](https://github.com/HackGT/ground-truth) + - Get users up and running quickly with GitHub, Google, and Facebook OAuth or Georgia Tech CAS logins right out of the box + - Full support for local logins as well if users prefer - Users can easily register (and confirm their attendance if accepted) and choose which "branch" they want to complete (e.g. partipant, mentor, volunteer) all from a single location - Users can create or join a team any time before or after completing registration. Admins can configure the maximum team size (defaults to 4). - For admins, the admin panel contains options for managing all aspects of registration including: - Statistics about the user of sign ups, registrations, acceptances, and confirmations - Graphs displaying aggregated registration data - - List of all users in a table including name, email, email verified status, admin status, and application status, and log in method + - List of all users in a table including name, email, admin status, and application status, and log in method - List of all applicants with application responses and accept / unaccept button sortable by application branch and accepted status - Acceptance emails are sent out only when a send acceptance emails button is clicked allowing for decisions to be reviewed before being finalized - Setting application and confirmation open and close times as well as what question branches from `questions.json` are for applications, confirmations, or hidden @@ -60,63 +60,29 @@ A [Dockerfile](Dockerfile) is provided for convenience. Configuration should normally be done by editing the `server/config/config.json` file. Environment variables take precedence over `config.json` and should be used when those options need to be overridden or `config.json` can't be used for some reason (e.g. certain deployment scenarios). -### OAuth IDs and secrets - -Can be obtained from: -- [GitHub](https://github.com/settings/developers) - - Register a new application - - Application name, URL, and description are up to you - - Callback URL should be in the format: `https://YOUR_DOMAIN/auth/github/callback` - - Local testing callback URL should be `http://localhost:3000/auth/github/callback` - - GitHub only lets you register one callback URL per application so you might want to make a testing application and a separate application for production usage. -- [Google API Console](https://console.developers.google.com/apis/credentials) - - Create an application - - Go to the credentials tab in the left panel - - Click Create credentials > OAuth client ID - - Set web application as the application type - - Give it a name (won't be shown publically, e.g. `HackGT Registration server testing`) - - Leave Authorized JavaScript origins blank - - List all testing / production callback URLs in Authorized redirect URIs - - Should be in the format: `https://YOUR_DOMAIN/auth/google/callback` - - For local testing: `http://localhost:3000/auth/google/callback` - - It is recommended that you create two OAuth applications with different IDs and secrets for testing and production usage. -- [Facebook](https://developers.facebook.com/) - - Create an application - - Add the Facebook Login product from the left panel - - Enable Client OAuth Login, Web OAuth Login, and Embedded Browser OAuth Login - - List all testing / production callback URLs in Valid OAuth redirect URLs - - Should be in the format: `https://YOUR_DOMAIN/auth/facebook/callback` - - For local testing: `http://localhost:3000/auth/facebook/callback` - - Optionally, repeat the process for separate testing and production applications - Environment Variable | Description ---------------------|------------ -PRODUCTION | Set to `true` to set OAuth callbacks to production URLs (default: `false`) +PRODUCTION | Set to `true` to enable reverse proxy trusting (default: `false`) PORT | The port the check in system should run on (default: `3000`) -MONGO_URL | The URL to the MongoDB server (default: `mongodb://localhost/`) -UNIQUE_APP_ID | The MongoDB database name to store data in (default: `registration`) +MONGO_URL | The URL to the MongoDB server (default: `mongodb://localhost/registration`) VERSION_HASH | The Git short hash used to identify the current commit (default: parsed automatically from the `.git` folder, if it exists) -*SOURCE_VERSION* | Same as `VERSION_HASH` but overrides it if present. Used by Deis. -*WORKFLOW_RELEASE_CREATED_AT* | Provided by Deis (default: `null`) -*WORKFLOW_RELEASE_SUMMARY* | Provided by Deis (default: `null`) +ADMIN_KEY_SECRET | An API key used to authenticate as admin an access the GraphQL api (default: random key that changes every server restart) COOKIE_MAX_AGE | The `maxAge` of cookies set in milliseconds (default: 6 months) **NOTE: this is different from the session TTL** COOKIE_SECURE_ONLY | Whether session cookies should sent exclusively over secure connections (default: `false`) PASSWORD_RESET_EXPIRATION | The time that password reset links sent via email should be valid for in milliseconds (default: 1 hour) SESSION_SECRET | The secret used to sign and validate session cookies (default: random 32 bytes regenerated on every start up) -GITHUB_CLIENT_ID | OAuth client ID for GitHub *required* -GITHUB_CLIENT_SECRET | OAuth client secret for GitHub *required* -GOOGLE_CLIENT_ID | OAuth client ID for Google *required* -GOOGLE_CLIENT_SECRET | OAuth client secret for Google *required* -FACEBOOK_CLIENT_ID | OAuth client ID for Facebook *required* -FACEBOOK_CLIENT_SECRET | OAuth client secret for Facebook *required* +GROUND_TRUTH_URL | Base URL of [Ground Truth](https://github.com/HackGT/ground-truth) instance (e.g. `https://login.hack.gt`) *required* +GROUND_TRUTH_ID | OAuth client ID from Ground Truth *required* +GROUND_TRUTH_SECRET | OAuth client secret from Ground Truth *required* EMAIL_FROM | The `From` header for sent emails (default: `HackGT Team `) -EMAIL_KEY | The SendGrid API key for sending emails (default: *none*) +EMAIL_KEY | The SendGrid API key for sending emails (default: *none*) *required* ADMIN_EMAILS | A JSON array of the emails of the users that you want promoted to admin status when they create their account (default: none) EVENT_NAME | The current event's name which affects rendered templates and sent emails (default: `Untitled Event`) STORAGE_ENGINE | The name of the storage engine that handles file uploads as defined in [storage.ts](server/storage.ts) (default: `disk`) STORAGE_ENGINE_OPTIONS | JSON-encoded object containing options to be passed to the storage engine. Must at least contain a value for the `uploadDirectory` key. For the default `disk` storage engine, this directory is relative to the app's root, can be absolute, and will be created if it doesn't exist. (default: `{ "uploadDirectory": "uploads" }`) +DEFAULT_TIMEZONE | Timezone used for dates and times (default: `America/New_York`) MAX_TEAM_SIZE | The maximum number of users allowed per team (default: `4`) -QUESTIONS_FILE | Specify a path for the `questions.json` file. +QUESTIONS_FILE | Specify a path for the `questions.json` file. (default: ./server/config/questions.json) THEME_FILE | Specify a path for the `theme.css` file, which will be loaded last at every page. FAVICON_FILE | Path to the favicon file (default is no favicon). FAVICON_FILE_BASE64 | Same as `FAVICON_FILE_BASE64` but the file is base64 encoded. @@ -124,9 +90,9 @@ FAVICON_FILE_BASE64 | Same as `FAVICON_FILE_BASE64` but the file is base64 encod ## Contributing -If you happen to find a bug or have a feature you'd like to see implemented, please [file an issue](https://github.com/HackGT/registration/issues). +If you happen to find a bug or have a feature you'd like to see implemented, please [file an issue](https://github.com/HackGT/registration/issues). -If you have some time and want to help us out with development, thank you! You can get started by taking a look at the open issues, particularly the ones marked [help wanted](https://github.com/HackGT/registration/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) or [help wanted - beginner](https://github.com/HackGT/registration/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted+-+beginner%22). Feel free to ask questions to clarify things, determine the best way to implement a new feature or bug fix, or anything else! +If you have some time and want to help us out with development, thank you! You can get started by taking a look at the open issues, particularly the ones marked [help wanted](https://github.com/HackGT/registration/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) or [good first issue](https://github.com/HackGT/registration/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22). Feel free to ask questions to clarify things, determine the best way to implement a new feature or bug fix, or anything else! ### Tips @@ -140,4 +106,4 @@ If you have some time and want to help us out with development, thank you! You c ## License -Copyright © 2018 HackGT. Released under the MIT license. See [LICENSE](LICENSE) for more information. +Copyright © 2019 HackGT. Released under the MIT license. See [LICENSE](LICENSE) for more information. diff --git a/api.graphql b/api.graphql index e582f152..37bb82c9 100644 --- a/api.graphql +++ b/api.graphql @@ -47,10 +47,6 @@ type User { name: String! # User's email email: String! - # If the user's email is a verified email - email_verified: Boolean! - # Login method(s) this user uses (merged by email address) - login_methods: [String!]! # If the user has admin privileges admin: Boolean! @@ -156,4 +152,3 @@ type File { # The formatted size of the file in human-readable units size_formatted: String! } - diff --git a/client/admin.html b/client/admin.html index b1d3ccbf..4c8a7ca7 100644 --- a/client/admin.html +++ b/client/admin.html @@ -106,7 +106,6 @@

Users

Name Email Status - Log in method @@ -122,14 +121,10 @@

Users

- - - - @@ -183,8 +178,6 @@

Applicants

- - @@ -350,22 +343,6 @@
List of variables:
-
-
-

Change login methods

-
-
-
- {{#each settings.loginMethodsInfo}} -
-
{{this.name}}
- -
- {{/each}} -

config.json options

@@ -382,14 +359,18 @@

config.json options

-
+
-
+
+
+ + +
diff --git a/client/css/admin.css b/client/css/admin.css index a2ac36ea..574f9d3e 100644 --- a/client/css/admin.css +++ b/client/css/admin.css @@ -46,12 +46,6 @@ table td:last-child { td.email > i.fa { display: none; } -td.email.verified > i.fa-check { - display: inline; -} -td.email.notverified > i.fa-exclamation-triangle { - display: inline; -} td.email.admin > i.fa-asterisk { display: inline; } diff --git a/client/css/login.css b/client/css/login.css index 602d5f0a..8058b0ee 100644 --- a/client/css/login.css +++ b/client/css/login.css @@ -1,98 +1,15 @@ -h1 { - margin-bottom: 64px; - text-align: center; -} -a > i { - margin-right: 10px; - transform: scale(1.5); -} -a.github { - color: white; - background-color: #444444; -} -a.google { - color: white; - background-color: #4285F4; -} -a.facebook { - color: white; - background-color: #3b5998; -} -a.gatech { - color: black; - background-color: #eeb211; -} -#error, #success { - text-align: center; - margin-top: 1em; - font-size: 2rem; - margin-bottom: 1em; - color: white; - padding: 10px; - border-radius: 5px; -} -#error { - background-color: rgba(255, 65, 54, 0.85); -} -#success { - background-color: #3D9970; -} -#error:empty, #success:empty { - display: none; -} - -main { - display: flex; - flex-direction: column-reverse; - align-items: center; -} -main > div { - box-sizing: border-box; - width: 100%; - display: none; -} -main > .active { - display: block; -} - -.simple-methods { - display: flex !important; - flex-direction: column; -} - -.unit { - display: flex; - align-items: center; - flex-direction: column; -} -.unit > *:first-child { - width: 125px; -} -#additional-logins { - display: flex; - flex-direction: column; - flex-wrap: wrap; - justify-content: center; - margin-bottom: 10px; - font-size: 85%; -} - -/* Desktop styles */ -@media only screen and (min-width: 700px) { - main { - flex-direction: row; - } - main > * { - width: 50%; - } - main > .active.border { - border-right: 1px dashed #333030; - padding-right: 15px; - } - main > .active:last-of-type { - padding-left: 10px; - } - .unit, #additional-logins { - flex-direction: row; - } -} +#error { + text-align: center; + margin-top: 1em; + font-size: 2rem; + margin-bottom: 1em; + color: white; + padding: 10px; + border-radius: 5px; + background-color: rgba(255, 65, 54, 0.85); +} +.btn { + display: block; + width: 100px; + margin: 0 auto; +} diff --git a/client/forgotpassword.html b/client/forgotpassword.html deleted file mode 100644 index d9d40136..00000000 --- a/client/forgotpassword.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - Reset Password - {{siteTitle}} - - - - - - - - -
-

{{siteTitle}}

- -
{{error}}
-
{{success}}
-
-
-

Reset your password

-

Enter your email address and we'll send you a link to reset your password.

-
- - -
-
-
-
- - diff --git a/client/js/admin.ts b/client/js/admin.ts index 490b8d44..384c99c9 100644 --- a/client/js/admin.ts +++ b/client/js/admin.ts @@ -111,9 +111,7 @@ class UserEntries { id, name, email, - email_verified, admin, - login_methods, application { type @@ -162,13 +160,7 @@ class UserEntries { node.style.display = "table-row"; node.querySelector("td.name")!.textContent = user.name; node.querySelector("td.email > span")!.textContent = user.email; - node.querySelector("td.email")!.classList.remove("verified", "notverified", "admin"); - if (user.email_verified) { - node.querySelector("td.email")!.classList.add("verified"); - } - else { - node.querySelector("td.email")!.classList.add("notverified"); - } + node.querySelector("td.email")!.classList.remove("admin"); if (user.admin) { node.querySelector("td.email")!.classList.add("admin"); } @@ -187,7 +179,6 @@ class UserEntries { userStatus = `Accepted (${user.application.type}) / Confirmed (${user.confirmation.type})`; } node.querySelector("td.status")!.textContent = userStatus; - node.querySelector("td.login-method")!.textContent = user.login_methods.join(", "); } else { node.style.display = "none"; @@ -316,7 +307,6 @@ class ApplicantEntries { id, name, email, - email_verified, admin, team { id, @@ -459,13 +449,7 @@ class ApplicantEntries { generalNode.querySelector("td.team")!.textContent = "No Team"; } generalNode.querySelector("td.email > span")!.textContent = user.email; - generalNode.querySelector("td.email")!.classList.remove("verified", "notverified", "admin"); - if (user.email_verified) { - generalNode.querySelector("td.email")!.classList.add("verified"); - } - else { - generalNode.querySelector("td.email")!.classList.add("notverified"); - } + generalNode.querySelector("td.email")!.classList.remove("admin"); if (user.admin) { generalNode.querySelector("td.email")!.classList.add("admin"); } @@ -709,16 +693,6 @@ function settingsUpdate(e: MouseEvent) { adminEmailData.append("adminString", (document.getElementById("admin-emails") as HTMLInputElement).value); adminEmailData.append("addAdmins", (document.getElementById("add-admins") as HTMLInputElement).checked ? "true" : "false"); - let loginMethodsData = new FormData(); - let loginMethods = document.querySelectorAll("div.auth-method") as NodeListOf; - let enabledMethods: string[] = []; - for (let i = 0; i < loginMethods.length; i++) { - if (loginMethods[i].querySelector("select")!.value === "enabled") { - enabledMethods.push(loginMethods[i].dataset.rawName!); - } - } - loginMethodsData.append("enabledMethods", JSON.stringify(enabledMethods)); - let branchRoleData = new FormData(); let branchRoles = document.querySelectorAll("div.branch-role") as NodeListOf; for (let i = 0; i < branchRoles.length; i++) { @@ -788,11 +762,6 @@ function settingsUpdate(e: MouseEvent) { ...defaultOptions, body: adminEmailData }); - }).then(checkStatus).then(parseJSON).then(() => { - return fetch("/api/settings/login_methods", { - ...defaultOptions, - body: loginMethodsData - }); }).then(checkStatus).then(parseJSON).then(() => { return fetch("/api/settings/branch_roles", { ...defaultOptions, diff --git a/client/js/login.ts b/client/js/login.ts deleted file mode 100644 index a52778e5..00000000 --- a/client/js/login.ts +++ /dev/null @@ -1,24 +0,0 @@ -document.addEventListener("DOMContentLoaded", () => { - const submitButtons = document.querySelectorAll('input[type="submit"]') as NodeListOf; - const forms = document.getElementsByTagName("form"); - - // Disable submit buttons when clicked - for (let i = 0; i < forms.length; i++) { - forms[i].addEventListener("submit", () => { - for (let j = 0; j < submitButtons.length; j++) { - submitButtons[j].disabled = true; - } - }); - } - // Navigate between login and sign up - let loginForm = document.getElementById("login") as HTMLFormElement; - let signupForm = document.getElementById("signup") as HTMLFormElement; - document.getElementById("signup-link")!.addEventListener("click", () => { - loginForm.classList.remove("active"); - signupForm.classList.add("active"); - }); - document.getElementById("login-link")!.addEventListener("click", () => { - signupForm.classList.remove("active"); - loginForm.classList.add("active"); - }); -}); diff --git a/client/login.html b/client/login.html index c760196a..9c0719ce 100644 --- a/client/login.html +++ b/client/login.html @@ -1,68 +1,26 @@ - - - - Login - {{siteTitle}} - - - - - - - - - - -
-

{{siteTitle}}

- -
{{error}}
-
{{success}}
-
- {{#ifIn "local" loginMethods}} - {{#if localOnly}} -
- {{else}} -
- {{/if}} -

Log In

- Forgot your password? -
- - - -
-

- Don't have an account? -
- Connect with an external service or sign up. -

-
- {{#if localOnly}} -
- {{else}} -
- {{/if}} -

Sign Up

-
- - - - -
-

- Already have a local account? Log in. -

-
- {{/ifIn}} - {{#unless localOnly}} -
- {{#ifIn "local" loginMethods}}{{else}} -

Log In

- {{/ifIn}} - {{> login-methods}} -
- {{/unless}} -
-
- - + + + + Login - {{siteTitle}} + + + + + + + + +
+

{{siteTitle}}

+ + {{#if isLogOut}} +

Logout successful

+ Log in + {{else}} +

Login Error

+
{{error}}
+ Try again + {{/if}} +
+ + diff --git a/client/postlogin.html b/client/postlogin.html deleted file mode 100644 index 3585c18e..00000000 --- a/client/postlogin.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - New Account - {{siteTitle}} - - - - - - - - - -
-

{{siteTitle}}

- -
{{error}}
-
{{success}}
-
-
-

New Login

- {{#if canAddLogins}} -

Connect your account with another provider or an existing account:

-
- {{> login-methods}} -
- {{/if}} -

Please confirm a few things for us:

-
-
- - -
-
- - -
-
- - -
- -
-
-
-
- - diff --git a/client/resetpassword.html b/client/resetpassword.html deleted file mode 100644 index a2729a81..00000000 --- a/client/resetpassword.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - Reset Password - {{siteTitle}} - - - - - - - - -
-

{{siteTitle}}

- -
{{error}}
-
{{success}}
-
-
-

Reset your password

-
- - - -
-
-
-
- - diff --git a/client/tsconfig.json b/client/tsconfig.json index 0cc10965..4cf04ce6 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -18,7 +18,7 @@ "noUnusedLocals": true, "plugins": [ { - "name": "tslint-language-service", + "name": "tslint-language-service-ts3", "alwaysShowRuleFailuresAsWarnings": false, "ignoreDefinitionFiles": true, "configFile": "../tslint.json" diff --git a/deployment.yaml b/deployment.yaml index a572a9e1..24837267 100644 --- a/deployment.yaml +++ b/deployment.yaml @@ -11,12 +11,9 @@ secrets: - ADMIN_KEY_SECRET - SESSION_SECRET - EMAIL_KEY - - GOOGLE_CLIENT_ID - - GOOGLE_CLIENT_SECRET - - GITHUB_CLIENT_ID - - GITHUB_CLIENT_SECRET - - FACEBOOK_CLIENT_ID - - FACEBOOK_CLIENT_SECRET + - GROUND_TRUTH_URL + - GROUND_TRUTH_ID + - GROUND_TRUTH_SECRET - STORAGE_ENGINE_OPTIONS env: diff --git a/package-lock.json b/package-lock.json index 86088f5d..9791d63a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "registration", - "version": "2.3.8", + "version": "3.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -60,9 +60,9 @@ } }, "@types/bson": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/bson/-/bson-1.0.9.tgz", - "integrity": "sha512-IO2bGcW3ApLptLPOQ0HY3RLY40psH5aG5/DAU9HBEJ21vqiNE0cYZM52P8iWw0Dzk5qiKLReEUsCtn6V6qVMNg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.0.0.tgz", + "integrity": "sha512-pq/rqJwJWkbS10crsG5bgnrisL8pML79KlMKQMoQwLUjlPAkrUHMvHJ3oGwE7WHR61Lv/nadMwXVAD2b+fpD8Q==", "dev": true, "requires": { "@types/node": "*" @@ -230,23 +230,21 @@ } }, "@types/mongodb": { - "version": "3.0.21", - "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.0.21.tgz", - "integrity": "sha512-O+jw6Pi3n0LKsVkO86NL+oM6GadFaLnEtioM0bRwD/LK/7ULL9+F30G7n6rW5HcNJZ0wKalP1ERz3ezIseMH2Q==", + "version": "3.1.22", + "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.1.22.tgz", + "integrity": "sha512-hvNR0txBlJJAy1eZOeIDshW4dnQaC694COou4eHHaMdIcteCfoCQATD7sYNlXxNxfTc1iIbHUi7A8CAhQe08uA==", "dev": true, "requires": { "@types/bson": "*", - "@types/events": "*", "@types/node": "*" } }, "@types/mongoose": { - "version": "5.0.18", - "resolved": "https://registry.npmjs.org/@types/mongoose/-/mongoose-5.0.18.tgz", - "integrity": "sha512-NTZYnHKJcIuVU7sqojZVG84SoitGEBHC6iDAT/hgGIzDHZ+mwO+snjUnglkHdS9+jJ2YssCJbSZp80i86fCBvQ==", + "version": "5.3.23", + "resolved": "https://registry.npmjs.org/@types/mongoose/-/mongoose-5.3.23.tgz", + "integrity": "sha512-UZJOkFe/ShSt3iYFBiadwwCu2Y8qm/RZyAoCQI2uf88wr3NfDBpbqqoIyrchBy1y2XtvAAyktEPzvvR7up6/TQ==", "dev": true, "requires": { - "@types/events": "*", "@types/mongodb": "*", "@types/node": "*" } @@ -270,57 +268,36 @@ } }, "@types/node": { - "version": "8.10.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.19.tgz", - "integrity": "sha512-+PU57o6DtOSx0/algmxgCwWrmCiomwC/K+LPfXonT0tQMbNTjHEqVzwL9dFEhFoPmLFIiSWjRorLH6Z0hJMT+Q==" - }, - "@types/passport": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/@types/passport/-/passport-0.4.5.tgz", - "integrity": "sha512-Ow5akVXwEZlOPCWGbEGy0GX4ocdwKz7JJH1K+BMd/BSOxmJTo2obH2AKbsgcncQvw5z7AGopdIu1Ap/j9sMRnQ==", - "dev": true, - "requires": { - "@types/express": "*" - } - }, - "@types/passport-facebook": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@types/passport-facebook/-/passport-facebook-2.1.8.tgz", - "integrity": "sha512-5FGF6zNN0ZELetEdIDjVjfHSJfXSehNWeRLv9/8JD6Des4Z9A7sthhyXVRQUXeUxv0SmQ/i+IHZjR8R/G61wIg==", + "version": "11.11.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-11.11.3.tgz", + "integrity": "sha512-wp6IOGu1lxsfnrD+5mX6qwSwWuqsdkKKxTN4aQc4wByHAKZJf9/D4KXPQ1POUjEbnCP5LMggB0OEFNY9OTsMqg==" + }, + "@types/oauth": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.1.tgz", + "integrity": "sha512-a1iY62/a3yhZ7qH7cNUsxoI3U/0Fe9+RnuFrpTKr+0WVOzbKlSLojShCKe20aOD1Sppv+i8Zlq0pLDuTJnwS4A==", "dev": true, "requires": { - "@types/express": "*", - "@types/passport": "*" - } - }, - "@types/passport-github2": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@types/passport-github2/-/passport-github2-1.1.3.tgz", - "integrity": "sha512-mv1ynABtAH9eGrCEncBA7i+/ztoFOAp83xT2VklilK0sG9Hp24Hyaa2S17tJKT548iA35j1uycoanSAMPpyqFg==", - "dev": true, - "requires": { - "@types/express": "*", - "@types/passport": "*" + "@types/node": "*" } }, - "@types/passport-local": { - "version": "1.0.33", - "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.33.tgz", - "integrity": "sha512-+rn6ZIxje0jZ2+DAiWFI8vGG7ZFKB0hXx2cUdMmudSWsigSq6ES7Emso46r4HJk0qCgrZVfI8sJiM7HIYf4SbA==", + "@types/passport": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.0.tgz", + "integrity": "sha512-R2FXqM+AgsMIym0PuKj08Ybx+GR6d2rU3b1/8OcHolJ+4ga2pRPX105wboV6hq1AJvMo2frQzYKdqXS5+4cyMw==", "dev": true, "requires": { - "@types/express": "*", - "@types/passport": "*", - "@types/passport-strategy": "*" + "@types/express": "*" } }, - "@types/passport-strategy": { - "version": "0.2.33", - "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.33.tgz", - "integrity": "sha512-tmj//XbNqCWmD+PJ/KnxAouircAmMGLN9IHBO3utH5DXuHHHYN4ZG53DRrQBjlZMiS/1b5IP38U2ay1GfbcQrQ==", + "@types/passport-oauth2": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.4.8.tgz", + "integrity": "sha512-tlX16wyFE5YJR2pHpZ308dgB1MV9/Ra2wfQh71eWk+/umPoD1Rca2D4N5M27W7nZm1wqUNGTk1I864nHvEgiFA==", "dev": true, "requires": { "@types/express": "*", + "@types/oauth": "*", "@types/passport": "*" } }, @@ -667,6 +644,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==" }, + "base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" + }, "basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -866,113 +848,6 @@ "stack-trace": "~0.0.7" } }, - "cas": { - "version": "git://github.com/joshchan/node-cas.git#344a8bfba9d054e2e378adaf95b720c898ae48a2", - "from": "git://github.com/joshchan/node-cas.git", - "requires": { - "cheerio": "0.19.0" - }, - "dependencies": { - "cheerio": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.19.0.tgz", - "integrity": "sha1-dy5wFfLuKZZQltcepBdbdas1SSU=", - "requires": { - "css-select": "~1.0.0", - "dom-serializer": "~0.1.0", - "entities": "~1.1.1", - "htmlparser2": "~3.8.1", - "lodash": "^3.2.0" - } - }, - "css-select": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.0.0.tgz", - "integrity": "sha1-sRIcpRhI3SZOIkTQWM7iVN7rRLA=", - "requires": { - "boolbase": "~1.0.0", - "css-what": "1.0", - "domutils": "1.4", - "nth-check": "~1.0.0" - } - }, - "css-what": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-1.0.0.tgz", - "integrity": "sha1-18wt9FGAZm+Z0rFEYmOUaeAPc2w=" - }, - "domhandler": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.3.0.tgz", - "integrity": "sha1-LeWaCCLVAn+r/28DLCsloqir5zg=", - "requires": { - "domelementtype": "1" - } - }, - "domutils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.4.3.tgz", - "integrity": "sha1-CGVRN5bGswYDGFDhdVFrr4C3Km8=", - "requires": { - "domelementtype": "1" - } - }, - "htmlparser2": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz", - "integrity": "sha1-mWwosZFRaovoZQGn15dX5ccMEGg=", - "requires": { - "domelementtype": "1", - "domhandler": "2.3", - "domutils": "1.5", - "entities": "1.0", - "readable-stream": "1.1" - }, - "dependencies": { - "domutils": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", - "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=", - "requires": { - "dom-serializer": "0", - "domelementtype": "1" - } - }, - "entities": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-1.0.0.tgz", - "integrity": "sha1-sph6o4ITR/zeZCsk/fyeT7cSvyY=" - } - } - }, - "isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" - }, - "lodash": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", - "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=" - }, - "readable-stream": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", - "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" - } - } - }, "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", @@ -2408,9 +2283,9 @@ } }, "mongoose": { - "version": "5.4.17", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.4.17.tgz", - "integrity": "sha512-9aAg8M0YUmGHQRDYvsKJ02wx/Qaof1Jn2iDH21ZtWGAZpQt9uVLNEOdcBuzi+lPJwGbLYh2dphdKX0sZ+dXAJQ==", + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.4.19.tgz", + "integrity": "sha512-paRU3nbCrPIUVw1GAlxo11uIIqrYORctUx1kcLj7i2NhkxPQuy5OK2/FYj8+tglsaixycmONSyop2HQp1IUQSA==", "requires": { "async": "2.6.1", "bson": "~1.1.0", @@ -2435,9 +2310,9 @@ } }, "bson": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.0.tgz", - "integrity": "sha512-9Aeai9TacfNtWXOYarkFJRW2CWo+dRon+fuLZYJmvLV3+MiUp0bEI6IAZfXEIg7/Pl/7IWlLaDnhzTsD81etQA==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.1.tgz", + "integrity": "sha512-jCGVYLoYMHDkOsbwJZBCqwMHyH4c+wzgI9hG7Z6SZJRXWr+x58pdIbm2i9a/jFGCkRJqRUr8eoI7lDWa0hTkxg==" }, "mongodb": { "version": "3.1.13", @@ -2669,59 +2544,20 @@ "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M=" }, "passport": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/passport/-/passport-0.3.2.tgz", - "integrity": "sha1-ndAJ+RXo/glbASSgG4+C2gdRAQI=", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.4.0.tgz", + "integrity": "sha1-xQlWkTR71a07XhgCOMORTRbwWBE=", "requires": { "passport-strategy": "1.x.x", "pause": "0.0.1" } }, - "passport-cas2": { - "version": "github:petschekr/passport-cas#3d026507a1c7949d25ce720886a127acb024744b", - "from": "github:petschekr/passport-cas", - "requires": { - "cas": "git://github.com/joshchan/node-cas.git", - "passport-strategy": "^1.0.0" - } - }, - "passport-facebook": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/passport-facebook/-/passport-facebook-2.1.1.tgz", - "integrity": "sha1-w50LUq5NWRYyRaTiGnubYyEwMxE=", - "requires": { - "passport-oauth2": "1.x.x" - } - }, - "passport-github2": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/passport-github2/-/passport-github2-0.1.11.tgz", - "integrity": "sha1-yStW88OKROdmqsfp58E4TF6TyZk=", - "requires": { - "passport-oauth2": "1.x.x" - } - }, - "passport-google-oauth20": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-1.0.0.tgz", - "integrity": "sha1-O5YOih1w0dvnlGFcgnxoxAOSpdA=", - "requires": { - "passport-oauth2": "1.x.x" - } - }, - "passport-local": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", - "integrity": "sha1-H+YyaMkudWBmJkN+O5BmYsFbpu4=", - "requires": { - "passport-strategy": "1.x.x" - } - }, "passport-oauth2": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.4.0.tgz", - "integrity": "sha1-9i+BWDy+EmCb585vFguTlaJ7hq0=", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.5.0.tgz", + "integrity": "sha512-kqBt6vR/5VlCK8iCx1/KpY42kQ+NEHZwsSyt4Y6STiNjU+wWICG1i8ucc1FapXDGO15C5O5VZz7+7vRzrDPXXQ==", "requires": { + "base64url": "3.x.x", "oauth": "0.9.x", "passport-strategy": "1.x.x", "uid2": "0.0.x", @@ -3293,15 +3129,15 @@ } }, "tslib": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.2.tgz", - "integrity": "sha512-AVP5Xol3WivEr7hnssHDsaM+lVrVXWUvd1cfXTRkTj80b//6g2wIFEH6hZG0muGZRnHGrfttpdzRk3YlBkWjKw==", + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz", + "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==", "dev": true }, "tslint": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.10.0.tgz", - "integrity": "sha1-EeJrzLiK+gLdDZlWyuPUVAtfVMM=", + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.14.0.tgz", + "integrity": "sha512-IUla/ieHVnB8Le7LdQFRGlVJid2T/gaJe5VkjzRVSRR6pA2ODYrnfR1hmxi+5+au9l50jBwpbBL34txgv4NnTQ==", "dev": true, "requires": { "babel-code-frame": "^6.22.0", @@ -3312,68 +3148,34 @@ "glob": "^7.1.1", "js-yaml": "^3.7.0", "minimatch": "^3.0.4", + "mkdirp": "^0.5.1", "resolve": "^1.3.2", "semver": "^5.3.0", "tslib": "^1.8.0", - "tsutils": "^2.12.1" + "tsutils": "^2.29.0" }, "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", - "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, "commander": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", - "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.19.0.tgz", + "integrity": "sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==", "dev": true - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "supports-color": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", - "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } } } }, - "tslint-language-service": { - "version": "0.9.9", - "resolved": "https://registry.npmjs.org/tslint-language-service/-/tslint-language-service-0.9.9.tgz", - "integrity": "sha1-9UbcOEg5eeb7PPpZWErYUls61No=", + "tslint-language-service-ts3": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tslint-language-service-ts3/-/tslint-language-service-ts3-1.0.0.tgz", + "integrity": "sha512-SE1QymT9i0bpKmDEiba+abgp8SUuxayM1sWZsrR9ffouiV2CtkB4GdGC/eFxp4rCPXuUXsdsVDxQBHNXSuah7A==", "dev": true, "requires": { "mock-require": "^2.0.2" } }, "tsutils": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.27.1.tgz", - "integrity": "sha512-AE/7uzp32MmaHvNNFES85hhUDHFdFZp6OAiZcd6y4ZKKIg6orJTm8keYWBhIhrJQH3a4LzNKat7ZPXZt5aTf6w==", + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz", + "integrity": "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA==", "dev": true, "requires": { "tslib": "^1.8.1" @@ -3413,9 +3215,9 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" }, "typescript": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-2.9.1.tgz", - "integrity": "sha512-h6pM2f/GDchCFlldnriOhs1QHuwbnmj6/v7499eMHqPeW4V2G0elua2eIc2nu8v2NdHV0Gm+tzX83Hr6nUFjQA==", + "version": "3.3.4000", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.3.4000.tgz", + "integrity": "sha512-jjOcCZvpkl2+z7JFn0yBOoLQyLoIkNZAs/fYJkUG6VKy6zLPHJGfQJYFHzibB6GJaF/8QrcECtlQ5cpvRHSMEA==", "dev": true }, "typo-js": { diff --git a/package.json b/package.json index 3616631c..12caf0f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "registration", - "version": "2.3.8", + "version": "3.0.0", "description": "Powerful and extensible registration system for hackathons and other large events", "main": "server/app.js", "scripts": { @@ -53,15 +53,11 @@ "marked": "^0.3.19", "moment": "^2.22.2", "moment-timezone": "^0.5.17", - "mongoose": "^5.4.17", + "mongoose": "^5.4.19", "morgan": "^1.9.1", "multer": "^1.3.0", - "passport": "^0.3.2", - "passport-cas2": "petschekr/passport-cas", - "passport-facebook": "^2.1.1", - "passport-github2": "^0.1.10", - "passport-google-oauth20": "^1.0.0", - "passport-local": "^1.0.0", + "passport": "^0.4.0", + "passport-oauth2": "^1.5.0", "qr-image": "^3.2.0", "serve-static": "^1.13.2", "striptags": "^3.1.1", @@ -88,15 +84,13 @@ "@types/marked": "0.0.28", "@types/mocha": "^2.2.48", "@types/moment-timezone": "^0.5.6", - "@types/mongodb": "^3.0.21", - "@types/mongoose": "^5.0.18", + "@types/mongodb": "^3.1.22", + "@types/mongoose": "^5.3.23", "@types/morgan": "^1.7.35", "@types/multer": "^1.3.6", - "@types/node": "^8.10.19", - "@types/passport": "^0.4.5", - "@types/passport-facebook": "^2.1.8", - "@types/passport-github2": "^1.1.3", - "@types/passport-local": "^1.0.33", + "@types/node": "^11.11.3", + "@types/passport": "^1.0.0", + "@types/passport-oauth2": "^1.4.8", "@types/qr-image": "^3.2.1", "@types/serve-static": "^1.13.2", "@types/supertest": "^2.0.4", @@ -106,8 +100,8 @@ "chai": "^4.0.0", "mocha": "^5.2.0", "supertest": "^3.1.0", - "tslint": "^5.10.0", - "tslint-language-service": "^0.9.9", - "typescript": "^2.9.1" + "tslint": "^5.14.0", + "tslint-language-service-ts3": "^1.0.0", + "typescript": "^3.3.0" } } diff --git a/server/common.ts b/server/common.ts index 46982edc..0baad9d6 100644 --- a/server/common.ts +++ b/server/common.ts @@ -16,19 +16,10 @@ class Config implements IConfig.Main { public secrets: IConfig.Secrets = { adminKey: crypto.randomBytes(32).toString("hex"), session: crypto.randomBytes(32).toString("hex"), - oauth: { - github: { - id: "", - secret: "" - }, - google: { - id: "", - secret: "" - }, - facebook: { - id: "", - secret: "" - } + groundTruth: { + url: "", + id: "", + secret: "" } }; public email: IConfig.Email = { @@ -43,11 +34,13 @@ class Config implements IConfig.Main { workflowReleaseSummary: null, cookieMaxAge: 1000 * 60 * 60 * 24 * 30 * 6, // 6 months cookieSecureOnly: false, - mongoURL: "mongodb://localhost/", - passwordResetExpiration: 1000 * 60 * 60, // 1 hour + mongoURL: "mongodb://localhost/registration", defaultTimezone: "America/New_York" }; - public admins: string[] = []; + public admins = { + domains: [] as string[], + emails: [] as string[] + }; public eventName: string = "Untitled Event"; public storageEngine = { "name": "disk", @@ -75,7 +68,7 @@ class Config implements IConfig.Main { } protected loadFromJSON(fileName: string): void { // tslint:disable-next-line:no-shadowed-variable - let config: IConfig.Main | null = null; + let config: Partial | null = null; try { config = JSON.parse(fs.readFileSync(path.resolve(__dirname, "./config", fileName), "utf8")); } @@ -103,7 +96,12 @@ class Config implements IConfig.Main { } } if (config.admins) { - this.admins = config.admins; + if (config.admins.domains) { + this.admins.domains = config.admins.domains; + } + if (config.admins.emails) { + this.admins.emails = config.admins.emails; + } } if (config.eventName) { this.eventName = config.eventName; @@ -131,42 +129,30 @@ class Config implements IConfig.Main { protected loadFromEnv(): void { // Secrets if (process.env.ADMIN_KEY_SECRET) { - this.secrets.adminKey = process.env.ADMIN_KEY_SECRET!; - } - else { - console.warn("Setting random admin key! Cannot use the service-to-service APIs."); + this.secrets.adminKey = process.env.ADMIN_KEY_SECRET; } if (process.env.SESSION_SECRET) { - this.secrets.session = process.env.SESSION_SECRET!; + this.secrets.session = process.env.SESSION_SECRET; this.sessionSecretSet = true; } - if (process.env.GITHUB_CLIENT_ID) { - this.secrets.oauth.github.id = process.env.GITHUB_CLIENT_ID!; + if (process.env.GROUND_TRUTH_URL) { + this.secrets.groundTruth.url = process.env.GROUND_TRUTH_URL; } - if (process.env.GITHUB_CLIENT_SECRET) { - this.secrets.oauth.github.secret = process.env.GITHUB_CLIENT_SECRET!; + if (process.env.GROUND_TRUTH_ID) { + this.secrets.groundTruth.id = process.env.GROUND_TRUTH_ID; } - if (process.env.GOOGLE_CLIENT_ID) { - this.secrets.oauth.google.id = process.env.GOOGLE_CLIENT_ID!; - } - if (process.env.GOOGLE_CLIENT_SECRET) { - this.secrets.oauth.google.secret = process.env.GOOGLE_CLIENT_SECRET!; - } - if (process.env.FACEBOOK_CLIENT_ID) { - this.secrets.oauth.facebook.id = process.env.FACEBOOK_CLIENT_ID!; - } - if (process.env.FACEBOOK_CLIENT_SECRET) { - this.secrets.oauth.facebook.secret = process.env.FACEBOOK_CLIENT_SECRET!; + if (process.env.GROUND_TRUTH_SECRET) { + this.secrets.groundTruth.secret = process.env.GROUND_TRUTH_SECRET; } // Email if (process.env.EMAIL_FROM) { - this.email.from = process.env.EMAIL_FROM!; + this.email.from = process.env.EMAIL_FROM; } if (process.env.EMAIL_KEY) { - this.email.key = process.env.EMAIL_KEY!; + this.email.key = process.env.EMAIL_KEY; } // Server - if (process.env.PRODUCTION && process.env.PRODUCTION!.toLowerCase() === "true") { + if (process.env.PRODUCTION && process.env.PRODUCTION.toLowerCase() === "true") { this.server.isProduction = true; } if (process.env.PORT) { @@ -176,68 +162,65 @@ class Config implements IConfig.Main { } } if (process.env.VERSION_HASH) { - this.server.versionHash = process.env.VERSION_HASH!; + this.server.versionHash = process.env.VERSION_HASH; } if (process.env.SOURCE_REV) { - this.server.versionHash = process.env.SOURCE_REV!; + this.server.versionHash = process.env.SOURCE_REV; } if (process.env.SOURCE_VERSION) { - this.server.versionHash = process.env.SOURCE_VERSION!; + this.server.versionHash = process.env.SOURCE_VERSION; } if (process.env.WORKFLOW_RELEASE_CREATED_AT) { - this.server.workflowReleaseCreatedAt = process.env.WORKFLOW_RELEASE_CREATED_AT!; + this.server.workflowReleaseCreatedAt = process.env.WORKFLOW_RELEASE_CREATED_AT; } if (process.env.WORKFLOW_RELEASE_SUMMARY) { - this.server.workflowReleaseSummary = process.env.WORKFLOW_RELEASE_SUMMARY!; + this.server.workflowReleaseSummary = process.env.WORKFLOW_RELEASE_SUMMARY; } if (process.env.COOKIE_MAX_AGE) { - let maxAge = parseInt(process.env.COOKIE_MAX_AGE!, 10); + let maxAge = parseInt(process.env.COOKIE_MAX_AGE, 10); if (!isNaN(maxAge) && maxAge > 0) { this.server.cookieMaxAge = maxAge; } } - if (process.env.COOKIE_SECURE_ONLY && process.env.COOKIE_SECURE_ONLY!.toLowerCase() === "true") { + if (process.env.COOKIE_SECURE_ONLY && process.env.COOKIE_SECURE_ONLY.toLowerCase() === "true") { this.server.cookieSecureOnly = true; } if (process.env.MONGO_URL) { - this.server.mongoURL = process.env.MONGO_URL!; + this.server.mongoURL = process.env.MONGO_URL; } if (process.env.DEFAULT_TIMEZONE) { this.server.defaultTimezone = process.env.DEFAULT_TIMEZONE; } - if (process.env.PASSWORD_RESET_EXPIRATION) { - let expirationTime = parseInt(process.env.PASSWORD_RESET_EXPIRATION!, 10); - if (!isNaN(expirationTime) && expirationTime > 0) { - this.server.passwordResetExpiration = expirationTime; - } - } // Admins if (process.env.ADMIN_EMAILS) { - this.admins = JSON.parse(process.env.ADMIN_EMAILS!); + this.admins.emails = JSON.parse(process.env.ADMIN_EMAILS!); + } + if (process.env.ADMIN_DOMAINS) { + this.admins.domains = JSON.parse(process.env.ADMIN_DOMAINS); } // Event name if (process.env.EVENT_NAME) { - this.eventName = process.env.EVENT_NAME!; + this.eventName = process.env.EVENT_NAME; } // Questions if (process.env.QUESTIONS_FILE) { - this.questionsLocation = process.env.QUESTIONS_FILE!; + this.questionsLocation = process.env.QUESTIONS_FILE; } // Style if (process.env.THEME_FILE) { - this.style.theme = process.env.THEME_FILE!; + this.style.theme = process.env.THEME_FILE; } if (process.env.FAVICON_FILE) { - this.style.favicon = process.env.FAVICON_FILE!; + this.style.favicon = process.env.FAVICON_FILE; } else if (process.env.FAVICON_FILE_BASE64) { - this.style.favicon = unbase64File(process.env.FAVICON_FILE_BASE64!); + this.style.favicon = unbase64File(process.env.FAVICON_FILE_BASE64); } // Storage engine if (process.env.STORAGE_ENGINE) { - this.storageEngine.name = process.env.STORAGE_ENGINE!; + this.storageEngine.name = process.env.STORAGE_ENGINE; if (process.env.STORAGE_ENGINE_OPTIONS) { - this.storageEngine.options = JSON.parse(process.env.STORAGE_ENGINE_OPTIONS!); + this.storageEngine.options = JSON.parse(process.env.STORAGE_ENGINE_OPTIONS); } else { console.warn("Custom storage engine defined but no storage engine options passed"); @@ -248,7 +231,7 @@ class Config implements IConfig.Main { } // Team size if (process.env.MAX_TEAM_SIZE) { - this.maxTeamSize = parseInt(process.env.MAX_TEAM_SIZE!, 10); + this.maxTeamSize = parseInt(process.env.MAX_TEAM_SIZE, 10); } } } @@ -285,7 +268,7 @@ export function formatSize(size: number, binary: boolean = true): string { // Database connection // import * as mongoose from "mongoose"; -mongoose.connect(config.server.mongoURL).catch(err => { +mongoose.connect(config.server.mongoURL, { useNewUrlParser: true }).catch(err => { throw err; }); export { mongoose }; @@ -299,8 +282,7 @@ async function setDefaultSettings() { const DEFAULTS: any = { "teamsEnabled": true, - "qrEnabled": true, - "loginMethods": ["local"] + "qrEnabled": true }; for (let setting in DEFAULTS) { @@ -490,11 +472,11 @@ export async function renderEmailHTML(markdown: string, user: IUser): Promise { - let question = user.applicationData.find(data => data.name === name); + let question = (user.applicationData || []).find(data => data.name === name); return formatFormItem(question); }); markdown = markdown.replace(/{{confirmation\.([a-zA-Z0-9\- ]+)}}/g, (match, name: string) => { - let question = user.confirmationData.find(data => data.name === name); + let question = (user.confirmationData || []).find(data => data.name === name); return formatFormItem(question); }); diff --git a/server/config/config.example.json b/server/config/config.example.json index 57d9d5f2..8b24fc2a 100644 --- a/server/config/config.example.json +++ b/server/config/config.example.json @@ -1,21 +1,16 @@ { "secrets": { "session": "", - "oauth": { - "github": { - "id": "", - "secret": "" - }, - "google": { - "id": "", - "secret": "" - }, - "facebook": { - "id": "", - "secret": "" - } + "groundTruth": { + "url": "", + "id": "", + "secret": "" } }, + "admins": { + "domains": ["gatech.edu", "hack.gt"], + "emails": ["george.p@burdell.com", "buzz@gatech.edu"] + }, "email": { "from": "HackGT Team ", "host": "smtp.sendgrid.net", @@ -27,10 +22,8 @@ "port": 3000, "cookieMaxAge": 15552000000, "cookieSecureOnly": true, - "passwordResetExpiration": 3600000, "mongoURL": "mongodb://localhost/registration" }, - "admins": ["example@example.com"], "eventName": "My Hackathon", "storageEngine": { "name": "disk | s3", diff --git a/server/middleware.ts b/server/middleware.ts index 6e54ed75..332d91b5 100644 --- a/server/middleware.ts +++ b/server/middleware.ts @@ -5,7 +5,7 @@ import * as os from "os"; import { config, isBranchOpen } from "./common"; import { BranchConfig, ApplicationBranch, ConfirmationBranch } from "./branch"; -import { User, IUser, DataLog, HackGTMetrics } from "./schema"; +import { IUser, User, DataLog, HackGTMetrics } from "./schema"; // // Express middleware @@ -60,7 +60,7 @@ export function isUserOrAdmin(request: express.Request, response: express.Respon "error": "You must log in to access this endpoint" }); } - else if ((user.uuid !== request.params.uuid && !user.admin) || !user.verifiedEmail || !user.accountConfirmed) { + else if (user.uuid !== request.params.uuid && !user.admin) { response.status(403).json({ "error": "You are not permitted to access this endpoint" }); @@ -76,7 +76,7 @@ export function isAdmin(request: express.Request, response: express.Response, ne const auth = request.headers.authorization; if (auth && typeof auth === "string" && auth.indexOf(" ") > -1) { - const key = new Buffer(auth.split(" ")[1], "base64").toString(); + const key = Buffer.from(auth.split(" ")[1], "base64").toString(); if (key === config.secrets.adminKey) { next(); } @@ -91,7 +91,7 @@ export function isAdmin(request: express.Request, response: express.Response, ne "error": "You must log in to access this endpoint" }); } - else if (!user.admin || !user.verifiedEmail || !user.accountConfirmed) { + else if (!user.admin) { response.status(403).json({ "error": "You are not permitted to access this endpoint" }); @@ -104,7 +104,7 @@ export function isAdmin(request: express.Request, response: express.Response, ne // For API endpoints export function authenticateWithReject(request: express.Request, response: express.Response, next: express.NextFunction) { response.setHeader("Cache-Control", "no-cache"); - if (!request.isAuthenticated() || !request.user || !request.user.verifiedEmail || !request.user.accountConfirmed) { + if (!request.isAuthenticated() || !request.user) { response.status(401).json({ "error": "You must log in to access this endpoint" }); @@ -117,7 +117,7 @@ export function authenticateWithReject(request: express.Request, response: expre // For directly user facing endpoints export function authenticateWithRedirect(request: express.Request, response: express.Response, next: express.NextFunction) { response.setHeader("Cache-Control", "private"); - if (!request.isAuthenticated() || !request.user || !request.user.verifiedEmail || !request.user.accountConfirmed) { + if (!request.isAuthenticated() || !request.user) { if (request.session) { request.session.returnTo = request.originalUrl; } @@ -171,7 +171,7 @@ export async function canUserModify(request: express.Request, response: express. }); return; } - if (user.applied && branchName.toLowerCase() !== user.applicationBranch.toLowerCase()) { + if (user.applied && branchName.toLowerCase() !== user.applicationBranch!.toLowerCase()) { response.status(400).json({ "error": "You can only edit the application branch that you originally submitted" }); @@ -233,8 +233,8 @@ export function branchRedirector(requestType: ApplicationType): (request: expres if (requestType === ApplicationType.Application) { // Redirect directly to branch if there is an existing application or confirmation - if (user.applied && branchName.toLowerCase() !== user.applicationBranch.toLowerCase()) { - response.redirect(`/apply/${encodeURIComponent(user.applicationBranch.toLowerCase())}`); + if (user.applied && branchName.toLowerCase() !== user.applicationBranch!.toLowerCase()) { + response.redirect(`/apply/${encodeURIComponent(user.applicationBranch!.toLowerCase())}`); return; } } diff --git a/server/routes/api/graphql.ts b/server/routes/api/graphql.ts index 952ddc1c..0fa41472 100644 --- a/server/routes/api/graphql.ts +++ b/server/routes/api/graphql.ts @@ -10,7 +10,6 @@ import { User, IUser, Team, IFormItem, QuestionBranchConfig } from "../../schema import { Branches, Tags, AllTags, BranchConfig, ApplicationBranch, ConfirmationBranch, NoopBranch } from "../../branch"; import { schema as types } from "./api.graphql.types"; import { formatSize } from "../../common"; -import { prettyNames as strategyNames } from "../strategies"; const typeDefs = fs.readFileSync(path.resolve(__dirname, "../../../api.graphql"), "utf8"); @@ -161,15 +160,17 @@ async function findQuestions( let items: types.FormItem[] = []; if (user.applied) { - items = items.concat(await Promise.all(user.applicationData - .reduce(questionFilter, []) - .map(item => recordToFormItem(item, user.applicationBranch)) + items = items.concat(await Promise.all( + user.applicationData! + .reduce(questionFilter, []) + .map(item => recordToFormItem(item, user.applicationBranch!)) )); } if (user.confirmed) { - items = items.concat(await Promise.all(user.confirmationData - .reduce(questionFilter, []) - .map(item => recordToFormItem(item, user.confirmationBranch!)) + items = items.concat(await Promise.all( + user.confirmationData! + .reduce(questionFilter, []) + .map(item => recordToFormItem(item, user.confirmationBranch!)) )); } return items; @@ -283,8 +284,8 @@ async function recordToFormItem(item: IFormItem, branchName: string): Promise> { const application: types.Branch | undefined = user.applied ? { - type: user.applicationBranch, - data: await Promise.all(user.applicationData.map(item => recordToFormItem(item, user.applicationBranch))), + type: user.applicationBranch!, + data: await Promise.all(user.applicationData!.map(item => recordToFormItem(item, user.applicationBranch!))), start_time: user.applicationStartTime && user.applicationStartTime.toDateString(), submit_time: user.applicationSubmitTime && @@ -293,21 +294,13 @@ async function userRecordToGraphql(user: IUser): Promise> { const confirmation: types.Branch | undefined = user.confirmed ? { type: user.confirmationBranch!, - data: await Promise.all(user.confirmationData.map(item => recordToFormItem(item, user.confirmationBranch!))), + data: await Promise.all(user.confirmationData!.map(item => recordToFormItem(item, user.confirmationBranch!))), start_time: user.confirmationStartTime && user.confirmationStartTime.toDateString(), submit_time: user.confirmationSubmitTime && user.confirmationSubmitTime.toDateString() } : undefined; - let loginMethods: string[] = []; - if (user.local && user.local!.hash) { - loginMethods.push("Local"); - } - for (let service of Object.keys(user.services || {}) as (keyof typeof user.services)[]) { - loginMethods.push(strategyNames[service]); - } - let team = user.teamId ? await Team.findById(user.teamId) : null; return { @@ -315,9 +308,7 @@ async function userRecordToGraphql(user: IUser): Promise> { name: user.name || "", email: user.email, - email_verified: !!user.verifiedEmail, admin: !!user.admin, - login_methods: loginMethods, applied: !!user.applied, accepted: !!user.accepted, diff --git a/server/routes/api/settings.ts b/server/routes/api/settings.ts index 400e8c3c..75c7fbbd 100644 --- a/server/routes/api/settings.ts +++ b/server/routes/api/settings.ts @@ -108,43 +108,6 @@ settingsRoutes.route("/admin_emails") }); }); -settingsRoutes.route("/login_methods") - .get(async (request, response) => { - let methods = await getSetting("loginMethods"); - response.json({ - methods - }); - }) - .put(isAdmin, uploadHandler.any(), async (request, response) => { - let { enabledMethods } = request.body; - if (!enabledMethods) { - response.status(400).json({ - "error": "Missing value for enabled methods" - }); - return; - } - try { - let methods = JSON.parse(enabledMethods); - if (!Array.isArray(methods)) { - response.status(400).json({ - "error": "Invalid value for enabled methods" - }); - return; - } - await updateSetting("loginMethods", methods); - await (await import("../auth")).reloadAuthentication(); - response.json({ - "success": true - }); - } - catch (err) { - console.error(err); - response.status(500).json({ - "error": "An error occurred while changing available login methods" - }); - } - }); - settingsRoutes.route("/branch_roles") .get(isAdmin, async (request, response) => { response.json({ @@ -283,8 +246,6 @@ settingsRoutes.route("/email_content/:type/rendered") settingsRoutes.route("/send_batch_email") .post(isAdmin, uploadHandler.any(), async (request, response) => { let filter = JSON.parse(request.body.filter); - filter.verifiedEmail = true; - filter.accountConfirmed = true; let subject = request.body.subject as string; let markdownContent = request.body.markdownContent; if (typeof filter !== "object") { diff --git a/server/routes/api/user.ts b/server/routes/api/user.ts index 78706367..ebf0aa41 100644 --- a/server/routes/api/user.ts +++ b/server/routes/api/user.ts @@ -15,11 +15,13 @@ import { trackEvent, canUserModify } from "../../middleware"; import { + Model, createNew, IFormItem, - IUserMongoose, User, - ITeamMongoose, Team + IUser, User, + Team } from "../../schema"; import * as Branches from "../../branch"; +import { GroundTruthStrategy } from "../strategies"; export let userRoutes = express.Router({ "mergeParams": true }); export let registrationRoutes = express.Router({ "mergeParams": true }); @@ -69,23 +71,32 @@ userRoutes.route("/confirmation/:branch").post( ); function postApplicationBranchHandler(anonymous: boolean): (request: express.Request, response: express.Response) => Promise { return async (request, response) => { - let user: IUserMongoose; + let user: Model; if (anonymous) { let email = request.body["anonymous-registration-email"] as string; let name = request.body["anonymous-registration-name"] as string; - if (await User.findOne({email})) { + if (await User.findOne({ email })) { response.status(400).json({ "error": `User with email "${email}" already exists` }); return; } - user = new User({ + user = createNew(User, { + ...GroundTruthStrategy.defaultUserProperties, uuid: uuid(), name, - email - }) as IUserMongoose; + email, + token: null + }); } else { - user = await User.findOne({uuid: request.params.uuid}) as IUserMongoose; + let existingUser = await User.findOne({ uuid: request.params.uuid }); + if (!existingUser) { + response.status(400).json({ + "error": "Invalid user id" + }); + return; + } + user = existingUser; } let branchName = await Branches.BranchConfig.getCanonicalName(request.params.branch); @@ -104,12 +115,15 @@ function postApplicationBranchHandler(anonymous: boolean): (request: express.Req return null; } let files = request.files as Express.Multer.File[]; - let preexistingFile: boolean = question.type === "file" && user.applicationData && user.applicationData.some(entry => entry.name === question.name && !!entry.value); + let preexistingFile: boolean = + question.type === "file" + && user.applicationData != undefined + && user.applicationData.some(entry => entry.name === question.name && !!entry.value); if (question.required && !request.body[question.name] && !files.find(file => file.fieldname === question.name)) { // Required field not filled in if (preexistingFile) { - let previousValue = user.applicationData.find(entry => entry.name === question.name && !!entry.value)!.value as Express.Multer.File; + let previousValue = user.applicationData!.find(entry => entry.name === question.name && !!entry.value)!.value as Express.Multer.File; unchangedFiles.push(previousValue.filename); return { "name": question.name, @@ -267,7 +281,13 @@ function postApplicationBranchHandler(anonymous: boolean): (request: express.Req async function deleteApplicationBranchHandler(request: express.Request, response: express.Response) { let requestType: ApplicationType = request.url.match(/\/application\//) ? ApplicationType.Application : ApplicationType.Confirmation; - let user = await User.findOne({uuid: request.params.uuid}) as IUserMongoose; + let user = await User.findOne({ uuid: request.params.uuid }); + if (!user) { + response.status(400).json({ + "error": "Invalid user id" + }); + return; + } if (requestType === ApplicationType.Application) { user.applied = false; user.accepted = false; @@ -327,7 +347,7 @@ userRoutes.route("/status").post(isAdmin, uploadHandler.any(), async (request, r } }); -async function updateUserStatus(user: IUserMongoose, status: string): Promise { +async function updateUserStatus(user: Model, status: string): Promise { if (status === user.confirmationBranch) { throw new Error(`User status is already ${status}!`); } else if (status === "no-decision") { @@ -442,14 +462,14 @@ userRoutes.route("/export").get(isAdmin, async (request, response): Promise { // TODO: Replace with more robust schema-agnostic version - let nameFormItem = user.applicationData.find(item => item.name === "name"); + let nameFormItem = (user.applicationData || []).find(item => item.name === "name"); return { "name": nameFormItem && typeof nameFormItem.value === "string" ? nameFormItem.value : user.name, "email": user.email @@ -467,12 +487,12 @@ userRoutes.route("/export").get(isAdmin, async (request, response): Promise { +async function removeUserFromAllTeams(user: IUser): Promise { if (!user.teamId) { return true; } - let currentUserTeam = await Team.findById(user.teamId) as ITeamMongoose; + let currentUserTeam = await Team.findById(user.teamId); if (!currentUserTeam) { return false; } @@ -518,13 +538,18 @@ userRoutes.route("/team/create/:teamName").post(isUserOrAdmin, async (request, r Else if the user's in a team, take them out of it Else make them a new team */ - let user = await User.findOne({uuid: request.params.uuid}) as IUserMongoose; + let user = await User.findOne({ uuid: request.params.uuid }); + if (!user) { + response.status(400).json({ + "error": "Invalid user id" + }); + return; + } let decodedTeamName = decodeURI(request.params.teamName); - let existingTeam = await Team.findOne({ teamName: decodedTeamName }) as ITeamMongoose; + let existingTeam = await Team.findOne({ teamName: decodedTeamName }); if (existingTeam) { - // Someone else has a team with this name response.status(400).json({ "error": `Someone else has a team called ${decodedTeamName}. Please pick a different name.` @@ -534,18 +559,20 @@ userRoutes.route("/team/create/:teamName").post(isUserOrAdmin, async (request, r // If the user is in a team, remove them from their current team unless they're the team leader if (user.teamId) { - let currentUserTeam = await Team.findById(user.teamId) as ITeamMongoose; + let currentUserTeam = await Team.findById(user.teamId); - if (currentUserTeam.teamLeader === user._id) { - // The user is in the team they made already - // Ideally this will never happen if we do some validation client side - response.status(400).json({ - "error": "You're already the leader of this team" - }); - return; - } + if (currentUserTeam) { + if (currentUserTeam.teamLeader === user._id) { + // The user is in the team they made already + // Ideally this will never happen if we do some validation client side + response.status(400).json({ + "error": "You're already the leader of this team" + }); + return; + } - await removeUserFromAllTeams(user); + await removeUserFromAllTeams(user); + } } let query = { @@ -562,9 +589,11 @@ userRoutes.route("/team/create/:teamName").post(isUserOrAdmin, async (request, r setDefaultsOnInsert: true }; - let team = await Team.findOneAndUpdate(query, {}, options) as ITeamMongoose; + let team = await Team.findOneAndUpdate(query, {}, options); - user.teamId = team._id; + if (team) { + user.teamId = team._id; + } await user.save(); response.json({ @@ -573,7 +602,14 @@ userRoutes.route("/team/create/:teamName").post(isUserOrAdmin, async (request, r }); userRoutes.route("/team/join/:teamName").post(isUserOrAdmin, async (request, response): Promise => { - let user = await User.findOne({uuid: request.params.uuid}) as IUserMongoose; + let user = await User.findOne({ uuid: request.params.uuid }); + if (!user) { + response.status(400).json({ + "error": "Invalid user id" + }); + return; + } + let decodedTeamName = decodeURI(request.params.teamName); if (user.teamId) { @@ -589,7 +625,7 @@ userRoutes.route("/team/join/:teamName").post(isUserOrAdmin, async (request, res return; } - let teamToJoin = await Team.findOne({ teamName: decodedTeamName }) as ITeamMongoose; + let teamToJoin = await Team.findOne({ teamName: decodedTeamName }); if (!teamToJoin) { // If the team they tried to join isn't real... @@ -626,7 +662,13 @@ userRoutes.route("/team/join/:teamName").post(isUserOrAdmin, async (request, res }); userRoutes.route("/team/leave").post(isUserOrAdmin, async (request, response): Promise => { - let user = await User.findOne({uuid: request.params.uuid}) as IUserMongoose; + let user = await User.findOne({ uuid: request.params.uuid }); + if (!user) { + response.status(400).json({ + "error": "Invalid user id" + }); + return; + } await removeUserFromAllTeams(user); response.status(200).json({ @@ -635,8 +677,13 @@ userRoutes.route("/team/leave").post(isUserOrAdmin, async (request, response): P }); userRoutes.route("/team/rename/:newTeamName").post(isUserOrAdmin, async (request, response): Promise => { - let user = await User.findOne({uuid: request.params.uuid}) as IUserMongoose; - + let user = await User.findOne({ uuid: request.params.uuid }); + if (!user) { + response.status(400).json({ + "error": "Invalid user id" + }); + return; + } if (!user.teamId) { response.status(400).json({ "error": "You're not in a team" @@ -644,8 +691,7 @@ userRoutes.route("/team/rename/:newTeamName").post(isUserOrAdmin, async (request return; } - let currentUserTeam = await Team.findById(user.teamId) as ITeamMongoose; - + let currentUserTeam = await Team.findById(user.teamId); if (!currentUserTeam) { // User tried to change their team name even though they don't have a team response.status(400).json({ @@ -682,18 +728,26 @@ userRoutes.route("/team/rename/:newTeamName").post(isUserOrAdmin, async (request }); }); -userRoutes.get('/', async (request, response) => { +userRoutes.get("/", async (request, response) => { if (request.user) { - let user = await User.findOne({uuid: request.user!.uuid}) as IUserMongoose; + let user = await User.findOne({ uuid: request.user.uuid }); + if (!user) { + response.status(400).json({ + "error": "Invalid user id" + }); + return; + } + response.json({ uuid: user.uuid, name: user.name, email: user.email, admin: user.admin || false }); - } else { + } + else { response.json({ - error: 1 + "error": "Not logged in" }); } }); diff --git a/server/routes/auth.ts b/server/routes/auth.ts index bc5a782a..cc08b118 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -1,5 +1,7 @@ +import * as http from "http"; +import * as https from "https"; +import { URL } from "url"; import * as crypto from "crypto"; -import * as path from "path"; import * as express from "express"; import * as session from "express-session"; import * as connectMongo from "connect-mongo"; @@ -7,18 +9,17 @@ const MongoStore = connectMongo(session); import * as passport from "passport"; import { - config, mongoose, COOKIE_OPTIONS, getSetting + config, mongoose, COOKIE_OPTIONS } from "../common"; import { - IUser, User, IUserMongoose + IUser, User } from "../schema"; -import { postParser } from "../middleware"; import { - RegistrationStrategy, strategies, validateAndCacheHostName, sendVerificationEmail + AuthenticateOptions, GroundTruthStrategy, createLink, validateAndCacheHostName } from "./strategies"; // Passport authentication -import {app} from "../app"; +import { app } from "../app"; if (!config.server.isProduction) { console.warn("OAuth callback(s) running in development mode"); @@ -40,121 +41,63 @@ app.use(session({ saveUninitialized: false })); passport.serializeUser((user, done) => { - done(null, user._id.toString()); + done(null, user.uuid); }); passport.deserializeUser((id, done) => { - User.findById(id, (err, user) => { + User.findOne({ uuid: id }, (err, user) => { done(err, user!); }); }); -let router = express.Router(); -export let authRoutes: express.RequestHandler = (request, response, next) => { - // Allows for dynamic dispatch when authentication gets reloaded - router(request, response, next); -}; +export let authRoutes = express.Router(); -export async function reloadAuthentication() { - router = express.Router(); - let authenticationMethods: RegistrationStrategy[] = []; - let methods = await getSetting<(keyof typeof strategies)[]>("loginMethods"); - console.info(`Using authentication methods: ${methods.join(", ")}`); - for (let methodName of methods) { - if (!strategies[methodName]) { - console.error(`Authentication method "${methodName}" is not available. Did you add it to the exported list of strategies?`); - continue; - } - let method = new strategies[methodName](); - authenticationMethods.push(method); - method.use(router); - } - - // These routes need to be redefined on every instance of a new router - router.post("/confirm", validateAndCacheHostName, postParser, async (request, response) => { - let user = request.user as IUserMongoose; - let name = request.body.name as string; - if (!name || !name.trim()) { - request.flash("error", "Invalid name"); - response.redirect("/login/confirm"); - return; - } - if (!request.isAuthenticated() || !user) { - request.flash("error", "Must be logged in"); - response.redirect("/login"); - return; - } - user.name = name.trim(); - - let email = request.body.email as string | undefined; - if (email && email !== user.email) { - if (!email.trim()) { - request.flash("error", "Invalid email"); - response.redirect("/login/confirm"); - return; - } - if (await User.count({ email }) > 0) { - request.flash("error", "That email address is already in use. You may already have an account from another login service."); - response.redirect("/login/confirm"); - return; - } - user.admin = false; - user.verifiedEmail = false; - user.email = email; - if (config.admins.includes(email)) { - user.admin = true; - console.info(`Adding new admin: ${email}`); - } - } - user.accountConfirmed = true; +authRoutes.get("/validatehost/:nonce", (request, response) => { + let nonce: string = request.params.nonce || ""; + response.send(crypto.createHmac("sha256", config.secrets.session).update(nonce).digest().toString("hex")); +}); - try { - await user.save(); - if (!user.verifiedEmail && (!user.local || !user.local.verificationCode)) { - await sendVerificationEmail(request, user); - } - if (!user.verifiedEmail) { - request.logout(); - request.flash("success", "Account created successfully. Please verify your email before logging in."); - response.redirect("/login"); - return; +authRoutes.all("/logout", (request, response) => { + let user = request.user as IUser | undefined; + if (user) { + let groundTruthURL = new URL(config.secrets.groundTruth.url); + let requester = groundTruthURL.protocol === "http:" ? http : https; + requester.request(new URL("/api/user/logout", config.secrets.groundTruth.url), { + method: "POST", + headers: { + "Authorization": `Bearer ${user.token}` } - response.redirect("/"); - } - catch (err) { - console.error(err); - request.flash("error", "An error occurred while creating your account"); - response.redirect("/login/confirm"); - } - }); + }).end(); - router.get("/validatehost/:nonce", (request, response) => { - let nonce: string = request.params.nonce || ""; - response.send(crypto.createHmac("sha256", config.secrets.session).update(nonce).digest().toString("hex")); - }); - - router.all("/logout", (request, response) => { request.logout(); - response.redirect("/login"); - }); -} -reloadAuthentication().catch(err => { - throw err; + } + if (request.session) { + request.session.loginAction = "render"; + } + response.redirect("/login"); }); app.use(passport.initialize()); app.use(passport.session()); -app.use((request, response, next) => { - // Only block requests for GET requests to non-auth pages - if (path.extname(request.url) !== "" || request.method !== "GET" || request.originalUrl.match(/^\/auth/)) { - next(); +const groundTruthStrategy = new GroundTruthStrategy(config.secrets.groundTruth.url); + +passport.use(groundTruthStrategy); + +authRoutes.get("/login", validateAndCacheHostName, (request, response, next) => { + let callbackURL = createLink(request, "auth/login/callback"); + passport.authenticate("oauth2", { callbackURL } as AuthenticateOptions)(request, response, next); +}); +authRoutes.get("/login/callback", validateAndCacheHostName, (request, response, next) => { + let callbackURL = createLink(request, "auth/login/callback"); + + if (request.query.error === "access_denied") { + request.flash("error", "Authentication request was denied"); + response.redirect("/login"); return; } - let user = request.user as IUser; - if (user && !user.accountConfirmed && request.originalUrl !== "/login/confirm") { - response.redirect("/login/confirm"); - } - else { - next(); - } + passport.authenticate("oauth2", { + failureRedirect: "/login", + successReturnToOrRedirect: "/", + callbackURL + } as AuthenticateOptions)(request, response, next); }); diff --git a/server/routes/strategies.ts b/server/routes/strategies.ts index 481b6f21..2dcf9f1a 100644 --- a/server/routes/strategies.ts +++ b/server/routes/strategies.ts @@ -1,627 +1,114 @@ -// tslint:disable:interface-name import * as crypto from "crypto"; import * as http from "http"; import * as https from "https"; -import * as path from "path"; +import { URL } from "url"; import * as passport from "passport"; -import * as moment from "moment-timezone"; -import * as uuid from "uuid/v4"; +import { Strategy as OAuthStrategy } from "passport-oauth2"; -import { config, pbkdf2Async, renderEmailHTML, renderEmailText, sendMailAsync } from "../common"; -import { postParser, trackEvent } from "../middleware"; -import { IConfig, IUser, IUserMongoose, User } from "../schema"; -import { Request, Response, NextFunction, Router } from "express"; +import { config } from "../common"; +// TODO import { trackEvent } from "../middleware"; +import { createNew, IUser, User } from "../schema"; +import { Request, Response, NextFunction } from "express"; -import {Strategy as LocalStrategy} from "passport-local"; -import {Strategy as GitHubStrategy} from "passport-github2"; -import {Strategy as FacebookStrategy} from "passport-facebook"; -// No type definitions available yet for these modules -// tslint:disable:no-var-requires -const GoogleStrategy: StrategyConstructor = require("passport-google-oauth20").Strategy; -const CASStrategyProvider: StrategyConstructor = require("passport-cas2").Strategy; - -type Strategy = passport.Strategy & { - logout?(request: Request, response: Response, returnURL: string): void; -}; -type PassportDone = (err: Error | null, user?: IUserMongoose | false, errMessage?: { message: string }) => void; -type Profile = passport.Profile & { - profileUrl?: string; - _json: any; -}; -interface StrategyOptions { +type PassportDone = (err: Error | null, user?: IUser | false, errMessage?: { message: string }) => void; +type PassportProfileDone = (err: Error | null, profile?: IProfile) => void; +interface IStrategyOptions { passReqToCallback: true; // Forced to true for our usecase } -interface OAuthStrategyOptions extends StrategyOptions { +interface IOAuthStrategyOptions extends IStrategyOptions { + authorizationURL: string; + tokenURL: string; clientID: string; clientSecret: string; - profileFields?: string[]; } -interface CASStrategyOptions extends StrategyOptions { - casURL: string; - pgtURL?: string; - sessionKey?: string; - propertyMap?: object; - sslCA?: any[]; -} -interface LocalStrategyOptions extends StrategyOptions { - usernameField: string; - passwordField: string; -} -interface StrategyConstructor { - // OAuth constructor - new(options: OAuthStrategyOptions, cb: (request: Request, accessToken: string, refreshToken: string, profile: Profile, done: PassportDone) => Promise): Strategy; - // CAS constructor - new(options: CASStrategyOptions, cb: (request: Request, username: string, profile: Profile, done: PassportDone) => Promise): Strategy; +interface IProfile { + uuid: string; + name: string; + email: string; + token: string; } + // Because the passport typedefs don't include this for some reason // Defined: https://github.com/jaredhanson/passport-oauth2/blob/9ddff909a992c3428781b7b2957ce1a97a924367/lib/strategy.js#L135 -type AuthenticateOptions = passport.AuthenticateOptions & { +export type AuthenticateOptions = passport.AuthenticateOptions & { callbackURL: string; }; -export const PBKDF2_ROUNDS: number = 300000; +export class GroundTruthStrategy extends OAuthStrategy { + public readonly url: string; -export interface RegistrationStrategy { - readonly name: string; - readonly passportStrategy: Strategy; - use(authRoutes: Router, scope?: string[]): void; -} -abstract class OAuthStrategy implements RegistrationStrategy { - public readonly passportStrategy: Strategy; - - public static get defaultUserProperties(): Partial { + public static get defaultUserProperties() { return { - "uuid": uuid(), - "verifiedEmail": false, - "accountConfirmed": false, - - "services": {}, + "admin": false, "applied": false, "accepted": false, "confirmed": false, "preConfirmEmailSent": false, - "applicationData": [], "applicationStartTime": undefined, - "applicationSubmitTime": undefined, - - "admin": false + "applicationSubmitTime": undefined }; } - constructor(public readonly name: IConfig.OAuthServices, strategy: StrategyConstructor, profileFields?: string[]) { - - const secrets = config.secrets.oauth[name]; + constructor(url: string) { + const secrets = config.secrets.groundTruth; if (!secrets || !secrets.id || !secrets.secret) { - throw new Error(`Client ID or secret not configured in config.json or environment variables for strategy "${this.name}"`); + throw new Error(`Client ID or secret not configured in config.json or environment variables for Ground Truth`); } - let options: OAuthStrategyOptions = { + let options: IOAuthStrategyOptions = { + authorizationURL: new URL("/oauth/authorize", url).toString(), + tokenURL: new URL("/oauth/token", url).toString(), clientID: secrets.id, clientSecret: secrets.secret, - profileFields, passReqToCallback: true }; - this.passportStrategy = new strategy(options, this.passportCallback.bind(this)); + super(options, GroundTruthStrategy.passportCallback); + this.url = url; } - protected async passportCallback(request: Request, accessToken: string, refreshToken: string, profile: Profile, done: PassportDone) { - let serviceName = this.name as IConfig.OAuthServices; - - let email: string = ""; - if (profile.emails && profile.emails.length > 0) { - email = profile.emails[0].value.trim(); - } - else if (!profile.emails || profile.emails.length === 0) { - done(null, false, { message: "Your GitHub profile does not have any public email addresses. Please make an email address public before logging in with GitHub." }); - return; - } - - let user = await User.findOne({[`services.${this.name}.id`]: profile.id}); - if (!user) { - user = await User.findOne({ email }); - } - let loggedInUser = request.user as IUserMongoose | undefined; - let isAdmin = false; - if (config.admins.includes(email)) { - isAdmin = true; - if (!user || !user.admin) { - console.info(`Adding new admin: ${email}`); - } - } - if (!user && !loggedInUser) { - user = new User({ - ...OAuthStrategy.defaultUserProperties, - email, - name: profile.displayName ? profile.displayName.trim() : "", - verifiedEmail: true, - admin: isAdmin - }); - if (!user.services) { - user.services = {}; - } - user.services[serviceName] = { - id: profile.id, - email, - username: profile.username, - profileUrl: profile.profileUrl - }; - try { - user.markModified("services"); - await user.save(); - trackEvent("created account (auth)", request, email); - } - catch (err) { + public userProfile(accessToken: string, done: PassportProfileDone) { + (this._oauth2 as any)._request("GET", new URL("/api/user", this.url).toString(), null, null, accessToken, (err: Error | null, data: string) => { + if (err) { done(err); return; } - - done(null, user); - } - else { - if (user && loggedInUser && user.uuid !== loggedInUser.uuid) { - // Remove extra account represented by loggedInUser and merge into user - user.services = { - ...loggedInUser.services, - // Don't overwrite any existing services - ...user.services - }; - if (loggedInUser.local && loggedInUser.local.hash && (!user.local || !user.local.hash)) { - user.local = { - ...loggedInUser.local - }; - } - await User.findOneAndRemove({ "uuid": loggedInUser.uuid }); - // So that the user has an indication of the linking - user.accountConfirmed = false; - } - else if (!user && loggedInUser) { - // Attach service info to logged in user instead of non-existant user pulled via email address - user = loggedInUser; - } - if (!user) { - done(null, false, { "message": "Shouldn't happen: no user defined" }); - return; - } - - if (!user.services) { - user.services = {}; - } - if (!user.services[serviceName]) { - user.services[serviceName] = { - id: profile.id, - email, - username: profile.username, - profileUrl: profile.profileUrl + try { + let profile: IProfile = { + ...JSON.parse(data), + token: accessToken }; - // So that the user has an indication of the linking - user.accountConfirmed = false; - } - if (!user.verifiedEmail && user.email === email) { - // We trust our OAuth provider to have verified the user's email for us - user.verifiedEmail = true; + done(null, profile); } - if (!user.admin && isAdmin) { - user.admin = true; + catch (err) { + return done(err); } - user.markModified("services"); - await user.save(); - done(null, user); - } - } - - public use(authRoutes: Router, scope: string[]) { - passport.use(this.passportStrategy); - - const callbackHref = `auth/${this.name}/callback`; - authRoutes.get(`/${this.name}`, validateAndCacheHostName, (request, response, next) => { - let callbackURL = `${request.protocol}://${request.hostname}:${getExternalPort(request)}/${callbackHref}`; - - passport.authenticate( - this.name, - { scope, callbackURL } as AuthenticateOptions - )(request, response, next); - }); - authRoutes.get(`/${this.name}/callback`, validateAndCacheHostName, (request, response, next) => { - let callbackURL = `${request.protocol}://${request.hostname}:${getExternalPort(request)}/${callbackHref}`; - - passport.authenticate( - this.name, - { - failureRedirect: "/login", - successReturnToOrRedirect: "/", - failureFlash: true, - callbackURL - } as AuthenticateOptions - )(request, response, next); }); } -} - -export class GitHub extends OAuthStrategy { - constructor() { - super("github", GitHubStrategy as any); - } - public use(authRoutes: Router) { - super.use(authRoutes, ["user:email"]); - } -} - -export class Google extends OAuthStrategy { - constructor() { - super("google", GoogleStrategy); - } - public use(authRoutes: Router) { - super.use(authRoutes, ["email", "profile"]); - } -} - -export class Facebook extends OAuthStrategy { - constructor() { - super("facebook", FacebookStrategy as any, ["id", "displayName", "email"]); - } - public use(authRoutes: Router) { - super.use(authRoutes, ["email"]); - } -} - -abstract class CASStrategy implements RegistrationStrategy { - public readonly passportStrategy: Strategy; - - constructor(public readonly name: IConfig.CASServices, url: string, private readonly emailDomain: string) { - this.passportStrategy = new CASStrategyProvider({ - casURL: url, - passReqToCallback: true - }, this.passportCallback.bind(this)); - } - private async passportCallback(request: Request, username: string, profile: Profile, done: PassportDone) { - // GT login will pass long invalid usernames of different capitalizations - username = username.toLowerCase().trim(); - let loggedInUser = request.user as IUserMongoose | undefined; - let user = await User.findOne({[`services.${this.name}.id`]: username}); - let email = `${username}@${this.emailDomain}`; - let isAdmin = false; - - if (config.admins.includes(email)) { - isAdmin = true; - if (!user || !user.admin) { - console.info(`Adding new admin: ${email}`); - } - } - if (!user && !loggedInUser) { - user = new User({ - ...OAuthStrategy.defaultUserProperties, - email, - name: "", - verifiedEmail: false, - admin: isAdmin + protected static async passportCallback(request: Request, accessToken: string, refreshToken: string, profile: IProfile, done: PassportDone) { + let user = await User.findOne({ uuid: profile.uuid }); + if (!user) { + user = createNew(User, { + ...GroundTruthStrategy.defaultUserProperties, + ...profile }); - if (!user.services) { - user.services = {}; - } - user.services[this.name] = { - id: username, - email, - username - }; - try { - user.markModified("services"); - await user.save(); - trackEvent("created account (auth)", request, email); - } - catch (err) { - done(err); - return; - } - - done(null, user); } else { - if (user && loggedInUser && user.uuid !== loggedInUser.uuid) { - // Remove extra account represented by loggedInUser and merge into user - user.services = { - ...loggedInUser.services, - // Don't overwrite any existing services - ...user.services - }; - if (loggedInUser.local && loggedInUser.local.hash && (!user.local || !user.local.hash)) { - user.local = { - ...loggedInUser.local - }; - } - await User.findOneAndRemove({ "uuid": loggedInUser.uuid }); - // So that the user has an indication of the linking - user.accountConfirmed = false; - } - else if (!user && loggedInUser) { - // Attach service info to logged in user instead of non-existant user pulled via email address - user = loggedInUser; - } - if (!user) { - done(null, false, { "message": "Shouldn't happen: no user defined" }); - return; - } - - if (!user.services) { - user.services = {}; - } - if (!user.services[this.name]) { - user.services[this.name] = { - id: username, - email, - username - }; - } - if (!user.admin && isAdmin && user.email === email && user.verifiedEmail) { - user.admin = true; - } - user.markModified("services"); - await user.save(); - if (!user.verifiedEmail && user.accountConfirmed) { - done(null, false, { "message": "You must verify your email before you can sign in" }); - return; - } - done(null, user); + user.token = accessToken; } - } - - public use(authRoutes: Router) { - passport.use(this.name, this.passportStrategy); - - authRoutes.get(`/${this.name}`, passport.authenticate(this.name, { - failureRedirect: "/login", - successReturnToOrRedirect: "/", - failureFlash: true - })); - } -} - -export class GeorgiaTechCAS extends CASStrategy { - constructor() { - // Registration must be hosted on a *.hack.gt domain for this to work - super("gatech", "https://login.gatech.edu/cas", "gatech.edu"); - } -} - -export class Local implements RegistrationStrategy { - public readonly name = "local"; - public readonly passportStrategy: Strategy; - constructor() { - let options: LocalStrategyOptions = { - usernameField: "email", - passwordField: "password", - passReqToCallback: true - }; - this.passportStrategy = new LocalStrategy(options, this.passportCallback.bind(this)); - } - - protected async passportCallback(request: Request, email: string, password: string, done: PassportDone) { - email = email.trim(); - let user = await User.findOne({ email }); - if (user && request.path.match(/\/signup$/i)) { - done(null, false, { "message": "That email address is already in use. You may already have an account from another login service." }); + let domain = user.email.split("@").pop(); + if (domain && config.admins.domains.includes(domain)) { + user.admin = true; } - else if (user && !user.local!.hash) { - done(null, false, { "message": "Please log back in with an external provider" }); - } - else if (!user || !user.local) { - // User hasn't signed up yet - if (!request.path.match(/\/signup$/i)) { - // Only create the user when targeting /signup - done(null, false, { "message": "Incorrect email or password" }); - return; - } - let name: string = request.body.name || ""; - name = name.trim(); - if (!name || !email || !password) { - done(null, false, { "message": "Missing email, name, or password" }); - return; - } - let salt = crypto.randomBytes(32); - let hash = await pbkdf2Async(password, salt, PBKDF2_ROUNDS); - user = new User({ - ...OAuthStrategy.defaultUserProperties, - email, - name: request.body.name, - verifiedEmail: false, - local: { - "hash": hash.toString("hex"), - "salt": salt.toString("hex") - } - }); - try { - await user.save(); - trackEvent("created account", request, email); - } - catch (err) { - done(err); - return; - } - done(null, user); - } - else { - // Log the user in - let hash = await pbkdf2Async(password, Buffer.from(user.local.salt || "", "hex"), PBKDF2_ROUNDS); - if (hash.toString("hex") === user.local.hash) { - if (user.verifiedEmail) { - done(null, user); - } - else { - done(null, false, { "message": "You must verify your email before you can sign in" }); - } - } - else { - done(null, false, { "message": "Incorrect email or password" }); - } + if (config.admins.emails.includes(profile.email)) { + user.admin = true; } - } - - public use(authRoutes: Router) { - passport.use(this.passportStrategy); - - authRoutes.post("/signup", validateAndCacheHostName, postParser, passport.authenticate("local", { failureRedirect: "/login", failureFlash: true }), (request, response) => { - // User is logged in automatically by Passport but we want them to verify their email first - response.redirect("/login/confirm"); - }); - - authRoutes.post("/login", postParser, passport.authenticate("local", { failureRedirect: "/login", failureFlash: true, successReturnToOrRedirect: "/" })); - - authRoutes.get("/verify/:code", async (request, response) => { - let user = await User.findOne({ "local.verificationCode": request.params.code }); - if (!user) { - request.flash("error", "Invalid email verification code"); - } - else { - user.verifiedEmail = true; - if (user.local) { - user.local.verificationCode = undefined; - } - // Possibly promote to admin status - if (config.admins.indexOf(user.email) !== -1) { - user.admin = true; - console.info(`Adding new admin: ${user.email}`); - } - await user.save(); - request.flash("success", "Thanks for verifying your email. You can now log in."); - trackEvent("verified email", request, user.email); - } - response.redirect("/login"); - }); - - authRoutes.post("/forgot", validateAndCacheHostName, postParser, async (request, response) => { - let email: string | undefined = request.body.email; - if (!email || !email.toString().trim()) { - request.flash("error", "Invalid email"); - response.redirect("/login/forgot"); - return; - } - email = email.toString().trim(); - - let user = await User.findOne({ email }); - if (!user) { - request.flash("error", "No account matching the email that you submitted was found"); - response.redirect("/login/forgot"); - return; - } - if (!user.verifiedEmail) { - request.flash("error", "Please verify your email first"); - response.redirect("/login"); - return; - } - if (!user.local || !user.local.hash) { - request.flash("error", "The account with the email that you submitted has no password set. Please log in with an external service like GitHub, Google, or Facebook instead."); - response.redirect("/login"); - return; - } - - user.local.resetRequested = true; - user.local.resetRequestedTime = new Date(); - user.local.resetCode = crypto.randomBytes(32).toString("hex"); - - // Send reset email (hostname validated by previous middleware) - let link = createLink(request, `/auth/forgot/${user.local.resetCode}`); - let markdown = -`Hi {{name}}, - -You (or someone who knows your email address) recently asked to reset the password for this account: {{email}}. - -You can update your password by [clicking here](${link}). - -If you don't use this link within ${moment.duration(config.server.passwordResetExpiration, "milliseconds").humanize()}, it will expire and you will have to [request a new one](${createLink(request, "/login/forgot")}). - -If you didn't request a password reset, you can safely disregard this email and no changes will be made to your account. - -Sincerely, - -The ${config.eventName} Team.`; - try { - await user.save(); - await sendMailAsync({ - from: config.email.from, - to: email, - subject: `[${config.eventName}] - Password reset request`, - html: await renderEmailHTML(markdown, user), - text: await renderEmailText(markdown, user) - }); - request.flash("success", "Please check your email for a link to reset your password. If it doesn't appear within a few minutes, check your spam folder."); - response.redirect("/login/forgot"); - } - catch (err) { - console.error(err); - request.flash("error", "An error occurred while sending you a password reset email"); - response.redirect("/login/forgot"); - } - }); - - authRoutes.post("/forgot/:code", validateAndCacheHostName, postParser, async (request, response) => { - let user = await User.findOne({ "local.resetCode": request.params.code }); - if (!user) { - request.flash("error", "Invalid password reset code"); - response.redirect("/login"); - return; - } - - let expirationDuration = moment.duration(config.server.passwordResetExpiration, "milliseconds"); - if (!user.local!.resetRequested || moment().isAfter(moment(user.local!.resetRequestedTime).add(expirationDuration))) { - request.flash("error", "Your password reset link has expired. Please request a new one."); - user.local!.resetCode = ""; - user.local!.resetRequested = false; - await user.save(); - response.redirect("/login"); - return; - } - - let password1: string | undefined = request.body.password1; - let password2: string | undefined = request.body.password2; - if (!password1 || !password2) { - request.flash("error", "Missing new password or confirm password"); - response.redirect(path.join("/auth", request.url)); - return; - } - if (password1 !== password2) { - request.flash("error", "Passwords must match"); - response.redirect(path.join("/auth", request.url)); - return; - } - - let salt = crypto.randomBytes(32); - let hash = await pbkdf2Async(password1, salt, PBKDF2_ROUNDS); - - try { - user.local!.salt = salt.toString("hex"); - user.local!.hash = hash.toString("hex"); - user.local!.resetCode = ""; - user.local!.resetRequested = false; - await user.save(); - - request.flash("success", "Password reset successfully. You can now log in."); - response.redirect("/login"); - } - catch (err) { - console.error(err); - request.flash("error", "An error occurred while saving your new password"); - response.redirect(path.join("/auth", request.url)); - } - }); + await user.save(); + done(null, user); } } -export const strategies = { - "local": Local, - "gatech": GeorgiaTechCAS, - "github": GitHub, - "google": Google, - "facebook": Facebook -}; -export const prettyNames: Record = { - "local": "Local", - "gatech": "Georgia Tech CAS", - "github": "GitHub", - "google": "Google", - "facebook": "Facebook" -}; - // Authentication helpers function getExternalPort(request: Request): number { function defaultPort(): number { @@ -685,7 +172,7 @@ export function validateAndCacheHostName(request: Request, response: Response, n } } -function createLink(request: Request, link: string): string { +export function createLink(request: Request, link: string): string { if (link[0] === "/") { link = link.substring(1); } @@ -696,29 +183,3 @@ function createLink(request: Request, link: string): string { return `http${request.secure ? "s" : ""}://${request.hostname}:${getExternalPort(request)}/${link}`; } } - -export async function sendVerificationEmail(request: Request, user: IUserMongoose) { - // Send verification email (hostname validated by previous middleware) - if (!user.local) { - user.local = {}; - } - user.local.verificationCode = crypto.randomBytes(32).toString("hex"); - await user.save(); - - let link = createLink(request, `/auth/verify/${user.local.verificationCode}`); - let markdown = -`Hi {{name}}, - -Thanks for signing up for ${config.eventName}! To verify your email, please [click here](${link}). - -Sincerely, - -The ${config.eventName} Team.`; - await sendMailAsync({ - from: config.email.from, - to: user.email, - subject: `[${config.eventName}] - Verify your email`, - html: await renderEmailHTML(markdown, user), - text: await renderEmailText(markdown, user) - }); -} diff --git a/server/routes/templates.ts b/server/routes/templates.ts index 0caab1b5..dd12c657 100644 --- a/server/routes/templates.ts +++ b/server/routes/templates.ts @@ -1,5 +1,6 @@ import * as fs from "fs"; import * as path from "path"; +import { URL } from "url"; import * as express from "express"; import * as Handlebars from "handlebars"; import * as moment from "moment-timezone"; @@ -15,49 +16,47 @@ import { onlyAllowAnonymousBranch, branchRedirector, ApplicationType } from "../middleware"; import { - IUser, IUserMongoose, User, - ITeamMongoose, Team, - IIndexTemplate, ILoginTemplate, IAdminTemplate, ITeamTemplate, + Model, + IUser, User, + ITeam, Team, + IIndexTemplate, IAdminTemplate, ITeamTemplate, IRegisterBranchChoiceTemplate, IRegisterTemplate, StatisticEntry, IFormItem, - IConfig + ILoginTemplate } from "../schema"; import * as Branches from "../branch"; -import { strategies, prettyNames } from "../routes/strategies"; export let templateRoutes = express.Router(); -// Load and compile Handlebars templates -let [ - indexTemplate, - loginTemplate, - postLoginTemplate, - forgotPasswordTemplate, - resetPasswordTemplate, - preregisterTemplate, - preconfirmTemplate, - registerTemplate, - confirmTemplate, - adminTemplate, - unsupportedTemplate, - teamTemplate -] = [ - "index.html", - "login.html", - "postlogin.html", - "forgotpassword.html", - "resetpassword.html", - "preapplication.html", - "preconfirmation.html", - "application.html", - "confirmation.html", - "admin.html", - "unsupported.html", - "team.html" -].map(file => { - let data = fs.readFileSync(path.resolve(STATIC_ROOT, file), "utf8"); - return Handlebars.compile(data); -}); +export class Template { + private template: Handlebars.TemplateDelegate | null = null; + + constructor(private readonly file: string) { + this.loadTemplate(); + } + + private loadTemplate(): void { + let data = fs.readFileSync(path.resolve(STATIC_ROOT, this.file), "utf8"); + this.template = Handlebars.compile(data); + } + + public render(input: T): string { + if (!config.server.isProduction) { + this.loadTemplate(); + } + return this.template!(input); + } +} + +const IndexTemplate = new Template("index.html"); +const PreRegisterTemplate = new Template("preapplication.html"); +const PreConfirmTemplate = new Template("preconfirmation.html"); +const RegisterTemplate = new Template("application.html"); +const ConfirmTemplate = new Template("confirmation.html"); +const AdminTemplate = new Template("admin.html"); +const UnsupportedTemplate = new Template<{ siteTitle: string }>("unsupported.html"); +const TeamTemplate = new Template("team.html"); +const LoginTemplate = new Template("login.html"); // Block IE templateRoutes.use(async (request, response, next) => { @@ -76,7 +75,7 @@ templateRoutes.use(async (request, response, next) => { let templateData = { siteTitle: config.eventName }; - response.send(unsupportedTemplate(templateData)); + response.send(UnsupportedTemplate.render(templateData)); } else { next(); @@ -314,7 +313,7 @@ templateRoutes.route("/").get(authenticateWithRedirect, async (request, response templateData.timeline.teamFormation = "complete"; } - response.send(indexTemplate(templateData)); + response.send(IndexTemplate.render(templateData)); }); templateRoutes.route("/login").get(async (request, response) => { @@ -322,96 +321,47 @@ templateRoutes.route("/login").get(async (request, response) => { if (request.session && request.query.r && request.query.r.startsWith('/')) { request.session.returnTo = request.query.r; } - let loginMethods = await getSetting("loginMethods"); - let templateData: ILoginTemplate = { - siteTitle: config.eventName, - error: request.flash("error"), - success: request.flash("success"), - loginMethods, - localOnly: loginMethods && loginMethods.length === 1 && loginMethods[0] === "local" - }; - response.send(loginTemplate(templateData)); -}); -templateRoutes.route("/login/confirm").get(async (request, response) => { - let user = request.user as IUser; - if (!user) { - response.redirect("/login"); - return; - } - if (user.accountConfirmed) { - response.redirect("/"); - return; - } - let usedLoginMethods: string[] = []; - if (user.local && user.local!.hash) { - usedLoginMethods.push("Local"); - } - let services = Object.keys(user.services || {}) as (keyof typeof user.services)[]; - for (let service of services) { - usedLoginMethods.push(prettyNames[service]); + let errorMessage = request.flash("error") as string[]; + if (request.session && request.session.loginAction === "render") { + request.session.loginAction = "redirect"; + let templateData = { + siteTitle: config.eventName, + isLogOut: true, + groundTruthLogOut: new URL("/logout", config.secrets.groundTruth.url).toString() + }; + response.send(LoginTemplate.render(templateData)); } - let loginMethods = (await getSetting("loginMethods")).filter(method => method !== "local" && !services.includes(method)); - - response.send(postLoginTemplate({ - siteTitle: config.eventName, - error: request.flash("error"), - success: request.flash("success"), - - name: user.name || "", - email: user.email || "", - verifiedEmail: user.verifiedEmail || false, - usedLoginMethods, - loginMethods, - canAddLogins: loginMethods.length !== 0 - })); -}); -templateRoutes.route("/login/forgot").get((request, response) => { - let templateData: ILoginTemplate = { - siteTitle: config.eventName, - error: request.flash("error"), - success: request.flash("success") - }; - response.send(forgotPasswordTemplate(templateData)); -}); -templateRoutes.route("/auth/forgot/:code").get(async (request, response) => { - let user = await User.findOne({ "local.resetCode": request.params.code }); - if (!user) { - request.flash("error", "Invalid password reset code"); - response.redirect("/login"); - return; + else if (errorMessage.length > 0) { + let templateData = { + siteTitle: config.eventName, + error: errorMessage.join(" "), + isLogOut: false + }; + response.send(LoginTemplate.render(templateData)); } - else if (!user.local!.resetRequested || Date.now() - user.local!.resetRequestedTime!.valueOf() > 1000 * 60 * 60) { - request.flash("error", "Your password reset link has expired. Please request a new one."); - user.local!.resetCode = ""; - user.local!.resetRequested = false; - await user.save(); - response.redirect("/login"); - return; + else { + response.redirect("/auth/login"); } - let templateData: ILoginTemplate = { - siteTitle: config.eventName, - error: request.flash("error"), - success: request.flash("success") - }; - response.send(resetPasswordTemplate(templateData)); }); templateRoutes.route("/team").get(authenticateWithRedirect, async (request, response) => { - let team: ITeamMongoose | null = null; - let membersAsUsers: IUserMongoose[] | null = null; - let teamLeaderAsUser: IUserMongoose | null = null; + let team: ITeam | null = null; + let membersAsUsers: IUser[] | null = null; + let teamLeaderAsUser: IUser | null = null; let isCurrentUserTeamLeader = false; if (request.user && request.user.teamId) { - team = await Team.findById(request.user.teamId) as ITeamMongoose; - membersAsUsers = await User.find({ - _id: { - $in: team.members - } - }); - teamLeaderAsUser = await User.findById(team.teamLeader) as IUserMongoose; - isCurrentUserTeamLeader = teamLeaderAsUser._id.toString() === request.user._id.toString(); + team = await Team.findById(request.user.teamId); + if (team) { + membersAsUsers = await User.find({ + _id: { + $in: team.members + } + }); + teamLeaderAsUser = await User.findById(team.teamLeader); + isCurrentUserTeamLeader = teamLeaderAsUser != null && teamLeaderAsUser._id.toString() === request.user._id.toString(); + } } let templateData: ITeamTemplate = { @@ -426,7 +376,7 @@ templateRoutes.route("/team").get(authenticateWithRedirect, async (request, resp qrEnabled: await getSetting("qrEnabled") } }; - response.send(teamTemplate(templateData)); + response.send(TeamTemplate.render(templateData)); }); templateRoutes.route("/apply").get( @@ -450,7 +400,7 @@ function applicationHandler(requestType: ApplicationType): (request: express.Req // NOTE: this assumes the user is still able to apply as this type at this point if (requestType === ApplicationType.Application) { if (user.applied) { - questionBranches = [user.applicationBranch.toLowerCase()]; + questionBranches = [user.applicationBranch!.toLowerCase()]; } else { const branches = await Branches.BranchConfig.getOpenBranches("Application"); @@ -477,10 +427,10 @@ function applicationHandler(requestType: ApplicationType): (request: express.Req }; if (requestType === ApplicationType.Application) { - response.send(preregisterTemplate(templateData)); + response.send(PreRegisterTemplate.render(templateData)); } else { - response.send(preconfirmTemplate(templateData)); + response.send(PreConfirmTemplate.render(templateData)); } }; } @@ -523,7 +473,7 @@ function applicationBranchHandler(requestType: ApplicationType, anonymous: boole let questionData = await Promise.all(questionBranch.questions.map(async question => { let savedValue: IFormItem | undefined; if (user) { - savedValue = user[requestType === ApplicationType.Application ? "applicationData" : "confirmationData"].find(item => item.name === question.name); + savedValue = (user[requestType === ApplicationType.Application ? "applicationData" : "confirmationData"] || []).find(item => item.name === question.name); } if (question.type === "checkbox" || question.type === "radio" || question.type === "select") { @@ -589,7 +539,7 @@ function applicationBranchHandler(requestType: ApplicationType, anonymous: boole } if (!anonymous) { - let thisUser = await User.findById(user._id) as IUserMongoose; + let thisUser = await User.findById(user._id) as Model; // TODO this is a bug - dates are wrong if (requestType === ApplicationType.Application && !thisUser.applicationStartTime) { thisUser.applicationStartTime = new Date(); @@ -614,9 +564,9 @@ function applicationBranchHandler(requestType: ApplicationType, anonymous: boole }; if (requestType === ApplicationType.Application) { - response.send(registerTemplate(templateData)); + response.send(RegisterTemplate.render(templateData)); } else if (requestType === ApplicationType.Confirmation) { - response.send(confirmTemplate(templateData)); + response.send(ConfirmTemplate.render(templateData)); } }; } @@ -630,16 +580,6 @@ templateRoutes.route("/admin").get(authenticateWithRedirect, async (request, res let teamsEnabled = await getSetting("teamsEnabled"); let qrEnabled = await getSetting("qrEnabled"); - type StrategyNames = keyof typeof strategies; - let enabledMethods = await getSetting("loginMethods"); - let loginMethodsInfo = Object.keys(strategies).map((name: StrategyNames) => { - return { - name: prettyNames[name], - raw: name, - enabled: enabledMethods.includes(name) - }; - }); - let adminEmails = await User.find({ admin: true }).select("email"); let noopBranches = (await Branches.BranchConfig.loadAllBranches("Noop")) as Branches.NoopBranch[]; @@ -649,10 +589,12 @@ templateRoutes.route("/admin").get(authenticateWithRedirect, async (request, res let teamIDNameMap: { [id: string]: string; } = {}; - (await Team.find()).forEach((team: ITeamMongoose) => { + (await Team.find()).forEach(team => { teamIDNameMap[team._id.toString()] = team.teamName; }); + let preconfiguredAdmins = config.admins.emails.concat(config.admins.domains.map(domain => `*@${domain}`)); + let templateData: IAdminTemplate = { siteTitle: config.eventName, user, @@ -706,12 +648,11 @@ templateRoutes.route("/admin").get(authenticateWithRedirect, async (request, res }; }) }, - loginMethodsInfo, adminEmails, apiKey: config.secrets.adminKey }, config: { - admins: config.admins.join(", "), + admins: preconfiguredAdmins.join(", "), eventName: config.eventName, storageEngine: config.storageEngine.name, uploadDirectoryRaw: config.storageEngine.options.uploadDirectory, @@ -730,11 +671,11 @@ templateRoutes.route("/admin").get(authenticateWithRedirect, async (request, res // Generate general statistics (await User.find({ "applied": true })).forEach(async statisticUser => { - let appliedBranch = applicationBranchMap[statisticUser.applicationBranch]; + let appliedBranch = applicationBranchMap[statisticUser.applicationBranch!]; if (!appliedBranch) { return; } - statisticUser.applicationData.forEach(question => { + statisticUser.applicationData!.forEach(question => { if (question.value === null) { return; } @@ -758,7 +699,7 @@ templateRoutes.route("/admin").get(authenticateWithRedirect, async (request, res statisticEntry = { questionName, questionLabel: removeTags(rawQuestion.label), - branch: statisticUser.applicationBranch, + branch: statisticUser.applicationBranch!, responses: [] }; templateData.generalStatistics.push(statisticEntry); @@ -844,5 +785,5 @@ templateRoutes.route("/admin").get(authenticateWithRedirect, async (request, res return question; }); - response.send(adminTemplate(templateData)); + response.send(AdminTemplate.render(templateData)); }); diff --git a/server/schema.ts b/server/schema.ts index b7d92f1f..09b3b8d9 100644 --- a/server/schema.ts +++ b/server/schema.ts @@ -12,11 +12,10 @@ export namespace IConfig { export interface Secrets { adminKey: string; session: string; - oauth: { - [Service in OAuthServices]: { - id: string; - secret: string; - } + groundTruth: { + url: string; + id: string; + secret: string; }; } export interface Email { @@ -32,7 +31,6 @@ export namespace IConfig { cookieMaxAge: number; cookieSecureOnly: boolean; mongoURL: string; - passwordResetExpiration: number; defaultTimezone: string; } export interface Style { @@ -45,7 +43,10 @@ export namespace IConfig { email: Email; server: Server; style: Style; - admins: string[]; + admins: { + domains: string[]; + emails: string[]; + }; eventName: string; questionsLocation: string; storageEngine: { @@ -63,60 +64,48 @@ export interface IFormItem { "value": string | string[] | Express.Multer.File | null; } -export interface ITeam { +// For stricter type checking of new object creation +type Omit = Pick>; +interface RootDocument { _id: mongoose.Types.ObjectId; +} +export function createNew(model: mongoose.Model, doc: Omit) { + return new model(doc); +} +export type Model = T & mongoose.Document; + +export interface ITeam extends RootDocument { teamLeader: mongoose.Types.ObjectId; members: mongoose.Types.ObjectId[]; teamName: string; } -export type ITeamMongoose = ITeam & mongoose.Document; - -export const Team = mongoose.model("Team", new mongoose.Schema({ +export const Team = mongoose.model>("Team", new mongoose.Schema({ teamLeader: { type: mongoose.Schema.Types.ObjectId }, members: [{ type: mongoose.Schema.Types.ObjectId }], - teamName: { - type: mongoose.Schema.Types.String - } + teamName: String })); -export interface IUser { - _id: mongoose.Types.ObjectId; +export interface IUser extends RootDocument { uuid: string; email: string; name: string; - verifiedEmail: boolean; - accountConfirmed: boolean; + token: string | null; - local?: Partial<{ - hash: string; - salt: string; - verificationCode: string; - resetRequested: boolean; - resetCode: string; - resetRequestedTime: Date; - }>; - services: { - [Service in Exclude]?: { - id: string; - // OAuth account email can be different than registration account email - email: string; - username?: string; - profileUrl?: string; - }; - }; + teamId?: mongoose.Types.ObjectId; + admin: boolean; applied: boolean; accepted: boolean; preConfirmEmailSent: boolean; confirmed: boolean; - applicationBranch: string; + applicationBranch?: string; reimbursementAmount?: string; - applicationData: IFormItem[]; + applicationData?: IFormItem[]; applicationStartTime?: Date; applicationSubmitTime?: Date; @@ -127,18 +116,14 @@ export interface IUser { }; confirmationBranch?: string; - confirmationData: IFormItem[]; + confirmationData?: IFormItem[]; confirmationStartTime?: Date; confirmationSubmitTime?: Date; - admin?: boolean; - - teamId?: mongoose.Types.ObjectId; } -export type IUserMongoose = IUser & mongoose.Document; // This is basically a type definition that exists at runtime and is derived manually from the IUser definition above -export const User = mongoose.model("User", new mongoose.Schema({ +export const User = mongoose.model>("User", new mongoose.Schema({ uuid: { type: String, required: true, @@ -155,23 +140,14 @@ export const User = mongoose.model("User", new mongoose.Schema({ type: String, index: true }, - verifiedEmail: Boolean, - accountConfirmed: Boolean, - - local: { - hash: String, - salt: String, - verificationCode: String, - resetRequested: Boolean, - resetCode: String, - resetRequestedTime: Date - }, - services: mongoose.Schema.Types.Mixed, + token: String, teamId: { type: mongoose.Schema.Types.ObjectId }, + admin: Boolean, + applied: Boolean, accepted: Boolean, preConfirmEmailSent: Boolean, @@ -191,22 +167,18 @@ export const User = mongoose.model("User", new mongoose.Schema({ confirmationBranch: String, confirmationData: [mongoose.Schema.Types.Mixed], confirmationStartTime: Date, - confirmationSubmitTime: Date, - - admin: Boolean + confirmationSubmitTime: Date }).index({ email: "text", name: "text" })); -export interface ISetting { - _id: mongoose.Types.ObjectId; +export interface ISetting extends RootDocument { name: string; value: any; } -export type ISettingMongoose = ISetting & mongoose.Document; -export const Setting = mongoose.model("Setting", new mongoose.Schema({ +export const Setting = mongoose.model>("Setting", new mongoose.Schema({ name: { type: String, required: true, @@ -228,16 +200,14 @@ export interface QuestionBranchSettings { isAcceptance?: boolean; // Used by confirmation branch autoConfirm?: boolean; // Used by confirmation branch } -export interface IQuestionBranchConfig { - _id: mongoose.Types.ObjectId; +export interface IQuestionBranchConfig extends RootDocument { name: string; type: QuestionBranchType; settings: QuestionBranchSettings; location: string; } -export type IQuestionBranchConfigMongoose = IQuestionBranchConfig & mongoose.Document; -export const QuestionBranchConfig = mongoose.model("QuestionBranchConfig", new mongoose.Schema({ +export const QuestionBranchConfig = mongoose.model>("QuestionBranchConfig", new mongoose.Schema({ name: { type: String, required: true, @@ -302,18 +272,11 @@ export interface IIndexTemplate extends ICommonTemplate { }[]; } export interface ITeamTemplate extends ICommonTemplate { - team?: ITeamMongoose | null; - membersAsUsers?: IUserMongoose[] | null; - teamLeaderAsUser?: IUserMongoose | null; + team?: ITeam | null; + membersAsUsers?: IUser[] | null; + teamLeaderAsUser?: IUser | null; isCurrentUserTeamLeader: boolean; } -export interface ILoginTemplate { - siteTitle: string; - error?: string; - success?: string; - loginMethods?: string[]; - localOnly?: boolean; -} export interface IRegisterBranchChoiceTemplate extends ICommonTemplate { branches: string[]; } @@ -371,12 +334,7 @@ export interface IAdminTemplate extends ICommonTemplate { close: string; }[]; }; - loginMethodsInfo: { - name: string; - raw: string; - enabled: boolean; - }[]; - adminEmails: IUserMongoose[]; + adminEmails: IUser[]; apiKey: string; }; config: { @@ -389,6 +347,13 @@ export interface IAdminTemplate extends ICommonTemplate { }; } +export interface ILoginTemplate { + siteTitle: string; + isLogOut: boolean; + error?: string; + groundTruthLogOut?: string; +} + export interface DataLog { action: string; url: string; diff --git a/server/storage.ts b/server/storage.ts index 558eb438..e0ce9b3e 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -121,9 +121,7 @@ class S3StorageEngine implements IStorageEngine { } interface IStorageEngines { - [name: string]: { - new(options: ICommonOptions): IStorageEngine; - }; + [name: string]: new(options: ICommonOptions) => IStorageEngine; } export const storageEngines: IStorageEngines = { "disk": DiskStorageEngine, diff --git a/server/tsconfig.json b/server/tsconfig.json index c8ed7565..6b4faded 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -19,7 +19,7 @@ "noUnusedLocals": true, "plugins": [ { - "name": "tslint-language-service", + "name": "tslint-language-service-ts3", "alwaysShowRuleFailuresAsWarnings": false, "ignoreDefinitionFiles": true, "configFile": "../tslint.json"