diff --git a/package.json b/package.json index 0b4914873f059239e076489802d4ba95c4fda230..ea44ce1bbefa4bd76e3fdc2b950fe966c8220d2a 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "watch": "NODE_PATH=src DEBUG=server:* nodemon server --ignore './client/src' --ignore './certs' --ignore 'google-storage.json'", "watch:queue": "NODE_PATH=src QUEUE_ACTION=consumer DEBUG=server:* nodemon worker --ignore './client/src' --ignore './certs' --ignore 'google-storage.json'", "exec:env": "docker-compose -p jetenv up", + "exec:ngrok": "ngrok http 7123 --hostname trash.4l2.uk", "test": "NODE_ENV=testing NODE_PATH=src node scripts/jest.js", "start": "NODE_PATH=src node server", "cmd": "NODE_PATH=src node run", diff --git a/src/database/models/User.js b/src/database/models/User.js index c6ec41b92775b1ea9c635f10cf2749afdeef9156..ea4037e5a23b380a32d6e93d23371bcc6481ce05 100644 --- a/src/database/models/User.js +++ b/src/database/models/User.js @@ -111,15 +111,24 @@ class User extends BaseModel { async asJWTToken(extras = {}) { const { sign } = require('core/utils/jwt') + const roles = await this.getAuthRoles() return await sign({ session: { id: this.id, - roles: ['overseer', 'user'], + roles, }, ...extras, }) } + async getAuthRoles() { + const roles = ['user'] + if (this.email === 'contact@louiscap.co') { + roles.unshift('overseer') + } + return roles + } + async checkPassword(password) { const crypto = require('core/utils/crypto') if (this.password == null) { diff --git a/src/domain/auth/AuthServer.js b/src/domain/auth/AuthServer.js index b237b6d248186e63e37bd660a53701773f11600f..39573c83a0de51d69775d2c2c82222e3e1df4086 100644 --- a/src/domain/auth/AuthServer.js +++ b/src/domain/auth/AuthServer.js @@ -52,7 +52,7 @@ const model = { where: { email: { [Op.eq]: email } }, }, { }) if (user != null) { - const valid = await crypto.verify(user.password, password) + const valid = await user.checkPassword(password) if (valid) { return user @@ -170,66 +170,26 @@ class KoaOAuthServer { // authorize to get code this.authorize = async ctx => { - const user = await ctx.services['core.auth'].getUser() + const OAuthFlow = require('./OAuthFlow') + const flow = await OAuthFlow.initialiseFlow(ctx) + const { + user, + redirect, + } = flow - let query = ctx.request.query - - if (ctx.request.query.auth_state) { - query = JSON.parse(await crypto.decrypt(ctx.request.query.auth_state)) - if (ctx.request.query.action && query.query) { - query = query.query - } - } - - console.log(ctx.request.query, query) - - const authState = await crypto.encrypt(JSON.stringify({ redirect: 'authorize', query: ctx.request.query })) if (!user) { - return ctx.redirect(`/login?login_state=${ authState }`) + return ctx.redirect(`/login?auth_state=${ redirect }`) } else if (ctx.method === 'GET') { - const client = await OAuthClient.findOne({ where: { id: query.client_id }}) - if (client == null) { - throw new HttpError(400, 'Invalid client id specified') - } - - const scopes = describeScopeRequest(query.scope) - - console.log(scopes) - - return ctx.render('auth/accept-oauth', { - user, - client, - scopes, - authState, - }) + return await OAuthFlow.showOAuthConsent(ctx, flow) } else { if (ctx.request.query.action === 'deny') { - const { redirect_uri } = query - const redirect = new URL(redirect_uri, 'http://localhost') - const search = new URLSearchParams(redirect.searchParams) - - search.set('error', 'access_denied') - search.set('error_description', 'The user has denied the requested permissions') - redirect.search = search.toString() - - ctx.set('Location', redirect.toString()) - ctx.status = 302 - return - } - if (!query.state) { - query.state = crypto.insecureHexString(32) - } - const { req, res } = this.transformContext(ctx, { query }) - await authServer.authorize(req, res, { authenticateHandler: { handle() { return user } }}) - for (const [name, value] of Object.entries(res.headers)) { - ctx.response.set(name, value) + return await OAuthFlow.handleConsentRejection(ctx, flow) } - ctx.response.status = res.status + return await OAuthFlow.handleConsentAcceptance(ctx, flow, this) } } this.authenticate = async ctx => { - const { req, res } = this.transformContext(ctx) await authServer.authenticate(req, res) @@ -243,48 +203,12 @@ class KoaOAuthServer { for (const [name, value] of Object.entries(res.headers)) { ctx.response.set(name, value) } + ctx.response.status = res.status ctx.response.body = res.body } } } -const scopeDescriptionMap = { - '*': { - icon: 'admin', - name: 'Full Access', - description: 'Full access to your account, including the ability to create, update and delete any user information, metrics and files.' - }, - 'metrics:create': { - name: 'Create Metrics', - description: 'The ability to add data metrics linked to your account. Remember that connected apps that create metrics will know your location!', - }, - 'files:upload': { - name: 'Upload Files', - description: 'The ability to upload images linked to your account', - }, - 'files:read': { - name: 'Read Files', - description: 'The ability to see and download images that you\'ve uploaded to your account', - }, - 'profile:read': { - name: 'Read Profile', - description: 'The ability to see any information associated with your user profile. This includes your name and email address', - }, - 'profile:write': { - name: 'Modify Profile', - description: 'The ability to edit any information associated with your user profile. This includes your name', - }, - 'profile:stats': { - name: 'Profile Stats', - description: 'The ability to see information about your account stats, including your points and citizen scientist level', - }, - -} -function describeScopeRequest(scope = '*') { - const scopes = scope.split(' ') - return scopes.map(s => scopeDescriptionMap[s]) -} - module.exports = new KoaOAuthServer(new OAuthServer({ model })) module.exports.KoaOauthServer = KoaOAuthServer diff --git a/src/domain/auth/OAuthFlow.js b/src/domain/auth/OAuthFlow.js new file mode 100644 index 0000000000000000000000000000000000000000..b2d369efa8dbc145a2dc1660994699e0a4130e75 --- /dev/null +++ b/src/domain/auth/OAuthFlow.js @@ -0,0 +1,124 @@ +const { OAuthClient } = require('database/models') +const HttpError = require('core/errors/HttpError') +const crypto = require('core/utils/crypto') + +/** + * Retrieve the correct query value and format auth state for an oauth request + * @param ctx + * @return {Promise<{redirect: *, query: ({auth_state}|*), action, state: null, user: *}>} + */ +exports.initialiseFlow = async ctx => { + const user = await ctx.services['core.auth'].getUser() + + let baseQuery = ctx.request.query + let queryState = null + + console.log(baseQuery) + + if (baseQuery.auth_state) { + queryState = JSON.parse(await crypto.decrypt(baseQuery.auth_state)) + if (queryState.query) { + queryState = queryState.query + } + } + + const action = baseQuery.action + + const redirectState = await crypto.encrypt(JSON.stringify({ redirect: 'authorize', query: baseQuery })) + + return { + user, + action, + query: baseQuery, + state: queryState, + redirect: redirectState, + } +} + +exports.showOAuthConsent = async (ctx, queryState) => { + const { + user, + query, + redirect, + } = queryState + + const client = await OAuthClient.findOne({ where: { id: query.client_id }}) + if (client == null) { + throw new HttpError(400, 'Invalid client id specified') + } + + const scopes = describeScopeRequest(query.scope) + + return ctx.render('auth/accept-oauth', { + user, + client, + scopes, + redirect, + }) +} + +exports.handleConsentRejection = async (ctx, flow) => { + const { redirect_uri } = flow.query + const redirect = new URL(redirect_uri, 'http://localhost') + const search = new URLSearchParams(redirect.searchParams) + + search.set('error', 'access_denied') + search.set('error_description', 'The user has denied the requested permissions') + redirect.search = search.toString() + + ctx.set('Location', redirect.toString()) + ctx.status = 302 + ctx.body = null +} + +exports.handleConsentAcceptance = async (ctx, flow, server) => { + const { state: queryState } = flow + if (!queryState.state) { + queryState.state = crypto.insecureHexString(32) + } + + const { req, res } = server.transformContext(ctx, { query: queryState }) + await server.getAuthServer().authorize(req, res, { authenticateHandler: { handle() { return flow.user } }}) + for (const [name, value] of Object.entries(res.headers)) { + ctx.response.set(name, value) + } + ctx.response.status = res.status +} + +const scopeDescriptionMap = { + '*': { + icon: 'admin', + name: 'Full Access', + description: 'Full access to your account, including the ability to create, update and delete any user information, metrics and files.' + }, + 'metrics:create': { + name: 'Create Metrics', + description: 'The ability to add data metrics linked to your account. Remember that connected apps that create metrics will know your location!', + }, + 'files:upload': { + name: 'Upload Files', + description: 'The ability to upload images linked to your account', + }, + 'files:read': { + name: 'Read Files', + description: 'The ability to see and download images that you\'ve uploaded to your account', + }, + 'profile:read': { + name: 'Read Profile', + description: 'The ability to see any information associated with your user profile. This includes your name and email address', + }, + 'profile:write': { + name: 'Modify Profile', + description: 'The ability to edit any information associated with your user profile. This includes your name', + }, + 'profile:stats': { + name: 'Profile Stats', + description: 'The ability to see information about your account stats, including your points and citizen scientist level', + }, + +} +function describeScopeRequest(scope = '*') { + const scopes = scope.split(' ') + return scopes.map(s => scopeDescriptionMap[s]) + .filter(Boolean) +} diff --git a/views/auth/accept-oauth.hbs b/views/auth/accept-oauth.hbs index 85bc1d6ed9830e2af5ab855cb8f83e325ab62084..5b73a73422048a9ef8a18dfd3032cd876192a5ee 100644 --- a/views/auth/accept-oauth.hbs +++ b/views/auth/accept-oauth.hbs @@ -21,10 +21,10 @@ {{/each}} </ul> <div class="flex justify-center"> - <form class="flex-1 flex flex-col justify-center align-stretch" method="post" action="/auth/authorize?action=accept&auth_state={{authState}}"> + <form class="flex-1 flex flex-col justify-center align-stretch" method="post" action="/auth/authorize?action=accept&auth_state={{redirect}}"> <button type="submit" class="bg-green-500 hover:bg-green-600 text-white shadow hover:shadow-md active:shadow">Accept</button> </form> - <form class="flex-1 flex flex-col justify-center align-stretch" method="post" action="/auth/authorize?action=deny&auth_state={{authState}}"> + <form class="flex-1 flex flex-col justify-center align-stretch" method="post" action="/auth/authorize?action=deny&auth_state={{redirect}}"> <button type="submit" class="bg-red-500 hover:bg-red-600 text-white shadow hover:shadow-md active:shadow">Reject</button> </form> </div>