diff --git a/Dockerfile b/Dockerfile index 1653996e773a364e4ba0c236d2216e9389329b8c..f6db5a421d2d41d84d9ac02c5cbb8300534c2de0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,15 @@ -FROM mhart/alpine-node:15 as base +FROM mhart/alpine-node:14 as base WORKDIR /app COPY package*.json ./ RUN npm install --only=production --unsafe-perm -FROM mhart/alpine-node:slim-15 as api +FROM mhart/alpine-node:slim-14 as api +ARG APP_VERSION + LABEL maintainer="Louis Capitanchik <louis@jetsam.tech>" LABEL description="The Jetsam API server" +LABEL version=$APP_VERSION RUN apk add --no-cache bash diff --git a/Makefile b/Makefile index 89609969c797e0b54289adf198f2d952d04762d2..bd5121cd40586a6a581a9315732810e852ef7a53 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,9 @@ TAG=latest REMOTE_TAG=$(TAG) REMOTE=registry.digitalocean.com +CHART_REPO=https://museum.lcr.gr/api/charts +CHART_TAG=0.0.0 + export build: @@ -19,6 +22,14 @@ push: tag docker: build tag push +chart: + helm package helm/api + +museum: + curl --data-binary "@api-$(CHART_TAG).tgz" "$(CHART_REPO)" + +helm: chart museum + hasura-claims: @echo @envsubst < hasura/claims_config.json | jq -c . diff --git a/docker-compose.yml b/docker-compose.yml index ff758fb2d366c1cd575477a18df004aeae174aca..2ae61a811f85d119bc24d4b2a30768d11a7782ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,15 @@ services: - "5432:5432" labels: tech.jetsam.environment: 'staging' + graphql-engine: + image: hasura/graphql-engine:v2.0.10 + restart: on-failure + ports: + - "15432:8080" + depends_on: + - "postgres" + env_file: + - hasura/.env # hsaura: # image: hasura/graphql-engine:v1.3.3 # ports: diff --git a/helm/api/.helmignore b/helm/api/.helmignore index 0e8a0eb36f4ca2c939201c0d54b5d82a1ea34778..54be7e08c62ce99461627c4f31ba17f51836999f 100644 --- a/helm/api/.helmignore +++ b/helm/api/.helmignore @@ -21,3 +21,4 @@ .idea/ *.tmproj .vscode/ +.dck/ diff --git a/helm/api/Chart.yaml b/helm/api/Chart.yaml index 2184794c60b81eb17848f8734eb244151b316af1..8bc50c1b0216a2dcf5a6bd40b542a1eab6e531d2 100644 --- a/helm/api/Chart.yaml +++ b/helm/api/Chart.yaml @@ -15,10 +15,10 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.2.10 +version: 0.3.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "2.0.0.beta-4" +appVersion: "2.2.0" diff --git a/helm/api/values.yaml b/helm/api/values.yaml index 10a5fbd2a92cce56292ee1ad021df3e9ac792e2f..968edbf1a581c2700df7de9ec9019646975e7d1b 100644 --- a/helm/api/values.yaml +++ b/helm/api/values.yaml @@ -29,7 +29,7 @@ secrets: image: - repository: lcr.gr/jetsam/api + repository: registry.digitalocean.com/jetsam/api pullPolicy: IfNotPresent # Overrides the image tag whose default is the chart appVersion. tag: "latest" diff --git a/package-lock.json b/package-lock.json index 30b24c68da48da9de382646c64625af504175933..a2d9da7a5c3db490c61358954473d62e9a429355 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jetsam-api", - "version": "2.0.0-beta.2", + "version": "2.2.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "jetsam-api", - "version": "2.0.0-beta.2", + "version": "2.2.2", "license": "GPL-3.0+", "dependencies": { "@commander-lol/vault-client": "^0.1.1", @@ -59,6 +59,7 @@ "yargs": "^13.3.2" }, "devDependencies": { + "hasura-cli": "^2.0.9", "jest": "^26.6.3", "nodemon": "^2.0.4", "prettier": "^2.2.1", @@ -4778,6 +4779,23 @@ "resolved": "https://registry.npmjs.org/hash-stream-validation/-/hash-stream-validation-0.2.4.tgz", "integrity": "sha512-Gjzu0Xn7IagXVkSu9cSFuK1fqzwtLwFhNhVL8IFJijRNMgUttFbBSIAzKuSIrsFMO1+g1RlsoN49zPIbwPDMGQ==" }, + "node_modules/hasura-cli": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/hasura-cli/-/hasura-cli-2.0.9.tgz", + "integrity": "sha512-95xAxNFfF1nntncULGKGQ9UEbhEWsgcMHdqOLsreq9E1emh2CVu1xuY/WezGMaCe1D4ZII7HxSQZBIhdnF9vKg==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "axios": "^0.21.1", + "chalk": "^2.4.2" + }, + "bin": { + "hasura": "hasura" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/header-case": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.3.tgz", @@ -16123,6 +16141,16 @@ "resolved": "https://registry.npmjs.org/hash-stream-validation/-/hash-stream-validation-0.2.4.tgz", "integrity": "sha512-Gjzu0Xn7IagXVkSu9cSFuK1fqzwtLwFhNhVL8IFJijRNMgUttFbBSIAzKuSIrsFMO1+g1RlsoN49zPIbwPDMGQ==" }, + "hasura-cli": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/hasura-cli/-/hasura-cli-2.0.9.tgz", + "integrity": "sha512-95xAxNFfF1nntncULGKGQ9UEbhEWsgcMHdqOLsreq9E1emh2CVu1xuY/WezGMaCe1D4ZII7HxSQZBIhdnF9vKg==", + "dev": true, + "requires": { + "axios": "^0.21.1", + "chalk": "^2.4.2" + } + }, "header-case": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.3.tgz", diff --git a/package.json b/package.json index 59878e97d640c0fcbfb6dcc52083841f5c1d51b5..126ac92971e250bee18721b0ca37c6ebaed234f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jetsam-api", - "version": "2.2.0", + "version": "2.2.2", "description": "The Jetsam App API Server", "main": "server.js", "scripts": { @@ -68,6 +68,7 @@ "yargs": "^13.3.2" }, "devDependencies": { + "hasura-cli": "^2.0.9", "jest": "^26.6.3", "nodemon": "^2.0.4", "prettier": "^2.2.1", diff --git a/public/css/jetsam.css b/public/css/jetsam.css index 17beca4d722f6a0aac94281da44160a75b6e970c..0dce446709ed754e0bd4319a690da62e151b8bf8 100644 --- a/public/css/jetsam.css +++ b/public/css/jetsam.css @@ -12,6 +12,11 @@ --colour-dark-grey: #697077; --colour-light-grey: #DDE1E6; + --colour-success: #48BB78; + --colour-success-dark: #25855A; + --colour-danger: #F56565; + --colour-danger-dark: #C53030; + --padding-block: 4rem; --gradient-jetsam: linear-gradient(135deg, var(--colour-jetsam-light) 0%, var(--colour-jetsam-dark) 100%); @@ -34,7 +39,7 @@ body { line-height: 19px; } -p { +h1, h2, h3, h4, h5, p { margin: 0; } @@ -71,6 +76,10 @@ body, .font-body { font-size: 12px; line-height: 14px; } +.text.head { + font-size: 32px; + line-height: 38px; +} .text.bold { font-weight: 500; } @@ -158,6 +167,12 @@ body, .font-body { background-color: var(--colour-light-grey); transition: background-color 0.1s linear; } + +.vertical-block { + padding-top: 10px; + padding-bottom: 10px; +} + .form .control input:focus { border-color: var(--colour-jetsam); background-color: var(--colour-white); @@ -189,6 +204,34 @@ body, .font-body { background-color: var(--colour-jetsam-dark); } +.button.success { + color: var(--colour-white); + background-color: var(--colour-success); +} +.button.success:hover { + background-color: var(--colour-success-dark); +} + +.button.danger { + color: var(--colour-white); + background-color: var(--colour-danger); +} +.button.danger:hover { + background-color: var(--colour-danger-dark); +} + +.content { + width: 100%; + max-width: var(--breakpoint-small); + border-radius: 12px; + align-self: center; + padding: 20px; +} + +.content.focus { + background-color: var(--colour-white); +} + @media (max-width: 720px) { :root { --padding-block: 2rem; @@ -197,4 +240,8 @@ body, .font-body { .split-container { flex-direction: column; } + + .focus-content { + border-radius: 0; + } } \ No newline at end of file diff --git a/src/config/app.js b/src/config/app.js index 28b9fe507d3a5c6da03213a893a68da93ddc16fb..6cdbb052505bb40da2517c53f3d3513f682dac51 100644 --- a/src/config/app.js +++ b/src/config/app.js @@ -17,6 +17,11 @@ module.exports = { public_key_b64: env('RSA_PUBLIC_KEY_B64', null), private_key: null, private_key_b64: env('RSA_PRIVATE_KEY_B64', null), + key_id: env('JWK_KEY_ID', null), + super_auth_clients: env('ADMIN_AUTH_CLIENTS', '') + .split(',') + .map(s => s.trim()) + .filter(Boolean) }, } diff --git a/src/core/utils/jwt.js b/src/core/utils/jwt.js index 8c4ba3480365c33f395fb4d4cfeabf044a785591..13832755faa97c5a3afd81e3284765090f4f6199 100644 --- a/src/core/utils/jwt.js +++ b/src/core/utils/jwt.js @@ -75,6 +75,7 @@ exports.loadKeys = async () => { exports.sign = async payload => { const threadContext = require('core/injection/ThreadContext') + const { config } = require('bootstrap') const { default: SignJWT } = require('jose/jwt/sign') const { priv } = exports.getKeys() @@ -82,7 +83,7 @@ exports.sign = async payload => { new SignJWT(payload) .setIssuer(exports.jwtOptions.issuer) .setIssuedAt() - .setProtectedHeader({ alg: 'RS256' }) + .setProtectedHeader({ alg: 'RS256', kid: exports.jwtOptions.keyid_prefix + config('app.security.key_id') }) .sign(priv), ) } @@ -99,6 +100,12 @@ exports.verify = async token => { }) } +exports.getClaims = tokenPayload => { + return tokenPayload[exports.jwtOptions.claims] +} + exports.jwtOptions = { issuer: 'urn:jetsam:systems:auth', + claims: 'urn:jetsam:resources:claims', + keyid_prefix: 'urn:jetsam:jwk:' } diff --git a/src/core/utils/urls.js b/src/core/utils/urls.js index ff3b8329d7a6ddafd418a4522469583dd1dead0b..2f04912890771a49b43ce85b2bf5d97f0eb04604 100644 --- a/src/core/utils/urls.js +++ b/src/core/utils/urls.js @@ -1,6 +1,8 @@ const { URL } = require('url') const qs = require('querystring') const { unset, config } = require('bootstrap') +const crypto = require('core/utils/crypto') +const {report} = require("../../http/middleware/SentryReporter"); exports.createUrl = (host, path, query = unset) => { const url = new URL(path, host) @@ -18,3 +20,59 @@ exports.queryValueToArray = (value = '') => .split(',') .map(s => s.trim()) .filter(Boolean) + +const redirectPairs = [ + ['authorize', '/auth/authorize'] +] + +const nameToUrl = redirectPairs.reduce((cur, [k, v]) => ({ ...cur, [k]: v }), {}) +const urlToName = redirectPairs.reduce((cur, [k, v]) => ({ ...cur, [v]: k }), {}) + +exports.createRedirectState = async ctx => { + const path = ctx.path + const redirect = urlToName[path] ?? '/' + const query = ctx.request.query ?? {} + return crypto.encrypt(JSON.stringify({ redirect, query })) +} + +exports.parseRedirectState = async ctx => { + const state = ctx.request?.query?.login_state + if (!state) { + return { + path: '/', + redirect: 'missing', + query: {} + } + } + + try { + const raw = await crypto.decrypt(state) + const value = JSON.parse(raw) + return { + ...value, + path: nameToUrl[value.redirect] ?? '/', + } + } catch(e) { + console.error(e) + await report(e, ctx) + } + + return { + path: '/', + redirect: 'missing', + query: {} + } +} + +exports.createRedirectedUrl = async (ctx) => { + const values = await exports.parseRedirectState(ctx) + const params = new URLSearchParams() + + console.log(values, params) + + Object.entries(values.query).forEach(([key, value]) => { + params.set(key, value) + }) + + return `${ values.path }?${ params.toString() }` +} \ No newline at end of file diff --git a/src/database/models/OAuthClient.js b/src/database/models/OAuthClient.js index dd6ad12764961561f9f35212fa03d3bfafc2a5ab..5eaa4d320f7645e1ea3bad60fdb51bf134613ce0 100644 --- a/src/database/models/OAuthClient.js +++ b/src/database/models/OAuthClient.js @@ -14,11 +14,13 @@ class OAuthClient extends BaseModel { this.hasMany(models.RefreshToken, { foreignKey: 'client_id' }) } - static async generateClient(userId, internal = false) { + static async generateClient(userId, params = {}, internal = false) { const crypto = require('core/utils/crypto') const secret = await crypto.secureHexString(32) return this.create({ owner_id: userId, + name: params.name ?? '', + description: params.description ?? '', secret, grant_types: ['authorization_code', 'refresh_token'], internal, diff --git a/src/database/models/Survey.js b/src/database/models/Survey.js index 8595c71a546a2955f199af130cb655e8e6420cc6..dbe253de1053f673195a60087c8d68261d072254 100644 --- a/src/database/models/Survey.js +++ b/src/database/models/Survey.js @@ -29,6 +29,8 @@ class Survey extends BaseModel { const value = this.toJSON() if (this.SurveyUser) { value.answers = this.SurveyUser.toJSON().properties + } else if (this.SurveyUsers?.length === 1) { + value.answers = this.SurveyUsers[0].toJSON().properties } return value } diff --git a/src/database/models/User.js b/src/database/models/User.js index 03e22efa8aec64d735c027970cff92211716dc37..25566ecf94b4e36a9dd14f99eb1a25503c5e2feb 100644 --- a/src/database/models/User.js +++ b/src/database/models/User.js @@ -1,5 +1,6 @@ const timestamps = require('./properties/timestamps') const BaseModel = require('./BaseModel') +const {jwtOptions, getClaims} = require("../../core/utils/jwt"); class User extends BaseModel { static associate(models) { @@ -59,7 +60,12 @@ class User extends BaseModel { static async idFromJwt(token) { const { verify } = require('core/utils/jwt') const payload = await verify(token) - return payload.session.id + + if (payload.session) { + return payload.session.id + } else { + return getClaims(payload)?.['user-id'] + } } static async idFromOpaque(token) { @@ -116,9 +122,10 @@ class User extends BaseModel { const { sign } = require('core/utils/jwt') const roles = await this.getAuthRoles() return await sign({ - session: { - id: this.id, - roles, + [jwtOptions.claims]: { + 'user-id': this.id, + 'default-role': roles[0], + 'allowed-roles': roles, }, ...extras, }) diff --git a/src/domain/auth/AuthServer.js b/src/domain/auth/AuthServer.js index 6a52adc0161a9b909d66ed71aba19a503340fbad..fed5552c8c8dc0c999c15dc921c5e43b3807fbd5 100644 --- a/src/domain/auth/AuthServer.js +++ b/src/domain/auth/AuthServer.js @@ -24,7 +24,7 @@ function createTokenPair(user, client, access, refresh = {}) { async function createClientEncryptedToken(client, userModel, scope) { const user = await User.findByPk(userModel.id) - const payload = user.asJWTToken({ cid: client.id, scope }) + const payload = user.asJWTToken({ sub: user.id, aud: [client.id], azp: client.id, scope }) return payload } @@ -200,14 +200,12 @@ class KoaOAuthServer { this.authorize = async ctx => { const OAuthFlow = require('./OAuthFlow') const flow = await OAuthFlow.initialiseFlow(ctx) - const { user, redirect } = flow + const { user, redirect, action } = flow - if (!user) { - return ctx.redirect(`/login?auth_state=${redirect}`) - } else if (ctx.method === 'GET') { + if (ctx.method === 'GET') { return await OAuthFlow.showOAuthConsent(ctx, flow) } else { - if (ctx.request.query.action === 'deny') { + if (action === 'deny') { return await OAuthFlow.handleConsentRejection(ctx, flow) } return await OAuthFlow.handleConsentAcceptance(ctx, flow, this) @@ -225,6 +223,7 @@ class KoaOAuthServer { this.token = async ctx => { const { req, res } = this.transformContext(ctx) + console.log(ctx.request.query) console.log(ctx.request.body) await authServer.token(req, res, { @@ -235,8 +234,6 @@ class KoaOAuthServer { ctx.response.set(name, value) } - console.log(res.body) - ctx.response.status = res.status ctx.response.body = res.body } diff --git a/src/domain/auth/OAuthFlow.js b/src/domain/auth/OAuthFlow.js index fa6af3032a4fedb67679f42c08be9ab2b04dd749..665c76227e31dd78896139727191346421c30051 100644 --- a/src/domain/auth/OAuthFlow.js +++ b/src/domain/auth/OAuthFlow.js @@ -1,6 +1,8 @@ const { OAuthClient } = require('database/models') const HttpError = require('core/errors/HttpError') const crypto = require('core/utils/crypto') +const {config} = require("bootstrap"); +const debug = require('debug')('server:oauth:flow') /** * Retrieve the correct query value and format auth state for an oauth request @@ -11,21 +13,19 @@ exports.initialiseFlow = async ctx => { const user = await ctx.services['core.auth'].getUser() let baseQuery = ctx.request.query - let queryState = null - - console.log(baseQuery) + let queryState = baseQuery if (baseQuery.auth_state) { queryState = JSON.parse(await crypto.decrypt(baseQuery.auth_state)) if (queryState.query) { queryState = queryState.query + } else { + queryState = null } } const action = baseQuery.action - console.log({ redirect: 'authorize', query: baseQuery }) - const redirectState = await crypto.encrypt( JSON.stringify({ redirect: 'authorize', query: baseQuery }), ) @@ -40,16 +40,32 @@ exports.initialiseFlow = async ctx => { } exports.showOAuthConsent = async (ctx, queryState) => { - const { user, query, redirect } = queryState + let { user, query, redirect } = queryState + + if (query.auth_state) { + try { + const realState = JSON.parse(await crypto.decrypt(query.auth_state)) + query = realState.query + } catch(e) { + console.error(e) + throw new HttpError(500, 'Bad auth state') + } + } + + const [scopes, isPrivileged] = describeScopeRequest(query.scope) + + if (isPrivileged) { + const allowed = new Set(config('app.security.super_auth_clients')) + if (!allowed.has(query.client_id)) { + throw new HttpError(401, 'This app is not allowed to request certain permissions') + } + } 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(client) return ctx.render('auth/accept-oauth', { user, @@ -59,8 +75,20 @@ exports.showOAuthConsent = async (ctx, queryState) => { }) } +async function getOauthQueryFromFlow(flow) { + let { state: queryState } = flow + + if (queryState.auth_state) { + const rawQueryState = await crypto.decrypt(queryState.auth_state) + queryState = JSON.parse(rawQueryState).query + } + + return queryState +} + exports.handleConsentRejection = async (ctx, flow) => { - const { redirect_uri } = flow.query + const queryState = await getOauthQueryFromFlow(flow) + const { redirect_uri } = queryState const redirect = new URL(redirect_uri, 'http://localhost') const search = new URLSearchParams(redirect.searchParams) @@ -69,20 +97,18 @@ exports.handleConsentRejection = async (ctx, flow) => { 'error_description', 'The user has denied the requested permissions', ) + if (queryState.state) { + search.set('state', queryState.state) + } redirect.search = search.toString() - ctx.set('Location', redirect.toString()) - ctx.status = 302 - ctx.body = null + return await ctx.redirect(redirect.toString(), 302) } exports.handleConsentAcceptance = async (ctx, flow, server) => { - const { state: queryState } = flow - if (!queryState.state) { - queryState.state = crypto.insecureHexString(32) - } - + const queryState = await getOauthQueryFromFlow(flow) const { req, res } = server.transformContext(ctx, { query: queryState }) + await server.getAuthServer().authorize(req, res, { authenticateHandler: { handle() { @@ -93,6 +119,7 @@ exports.handleConsentAcceptance = async (ctx, flow, server) => { for (const [name, value] of Object.entries(res.headers)) { ctx.response.set(name, value) } + ctx.response.status = res.status } @@ -103,6 +130,18 @@ const scopeDescriptionMap = { description: 'Full access to your account, including the ability to create, update and delete any user information, metrics and files.', }, + 'admin': { + icon: 'admin', + name: 'Admin System Access', + description: + 'Access to Jetsam backend data. Access requests for this permission are logged, and will only be granted to privileged users', + }, + 'openid': { + icon: 'openid', + name: 'Open ID Connect', + description: + 'Use your Jetsam account to log in to a third party services. This will provide the third party with your name (if provided), your email address and your Jetsam account ID.' + }, 'metrics:create': { name: 'Create Metrics', description: @@ -133,7 +172,32 @@ const scopeDescriptionMap = { 'The ability to see information about your account stats, including your points and citizen scientist level', }, } + +/** + * @typedef {object} ScopeDescription + * @property {string} name + * @property {string} description + */ + +/** + * @param scope + * @return {[ScopeDescription[], boolean]} + */ function describeScopeRequest(scope = '*') { - const scopes = scope.split(' ') - return scopes.map(s => scopeDescriptionMap[s]).filter(Boolean) + let hasAdminRequest = false + + console.log(scope) + let scopes = [] + if (scope.includes(',')) { + scopes = scope.split(',') + } else { + scopes = scope.split(' ') + } + + return [scopes.map(s => { + if (s.startsWith('admin') || s === '*') { + hasAdminRequest = true + } + return scopeDescriptionMap[s]; + }).filter(Boolean), hasAdminRequest] } diff --git a/src/domain/data/MetricsService.js b/src/domain/data/MetricsService.js index 8aa9027bb87d283988597cd0ff8e435394821635..6775c843d5c443488867d21435e9ff1476f1ec32 100644 --- a/src/domain/data/MetricsService.js +++ b/src/domain/data/MetricsService.js @@ -18,7 +18,7 @@ module.exports = class MetricsService extends ContextualModule { async recordMetric(value, type, location, meta = {}, transaction) { const user = await this.ctx.services['core.auth'].getUser() - const surveys = await user.getSurveys({ + const surveys = user ? await user.getSurveys({ where: { expires_at: { [Sequelize.Op.gt]: moment.utc().toISOString() @@ -28,7 +28,7 @@ module.exports = class MetricsService extends ContextualModule { }, public: true, }, - }) + }) : [] if (surveys.length > 0) { meta.surveys = surveys.map(s => s.id) diff --git a/src/http/controllers/api/content.js b/src/http/controllers/api/content.js index c688964594d169f2f912cdcb31ca3d810cb1eb33..1f126df9f4a691a6499f3268974f72223aad764c 100644 --- a/src/http/controllers/api/content.js +++ b/src/http/controllers/api/content.js @@ -144,7 +144,5 @@ exports.getWithin = async ctx => { toDate.toISOString(), ) - - console.log(metrics) ctx.body = { metrics } } diff --git a/src/http/controllers/api/oauth.js b/src/http/controllers/api/oauth.js index 643b2cdb93007e935918f21d4f42b00966f240f8..d8c99c11d8723f39c24d90f44d1f94c29acf5019 100644 --- a/src/http/controllers/api/oauth.js +++ b/src/http/controllers/api/oauth.js @@ -7,10 +7,11 @@ exports.createClient = async ctx => { throw new HttpError(403, 'You must be logged in to create an OAuth client') } - const client = await OAuthClient.generateClient(user.id) + const { name, description } = ctx.request.body + const client = await OAuthClient.generateClient(user.id, { name, description }) ctx.body = { - client: client.toOAuthInterface(), + client: client.toJSON(), } } exports.listClients = async ctx => { @@ -22,7 +23,7 @@ exports.listClients = async ctx => { const clients = await user.getOAuthClients() ctx.body = { - clients: clients.map(c => c.toOAuthInterface()), + clients: clients.map(c => c.toJSON()), } } @@ -50,7 +51,7 @@ exports.addClientRedirect = async ctx => { await client.save() } - ctx.body = { client: client.toOAuthInterface() } + ctx.body = { client: client.toJSON() } } exports.removeClientRedirect = async ctx => { @@ -77,5 +78,5 @@ exports.removeClientRedirect = async ctx => { await client.save() } - ctx.body = { client: client.toOAuthInterface() } + ctx.body = { client: client.toJSON() } } diff --git a/src/http/controllers/api/v2/surveys.js b/src/http/controllers/api/v2/surveys.js index 8a4251a025d3892e1f18b7cebcd222691e839b76..07127a5e9b9db3bed7fb359bd2f9ac3ed251092f 100644 --- a/src/http/controllers/api/v2/surveys.js +++ b/src/http/controllers/api/v2/surveys.js @@ -5,6 +5,17 @@ const HttpError = require("../../../../core/errors/HttpError"); const {Survey, SurveyUser, User, sequelize} = require('database/models') const {QueryTypes} = require('sequelize') +exports.get = async ctx => { + const {survey} = ctx.models + if (survey) { + ctx.body = { + survey: survey.asOwnSurvey() + } + } else { + throw new NotFoundError('Survey') + } +} + exports.list = async ctx => { const {Sequelize, Survey} = require('database/models') const user = await ctx.services['core.auth'].getUser() @@ -28,7 +39,6 @@ exports.list = async ctx => { public: true, }, include: includes - }, { }) ctx.body = { @@ -70,8 +80,6 @@ exports.join = async ctx => { const {survey} = ctx.models const {properties = {}} = ctx.request.body - console.log(survey.properties, properties) - if (Object.values(survey.properties).length !== Object.values(properties).length) { throw new HttpError(400, `Must provide values for all properties. Expecting: [${Object.keys(survey.properties).join(', ')}]; Found [${Object.keys(properties).join(', ')}] `) } @@ -144,8 +152,6 @@ exports.leave = async ctx => { type: QueryTypes.DELETE }) - console.log(result) - ctx.body = { success: true } diff --git a/src/http/controllers/auth.js b/src/http/controllers/auth.js index 2b5b170be0d90deb20076e6a206873f4e7536293..1284e3b269e41f6d0745c92c0a958b705642467f 100644 --- a/src/http/controllers/auth.js +++ b/src/http/controllers/auth.js @@ -1,5 +1,6 @@ const crypto = require('core/utils/crypto') const moment = require('moment') +const {createRedirectedUrl} = require("../../core/utils/urls"); exports.login = async ctx => { const { email, password } = ctx.request.body @@ -12,25 +13,42 @@ exports.logout = async ctx => { return ctx.redirect('/') } -exports.handleLoginRedirect = async ctx => { - const query = ctx.request.query - - if (query.login_state || query.auth_state) { - const { login_state, auth_state } = ctx.request.query - const state = login_state ?? auth_state - - const values = JSON.parse(await crypto.decrypt(state)) - - if (values.redirect === 'authorize') { - return ctx.redirect( - `/auth/authorize?auth_state=${state}`, - ) - } else { - return ctx.redirect('/') - } - } else { - return ctx.redirect('/') +exports.showLogin = async ctx => { + const data = {} + const state = new URLSearchParams() + if (ctx.query.login_state) { + state.set('login_state', ctx.query.login_state) + } + if (ctx.query.auth_state) { + state.set('auth_state', ctx.query.auth_state) } + + data.state_string = `?${ state.toString() }` + return ctx.render('auth/login', data) +} + +exports.handleLoginRedirect = async ctx => { + const redirectTo = await createRedirectedUrl(ctx) + return ctx.redirect(redirectTo) + + // const query = ctx.request.query + // + // if (query.login_state || query.auth_state) { + // const { login_state, auth_state } = ctx.request.query + // const state = login_state ?? auth_state + // + // const values = JSON.parse(await crypto.decrypt(state)) + // + // if (values.redirect === 'authorize') { + // return ctx.redirect( + // `/auth/authorize?auth_state=${state}`, + // ) + // } else { + // return ctx.redirect('/') + // } + // } else { + // return ctx.redirect('/') + // } } const resetErrorMessages = { diff --git a/src/http/middleware/RedirectToLogin.js b/src/http/middleware/RedirectToLogin.js new file mode 100644 index 0000000000000000000000000000000000000000..52925552876724d82cceb4ec395d6885ef022c83 --- /dev/null +++ b/src/http/middleware/RedirectToLogin.js @@ -0,0 +1,12 @@ +const {createRedirectState} = require("../../core/utils/urls"); + +module.exports = async (ctx, next) => { + const user = await ctx.services['core.auth'].getUser() + + if (user) { + return await next() + } + + const redirectState = await createRedirectState(ctx) + return ctx.redirect(`/login?login_state=${ redirectState }`) +} diff --git a/src/http/params/survey.js b/src/http/params/survey.js index 0545c818110c7b4e00b6f5a3842aa187f7206b21..92ded5d8e02be21c1f5280ff39c08f028dc2ec2e 100644 --- a/src/http/params/survey.js +++ b/src/http/params/survey.js @@ -1,7 +1,37 @@ -const { Survey } = require('database/models') +const { Survey, SurveyUser, Sequelize} = require('database/models') +const moment = require("moment"); module.exports = async (id, ctx, next) => { ctx.models = ctx.models ?? {} - ctx.models.survey = await Survey.findByPk(id) + + const user = await ctx.services['core.auth'].getUser() + + let includes = user ? [{ + model: SurveyUser, + required: false, + where: { + user_id: user.id, + } + }] : [] + + console.log("INCLUDES", includes) + + + ctx.models.survey = await Survey.findOne({ + where: { + id, + expires_at: { + [Sequelize.Op.gt]: moment.utc().toISOString() + }, + published_at: { + [Sequelize.Op.lt]: moment.utc().toISOString() + }, + public: true, + }, + include: includes + }) + + console.log(ctx.models.survey) + return await next() } diff --git a/src/http/routers/routes_v2.js b/src/http/routers/routes_v2.js index be47d9492453703a9cf3ee9eb1d108644266dce0..fc6db7be393b145036e43450caeab180fad6e7b6 100644 --- a/src/http/routers/routes_v2.js +++ b/src/http/routers/routes_v2.js @@ -53,6 +53,7 @@ router.put('/uploads/:upload_id/:property', noop) router.param('survey', param('survey')) router.get('/surveys', controller('api/v2/surveys', 'list')) +router.get('/surveys/:survey', controller('api/v2/surveys', 'get')) router.post('/surveys/:survey/membership', controller('api/v2/surveys', 'join')) router.delete('/surveys/:survey/membership', controller('api/v2/surveys', 'leave')) if (config('app.dev')) { diff --git a/src/http/routes.js b/src/http/routes.js index f88661129bf8c783e9d09654db50b1619b94a48d..f268250e40587a65367a59ed96462775360e14dd 100644 --- a/src/http/routes.js +++ b/src/http/routes.js @@ -15,6 +15,7 @@ const includes = require('http/middleware/ParseIncludes') const profiling = require('http/middleware/Profiler') const loaders = require('http/middleware/MountLoaders') const userGate = require('http/middleware/RequiresAuth') +const authRedirect = require('http/middleware/RedirectToLogin') const device = require('http/middleware/DeviceProperties').extractDevice const v2 = require('./routers/routes_v2') @@ -47,26 +48,14 @@ web.use(device) web.use(well_known.allowedMethods()) web.use(well_known.routes()) -web.get('/login', ctx => { - const data = {} - const state = new URLSearchParams() - if (ctx.query.login_state) { - state.set('login_state', ctx.query.login_state) - } - if (ctx.query.auth_state) { - state.set('auth_state', ctx.query.auth_state) - } - - data.state_string = `?${ state.toString() }` - return ctx.render('auth/login', data) -}) -web.get('/logout', controller('auth', 'logout')) +web.get('/login', controller('auth', 'showLogin')) web.post('/login', controller('auth', 'login')) +web.get('/logout', controller('auth', 'logout')) web.get('/reset-password', controller('auth', 'resetPassword')) web.post('/reset-password', controller('auth', 'handleResetPassword')) -web.get('/auth/authorize', AuthServer.authorize) +web.get('/auth/authorize', authRedirect, AuthServer.authorize) web.post('/auth/authorize', AuthServer.authorize) web.post('/auth/token', AuthServer.token) env('FS_DRIVER', 'local') === 'local' && diff --git a/views/auth/accept-oauth.hbs b/views/auth/accept-oauth.hbs index fcc291fc304ef4e10cef8677e07291980f76c300..7b1433a8d91cb71cc65b70ae4ec36144187c2563 100644 --- a/views/auth/accept-oauth.hbs +++ b/views/auth/accept-oauth.hbs @@ -16,27 +16,40 @@ </style> </head> <body> - <div class="max-w-lg rounded overflow-hidden bg-white shadow"> - {{#if client.name }}<h1 class="p-2 text-xl">{{ client.name }}</h1>{{/if}} - {{#if client.description }}<p class="p-2">{{ client.description }}</p>{{/if}} - <p class="p-2 border-t border-b border-solid border-gray-400"> +<div class="bg-jetsam column center-content"> + <div class="row space-1 content"> + <img src="/logo.png" width="75px"> + <h1 class="font-brand text head light">Jetsam</h1> + </div> + <div class="content focus"> + {{#if client.name }}<h1 class="text lg">{{ client.name }} wants to access your Jetsam account</h1>{{/if}} + {{#if client.description }}<p class="vertical-block text md">{{ client.description }}</p>{{/if}} + <p class="vertical-block text md italic"> {{#if client.name}}{{client.name}}{{else}}An application{{/if}} is requesting access to the following - permissions. Please carefully read what each does before accepting or rejecting this request. Only accept this + permissions. Please carefully read what each does before accepting or rejecting this request. Only accept + this request for access if you trust the application. </p> - <ul class="p-2"> - {{#each scopes}} - <li><strong>{{ this.name }}</strong> - {{ this.description }}</li> - {{/each}} + <ul class="vertical-block"> + {{#each scopes}} + <li class="vertical-block"><strong class="text md bold">{{ this.name }}</strong> - {{ this.description }}</li> + {{/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={{redirect}}"> - <button type="submit" class="bg-green-500 hover:bg-green-600 text-white shadow hover:shadow-md active:shadow">Accept</button> + <div class="row space-4 center-content"> + <form method="post" + action="/auth/authorize?action=accept&auth_state={{redirect}}"> + <button type="submit" + class="button success">Accept + </button> </form> - <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 method="post" + action="/auth/authorize?action=deny&auth_state={{redirect}}"> + <button type="submit" class="button danger"> + Reject + </button> </form> </div> </div> - </body> -</html> \ No newline at end of file +</div> +</body> +</html>