Skip to content

Commit

Permalink
Backend auth (#14)
Browse files Browse the repository at this point in the history
* Enable connection to Mongo

* Install passport and express-session

* Change most member attributes to not required

* Add auth routes

* Add passport setup with cookie sessions

* Add options for express-session

* Add options for express-session

* Move passport-setup to root

* Add middleware for auth

* Change auth routes to use result instead of data

* Add protected route as an example

* Remove home test and replace with dummy test

* Remove dummy test

* Remove console.log

* Remove extra || null

* Add back home.test.js and new env vars to api.yaml

* Add example dev.env

* Add redirectURI endpoint to allow for changing Vercel subdomains

* Add redirect URLs as parameters for /login

* Format auth.js

* Remove FRONTEND_URI from example env file

* Enable failure flash

* Add back .env files

* Add vercel env vars

* Remove OAUTH_CALLBACK_URI from production env
  • Loading branch information
Qrtn authored and n3a9 committed May 23, 2021
1 parent ae25ee4 commit dde39a6
Show file tree
Hide file tree
Showing 17 changed files with 354 additions and 30 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,7 @@ jobs:
run: yarn test
env:
MONGO_URL: ${{ secrets.MONGO_URL }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
OAUTH_CALLBACK_URI: ${{ secrets.OAUTH_CALLBACK_URI }}
SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
5 changes: 5 additions & 0 deletions api/config/dev.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
MONGO_URL=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
SESSION_SECRET=keyboard cat
OAUTH_CALLBACK_URI=http://localhost:9000/api/auth/redirectURI
5 changes: 5 additions & 0 deletions api/config/dev.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
MONGO_URL=mongodb://localhost
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
SESSION_SECRET=
OAUTH_CALLBACK_URI=http://localhost:9000/api/auth/redirectURI
1 change: 0 additions & 1 deletion api/config/production.env
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
MONGO_URL=production-mongo
6 changes: 5 additions & 1 deletion api/config/test.env
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
MONGO_URL=test-mongo
MONGO_URL=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
SESSION_SECRET=
OAUTH_CALLBACK_URI=http://localhost:9000/api/auth/redirectURI
5 changes: 4 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@
"debug": "~2.6.9",
"dotenv": "^8.2.0",
"express": "~4.16.1",
"express-session": "^1.17.1",
"helmet": "^3.21.1",
"http-errors": "~1.6.3",
"if-env": "^1.0.4",
"isomorphic-unfetch": "^3.0.0",
"mongodb": "^3.3.2",
"mongoose": "^5.7.5",
"morgan": "~1.9.1"
"morgan": "~1.9.1",
"passport": "^0.4.1",
"passport-google-oauth20": "^2.0.0"
},
"devDependencies": {
"babel-eslint": "^10.0.3",
Expand Down
89 changes: 89 additions & 0 deletions api/src/api/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
const express = require('express');
const router = express.Router();
const passport = require('passport');

// Defines the callback endpoint which will be passed to LAH's redirect URI
// via Google OAuth's state parameter
const CALLBACK_ENDPOINT = '/api/auth/callback';

router.get('/user', (req, res) => {
res.json({
result: req.user || null,
success: true,
});
});

router.get('/login', (req, res, next) => {
// Get URLs to redirect to on success and failure
const { successRedirect = '/', failureRedirect = '/login' } = req.query;
// Construct the "callback" url by concatenating the current base URL (host) with the callback URL
// We do all this to use one single callback URL so it works with Vercel deploying on multiple
// domains. See LAH's api/auth/login.js for more details on how this works
const callbackUrl = `${req.protocol}://${req.get(
'host',
)}${CALLBACK_ENDPOINT}`;
// State object that will be passed to OAuth and back to our callback
const state = {
callbackUrl,
successRedirect,
failureRedirect,
};

const auth = passport.authenticate('google', {
scope: ['profile', 'email'],
state: Buffer.from(JSON.stringify(state)).toString('base64'),
});
auth(req, res, next);
});

router.get('/redirectURI', (req, res) => {
try {
// If we are here, this endpoint is likely being run on the MAIN deployment
const { state } = req.query;
// Grab the branch deployment (lah-branch-deploy.hack4impact.now.sh) for example
const { callbackUrl } = JSON.parse(Buffer.from(state, 'base64').toString());
if (typeof callbackUrl === 'string') {
// Reconstruct the URL and redirect
const callbackURL = `${callbackUrl}?${req._parsedUrl.query}`;
return res.redirect(callbackURL);
}
// There was no base
return res.redirect(CALLBACK_ENDPOINT);
} catch (e) {
return res.status(400).json({
message: 'Something went wrong with the URL redirection',
success: false,
});
}
});

router.get(
'/callback',
(req, res, next) => {
const { state } = req.query;
const { successRedirect, failureRedirect } = JSON.parse(
Buffer.from(state, 'base64').toString(),
);

const auth = passport.authenticate('google', {
successRedirect,
failureRedirect,
failureFlash: true,
});
auth(req, res, next);
},
(req, res) => {
// Successful authentication, redirect home.
res.redirect(LOGIN_SUCCESS_REDIRECT);
},
);

router.post('/logout', (req, res) => {
req.logout();
res.json({
message: 'Logged out',
success: true,
});
});

module.exports = router;
14 changes: 14 additions & 0 deletions api/src/api/home.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const express = require('express');
const router = express.Router();
const { errorWrap } = require('../middleware');
const { requireRegistered } = require('../middleware/auth');

const Home = require('../models/home');

Expand All @@ -16,4 +17,17 @@ router.get(
}),
);

router.get(
'/registeredOnly',
[requireRegistered],
errorWrap(async (req, res) => {
const home = await Home.findOne();
res.status(200).json({
message: `Successfully returned home text`,
success: true,
result: home.text,
});
}),
);

module.exports = router;
2 changes: 2 additions & 0 deletions api/src/api/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
const home = require('./home');
const auth = require('./auth');

module.exports = {
home,
auth,
};
19 changes: 18 additions & 1 deletion api/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ const helmet = require('helmet');
const logger = require('morgan');
const mongoose = require('mongoose');
const path = require('path');
const routes = require('./routes');
const session = require('express-session');
const bodyParser = require('body-parser');
const passport = require('passport');
const routes = require('./routes');

const app = express();
const { errorHandler } = require('./middleware');
Expand Down Expand Up @@ -40,6 +42,21 @@ app.use(logger('dev'));
app.use(bodyParser.json({ limit: '2.1mb' }));
app.use(bodyParser.urlencoded({ limit: '2.1mb', extended: false }));

// Session support, needed for authentication
app.use(
session({
secret: process.env.SESSION_SECRET,
cookie: {},
resave: false,
saveUninitialized: false,
}),
);

// Passport setup
require('./passport-setup');
app.use(passport.initialize());
app.use(passport.session());

app.use('/', routes);

app.get('/', (req, res) => res.json('API working!'));
Expand Down
41 changes: 41 additions & 0 deletions api/src/middleware/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const { levelEnum } = require('../models/member');

const requireLevel = (levels) => (req, res, next) => {
console.log(req.user);
if (!req.user || !levels.includes(req.user.level)) {
return res.status(401).json({
success: false,
message: 'Unauthorized',
});
} else {
next();
}
};

const requireRegistered = requireLevel(Object.values(levelEnum));

const requireMember = requireLevel([
levelEnum.ADMIN,
levelEnum.DIRECTOR,
levelEnum.LEAD,
levelEnum.MEMBER,
]);

const requireLead = requireLevel([
levelEnum.ADMIN,
levelEnum.DIRECTOR,
levelEnum.LEAD,
]);

const requireDirector = requireLevel([levelEnum.ADMIN, levelEnum.DIRECTOR]);

const requireAdmin = requireLevel([levelEnum.ADMIN]);

module.exports = {
requireLevel,
requireRegistered,
requireMember,
requireLead,
requireDirector,
requireAdmin,
};
2 changes: 2 additions & 0 deletions api/src/middleware/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
const errorHandler = require('./errorHandler');
const errorWrap = require('./errorWrap');
const auth = require('./auth');

module.exports = {
errorHandler,
errorWrap,
auth,
};
34 changes: 17 additions & 17 deletions api/src/models/member.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,50 +53,50 @@ const classStandingEnum = {
};

const Member = new mongoose.Schema({
firstName: { type: String, required: true },
lastName: { type: String, required: true },
firstName: { type: String, required: false },
lastName: { type: String, required: false },
oauthID: { type: String, required: true, unique: true },
email: { type: String, required: true, unique: true },
phone: { type: String, required: true },
netID: { type: String, required: true },
UIN: { type: String, required: true },
major: { type: String, required: true },
birthdate: { type: Date, required: true },
email: { type: String, required: false, unique: true },
phone: { type: String, required: false },
netID: { type: String, required: false },
UIN: { type: String, required: false },
major: { type: String, required: false },
birthdate: { type: Date, required: false },
github: { type: String, required: false },
snapchat: { type: String, required: false },
instagram: { type: String, required: false },
areDuesPaid: { type: Boolean, required: true },
areDuesPaid: { type: Boolean, required: false },

gradYear: { type: Number, required: true },
gradYear: { type: Number, required: false },
gradSemester: {
type: String,
enum: Object.values(semesterEnum),
required: true,
required: false,
},

classStanding: {
type: String,
enum: Object.values(classStandingEnum),
required: true,
required: false,
},

generationYear: { type: Number, required: true },
generationYear: { type: Number, required: false },
generationSemester: {
type: String,
enum: Object.values(semesterEnum),
required: true,
required: false,
},

location: {
type: String,
enum: Object.values(locationEnum),
required: true,
required: false,
},

role: {
type: String,
enum: Object.values(roleEnum),
required: true,
required: false,
},

level: {
Expand All @@ -108,7 +108,7 @@ const Member = new mongoose.Schema({
status: {
type: String,
enum: Object.values(statusEnum),
required: true,
required: false,
},
});

Expand Down
50 changes: 50 additions & 0 deletions api/src/passport-setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const passport = require('passport');
const Member = require('./models/member');

// Defines the default level a user gets assigned with upon first sign-in
const DEFAULT_LEVEL = process.env.DEFAULT_LEVEL || Member.levelEnum.TBD;

passport.serializeUser((user, done) => {
done(null, user._id);
});

passport.deserializeUser((id, done) => {
// Find in DB and return user
Member.findById(id, (err, user) => {
if (err) {
console.error('err');
done(err);
}
done(null, user);
});
});

passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.OAUTH_CALLBACK_URI,
},
async (accessToken, refreshToken, profile, cb) => {
// find the user in the database based on their oauth id
const user = await Member.findOne({ oauthID: profile.id });

if (user) {
// user exists
return cb(null, user);
} else {
const newUser = await new Member({
firstName: profile.name.givenName,
lastName: profile.name.familyName,
oauthID: profile.id,
email: profile.emails[0].value,
level: DEFAULT_LEVEL,
}).save();

cb(null, newUser);
}
},
),
);
3 changes: 2 additions & 1 deletion api/src/routes/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
const express = require('express');

const router = express.Router();
const { home } = require('../api');
const { home, auth } = require('../api');

router.use('/api/home', home);
router.use('/api/auth', auth);

module.exports = router;
Loading

0 comments on commit dde39a6

Please sign in to comment.