Skip to content

Commit

Permalink
Merge branch '746-apis-to-create-user-and-group' into dev
Browse files Browse the repository at this point in the history
  • Loading branch information
lorenjohnson committed Apr 27, 2022
2 parents 73d38ef + ec26175 commit 7f7b181
Show file tree
Hide file tree
Showing 45 changed files with 3,003 additions and 1,719 deletions.
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ GOOGLE_CLIENT_SECRET=
HYLO_ADMINS=
INTERCOM_KEY=
IOS_APP_STORE_URL=
JWT_SECRET=somesecretkey
LINKEDIN_API_KEY=
LINKEDIN_API_SECRET=
MAILGUN_DOMAIN=
Expand All @@ -30,6 +29,7 @@ MAPBOX_TOKEN=
NEW_GROUP_EMAIL=
NEW_RELIC_LICENSE_KEY_DISABLED=
NODE_ENV=development
OIDC_KEYS=
PLAY_APP_SECRET=
PRETTY_JSON=true
PROTOCOL=http
Expand All @@ -43,7 +43,8 @@ SLACK_APP_CLIENT_SECRET=
UPLOADER_PATH_PREFIX=

# Enable for crazy amounts of detail (can be helpful for socket debugging)
#
# DEBUG=*
#
# DEBUG=knex:query
# DEBUG=knex:tx
# DEBUG=oidc-provider:error
85 changes: 85 additions & 0 deletions APIs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Authenticated APIs

We have recently launched our MVP of APIs to be used by partners. Currently you have to contact us at hello@hylo.com if you want access to the APIs. We will give you an API key and secret manually.

# API calls

### Authenticate
Before making any API calls you must get an auth token

`POST to https://hylo.com/noo/oauth/token`

__Headers:__
Content-Type: application/x-www-form-urlencoded

__Parameters (all required):__
- grant_type = client_credentials
- resource = the server URL you are making the call to (e.g. https://hylo.com, https://staging.hylo.com, or https://localhost:3000)
- scope = api:write
- client_id = YOUR_ID
- client_secret = YOUR_SECRET

This call will return an Auth Token for use in later API calls. This token will expire in 2 hours at which point you will need to make another API call to get a new Auth Token (AUTH_TOKEN).

For every subsequent API you will need to authorize by passing this token as Bearer Token in the Authorization Header:
`Authorization: Bearer AUTH_TOKEN`

### Create a User

`POST to https://hylo.com/noo/user`

__Headers:__
Content-Type: application/x-www-form-urlencoded

__Parameters:__
- name (required) = Judy Mangrove
- email (required) = email@email.com
- groupId (optional) = the id of a group to add the user to

__Return value__:

On success this will return a JSON object that looks like:
```
{
"id": "44692",
"name": "Judy Mangrove",
"email": "email@email.com"
}
```

If there is already a user with this email registered you will receive:
`{ "message": "User already exists" }`


### Create a Group

`POST to https://hylo.com/noo/graphql`

__Headers:__
Content-Type: application/json

This is a GraphQL based endpoint so you will want the pass in a raw POST data
Example GraphQL mutation:
```
{
"query": "mutation ($data: GroupInput, $asUserId: ID) { createGroup(data: $data, asUserId: $asUserId) { id name slug } }",
"variables": {
"data": {
"accessibility": 1,
"name": "Test Group",
"slug": "unique-url-slug",
"parentIds": [],
"visibility": 1,
"groupExtensions": [
{
"type": "farm-onboarding",
"data": {
"farm_email": "test@farm.org"
}
}
]
},
"asUserId": USER_ID
}
}
```
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ Change your `.env` file to have:
PROTOCOL=https
```

### Setting up to handle auth with JWTs and become an OpenID Connect provider
- Run `yarn generate-rsa-key-base64`
- Copy generated base64 string to .env file: `OIDC_KEYS=base64key`
- You can add multiple keys by separating them with a comma (to rotate keys in the future)

### running tests

Run `yarn test` or `yarn cover`. The tests should use a different database (see below), because it creates and drops the database schema on each run.
Expand Down
15 changes: 8 additions & 7 deletions api/controllers/SessionController.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import crypto from 'crypto'

const rollbar = require('../../lib/rollbar')

import checkJWT from '../policies/checkJWT'

const findUser = function (service, email, id) {
return User.query(function (qb) {
qb.leftJoin('linked_account', (q2) => {
Expand Down Expand Up @@ -76,9 +74,10 @@ const upsertLinkedAccount = (req, service, profile) => {
return account.save({user_id: userId}, {patch: true})
.then(() => LinkedAccount.updateUser(userId, {type: service, profile}))
}

// we create a new account regardless of whether one exists for the service;
// this allows the user to continue to log in with the old one
// NOTE: This is currently having the effect of creating a new LinkedAccount for a service
// EVERY TIME a user authenticates with that service, even using an already linked account.
return LinkedAccount.create(userId, {type: service, profile}, {updateUser: true})
})
}
Expand Down Expand Up @@ -177,7 +176,7 @@ module.exports = {
},

startGoogleOAuth: setSessionFromParams(function (req, res) {
passport.authenticate('google', {scope: 'email'})(req, res)
passport.authenticate('google', {scope: ['email', 'profile']})(req, res)
}),

finishGoogleOAuth: function (req, res, next) {
Expand Down Expand Up @@ -232,10 +231,12 @@ module.exports = {
const shouldRedirect = req.method === 'GET'
const nextUrl = req.param('n') || Frontend.Route.evo.passwordSetting()

if (req.session.authenticated) {
// NOTE: this was `req.session.authenticated` but that doesn't seem to
// populate in the case (or in time) for a POST request? This works.
if (req.session.userId) {
return shouldRedirect
? res.redirect(nextUrl)
: res.ok({success: true})
: res.ok({ success: true })
} else {
// still redirect, to give the user a chance to log in manually
// if a specific URL other than the default was the entry point
Expand Down Expand Up @@ -271,7 +272,7 @@ module.exports = {
return res.serverError
}
},

// these are here for testing
findUser,
upsertLinkedAccount
Expand Down
148 changes: 49 additions & 99 deletions api/controllers/UserController.js
Original file line number Diff line number Diff line change
@@ -1,113 +1,63 @@
import jwt from 'jsonwebtoken'
import InvitationService from '../services/InvitationService'
import OIDCAdapter from '../services/oidc/KnexAdapter'

module.exports = {
create: function (req, res) {
const { name, email, email_validated, password } = req.allParams()

return User.create({name, email: email ? email.toLowerCase() : null, email_validated, account: {type: 'password', password}})
.then(async (user) => {
await Analytics.trackSignup(user.id, req)
if (req.param('login')) {
await UserSession.login(req, user, 'password')
}
await user.refresh()

if (req.param('resp') === 'user') {
return res.ok({
name: user.get('name'),
email: user.get('email')
})
} else {
return res.ok({})
}
})
.catch(function (err) {
res.status(422).send({ error: err.message ? err.message : err })
})
},
create: async function (req, res) {
const { name, email, groupId } = req.allParams()
const group = groupId && await Group.find(groupId)

status: function (req, res) {
res.ok({signedIn: UserSession.isLoggedIn(req)})
},

sendEmailVerification: async function (req, res) {
const email = req.param('email')

const user = await User.find(email)
let user = await User.find(email, {}, false)
if (user) {
return res.status(422).send({ error: "duplicate-email" })
}

const code = await UserVerificationCode.create(email)
const token = jwt.sign({
iss: 'https://hylo.com',
aud: 'https://hylo.com',
sub: email,
exp: Math.floor(Date.now() / 1000) + (60 * 60 * 4), // 4 hour expiration
code: code.get('code')
}, process.env.JWT_SECRET);

Queue.classMethod('Email', 'sendEmailVerification', {
email,
templateData: {
code: code.get('code'),
verify_url: Frontend.Route.verifyEmail(token)
// User already exists
if (group) {
if (!(await GroupMembership.hasActiveMembership(user, group))) {
// If user exists but is not part of the group then invite them
let message = `${req.api_client} is excited to invite you to join our community on Hylo.`
let subject = `Join me in ${group.get('name')} on Hylo!`
if (req.api_client) {
const client = await (new OIDCAdapter("Client")).find(req.api_client.id)
if (!client) {
return res.status(403).json({ error: 'Unauthorized' })
}
subject = client.invite_subject || `You've been invited to join ${group.get('name')} on Hylo`
message = client.invite_message || `Hi ${user.get('name')}, <br><br> We're excited to welcome you into our community. Click below to join ${group.get('name')} on Hylo.`
}
const inviteBy = await group.moderators().fetchOne()

await InvitationService.create({
sessionUserId: inviteBy?.id,
groupId: group.id,
userIds: [user.id],
message,
subject
})
}
}
})

return res.ok({})
},
return res.ok({ message: "User already exists" })
}

sendPasswordReset: function (req, res) {
const email = req.param('email')
return User.query(q => q.whereRaw('lower(email) = ?', email.toLowerCase())).fetch().then(function (user) {
if (!user) {
return res.ok({})
} else {
const nextUrl = req.param('evo')
? Frontend.Route.evo.passwordSetting()
: null
const token = user.generateJWT()
Queue.classMethod('Email', 'sendPasswordReset', {
email: user.get('email'),
return User.create({name, email: email ? email.toLowerCase() : null, email_validated: false, active: false, group })
.then(async (user) => {
Queue.classMethod('Email', 'sendFinishRegistration', {
email,
templateData: {
login_url: Frontend.Route.jwtLogin(user, token, nextUrl)
api_client: req.api_client?.name,
group_name: group && group.get('name'),
version: 'with link',
verify_url: Frontend.Route.verifyEmail(email, user.generateJWT())
}
})
return res.ok({})
}
})
.catch(res.serverError.bind(res))
},

verifyEmailByCode: async function (req, res) {
let { code, email } = req.allParams()

if (await UserVerificationCode.verify(email, code)) {
// Store verified email for 4 hours
res.cookie('verifiedEmail', email, { maxAge: 1000 * 60 * 60 * 4 });
return res.ok(email)
}

return res.status(403).json({ error: 'invalid code' });
},

verifyEmailByToken: async function (req, res) {
let { token } = req.allParams()
const verify = Promise.promisify(jwt.verify, jwt)
try {
const decoded = await jwt.verify(token, process.env.JWT_SECRET, { audience: 'https://hylo.com', issuer: 'https://hylo.com' })
const email = decoded.sub
const code = decoded.code

if (await UserVerificationCode.verify(email, code)) {
// Store verified email for 4 hours
res.cookie('verifiedEmail', email, { maxAge: 1000 * 60 * 60 * 4 });
return res.redirect(Frontend.Route.signupFinish())
}
} catch (e) {}

return res.redirect(Frontend.Route.signup('invalid-link'))
return res.ok({
id: user.id,
name: user.get('name'),
email: user.get('email')
})
})
.catch(function (err) {
res.status(422).send({ error: err.message ? err.message : err })
})
}

}
18 changes: 11 additions & 7 deletions api/graphql/filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,16 @@ export function makeFilterToggle (enabled) {
enabled ? filterFn(relation) : relation
}

export const membershipFilter = userId => relation =>
relation.query(q => {
// XXX: why are we passing in AXOLOTL_ID? wouldnt that return all memberships the AXOLOTL has too?
const subq = GroupMembership.forMember([userId, User.AXOLOTL_ID]).query().select('group_id')
q.whereIn('group_memberships.group_id', subq)
})
export const membershipFilter = userId => relation => {
if (userId) {
return relation.query(q => {
// XXX: why are we passing in AXOLOTL_ID? wouldnt that return all memberships the AXOLOTL has too?
const subq = GroupMembership.forMember([userId, User.AXOLOTL_ID]).query().select('group_id')
q.whereIn('group_memberships.group_id', subq)
})
}
return relation
}

export const messageFilter = userId => relation => relation.query(q => {
q.whereNotIn('comments.user_id', BlockedUser.blockedFor(userId))
Expand Down Expand Up @@ -138,7 +142,7 @@ export const postFilter = (userId, isAdmin) => relation => {

if (!userId) {
// non authenticated queries can only see public posts
q.where(tableName + '.is_public', true)
q.where('posts.is_public', true)
} else if (!isAdmin) {
// Only show posts that are public or posted to a group the user is a member of
q.where(q3 => {
Expand Down
Loading

0 comments on commit 7f7b181

Please sign in to comment.