Skip to content

Commit

Permalink
Add ability to sign in with Google Accounts (#138)
Browse files Browse the repository at this point in the history
* docs(contributor): contrib-readme-action has updated readme

* google auth work in progress

* docs: reset github bot change

* common port for local debugging with vscode

* remove debug logging

* Add sign in with Google button

* add ability to add sso user

* only show google sso when google env vars set

* add docs about new env vars

* fix formatting on button

* store sso user as boolean

* make google login strategy async

* clean-up duplicate variables

* add translations

* docs(contributor): contrib-readme-action has updated readme

* use config not process env vars

* google account linking and display names addition

* support display name

* remove addSSOUsers config option

* remove displayname stuff

* change property name

* handle accounts already being linked

* add redirect override environment variables

* fix formatting

* move to using google id instead of first email

* cleaner migration handling

* Fix ESLint violations

* rootUrl changes

* Fix failure flashes

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Wingy <[email protected]>
  • Loading branch information
3 people authored Oct 15, 2024
1 parent 3c71634 commit ae176ec
Show file tree
Hide file tree
Showing 15 changed files with 197 additions and 16 deletions.
4 changes: 2 additions & 2 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/src/index.js",
"program": "${workspaceFolder}/built/index.js",
"env": {
"PORT": "8888"
"PORT": "3000"
}
}
]
Expand Down
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,9 @@ SESSION_MAX_AGE=604800000
SITE_TITLE=Christmas Community
# Used when shared to home screen
SHORT_TITLE=Christmas
# The root path for forms, CSS, and a small amount of JS. Useful when proxying.
ROOT_PATH=/
# The root URL for forms, CSS, and a small amount of JS. Useful when proxying or using SSO.
# If not using SSO, this can be a relative path.
ROOT_URL=/
# Where to trust the X-Forwarded-For header from. Defaults to "loopback". Useful for proxying to docker.
TRUST_PROXY=loopback
# Any theme from https://jenil.github.io/bulmaswatch
Expand Down Expand Up @@ -126,6 +127,16 @@ MARKDOWN=false
# If you wish to include a custom stylesheet you can add the filename in the variable here.
# Remember to add the stylesheet to the filesystem at `static/css/custom.css`. In docker, mount `/usr/src/app/src/static/css/custom.css`.
# CUSTOM_CSS=custom.css

## Google Client Details
# You can configure single sign-on to your Christmas Community instance using Google accounts. Read this guide for details of what to configure on the Google side: https://developers.google.com/identity/openid-connect/openid-connect
# Once you've created a client ID and secret in your Google project use the below environment variables to enable SSO
# GOOGLE_CLIENT_ID=abc123
# GOOGLE_CLIENT_SECRET=123abc
# When running in docker, you may face issues with the protocol or URL that is redirecting the call, override that by specifying the full redirect URL in the below variables
# GOOGLE_SIGNIN_REDIRECT=https://wishlist.example.com/auth/google/redirect
# GOOGLE_LINK_REDIRECT=https://wishlist.example.com/auth/google/link/redirect

```

## Default Profile Pictures
Expand Down Expand Up @@ -158,10 +169,10 @@ Hi, I'm Wingy. I made this app. My website is [samwing.dev](https://samwing.dev)
<table>
<tr>
<td align="center">
<a href="https://github.com/Wingysam">
<img src="https://avatars.githubusercontent.com/u/18403742?v=4" width="100;" alt="Wingysam"/>
<a href="https://github.com/cj13579">
<img src="https://avatars.githubusercontent.com/u/1965454?v=4" width="100;" alt="cj13579"/>
<br />
<sub><b>Wingysam</b></sub>
<sub><b>cj13579</b></sub>
</a>
</td></tr>
</table>
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@
"nanoid": "^3.1.25",
"node-fetch": "^3.3.2",
"passport": "^0.4.0",
"passport-google-oidc": "^0.1.0",
"passport-local": "^1.0.0",
"pouchdb": "^7.2.2",
"pouchdb-find": "^9.0.0",
"pug": "^3.0.2",
"session-pouchdb-store": "^0.4.1",
"u64": "^1.0.1",
Expand Down
2 changes: 2 additions & 0 deletions src/PouchDB.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import PouchDB from 'pouchdb'
import config from './config/index.js'
import PouchFind from 'pouchdb-find'
PouchDB.plugin(PouchFind)

export default PouchDB.defaults({
prefix: config.dbPrefix
Expand Down
28 changes: 26 additions & 2 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ const config = {
siteTitle: process.env.SITE_TITLE || 'Christmas Community',
shortTitle: process.env.SHORT_TITLE || 'Christmas',
wishlist: (await import('./wishlist/index.js')).default,
base: (process.env.ROOT_PATH || '/').endsWith('/') ? (process.env.ROOT_PATH || '/') : `${process.env.ROOT_PATH}/`,
trustProxy: process.env.TRUST_PROXY === 'true' ? true : process.env.TRUST_PROXY || 'loopback',
bulmaswatch: (process.env.BULMASWATCH || 'default').toLowerCase(),
pfp: process.env.PFP !== 'false',
Expand All @@ -21,7 +20,12 @@ const config = {
login: process.env.CUSTOM_HTML_LOGIN
},
customCSS: process.env.CUSTOM_CSS || null,
updateCheck: process.env.UPDATE_CHECK !== 'false'
updateCheck: process.env.UPDATE_CHECK !== 'false',
googleSSOClientId: process.env.GOOGLE_CLIENT_ID || null,
googleSSOClientSecret: process.env.GOOGLE_CLIENT_SECRET || null,
googleSSOEnabled: false,
rootUrl: appendSlash(process.env.ROOT_URL ?? process.env.ROOT_PATH ?? '/'),
base: '' // automatically set below
}

if (config.guestPassword) config.wishlist.public = false
Expand All @@ -30,4 +34,24 @@ if (config.guestPassword === 'ReplaceWithYourGuestPassword') {
process.exit(1)
}

if (config.googleSSOClientId != null && config.googleSSOClientSecret != null) {
config.googleSSOEnabled = true
}

// The base path is used in HTML templates rather than the fully qualified path, mostly for legacy reasons. It also has the following advantages:
// * It saves a tiny amount of bandwidth.
// * It allows the admin to deploy to multiple subdomains. This isn't really supported, but it's nice if the admin migrates domains on the same path (typically /), and can keep the old one around because users won't migrate immediately.
// This used to be set with ROOT_PATH, but is now calculated based on ROOT_URL, which falls back to the former if unset.
// rootUrl may be a fully-qualified `https://domain/path/`-style value, or may simply be `/`, or a subpath.
try {
config.base = new URL(config.rootUrl).pathname
} catch {
config.base = config.rootUrl
}

function appendSlash (path: string) {
if (path.endsWith('/')) return path
return path + '/'
}

export default config
66 changes: 64 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import './CC.js'

import PouchSession from 'session-pouchdb-store'
import { Strategy as LocalStrategy } from 'passport-local'
import GoogleStrategy from 'passport-google-oidc'
import session from 'express-session'
import bcrypt from 'bcrypt-nodejs'
import flash from 'connect-flash'
Expand Down Expand Up @@ -45,17 +46,78 @@ passport.use('local', new LocalStrategy(
.then((doc: any) => {
bcrypt.compare(password, doc.password, (err, correct) => {
if (err) return done(err)
if (!correct) return done(null, false, { message: 'Incorrect password' })
if (!correct) return done(null, false, { message: _CC.lang('LOGIN_INCORRECT_PASSWORD') })
if (correct) return done(null, doc)
})
})
.catch(err => {
if (err.message === 'missing') return done(null, false, { message: 'Incorrect username.' })
if (err.message === 'missing') return done(null, false, { message: _CC.lang('LOGIN_INCORRECT_USERNAME') })
return done(err)
})
}
))

if (config.googleSSOEnabled) {
passport.use('google-login', new GoogleStrategy({
clientID: config.googleSSOClientId,
clientSecret: config.googleSSOClientSecret,
callbackURL: config.rootUrl + 'auth/google/redirect'
},
async (issuer, profile, done) => {
const googleId = profile.id.trim() // Get Google id
try {
// Try to get the user from the database
const docs = await db.find({
selector: { 'oauthConnections.google': { $eq: googleId } }
})
if (docs.docs.length === 1) {
return done(null, docs.docs[0])
} else {
// Handle other errors, including missing user
return done(null, false, { message: _CC.lang('LOGIN_SSO_UNKNOWN_USER') })
}
} catch (err) {
// Handle other errors, including missing user
if (err.message === 'missing') {
return done(null, false, { message: _CC.lang('LOGIN_SSO_UNKNOWN_USER') })
}
return done(err)
}
}
))

passport.use('google-link', new GoogleStrategy({
clientID: config.googleSSOClientId,
clientSecret: config.googleSSOClientSecret,
callbackURL: config.rootUrl + 'auth/google/link/redirect',
passReqToCallback: true
},
async (req, issuer, profile, done) => {
const googleId = profile.id.trim() // Get Google id

const docs = await db.find({
selector: { 'oauthConnections.google': { $eq: googleId } }
})
if (docs.docs.length === 1) {
req.flash('error', _CC.lang('LOGIN_SSO_LINK_FAILURE_ACCOUNT_EXISTS'))
return done(null)
} else {
try {
const doc = await db.get(req.session.passport.user)
doc.oauthConnections ??= {}
doc.oauthConnections.google = googleId
await db.put(doc)
req.flash('success', _CC.lang('LOGIN_SSO_LINK_SUCCESS'))
return done(null, doc)
} catch (err) {
req.flash('error', _CC.lang('LOGIN_SSO_LINK_FAILURE'))
return done(err)
}
}
}
))
}

passport.serializeUser((user, callback) => callback(null, user._id))

passport.deserializeUser((user, callback) => {
Expand Down
11 changes: 11 additions & 0 deletions src/languages/en-us.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,19 @@ export const strings = {
CONFIRM_ACCOUNT_SET_PW_TEXT: name => `Hello ${name}! Please set your password here.`,
CONFIRM_ACCOUNT_SUCCESS: `Welcome to ${_CC.config.siteTitle}!`,
LOGIN_BUTTON: 'Log In',
LOGIN_GOOGLE_BUTTON: 'Sign in with Google',
LOGIN_PASSWORD_PLACEHOLDER: 'pa$$word!',
LOGIN_PASSWORD: 'Password',
LOGIN_USERNAME_PLACEHOLDER: 'john',
LOGIN_USERNAME: 'Username',
LOGIN_INCORRECT_USERNAME: 'Incorrect username',
LOGIN_INCORRECT_PASSWORD: 'Incorrect password',
LOGIN_SSO_UNKNOWN_USER: 'Unknown user',
LOGIN_SSO_LINK_SUCCESS: 'Successfully linked account',
LOGIN_SSO_LINK_FAILURE: 'Unable to link account',
LOGIN_SSO_UNLINK_SUCCESS: 'Successfully unlinked account',
LOGIN_SSO_UNLINK_FAILURE: 'Failed to unlink account',
LOGIN_SSO_LINK_FAILURE_ACCOUNT_EXISTS: 'The external account is already linked to another account on this site!',
LOGOUT_BUTTON: 'Log Out',
NAVBAR_ADMIN: 'Admin Settings',
NAVBAR_LOGIN: 'Log In',
Expand Down Expand Up @@ -114,6 +123,8 @@ export const strings = {
PROFILE_SAVE_PFP_SUCCESS: 'Saved profile picture!',
PROFILE_SECURITY_CHANGE_PASSWORD: 'Change Password',
PROFILE_SECURITY: 'Security',
PROFILE_SECURITY_LINK_GOOGLE: 'Link Google Account',
PROFILE_SECURITY_UNLINK_GOOGLE: 'Unlink Google Account',
PROFILE_SHARED_INFORMATION: 'Shared Information',
PROFILE_SHIRT_SIZE: 'Shirt Size',
PROFILE_SHOE_SIZE: 'Shoe Size',
Expand Down
1 change: 1 addition & 0 deletions src/routes/adminSettings/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export default function ({ db, ensurePfp }) {
signupToken: nanoid(SECRET_TOKEN_LENGTH),
expiry: new Date().getTime() + SECRET_TOKEN_LIFETIME
})

await ensurePfp(username)
res.redirect(`/admin-settings/edit/${req.body.newUserUsername.trim()}`)
})
Expand Down
46 changes: 46 additions & 0 deletions src/routes/google-auth/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import passport from 'passport'
import express from 'express'

export default function ({ db }) {
const router = express.Router()

router.get('/', passport.authenticate('google-login', {
scope: ['openid', 'profile']
}))

// Callback route once Google has authenticated the user
router.get('/redirect',
passport.authenticate('google-login', {
successRedirect: '/',
failureRedirect: '/login',
failureMessage: 'Unable to sign-in using Google',
failureFlash: true
})

)

router.get('/link', passport.authenticate('google-link', {
scope: ['profile']
}))

router.get('/link/redirect',
passport.authenticate('google-link', { failureRedirect: '/profile', failureFlash: true }),
(req, res) => {
res.redirect('/profile')
}
)

router.get('/unlink', async (req, res) => {
try {
const doc = await db.get(req.session.passport.user)
delete doc.oauthConnections.google
await db.put(doc)
req.flash('success', _CC.lang('LOGIN_SSO_UNLINK_SUCCESS'))
} catch (err) {
req.flash('error', _CC.lang('LOGIN_SSO_UNLINK_FAILURE'))
}
res.redirect('/profile')
})

return router
}
5 changes: 4 additions & 1 deletion src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import SupportedSites from './supported-sites/index.js'
import Profile from './profile/index.js'
import AdminSettings from './adminSettings/index.js'
import ManifestJson from './manifest.json/index.js'
import Google from './google-auth/index.js'

export default ({ db, config }) => {
async function ensurePfp (username) {
Expand Down Expand Up @@ -61,7 +62,7 @@ export default ({ db, config }) => {

router.use('/setup', Setup({ db, ensurePfp }))

router.use('/login', Login({ ensurePfp }))
router.use('/login', Login({ config }))
router.use('/logout', Logout())
router.use('/resetpw', ResetPw(db))
router.use('/confirm-account', ConfirmAccount(db))
Expand All @@ -75,5 +76,7 @@ export default ({ db, config }) => {

router.use('/manifest.json', ManifestJson({ config }))

router.use('/auth/google', Google({ db }))

return router
}
4 changes: 2 additions & 2 deletions src/routes/login/index.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import passport from 'passport'
import express from 'express'

export default function () {
export default function ({ config }) {
const router = express.Router()

router.get('/',
(req, res) => {
if (req.isAuthenticated()) {
res.redirect('/')
} else {
res.render('login')
res.render('login', { googleSSOEnabled: config.googleSSOEnabled })
}
}
)
Expand Down
3 changes: 2 additions & 1 deletion src/routes/setup/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export default function ({ db, ensurePfp }) {
_id: username,
password: adminPasswordHash,
admin: true,
wishlist: []
wishlist: [],
oauthConnections: {}
})
resolve()
})
Expand Down
2 changes: 1 addition & 1 deletion src/routes/wishlist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ export default function (db) {
await wishlist.moveTop(req.params.itemId)
} else if (req.params.direction === 'bottom') {
await wishlist.moveBottom(req.params.itemId)
}else if (req.params.direction === 'up') {
} else if (req.params.direction === 'up') {
await wishlist.move(req.params.itemId, -1)
} else if (req.params.direction === 'down') {
await wishlist.move(req.params.itemId, 1)
Expand Down
6 changes: 6 additions & 0 deletions src/views/login.pug
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,11 @@ block content
.field
.control
input.button.is-primary(type='submit' value=lang('LOGIN_BUTTON'))
br
p
if googleSSOEnabled
a.button.is-primary(href="/auth/google")
i.fab.fa-google
|&nbsp;&nbsp;#{lang('LOGIN_GOOGLE_BUTTON')}
if _CC.config.customHtml.login
div!= _CC.config.customHtml.login
12 changes: 12 additions & 0 deletions src/views/profile.pug
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,15 @@ block content
span.icon
i.fas.fa-shield-alt
span= lang('PROFILE_SECURITY_CHANGE_PASSWORD')
| &nbsp;
if _CC.config.googleSSOEnabled
if req.user.oauthConnections && req.user.oauthConnections.google
a.button.is-danger(href=`${_CC.config.base}auth/google/unlink`)
span.icon
i.fab.fa-google
span= lang('PROFILE_SECURITY_UNLINK_GOOGLE')
else
a.button.is-primary(href=`${_CC.config.base}auth/google/link`)
span.icon
i.fab.fa-google
span= lang('PROFILE_SECURITY_LINK_GOOGLE')

0 comments on commit ae176ec

Please sign in to comment.