Skip to content

Commit

Permalink
Merge pull request #13 from HackGT/authentication
Browse files Browse the repository at this point in the history
Implement OAuth authentication with GitHub, Google, and Facebook
  • Loading branch information
petschekr committed Feb 13, 2017
2 parents 1733703 + 0b82e7b commit 7f31205
Show file tree
Hide file tree
Showing 10 changed files with 270 additions and 191 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Expand Up @@ -38,4 +38,5 @@ jspm_packages

*.js
*.js.map
.vscode/
.vscode/
server/config/config.json
3 changes: 2 additions & 1 deletion client/index.html
Expand Up @@ -15,7 +15,8 @@
<body>
<h1>Main page</h1>

<a href="/auth/logout">Log out</a>

<script src="/node_modules/material-components-web/dist/material-components-web.js"></script>
<!--<script src="/js/login.js"></script>-->
</body>
</html>
7 changes: 6 additions & 1 deletion client/login.html
Expand Up @@ -14,8 +14,13 @@
</head>
<body>
<h1>Login</h1>

<a href="/auth/github">Log in with GitHub</a>
<br />
<a href="/auth/google">Log in with Google</a>
<br />
<a href="/auth/facebook">Log in with Facebook</a>

<script src="/node_modules/material-components-web/dist/material-components-web.js"></script>
<!--<script src="/js/login.js"></script>-->
</body>
</html>
10 changes: 10 additions & 0 deletions package.json
Expand Up @@ -26,29 +26,39 @@
"ajv": "^4.11.2",
"body-parser": "^1.15.2",
"compression": "^1.6.2",
"connect-mongo": "^1.3.2",
"cookie-parser": "^1.4.3",
"express": "^4.14.0",
"express-session": "^1.15.1",
"git-rev-sync": "^1.8.0",
"handlebars": "^4.0.6",
"material-components-web": "^0.2.0",
"mongoose": "^4.7.6",
"morgan": "^1.8.0",
"multer": "^1.2.1",
"passport": "^0.3.2",
"passport-facebook": "^2.1.1",
"passport-github2": "^0.1.10",
"passport-google-oauth20": "^1.0.0",
"serve-static": "^1.11.1"
},
"devDependencies": {
"@types/ajv": "^1.0.0",
"@types/body-parser": "0.0.33",
"@types/chai": "^3.4.34",
"@types/compression": "0.0.33",
"@types/connect-mongo": "0.0.32",
"@types/cookie-parser": "^1.3.30",
"@types/express": "^4.0.34",
"@types/express-session": "0.0.32",
"@types/handlebars": "^4.0.31",
"@types/mocha": "^2.2.38",
"@types/mongoose": "^4.7.3",
"@types/morgan": "^1.7.32",
"@types/multer": "0.0.32",
"@types/node": "^7.0.0",
"@types/passport": "^0.3.3",
"@types/passport-facebook": "^2.1.1",
"@types/qwest": "^1.7.28",
"@types/serve-static": "^1.7.31",
"@types/supertest": "^2.0.0",
Expand Down
53 changes: 14 additions & 39 deletions server/app.ts
Expand Up @@ -8,62 +8,37 @@ import * as compression from "compression";
import * as cookieParser from "cookie-parser";
import * as morgan from "morgan";

// Set up Express and its middleware
export let app = express();
app.use(morgan("dev"));
app.use(compression());
let cookieParserInstance = cookieParser(undefined, {
"path": "/",
"maxAge": 1000 * 60 * 60 * 24 * 30 * 6, // 6 months
"secure": false,
"httpOnly": true
});
app.use(cookieParserInstance);

import {
// Functions
pbkdf2Async,
// Constants
PORT, STATIC_ROOT, VERSION_NUMBER, VERSION_HASH
PORT, STATIC_ROOT, VERSION_NUMBER, VERSION_HASH, COOKIE_OPTIONS
} from "./common";
import {
IUser, IUserMongoose, User
} from "./schema";

// Check for number of admin users and create default admin account if none
const DEFAULT_EMAIL = "admin@hack.gt";
const DEFAULT_PASSWORD = "admin";
// Set up Express and its middleware
export let app = express();
app.use(morgan("dev"));
app.use(compression());
let cookieParserInstance = cookieParser(undefined, COOKIE_OPTIONS);
app.use(cookieParserInstance);

// Check for number of admin users and warn if none
(async () => {
let users = await User.find({"admin": true});
if (users.length !== 0)
return;

let salt = crypto.randomBytes(32);
let passwordHashed = await pbkdf2Async(DEFAULT_PASSWORD, salt, 500000, 128, "sha256");

let defaultUser = new User({
email: DEFAULT_EMAIL,
name: "Default Admin",
login: {
hash: passwordHashed.toString("hex"),
salt: salt.toString("hex")
},
auth_keys: [],
admin: true
});
await defaultUser.save();
console.info(`
Created default admin user
Username: ${DEFAULT_EMAIL}
Password: ${DEFAULT_PASSWORD}
**Delete this user after you have used it to set up your account**
`);
console.warn("No admin users are configured; admins can be added in config.json");
})();

// Auth needs to be the first route configured or else requests handled before it will always be unauthenticated
import {authRoutes} from "./routes/auth";
app.use("/auth", authRoutes);

let apiRouter = express.Router();
// API routes go here
import {userRoutes} from "./routes/user";
apiRouter.use("/user", userRoutes);

app.use("/api", apiRouter);

Expand Down
16 changes: 8 additions & 8 deletions server/common.ts
Expand Up @@ -11,6 +11,12 @@ export const PORT = parseInt(process.env.PORT) || 3000;
export const STATIC_ROOT = path.resolve(__dirname, "../client");
export const VERSION_NUMBER = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../package.json"), "utf8")).version;
export const VERSION_HASH = require("git-rev-sync").short();
export const COOKIE_OPTIONS = {
"path": "/",
"maxAge": 1000 * 60 * 60 * 24 * 30 * 6, // 6 months
"secure": false,
"httpOnly": true
};

//
// Database connection
Expand Down Expand Up @@ -53,27 +59,21 @@ export let uploadHandler = multer({
});
// For API endpoints
export async function authenticateWithReject (request: express.Request, response: express.Response, next: express.NextFunction) {
let authKey = request.cookies.auth;
let user = await User.findOne({"auth_keys": authKey});
if (!user) {
if (!request.isAuthenticated()) {
response.status(401).json({
"error": "You must log in to access this endpoint"
});
}
else {
response.locals.email = user.email;
next();
}
};
// For directly user facing endpoints
export async function authenticateWithRedirect (request: express.Request, response: express.Response, next: express.NextFunction) {
let authKey = request.cookies.auth;
let user = await User.findOne({"auth_keys": authKey});
if (!user) {
if (!request.isAuthenticated()) {
response.redirect("/login");
}
else {
response.locals.email = user.email;
next();
}
};
Expand Down
21 changes: 21 additions & 0 deletions server/config/config.example.json
@@ -0,0 +1,21 @@
{
"secrets": {
"session": "",
"github": {
"id": "",
"secret": ""
},
"google": {
"id": "",
"secret": ""
},
"facebook": {
"id": "",
"secret": ""
}
},
"server": {
"isProduction": true
},
"admins": ["example@example.com"]
}
163 changes: 163 additions & 0 deletions server/routes/auth.ts
@@ -0,0 +1,163 @@
import * as fs from "fs";
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";
const MongoStore = connectMongo(session);
import * as passport from "passport";

import {
mongoose, PORT, COOKIE_OPTIONS
} from "../common";
import {
Config,
IUser, IUserMongoose, User
} from "../schema";

// Passport authentication
import {app} from "../app";
const GitHubStrategy = require("passport-github2").Strategy; // No type definitions available yet for this module (or for Google)
const GoogleStrategy = require("passport-google-oauth20").Strategy;
import {Strategy as FacebookStrategy} from "passport-facebook";

let config: Config | null = null;
try {
config = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../config", "config.json"), "utf8"));
}
catch (err) {
if (err.code !== "ENOENT")
throw err;
}

const BASE_URL: string = (config && config.server.isProduction) ? "https://registration.hack.gt" : `http://localhost:${PORT}`;

// GitHub
const GITHUB_CLIENT_ID: string | null = process.env.GITHUB_CLIENT_ID || (config && config.secrets.github.id);
const GITHUB_CLIENT_SECRET: string | null = process.env.GITHUB_CLIENT_SECRET || (config && config.secrets.github.secret);
if (!GITHUB_CLIENT_ID || !GITHUB_CLIENT_SECRET) {
throw new Error("GitHub client ID or secret not configured in config.json or environment variables");
}
const GITHUB_CALLBACK_HREF: string = "auth/github/callback";

// Google
const GOOGLE_CLIENT_ID: string | null = process.env.GOOGLE_CLIENT_ID || (config && config.secrets.google.id);
const GOOGLE_CLIENT_SECRET: string | null = process.env.GOOGLE_CLIENT_SECRET || (config && config.secrets.google.secret);
if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) {
throw new Error("Google client ID or secret not configured in config.json or environment variables");
}
const GOOGLE_CALLBACK_HREF: string = "auth/google/callback";

// Facebook
const FACEBOOK_CLIENT_ID: string | null = process.env.FACEBOOK_CLIENT_ID || (config && config.secrets.facebook.id);
const FACEBOOK_CLIENT_SECRET: string | null = process.env.FACEBOOK_CLIENT_SECRET || (config && config.secrets.facebook.secret);
if (!FACEBOOK_CLIENT_ID || !FACEBOOK_CLIENT_SECRET) {
throw new Error("Facebook client ID or secret not configured in config.json or environment variables");
}
const FACEBOOK_CALLBACK_HREF: string = "auth/facebook/callback";


if (!config || !config.server.isProduction) {
console.warn("OAuth callback(s) running in development mode");
}
if (!config || !config.secrets.session) {
console.warn("No session secret set; sessions won't carry over server restarts");
}
app.use(session({
secret: (config && config.secrets.session) || crypto.randomBytes(32).toString("hex"),
cookie: COOKIE_OPTIONS,
resave: false,
store: new MongoStore({
mongooseConnection: mongoose.connection,
touchAfter: 24 * 60 * 60 // Check for TTL every 24 hours at minimum
}),
saveUninitialized: true
}));
passport.serializeUser<IUser, string>((user, done) => {
done(null, user._id);
});
passport.deserializeUser<IUser, string>((id, done) => {
User.findById(id, (err, user) => {
done(err, user);
});
});

function useLoginStrategy(strategy: any, dataFieldName: "githubData" | "googleData" | "facebookData", options: { clientID: string; clientSecret: string; callbackURL: string; profileFields?: string[] }) {
passport.use(new strategy(options, async (accessToken, refreshToken, profile, done) => {
let email = profile.emails[0].value;
let user = await User.findOne({"email": email});
let isAdmin = false;
if (config && config.admins.indexOf(email) !== -1) {
isAdmin = true;
if (!user || !user.admin)
console.info(`Adding new admin: ${email}`);
}
if (!user) {
user = new User({
"email": email,
"name": profile.displayName,
"admin": isAdmin
});
user[dataFieldName].id = profile.id;
if (dataFieldName === "githubData") {
user[dataFieldName].username = profile.username;
user[dataFieldName].profileUrl = profile.profileUrl;
}
await user.save();
done(null, user);
}
else {
if (!user[dataFieldName].id) {
user[dataFieldName].id = profile.id;
}
if (dataFieldName === "githubData" && (!user.githubData.username || !user.githubData.profileUrl)) {
user[dataFieldName].username = profile.username;
user[dataFieldName].profileUrl = profile.profileUrl;
}
if (!user.admin && isAdmin) {
user.admin = true;
}
await user.save();
done(null, user);
}
}));
}

useLoginStrategy(GitHubStrategy, "githubData", {
clientID: GITHUB_CLIENT_ID,
clientSecret: GITHUB_CLIENT_SECRET,
callbackURL: `${BASE_URL}/${GITHUB_CALLBACK_HREF}`
});
useLoginStrategy(GoogleStrategy, "googleData", {
clientID: GOOGLE_CLIENT_ID,
clientSecret: GOOGLE_CLIENT_SECRET,
callbackURL: `${BASE_URL}/${GOOGLE_CALLBACK_HREF}`
});
useLoginStrategy(FacebookStrategy, "facebookData", {
clientID: FACEBOOK_CLIENT_ID,
clientSecret: FACEBOOK_CLIENT_SECRET,
callbackURL: `${BASE_URL}/${FACEBOOK_CALLBACK_HREF}`,
profileFields: ["id", "displayName", "email"]
});

app.use(passport.initialize());
app.use(passport.session());

export let authRoutes = express.Router();

function addAuthenticationRoute(serviceName: string, scope: string[]) {
authRoutes.get(`/${serviceName}`, passport.authenticate(serviceName, { scope: scope }));
authRoutes.get(`/${serviceName}/callback`, passport.authenticate(serviceName, { failureRedirect: "/login" }), (request, response) => {
// Successful authentication, redirect home
response.redirect("/");
});
}

addAuthenticationRoute("github", ["user:email"]);
addAuthenticationRoute("google", ["email"]);
addAuthenticationRoute("facebook", ["email"]);

authRoutes.all("/logout", (request, response) => {
request.logout();
response.redirect("/login");
});

0 comments on commit 7f31205

Please sign in to comment.