diff --git a/.github/values.yaml b/.github/values.yaml index 5fd38b031..819df5066 100644 --- a/.github/values.yaml +++ b/.github/values.yaml @@ -1,11 +1,5 @@ -##### -# INFISICAL K8 DEFAULT VALUES FILE -# PLEASE REPLACE VALUES/EDIT AS REQUIRED -##### - -nameOverride: "" - frontend: + enabled: true name: frontend podAnnotations: {} deploymentAnnotations: @@ -13,17 +7,18 @@ frontend: replicaCount: 2 image: repository: infisical/frontend - pullPolicy: Always tag: "latest" + pullPolicy: Always kubeSecretRef: managed-secret-frontend service: - # type of the frontend service - type: ClusterIP - # define the nodePort if service type is NodePort - # nodePort: annotations: {} + type: ClusterIP + nodePort: "" + +frontendEnvironmentVariables: null backend: + enabled: true name: backend podAnnotations: {} deploymentAnnotations: @@ -31,63 +26,46 @@ backend: replicaCount: 2 image: repository: infisical/backend - pullPolicy: Always tag: "latest" + pullPolicy: Always kubeSecretRef: managed-backend-secret service: annotations: {} + type: ClusterIP + nodePort: "" +backendEnvironmentVariables: null + +## Mongo DB persistence mongodb: - name: mongodb - podAnnotations: {} - image: - repository: mongo - pullPolicy: IfNotPresent - tag: "latest" - service: - annotations: {} + enabled: true + persistence: + enabled: false -# By default the backend will be connected to a Mongo instance in the cluster. -# However, it is recommended to add a managed document DB connection string because the DB instance in the cluster does not have persistence yet ( data will be deleted on next deploy). -# Learn about connection string type here https://www.mongodb.com/docs/manual/reference/connection-string/ -mongodbConnection: {} - # externalMongoDBConnectionString: <> +## By default the backend will be connected to a Mongo instance within the cluster +## However, it is recommended to add a managed document DB connection string for production-use (DBaaS) +## Learn about connection string type here https://www.mongodb.com/docs/manual/reference/connection-string/ +## e.g. "mongodb://:@:/" +mongodbConnection: + externalMongoDBConnectionString: "" ingress: enabled: true annotations: kubernetes.io/ingress.class: "nginx" - hostName: gamma.infisical.com # replace with your domain - frontend: + # cert-manager.io/issuer: letsencrypt-nginx + hostName: gamma.infisical.com ## <- Replace with your own domain + frontend: path: / pathType: Prefix backend: path: /api pathType: Prefix - tls: [] + tls: + [] + # - secretName: letsencrypt-nginx + # hosts: + # - infisical.local - -## Complete Ingress example -# ingress: -# enabled: true -# annotations: -# kubernetes.io/ingress.class: "nginx" -# cert-manager.io/issuer: letsencrypt-nginx -# hostName: k8.infisical.com -# frontend: -# path: / -# pathType: Prefix -# backend: -# path: /api -# pathType: Prefix -# tls: -# - secretName: letsencrypt-nginx -# hosts: -# - k8.infisical.com - -### -### YOU MUST FILL IN ALL SECRETS BELOW -### -backendEnvironmentVariables: {} - -frontendEnvironmentVariables: {} +mailhog: + enabled: false diff --git a/.github/workflows/release_build.yml b/.github/workflows/release_build.yml index 3d11a1157..af395ce6c 100644 --- a/.github/workflows/release_build.yml +++ b/.github/workflows/release_build.yml @@ -4,7 +4,7 @@ on: push: # run only against tags tags: - - 'v*' + - "v*" permissions: contents: write @@ -18,11 +18,16 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 + - name: 🐋 Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - run: git fetch --force --tags - run: echo "Ref name ${{github.ref_name}}" - uses: actions/setup-go@v3 with: - go-version: '>=1.19.3' + go-version: ">=1.19.3" cache: true cache-dependency-path: cli/go.sum - name: libssl1.1 => libssl1.0-dev for OSXCross @@ -45,8 +50,7 @@ jobs: AUR_KEY: ${{ secrets.AUR_KEY }} - uses: actions/setup-python@v4 - run: pip install --upgrade cloudsmith-cli - - name: Publish to CloudSmith + - name: Publish to CloudSmith run: sh cli/upload_to_cloudsmith.sh env: CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} - diff --git a/.goreleaser.yaml b/.goreleaser.yaml index fc39224aa..dfcacf59d 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -68,10 +68,10 @@ archives: release: replace_existing_draft: true - mode: 'replace' + mode: "replace" checksum: - name_template: 'checksums.txt' + name_template: "checksums.txt" snapshot: name_template: "{{ incpatch .Version }}-devel" @@ -80,8 +80,8 @@ changelog: sort: asc filters: exclude: - - '^docs:' - - '^test:' + - "^docs:" + - "^test:" # publishers: # - name: fury.io @@ -109,30 +109,30 @@ brews: man1.install "manpages/infisical.1.gz" nfpms: -- id: infisical - package_name: infisical - builds: - - all-other-builds - vendor: Infisical, Inc - homepage: https://infisical.com/ - maintainer: Infisical, Inc - description: The offical Infisical CLI - license: MIT - formats: - - rpm - - deb - - apk - - archlinux - bindir: /usr/bin - contents: - - src: ./completions/infisical.bash - dst: /etc/bash_completion.d/infisical - - src: ./completions/infisical.fish - dst: /usr/share/fish/vendor_completions.d/infisical.fish - - src: ./completions/infisical.zsh - dst: /usr/share/zsh/site-functions/_infisical - - src: ./manpages/infisical.1.gz - dst: /usr/share/man/man1/infisical.1.gz + - id: infisical + package_name: infisical + builds: + - all-other-builds + vendor: Infisical, Inc + homepage: https://infisical.com/ + maintainer: Infisical, Inc + description: The offical Infisical CLI + license: MIT + formats: + - rpm + - deb + - apk + - archlinux + bindir: /usr/bin + contents: + - src: ./completions/infisical.bash + dst: /etc/bash_completion.d/infisical + - src: ./completions/infisical.fish + dst: /usr/share/fish/vendor_completions.d/infisical.fish + - src: ./completions/infisical.zsh + dst: /usr/share/zsh/site-functions/_infisical + - src: ./manpages/infisical.1.gz + dst: /usr/share/man/man1/infisical.1.gz scoop: bucket: @@ -146,15 +146,14 @@ scoop: license: MIT aurs: - - - name: infisical-bin + - name: infisical-bin homepage: "https://infisical.com" description: "The official Infisical CLI" maintainers: - Infisical, Inc license: MIT - private_key: '{{ .Env.AUR_KEY }}' - git_url: 'ssh://aur@aur.archlinux.org/infisical-bin.git' + private_key: "{{ .Env.AUR_KEY }}" + git_url: "ssh://aur@aur.archlinux.org/infisical-bin.git" package: |- # bin install -Dm755 "./infisical" "${pkgdir}/usr/bin/infisical" @@ -169,19 +168,13 @@ aurs: install -Dm644 "./completions/infisical.fish" "${pkgdir}/usr/share/fish/vendor_completions.d/infisical.fish" # man pages install -Dm644 "./manpages/infisical.1.gz" "${pkgdir}/usr/share/man/man1/infisical.1.gz" + # dockers: -# - dockerfile: goreleaser.dockerfile +# - dockerfile: cli/docker/Dockerfile # goos: linux # goarch: amd64 # ids: # - infisical # image_templates: -# - "infisical/cli:{{ .Version }}" -# - "infisical/cli:{{ .Major }}.{{ .Minor }}" -# - "infisical/cli:{{ .Major }}" +# - "infisical/cli:{{ .Version }}" # - "infisical/cli:latest" -# build_flag_templates: -# - "--label=org.label-schema.schema-version=1.0" -# - "--label=org.label-schema.version={{.Version}}" -# - "--label=org.label-schema.name={{.ProjectName}}" -# - "--platform=linux/amd64" \ No newline at end of file diff --git a/README.md b/README.md index 64d643e7f..791a2f2b2 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ infisical

-

Open-source, E2EE, simple tool to manage secrets and configs across your team and infrastructure.

+

Open-source, end-to-end encrypted tool to manage secrets and configs across your team, devices, and infrastructure.

@@ -16,7 +16,7 @@

- Medusa is released under the MIT license. + Infisical is released under the MIT license. PRs welcome! @@ -25,7 +25,7 @@ git commit activity - Cloudsmith downloads + Cloudsmith downloads Slack community channel @@ -37,13 +37,12 @@ Dashboard - -**Read this in other languages**: [English language](i18n/README.en.md) +**Read this in other languages**: [English language](i18n/README.en.md) [Spanish language](i18n/README.es.md) [Korean language](i18n/README.ko.md) [Turkish language](i18n/README.tr.md) -**[Infisical](https://infisical.com)** is an open source, E2EE tool to help teams manage and sync secrets and configs across their development workflow and infrastructure. It's designed to be simple and take minutes to get going. +**[Infisical](https://infisical.com)** is an open source, end-to-end encrypted secret manager which you can use to centralize your API keys and configs. From Infisical, you can then distribute these secrets across your whole developemnt lifecycle - from development to production . It's designed to be simple and take minutes to get going. - **[User-Friendly Dashboard](https://infisical.com/docs/getting-started/dashboard/project)** to manage your team's secrets and configs within projects - **[Language-Agnostic CLI](https://infisical.com/docs/cli/overview)** that pulls and injects esecrets and configs into your local workflow @@ -56,9 +55,11 @@ - **[Activity Logs](https://infisical.com/docs/getting-started/dashboard/audit-logs)** to record every action taken in a project - **[Point-in-time Secrets Recovery](https://infisical.com/docs/getting-started/dashboard/pit-recovery)** for rolling back to any snapshot of your secrets - **Role-based Access Controls** per environment -- 🔜 **2FA** (next week) -- 🔜 **1-Click Deploy** to Digital Ocean and Heroku +- **2FA** (more options coming soon) +- 🔜 **1-Click Deploy** to AWS - 🔜 **Automatic Secret Rotation** +- 🔜 **Smart Security Alerts** +- 🔜 **Secrets Rotation** - 🔜 **Slack & MS Teams** integrations And more. @@ -68,9 +69,8 @@ And more. To quickly get started, visit our [get started guide](https://infisical.com/docs/getting-started/introduction).

- - - + +

## 🔥 What's cool about this? @@ -83,29 +83,7 @@ If you care about efficiency and security, then Infisical is right for you. We are currently working hard to make Infisical more extensive. Need any integrations or want a new feature? Feel free to [create an issue](https://github.com/Infisical/infisical/issues) or [contribute](https://infisical.com/docs/contributing/overview) directly to the repository. -## 🌱 Contributing - -Whether it's big or small, we love contributions ❤️ Check out our guide to see how to [get started](https://infisical.com/docs/contributing/overview). - -Not sure where to get started? You can: - -- [Book a free, non-pressure pairing sessions with one of our teammates](mailto:tony@infisical.com?subject=Pairing%20session&body=I'd%20like%20to%20do%20a%20pairing%20session!)! -- Join our Slack, and ask us any questions there. - -## 💚 Community & Support - -- [Slack](https://join.slack.com/t/infisical-users/shared_invite/zt-1kdbk07ro-RtoyEt_9E~fyzGo_xQYP6g) (For live discussion with the community and the Infisical team) -- [GitHub Discussions](https://github.com/Infisical/infisical/discussions) (For help with building and deeper conversations about features) -- [GitHub Issues](https://github.com/Infisical/infisical-cli/issues) (For any bugs and errors you encounter using Infisical) -- [Twitter](https://twitter.com/infisical) (Get news fast) - -## 🌄 Status - -- [x] Public Alpha: Anyone can sign up over at [infisical.com](https://infisical.com) but there are some minor kinks. -- [ ] Public Beta: Stable enough for most non-enterprise use-cases. -- [ ] Public: Production-ready. - -## �🔌 Integrations +## 🔌 Integrations We're currently setting the foundation and building [integrations](https://infisical.com/docs/integrations/overview) so secrets can be synced everywhere. Any help is welcome! :) @@ -155,7 +133,7 @@ We're currently setting the foundation and building [integrations](https://infis - 🔜 AWS PS (https://github.com/Infisical/infisical/issues/286) + 🔜 Supabase @@ -195,8 +173,8 @@ We're currently setting the foundation and building [integrations](https://infis 🔜 TravisCI - - ✔️ Netlify + + ✔️ AWS Secrets Manager @@ -208,7 +186,9 @@ We're currently setting the foundation and building [integrations](https://infis 🔜 Bitbucket - 🔜 Supabase + + ✔️ AWS Parameter Store + @@ -224,7 +204,9 @@ We're currently setting the foundation and building [integrations](https://infis 🔜 Serverless - 🔜 AWS Lambda + + ✔️ Netlify + @@ -336,6 +318,13 @@ We're currently setting the foundation and building [integrations](https://infis +## 💚 Community & Support + +- [Slack](https://join.slack.com/t/infisical-users/shared_invite/zt-1kdbk07ro-RtoyEt_9E~fyzGo_xQYP6g) (For live discussion with the community and the Infisical team) +- [GitHub Discussions](https://github.com/Infisical/infisical/discussions) (For help with building and deeper conversations about features) +- [GitHub Issues](https://github.com/Infisical/infisical-cli/issues) (For any bugs and errors you encounter using Infisical) +- [Twitter](https://twitter.com/infisical) (Get news fast) + ## 🏘 Open-source vs. paid This repo is entirely MIT licensed, with the exception of the `ee` directory which will contain premium enterprise features requiring a Infisical license in the future. We're currently focused on developing non-enterprise offerings first that should suit most use-cases. @@ -350,6 +339,15 @@ Infisical officially launched as v.1.0 on November 21st, 2022. There are a lot o ![infisical-star-github](https://github.com/Infisical/infisical/blob/main/.github/images/star-infisical.gif?raw=true) +## 🌱 Contributing + +Whether it's big or small, we love contributions ❤️ Check out our guide to see how to [get started](https://infisical.com/docs/contributing/overview). + +Not sure where to get started? You can: + +- [Book a free, non-pressure pairing sessions with one of our teammates](mailto:tony@infisical.com?subject=Pairing%20session&body=I'd%20like%20to%20do%20a%20pairing%20session!)! +- Join our Slack, and ask us any questions there. + ## 🦸 Contributors [//]: contributor-faces @@ -358,7 +356,7 @@ Infisical officially launched as v.1.0 on November 21st, 2022. There are a lot o - + ## 🌎 Translations diff --git a/backend/src/app.ts b/backend/src/app.ts index cf44804e9..550cf08f6 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line @typescript-eslint/no-var-requires const { patchRouterParam } = require('./utils/patchAsyncRoutes'); -import express, { Request, Response } from 'express'; +import express from 'express'; import helmet from 'helmet'; import cors from 'cors'; import cookieParser from 'cookie-parser'; @@ -42,6 +42,8 @@ import { integrationAuth as v1IntegrationAuthRouter } from './routes/v1'; import { + signup as v2SignupRouter, + auth as v2AuthRouter, users as v2UsersRouter, organizations as v2OrganizationsRouter, workspace as v2WorkspaceRouter, @@ -110,6 +112,8 @@ app.use('/api/v1/integration', v1IntegrationRouter); app.use('/api/v1/integration-auth', v1IntegrationAuthRouter); // v2 routes +app.use('/api/v2/signup', v2SignupRouter); +app.use('/api/v2/auth', v2AuthRouter); app.use('/api/v2/users', v2UsersRouter); app.use('/api/v2/organizations', v2OrganizationsRouter); app.use('/api/v2/workspace', v2EnvironmentRouter); diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index ad1acb1a6..c0c9988f0 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,9 +1,12 @@ const PORT = process.env.PORT || 4000; -const EMAIL_TOKEN_LIFETIME = process.env.EMAIL_TOKEN_LIFETIME! || '86400'; +const EMAIL_TOKEN_LIFETIME = parseInt(process.env.EMAIL_TOKEN_LIFETIME! || '86400'); +const INVITE_ONLY_SIGNUP = process.env.INVITE_ONLY_SIGNUP == undefined ? false : process.env.INVITE_ONLY_SIGNUP const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY!; const SALT_ROUNDS = parseInt(process.env.SALT_ROUNDS!) || 10; const JWT_AUTH_LIFETIME = process.env.JWT_AUTH_LIFETIME! || '10d'; const JWT_AUTH_SECRET = process.env.JWT_AUTH_SECRET!; +const JWT_MFA_LIFETIME = process.env.JWT_MFA_LIFETIME! || '5m'; +const JWT_MFA_SECRET = process.env.JWT_MFA_SECRET!; const JWT_REFRESH_LIFETIME = process.env.JWT_REFRESH_LIFETIME! || '90d'; const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!; const JWT_SERVICE_SECRET = process.env.JWT_SERVICE_SECRET!; @@ -24,7 +27,7 @@ const CLIENT_SECRET_HEROKU = process.env.CLIENT_SECRET_HEROKU!; const CLIENT_SECRET_VERCEL = process.env.CLIENT_SECRET_VERCEL!; const CLIENT_SECRET_NETLIFY = process.env.CLIENT_SECRET_NETLIFY!; const CLIENT_SECRET_GITHUB = process.env.CLIENT_SECRET_GITHUB!; -const CLIENT_SLUG_VERCEL= process.env.CLIENT_SLUG_VERCEL!; +const CLIENT_SLUG_VERCEL = process.env.CLIENT_SLUG_VERCEL!; const POSTHOG_HOST = process.env.POSTHOG_HOST! || 'https://app.posthog.com'; const POSTHOG_PROJECT_API_KEY = process.env.POSTHOG_PROJECT_API_KEY! || @@ -50,10 +53,13 @@ const LICENSE_KEY = process.env.LICENSE_KEY!; export { PORT, EMAIL_TOKEN_LIFETIME, + INVITE_ONLY_SIGNUP, ENCRYPTION_KEY, SALT_ROUNDS, JWT_AUTH_LIFETIME, JWT_AUTH_SECRET, + JWT_MFA_LIFETIME, + JWT_MFA_SECRET, JWT_REFRESH_LIFETIME, JWT_REFRESH_SECRET, JWT_SERVICE_SECRET, diff --git a/backend/src/controllers/v1/authController.ts b/backend/src/controllers/v1/authController.ts index 6e2de53b3..e329b15c7 100644 --- a/backend/src/controllers/v1/authController.ts +++ b/backend/src/controllers/v1/authController.ts @@ -5,7 +5,8 @@ import * as Sentry from '@sentry/node'; import * as bigintConversion from 'bigint-conversion'; const jsrp = require('jsrp'); import { User, LoginSRPDetail } from '../../models'; -import { createToken, issueTokens, clearTokens } from '../../helpers/auth'; +import { createToken, issueAuthTokens, clearTokens } from '../../helpers/auth'; +import { checkUserDevice } from '../../helpers/user'; import { ACTION_LOGIN, ACTION_LOGOUT @@ -111,7 +112,14 @@ export const login2 = async (req: Request, res: Response) => { // compare server and client shared keys if (server.checkClientProof(clientProof)) { // issue tokens - const tokens = await issueTokens({ userId: user._id.toString() }); + + await checkUserDevice({ + user, + ip: req.ip, + userAgent: req.headers['user-agent'] ?? '' + }); + + const tokens = await issueAuthTokens({ userId: user._id.toString() }); // store (refresh) token in httpOnly cookie res.cookie('jid', tokens.refreshToken, { diff --git a/backend/src/controllers/v1/membershipOrgController.ts b/backend/src/controllers/v1/membershipOrgController.ts index 0040b583e..aa3022eac 100644 --- a/backend/src/controllers/v1/membershipOrgController.ts +++ b/backend/src/controllers/v1/membershipOrgController.ts @@ -1,14 +1,13 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; -import crypto from 'crypto'; import { SITE_URL, JWT_SIGNUP_LIFETIME, JWT_SIGNUP_SECRET } from '../../config'; -import { MembershipOrg, Organization, User, Token } from '../../models'; +import { MembershipOrg, Organization, User } from '../../models'; import { deleteMembershipOrg as deleteMemberFromOrg } from '../../helpers/membershipOrg'; -import { checkEmailVerification } from '../../helpers/signup'; import { createToken } from '../../helpers/auth'; import { updateSubscriptionOrgQuantity } from '../../helpers/organization'; import { sendMail } from '../../helpers/nodemailer'; -import { OWNER, ADMIN, MEMBER, ACCEPTED, INVITED } from '../../variables'; +import { TokenService } from '../../services'; +import { OWNER, ADMIN, MEMBER, ACCEPTED, INVITED, TOKEN_EMAIL_ORG_INVITATION } from '../../variables'; /** * Delete organization membership with id [membershipOrgId] from organization @@ -113,14 +112,14 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => { if (!membershipOrg) { throw new Error('Failed to validate organization membership'); } - + invitee = await User.findOne({ email: inviteeEmail }).select('+publicKey'); if (invitee) { // case: invitee is an existing user - + inviteeMembershipOrg = await MembershipOrg.findOne({ user: invitee._id, organization: organizationId @@ -163,17 +162,11 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => { const organization = await Organization.findOne({ _id: organizationId }); if (organization) { - const token = crypto.randomBytes(16).toString('hex'); - - await Token.findOneAndUpdate( - { email: inviteeEmail }, - { - email: inviteeEmail, - token, - createdAt: new Date() - }, - { upsert: true, new: true } - ); + const token = await TokenService.createToken({ + type: TOKEN_EMAIL_ORG_INVITATION, + email: inviteeEmail, + organizationId: organization._id + }); await sendMail({ template: 'organizationInvitation.handlebars', @@ -225,10 +218,12 @@ export const verifyUserToOrganization = async (req: Request, res: Response) => { if (!membershipOrg) throw new Error('Failed to find any invitations for email'); - - await checkEmailVerification({ + + await TokenService.validateToken({ + type: TOKEN_EMAIL_ORG_INVITATION, email, - code + organizationId: membershipOrg.organization, + token: code }); if (user && user?.publicKey) { @@ -241,7 +236,7 @@ export const verifyUserToOrganization = async (req: Request, res: Response) => { message: 'Successfully verified email', user, }); - } + } if (!user) { // initialize user account diff --git a/backend/src/controllers/v1/organizationController.ts b/backend/src/controllers/v1/organizationController.ts index 6fbccffeb..66326e560 100644 --- a/backend/src/controllers/v1/organizationController.ts +++ b/backend/src/controllers/v1/organizationController.ts @@ -14,11 +14,13 @@ import { MembershipOrg, Organization, Workspace, - IncidentContactOrg + IncidentContactOrg, + IMembershipOrg } from '../../models'; import { createOrganization as create } from '../../helpers/organization'; import { addMembershipsOrg } from '../../helpers/membershipOrg'; import { OWNER, ACCEPTED } from '../../variables'; +import _ from 'lodash'; export const getOrganizations = async (req: Request, res: Response) => { let organizations; @@ -382,3 +384,44 @@ export const getOrganizationSubscriptions = async ( subscriptions }); }; + + +/** + * Given a org id, return the projects each member of the org belongs to + * @param req + * @param res + * @returns + */ +export const getOrganizationMembersAndTheirWorkspaces = async ( + req: Request, + res: Response +) => { + const { organizationId } = req.params; + + const workspacesSet = ( + await Workspace.find( + { + organization: organizationId + }, + '_id' + ) + ).map((w) => w._id.toString()); + + const memberships = ( + await Membership.find({ + workspace: { $in: workspacesSet } + }).populate('workspace') + ); + const userToWorkspaceIds: any = {}; + + memberships.forEach(membership => { + const user = membership.user.toString(); + if (userToWorkspaceIds[user]) { + userToWorkspaceIds[user].push(membership.workspace); + } else { + userToWorkspaceIds[user] = [membership.workspace]; + } + }); + + return res.json(userToWorkspaceIds); +}; \ No newline at end of file diff --git a/backend/src/controllers/v1/passwordController.ts b/backend/src/controllers/v1/passwordController.ts index 0c5530f58..63d2b9184 100644 --- a/backend/src/controllers/v1/passwordController.ts +++ b/backend/src/controllers/v1/passwordController.ts @@ -1,14 +1,14 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; -import crypto from 'crypto'; // eslint-disable-next-line @typescript-eslint/no-var-requires const jsrp = require('jsrp'); import * as bigintConversion from 'bigint-conversion'; -import { User, Token, BackupPrivateKey, LoginSRPDetail } from '../../models'; -import { checkEmailVerification } from '../../helpers/signup'; +import { User, BackupPrivateKey, LoginSRPDetail } from '../../models'; import { createToken } from '../../helpers/auth'; import { sendMail } from '../../helpers/nodemailer'; +import { TokenService } from '../../services'; import { JWT_SIGNUP_LIFETIME, JWT_SIGNUP_SECRET, SITE_URL } from '../../config'; +import { TOKEN_EMAIL_PASSWORD_RESET } from '../../variables'; import { BadRequestError } from '../../utils/errors'; /** @@ -31,19 +31,12 @@ export const emailPasswordReset = async (req: Request, res: Response) => { error: 'Failed to send email verification for password reset' }); } - - const token = crypto.randomBytes(16).toString('hex'); - - await Token.findOneAndUpdate( - { email }, - { - email, - token, - createdAt: new Date() - }, - { upsert: true, new: true } - ); - + + const token = await TokenService.createToken({ + type: TOKEN_EMAIL_PASSWORD_RESET, + email + }); + await sendMail({ template: 'passwordReset.handlebars', subjectLine: 'Infisical password reset', @@ -54,7 +47,6 @@ export const emailPasswordReset = async (req: Request, res: Response) => { callback_url: SITE_URL + '/password-reset' } }); - } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -87,10 +79,11 @@ export const emailPasswordResetVerify = async (req: Request, res: Response) => { error: 'Failed email verification for password reset' }); } - - await checkEmailVerification({ + + await TokenService.validateToken({ + type: TOKEN_EMAIL_PASSWORD_RESET, email, - code + token: code }); // generate temporary password-reset token @@ -173,8 +166,18 @@ export const srp1 = async (req: Request, res: Response) => { */ export const changePassword = async (req: Request, res: Response) => { try { - const { clientProof, encryptedPrivateKey, iv, tag, salt, verifier } = - req.body; + const { + clientProof, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier + } = req.body; + const user = await User.findOne({ email: req.user.email }).select('+salt +verifier'); @@ -204,9 +207,13 @@ export const changePassword = async (req: Request, res: Response) => { await User.findByIdAndUpdate( req.user._id.toString(), { + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, encryptedPrivateKey, - iv, - tag, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, salt, verifier }, @@ -340,9 +347,12 @@ export const getBackupPrivateKey = async (req: Request, res: Response) => { export const resetPassword = async (req: Request, res: Response) => { try { const { + protectedKey, + protectedKeyIV, + protectedKeyTag, encryptedPrivateKey, - iv, - tag, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, verifier, } = req.body; @@ -350,9 +360,13 @@ export const resetPassword = async (req: Request, res: Response) => { await User.findByIdAndUpdate( req.user._id.toString(), { + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, encryptedPrivateKey, - iv, - tag, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, salt, verifier }, diff --git a/backend/src/controllers/v1/signupController.ts b/backend/src/controllers/v1/signupController.ts index 62e5a62a3..1c5c9e298 100644 --- a/backend/src/controllers/v1/signupController.ts +++ b/backend/src/controllers/v1/signupController.ts @@ -1,16 +1,13 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; -import { NODE_ENV, JWT_SIGNUP_LIFETIME, JWT_SIGNUP_SECRET } from '../../config'; -import { User, MembershipOrg } from '../../models'; -import { completeAccount } from '../../helpers/user'; +import { User } from '../../models'; +import { JWT_SIGNUP_LIFETIME, JWT_SIGNUP_SECRET, INVITE_ONLY_SIGNUP } from '../../config'; import { sendEmailVerification, checkEmailVerification, - initializeDefaultOrg } from '../../helpers/signup'; -import { issueTokens, createToken } from '../../helpers/auth'; -import { INVITED, ACCEPTED } from '../../variables'; -import axios from 'axios'; +import { createToken } from '../../helpers/auth'; +import { BadRequestError } from '../../utils/errors'; /** * Signup step 1: Initialize account for user under email [email] and send a verification code @@ -24,6 +21,14 @@ export const beginEmailSignup = async (req: Request, res: Response) => { try { email = req.body.email; + if (INVITE_ONLY_SIGNUP) { + // Only one user can create an account without being invited. The rest need to be invited in order to make an account + const userCount = await User.countDocuments({}) + if (userCount != 0) { + throw BadRequestError({ message: "New user sign ups are not allowed at this time. You must be invited to sign up." }) + } + } + const user = await User.findOne({ email }).select('+publicKey'); if (user && user?.publicKey) { // case: user has already completed account @@ -103,201 +108,3 @@ export const verifyEmailSignup = async (req: Request, res: Response) => { token }); }; - -/** - * Complete setting up user by adding their personal and auth information as part of the - * signup flow - * @param req - * @param res - * @returns - */ -export const completeAccountSignup = async (req: Request, res: Response) => { - let user, token, refreshToken; - try { - const { - email, - firstName, - lastName, - publicKey, - encryptedPrivateKey, - iv, - tag, - salt, - verifier, - organizationName - } = req.body; - - // get user - user = await User.findOne({ email }); - - if (!user || (user && user?.publicKey)) { - // case 1: user doesn't exist. - // case 2: user has already completed account - return res.status(403).send({ - error: 'Failed to complete account for complete user' - }); - } - - // complete setting up user's account - user = await completeAccount({ - userId: user._id.toString(), - firstName, - lastName, - publicKey, - encryptedPrivateKey, - iv, - tag, - salt, - verifier - }); - - if (!user) - throw new Error('Failed to complete account for non-existent user'); // ensure user is non-null - - // initialize default organization and workspace - await initializeDefaultOrg({ - organizationName, - user - }); - - // update organization membership statuses that are - // invited to completed with user attached - await MembershipOrg.updateMany( - { - inviteEmail: email, - status: INVITED - }, - { - user, - status: ACCEPTED - } - ); - - // issue tokens - const tokens = await issueTokens({ - userId: user._id.toString() - }); - - token = tokens.token; - refreshToken = tokens.refreshToken; - - // sending a welcome email to new users - if (process.env.LOOPS_API_KEY) { - await axios.post("https://app.loops.so/api/v1/events/send", { - "email": email, - "eventName": "Sign Up", - "firstName": firstName, - "lastName": lastName - }, { - headers: { - "Accept": "application/json", - "Authorization": "Bearer " + process.env.LOOPS_API_KEY - }, - }); - } - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to complete account setup' - }); - } - - return res.status(200).send({ - message: 'Successfully set up account', - user, - token, - refreshToken - }); -}; -/** - * Complete setting up user by adding their personal and auth information as part of the - * invite flow - * @param req - * @param res - * @returns - */ -export const completeAccountInvite = async (req: Request, res: Response) => { - let user, token, refreshToken; - try { - const { - email, - firstName, - lastName, - publicKey, - encryptedPrivateKey, - iv, - tag, - salt, - verifier - } = req.body; - - // get user - user = await User.findOne({ email }); - - if (!user || (user && user?.publicKey)) { - // case 1: user doesn't exist. - // case 2: user has already completed account - return res.status(403).send({ - error: 'Failed to complete account for complete user' - }); - } - - const membershipOrg = await MembershipOrg.findOne({ - inviteEmail: email, - status: INVITED - }); - - if (!membershipOrg) throw new Error('Failed to find invitations for email'); - - // complete setting up user's account - user = await completeAccount({ - userId: user._id.toString(), - firstName, - lastName, - publicKey, - encryptedPrivateKey, - iv, - tag, - salt, - verifier - }); - - if (!user) - throw new Error('Failed to complete account for non-existent user'); - - // update organization membership statuses that are - // invited to completed with user attached - await MembershipOrg.updateMany( - { - inviteEmail: email, - status: INVITED - }, - { - user, - status: ACCEPTED - } - ); - - // issue tokens - const tokens = await issueTokens({ - userId: user._id.toString() - }); - - token = tokens.token; - refreshToken = tokens.refreshToken; - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to complete account setup' - }); - } - - return res.status(200).send({ - message: 'Successfully set up account', - user, - token, - refreshToken - }); -}; diff --git a/backend/src/controllers/v2/authController.ts b/backend/src/controllers/v2/authController.ts new file mode 100644 index 000000000..95a1613d2 --- /dev/null +++ b/backend/src/controllers/v2/authController.ts @@ -0,0 +1,351 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import { Request, Response } from 'express'; +import jwt from 'jsonwebtoken'; +import * as Sentry from '@sentry/node'; +import * as bigintConversion from 'bigint-conversion'; +const jsrp = require('jsrp'); +import { User, LoginSRPDetail } from '../../models'; +import { issueAuthTokens, createToken } from '../../helpers/auth'; +import { checkUserDevice } from '../../helpers/user'; +import { sendMail } from '../../helpers/nodemailer'; +import { TokenService } from '../../services'; +import { EELogService } from '../../ee/services'; +import { + NODE_ENV, + JWT_MFA_LIFETIME, + JWT_MFA_SECRET +} from '../../config'; +import { BadRequestError, InternalServerError } from '../../utils/errors'; +import { + TOKEN_EMAIL_MFA, + ACTION_LOGIN +} from '../../variables'; +import { getChannelFromUserAgent } from '../../utils/posthog'; // TODO: move this + +declare module 'jsonwebtoken' { + export interface UserIDJwtPayload extends jwt.JwtPayload { + userId: string; + } +} + +const clientPublicKeys: any = {}; + +/** + * Log in user step 1: Return [salt] and [serverPublicKey] as part of step 1 of SRP protocol + * @param req + * @param res + * @returns + */ +export const login1 = async (req: Request, res: Response) => { + try { + const { + email, + clientPublicKey + }: { email: string; clientPublicKey: string } = req.body; + + const user = await User.findOne({ + email + }).select('+salt +verifier'); + + if (!user) throw new Error('Failed to find user'); + + const server = new jsrp.server(); + server.init( + { + salt: user.salt, + verifier: user.verifier + }, + async () => { + // generate server-side public key + const serverPublicKey = server.getPublicKey(); + + await LoginSRPDetail.findOneAndReplace({ email: email }, { + email: email, + clientPublicKey: clientPublicKey, + serverBInt: bigintConversion.bigintToBuf(server.bInt), + }, { upsert: true, returnNewDocument: false }); + + return res.status(200).send({ + serverPublicKey, + salt: user.salt + }); + } + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to start authentication process' + }); + } +}; + +/** + * Log in user step 2: complete step 2 of SRP protocol and return token and their (encrypted) + * private key + * @param req + * @param res + * @returns + */ +export const login2 = async (req: Request, res: Response) => { + try { + + if (!req.headers['user-agent']) throw InternalServerError({ message: 'User-Agent header is required' }); + + const { email, clientProof } = req.body; + const user = await User.findOne({ + email + }).select('+salt +verifier +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag +publicKey +encryptedPrivateKey +iv +tag'); + + if (!user) throw new Error('Failed to find user'); + + const loginSRPDetail = await LoginSRPDetail.findOneAndDelete({ email: email }) + + if (!loginSRPDetail) { + return BadRequestError(Error("Failed to find login details for SRP")) + } + + const server = new jsrp.server(); + server.init( + { + salt: user.salt, + verifier: user.verifier, + b: loginSRPDetail.serverBInt + }, + async () => { + server.setClientPublicKey(loginSRPDetail.clientPublicKey); + + // compare server and client shared keys + if (server.checkClientProof(clientProof)) { + + if (user.isMfaEnabled) { + // case: user has MFA enabled + + // generate temporary MFA token + const token = createToken({ + payload: { + userId: user._id.toString() + }, + expiresIn: JWT_MFA_LIFETIME, + secret: JWT_MFA_SECRET + }); + + const code = await TokenService.createToken({ + type: TOKEN_EMAIL_MFA, + email + }); + + // send MFA code [code] to [email] + await sendMail({ + template: 'emailMfa.handlebars', + subjectLine: 'Infisical MFA code', + recipients: [email], + substitutions: { + code + } + }); + + return res.status(200).send({ + mfaEnabled: true, + token + }); + } + + await checkUserDevice({ + user, + ip: req.ip, + userAgent: req.headers['user-agent'] ?? '' + }); + + // issue tokens + const tokens = await issueAuthTokens({ userId: user._id.toString() }); + + // store (refresh) token in httpOnly cookie + res.cookie('jid', tokens.refreshToken, { + httpOnly: true, + path: '/', + sameSite: 'strict', + secure: NODE_ENV === 'production' ? true : false + }); + + // case: user does not have MFA enablgged + // return (access) token in response + + interface ResponseData { + mfaEnabled: boolean; + encryptionVersion: any; + protectedKey?: string; + protectedKeyIV?: string; + protectedKeyTag?: string; + token: string; + publicKey?: string; + encryptedPrivateKey?: string; + iv?: string; + tag?: string; + } + + const response: ResponseData = { + mfaEnabled: false, + encryptionVersion: user.encryptionVersion, + token: tokens.token, + publicKey: user.publicKey, + encryptedPrivateKey: user.encryptedPrivateKey, + iv: user.iv, + tag: user.tag + } + + if ( + user?.protectedKey && + user?.protectedKeyIV && + user?.protectedKeyTag + ) { + response.protectedKey = user.protectedKey; + response.protectedKeyIV = user.protectedKeyIV + response.protectedKeyTag = user.protectedKeyTag; + } + + const loginAction = await EELogService.createAction({ + name: ACTION_LOGIN, + userId: user._id + }); + + loginAction && await EELogService.createLog({ + userId: user._id, + actions: [loginAction], + channel: getChannelFromUserAgent(req.headers['user-agent']), + ipAddress: req.ip + }); + + return res.status(200).send(response); + } + + return res.status(400).send({ + message: 'Failed to authenticate. Try again?' + }); + } + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to authenticate. Try again?' + }); + } +}; + +/** + * Send MFA token to email [email] + * @param req + * @param res + */ +export const sendMfaToken = async (req: Request, res: Response) => { + try { + const { email } = req.body; + + const code = await TokenService.createToken({ + type: TOKEN_EMAIL_MFA, + email + }); + + // send MFA code [code] to [email] + await sendMail({ + template: 'emailMfa.handlebars', + subjectLine: 'Infisical MFA code', + recipients: [email], + substitutions: { + code + } + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to send MFA code' + }); + } + + return res.status(200).send({ + message: 'Successfully sent new MFA code' + }); +} + +/** + * Verify MFA token [mfaToken] and issue JWT and refresh tokens if the + * MFA token [mfaToken] is valid + * @param req + * @param res + */ +export const verifyMfaToken = async (req: Request, res: Response) => { + const { email, mfaToken } = req.body; + + await TokenService.validateToken({ + type: TOKEN_EMAIL_MFA, + email, + token: mfaToken + }); + + const user = await User.findOne({ + email + }).select('+salt +verifier +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag +publicKey +encryptedPrivateKey +iv +tag'); + + if (!user) throw new Error('Failed to find user'); + + await checkUserDevice({ + user, + ip: req.ip, + userAgent: req.headers['user-agent'] ?? '' + }); + + // issue tokens + const tokens = await issueAuthTokens({ userId: user._id.toString() }); + + // store (refresh) token in httpOnly cookie + res.cookie('jid', tokens.refreshToken, { + httpOnly: true, + path: '/', + sameSite: 'strict', + secure: NODE_ENV === 'production' ? true : false + }); + + interface VerifyMfaTokenRes { + encryptionVersion: number; + protectedKey?: string; + protectedKeyIV?: string; + protectedKeyTag?: string; + token: string; + publicKey: string; + encryptedPrivateKey: string; + iv: string; + tag: string; + } + + const resObj: VerifyMfaTokenRes = { + encryptionVersion: user.encryptionVersion, + token: tokens.token, + publicKey: user.publicKey as string, + encryptedPrivateKey: user.encryptedPrivateKey as string, + iv: user.iv as string, + tag: user.tag as string + } + + if (user?.protectedKey && user?.protectedKeyIV && user?.protectedKeyTag) { + resObj.protectedKey = user.protectedKey; + resObj.protectedKeyIV = user.protectedKeyIV; + resObj.protectedKeyTag = user.protectedKeyTag; + } + + const loginAction = await EELogService.createAction({ + name: ACTION_LOGIN, + userId: user._id + }); + + loginAction && await EELogService.createLog({ + userId: user._id, + actions: [loginAction], + channel: getChannelFromUserAgent(req.headers['user-agent']), + ipAddress: req.ip + }); + + return res.status(200).send(resObj); +} + diff --git a/backend/src/controllers/v2/environmentController.ts b/backend/src/controllers/v2/environmentController.ts index 7a0d5e1c5..b82dca9fe 100644 --- a/backend/src/controllers/v2/environmentController.ts +++ b/backend/src/controllers/v2/environmentController.ts @@ -246,13 +246,14 @@ export const getAllAccessibleEnvironmentsOfWorkspace = async ( relatedWorkspace.environments.forEach(environment => { const isReadBlocked = _.some(deniedPermission, { environmentSlug: environment.slug, ability: ABILITY_READ }) const isWriteBlocked = _.some(deniedPermission, { environmentSlug: environment.slug, ability: ABILITY_WRITE }) - if (isReadBlocked) { + if (isReadBlocked && isWriteBlocked) { return } else { accessibleEnvironments.push({ name: environment.name, slug: environment.slug, - isWriteDenied: isWriteBlocked + isWriteDenied: isWriteBlocked, + isReadDenied: isReadBlocked }) } }) diff --git a/backend/src/controllers/v2/index.ts b/backend/src/controllers/v2/index.ts index 3183ac60f..d266ace3f 100644 --- a/backend/src/controllers/v2/index.ts +++ b/backend/src/controllers/v2/index.ts @@ -1,3 +1,5 @@ +import * as authController from './authController'; +import * as signupController from './signupController'; import * as usersController from './usersController'; import * as organizationsController from './organizationsController'; import * as workspaceController from './workspaceController'; @@ -9,6 +11,8 @@ import * as environmentController from './environmentController'; import * as tagController from './tagController'; export { + authController, + signupController, usersController, organizationsController, workspaceController, diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index 7267ae50c..c42dcb28b 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -1,7 +1,8 @@ import to from 'await-to-js'; import { Types } from 'mongoose'; import { Request, Response } from 'express'; -import { ISecret, Membership, Secret, Workspace } from '../../models'; +import { ISecret, Secret } from '../../models'; +import { IAction } from '../../ee/models'; import { SECRET_PERSONAL, SECRET_SHARED, @@ -17,7 +18,255 @@ import { EESecretService, EELogService } from '../../ee/services'; import { postHogClient } from '../../services'; import { getChannelFromUserAgent } from '../../utils/posthog'; import { ABILITY_READ, ABILITY_WRITE } from '../../variables/organization'; -import { userHasWorkspaceAccess } from '../../ee/helpers/checkMembershipPermissions'; +import { userHasNoAbility, userHasWorkspaceAccess, userHasWriteOnlyAbility } from '../../ee/helpers/checkMembershipPermissions'; +import Tag from '../../models/tag'; +import _ from 'lodash'; +import { + BatchSecretRequest, + BatchSecret +} from '../../types/secret'; + +/** + * Peform a batch of any specified CUD secret operations + * @param req + * @param res + */ +export const batchSecrets = async (req: Request, res: Response) => { + const channel = getChannelFromUserAgent(req.headers['user-agent']); + const { + workspaceId, + environment, + requests + }: { + workspaceId: string; + environment: string; + requests: BatchSecretRequest[]; + }= req.body; + + const createSecrets: BatchSecret[] = []; + const updateSecrets: BatchSecret[] = []; + const deleteSecrets: Types.ObjectId[] = []; + const actions: IAction[] = []; + + requests.forEach((request) => { + switch (request.method) { + case 'POST': + createSecrets.push({ + ...request.secret, + version: 1, + user: request.secret.type === SECRET_PERSONAL ? req.user : undefined, + environment, + workspace: new Types.ObjectId(workspaceId) + }); + break; + case 'PATCH': + updateSecrets.push({ + ...request.secret, + _id: new Types.ObjectId(request.secret._id) + }); + break; + case 'DELETE': + deleteSecrets.push(new Types.ObjectId(request.secret._id)); + break; + } + }); + + // handle create secrets + let createdSecrets: ISecret[] = []; + if (createSecrets.length > 0) { + createdSecrets = await Secret.insertMany(createSecrets); + // (EE) add secret versions for new secrets + await EESecretService.addSecretVersions({ + secretVersions: createdSecrets.map((n: any) => { + return ({ + ...n._doc, + _id: new Types.ObjectId(), + secret: n._id, + isDeleted: false + }); + }) + }); + + const addAction = await EELogService.createAction({ + name: ACTION_ADD_SECRETS, + userId: req.user._id, + workspaceId: new Types.ObjectId(workspaceId), + secretIds: createdSecrets.map((n) => n._id) + }) as IAction; + actions.push(addAction); + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets added', + distinctId: req.user.email, + properties: { + numberOfSecrets: createdSecrets.length, + environment, + workspaceId, + channel, + userAgent: req.headers?.['user-agent'] + } + }); + } + } + + // handle update secrets + let updatedSecrets: ISecret[] = []; + if (updateSecrets.length > 0 && req.secrets) { + // construct object containing all secrets + let listedSecretsObj: { + [key: string]: { + version: number; + type: string; + } + } = {}; + + listedSecretsObj = req.secrets.reduce((obj: any, secret: ISecret) => ({ + ...obj, + [secret._id.toString()]: secret + }), {}); + + const updateOperations = updateSecrets.map((u) => ({ + updateOne: { + filter: { _id: new Types.ObjectId(u._id) }, + update: { + $inc: { + version: 1 + }, + ...u, + _id: new Types.ObjectId(u._id) + } + } + })); + + await Secret.bulkWrite(updateOperations); + + const secretVersions = updateSecrets.map((u) => ({ + secret: new Types.ObjectId(u._id), + version: listedSecretsObj[u._id.toString()].version, + workspace: new Types.ObjectId(workspaceId), + type: listedSecretsObj[u._id.toString()].type, + environment, + isDeleted: false, + secretKeyCiphertext: u.secretKeyCiphertext, + secretKeyIV: u.secretKeyIV, + secretKeyTag: u.secretKeyTag, + secretValueCiphertext: u.secretValueCiphertext, + secretValueIV: u.secretValueIV, + secretValueTag: u.secretValueTag, + secretCommentCiphertext: u.secretCommentCiphertext, + secretCommentIV: u.secretCommentIV, + secretCommentTag: u.secretCommentTag, + tags: u.tags + })); + + await EESecretService.addSecretVersions({ + secretVersions + }); + + updatedSecrets = await Secret.find({ + _id: { + $in: updateSecrets.map((u) => new Types.ObjectId(u._id)) + } + }); + + const updateAction = await EELogService.createAction({ + name: ACTION_UPDATE_SECRETS, + userId: req.user._id, + workspaceId: new Types.ObjectId(workspaceId), + secretIds: updatedSecrets.map((u) => u._id) + }) as IAction; + actions.push(updateAction); + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets modified', + distinctId: req.user.email, + properties: { + numberOfSecrets: updateSecrets.length, + environment, + workspaceId, + channel, + userAgent: req.headers?.['user-agent'] + } + }); + } + } + + // handle delete secrets + if (deleteSecrets.length > 0) { + await Secret.deleteMany({ + _id: { + $in: deleteSecrets + } + }); + + await EESecretService.markDeletedSecretVersions({ + secretIds: deleteSecrets + }); + + const deleteAction = await EELogService.createAction({ + name: ACTION_DELETE_SECRETS, + userId: req.user._id, + workspaceId: new Types.ObjectId(workspaceId), + secretIds: deleteSecrets + }) as IAction; + actions.push(deleteAction); + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets deleted', + distinctId: req.user.email, + properties: { + numberOfSecrets: deleteSecrets.length, + environment, + workspaceId, + channel: channel, + userAgent: req.headers?.['user-agent'] + } + }); + } + } + + if (actions.length > 0) { + // (EE) create (audit) log + await EELogService.createLog({ + userId: req.user._id.toString(), + workspaceId: new Types.ObjectId(workspaceId), + actions, + channel, + ipAddress: req.ip + }); + } + + // // trigger event - push secrets + await EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId + }) + }); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId + }); + + const resObj: { [key: string]: ISecret[] | string[] } = {} + + if (createSecrets.length > 0) { + resObj['createdSecrets'] = createdSecrets; + } + + if (updateSecrets.length > 0) { + resObj['updatedSecrets'] = updatedSecrets; + } + + if (deleteSecrets.length > 0) { + resObj['deletedSecrets'] = deleteSecrets.map((d) => d.toString()); + } + + return res.status(200).send(resObj); +} /** * Create secret(s) for workspace with id [workspaceId] and environment [environment] @@ -164,11 +413,9 @@ export const createSecrets = async (req: Request, res: Response) => { secretKeyCiphertext, secretKeyIV, secretKeyTag, - secretKeyHash, secretValueCiphertext, secretValueIV, secretValueTag, - secretValueHash, secretCommentCiphertext, secretCommentIV, secretCommentTag, @@ -185,11 +432,9 @@ export const createSecrets = async (req: Request, res: Response) => { secretKeyCiphertext, secretKeyIV, secretKeyTag, - secretKeyHash, secretValueCiphertext, secretValueIV, secretValueTag, - secretValueHash, secretCommentCiphertext, secretCommentIV, secretCommentTag, @@ -284,8 +529,10 @@ export const getSecrets = async (req: Request, res: Response) => { } } */ - const { workspaceId, environment } = req.query; + + const { workspaceId, environment, tagSlugs } = req.query; + const tagNamesList = typeof tagSlugs === 'string' && tagSlugs !== '' ? tagSlugs.split(',') : []; let userId = "" // used for getting personal secrets for user let userEmail = "" // used for posthog if (req.user) { @@ -298,16 +545,38 @@ export const getSecrets = async (req: Request, res: Response) => { userEmail = req.serviceTokenData.user.email; } - // none service token case as service tokens are already scoped + // none service token case as service tokens are already scoped to env and project + let hasWriteOnlyAccess if (!req.serviceTokenData) { - const hasAccess = await userHasWorkspaceAccess(userId, workspaceId, environment, ABILITY_READ) - if (!hasAccess) { + hasWriteOnlyAccess = await userHasWriteOnlyAbility(userId, workspaceId, environment) + const hasNoAccess = await userHasNoAbility(userId, workspaceId, environment) + if (hasNoAccess) { throw UnauthorizedRequestError({ message: "You do not have the necessary permission(s) perform this action" }) } } + let secrets: any + let secretQuery: any - const [err, secrets] = await to(Secret.find( - { + if (tagNamesList != undefined && tagNamesList.length != 0) { + const workspaceFromDB = await Tag.find({ workspace: workspaceId }) + + const tagIds = _.map(tagNamesList, (tagName) => { + const tag = _.find(workspaceFromDB, { slug: tagName }); + return tag ? tag.id : null; + }); + + secretQuery = { + workspace: workspaceId, + environment, + $or: [ + { user: userId }, + { user: { $exists: false } } + ], + tags: { $in: tagIds }, + type: { $in: [SECRET_SHARED, SECRET_PERSONAL] } + } + } else { + secretQuery = { workspace: workspaceId, environment, $or: [ @@ -316,9 +585,13 @@ export const getSecrets = async (req: Request, res: Response) => { ], type: { $in: [SECRET_SHARED, SECRET_PERSONAL] } } - ).populate("tags").then()) + } - if (err) throw ValidationError({ message: 'Failed to get secrets', stack: err.stack }); + if (hasWriteOnlyAccess) { + secrets = await Secret.find(secretQuery).select("secretKeyCiphertext secretKeyIV secretKeyTag") + } else { + secrets = await Secret.find(secretQuery).populate("tags") + } const channel = getChannelFromUserAgent(req.headers['user-agent']) @@ -356,6 +629,59 @@ export const getSecrets = async (req: Request, res: Response) => { }); } + +export const getOnlySecretKeys = async (req: Request, res: Response) => { + const { workspaceId, environment } = req.query; + + let userId = "" // used for getting personal secrets for user + let userEmail = "" // used for posthog + if (req.user) { + userId = req.user._id; + userEmail = req.user.email; + } + + if (req.serviceTokenData) { + userId = req.serviceTokenData.user._id + userEmail = req.serviceTokenData.user.email; + } + + // none service token case as service tokens are already scoped + if (!req.serviceTokenData) { + const hasAccess = await userHasWorkspaceAccess(userId, workspaceId, environment, ABILITY_READ) + if (!hasAccess) { + throw UnauthorizedRequestError({ message: "You do not have the necessary permission(s) perform this action" }) + } + } + + const [err, secretKeys] = await to(Secret.find( + { + workspace: workspaceId, + environment, + $or: [ + { user: userId }, + { user: { $exists: false } } + ], + type: { $in: [SECRET_SHARED, SECRET_PERSONAL] } + } + ) + .select("secretKeyIV secretKeyTag secretKeyCiphertext") + .then()) + + if (err) throw ValidationError({ message: 'Failed to get secrets', stack: err.stack }); + + // readAction && await EELogService.createLog({ + // userId: new Types.ObjectId(userId), + // workspaceId: new Types.ObjectId(workspaceId as string), + // actions: [readAction], + // channel, + // ipAddress: req.ip + // }); + + return res.status(200).send({ + secretKeys + }); +} + /** * Update secret(s) * @param req diff --git a/backend/src/controllers/v2/signupController.ts b/backend/src/controllers/v2/signupController.ts new file mode 100644 index 000000000..0fb349edc --- /dev/null +++ b/backend/src/controllers/v2/signupController.ts @@ -0,0 +1,250 @@ +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import { User, MembershipOrg } from '../../models'; +import { completeAccount } from '../../helpers/user'; +import { + initializeDefaultOrg +} from '../../helpers/signup'; +import { issueAuthTokens } from '../../helpers/auth'; +import { INVITED, ACCEPTED } from '../../variables'; +import { NODE_ENV } from '../../config'; +import axios from 'axios'; + +/** + * Complete setting up user by adding their personal and auth information as part of the + * signup flow + * @param req + * @param res + * @returns + */ +export const completeAccountSignup = async (req: Request, res: Response) => { + let user, token, refreshToken; + try { + const { + email, + firstName, + lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier, + organizationName + }: { + email: string; + firstName: string; + lastName: string; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; + publicKey: string; + encryptedPrivateKey: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; + salt: string; + verifier: string; + organizationName: string; + } = req.body; + + // get user + user = await User.findOne({ email }); + + if (!user || (user && user?.publicKey)) { + // case 1: user doesn't exist. + // case 2: user has already completed account + return res.status(403).send({ + error: 'Failed to complete account for complete user' + }); + } + + // complete setting up user's account + user = await completeAccount({ + userId: user._id.toString(), + firstName, + lastName, + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier + }); + + if (!user) + throw new Error('Failed to complete account for non-existent user'); // ensure user is non-null + + // initialize default organization and workspace + await initializeDefaultOrg({ + organizationName, + user + }); + + // update organization membership statuses that are + // invited to completed with user attached + await MembershipOrg.updateMany( + { + inviteEmail: email, + status: INVITED + }, + { + user, + status: ACCEPTED + } + ); + + // issue tokens + const tokens = await issueAuthTokens({ + userId: user._id.toString() + }); + + token = tokens.token; + + // sending a welcome email to new users + if (process.env.LOOPS_API_KEY) { + await axios.post("https://app.loops.so/api/v1/events/send", { + "email": email, + "eventName": "Sign Up", + "firstName": firstName, + "lastName": lastName + }, { + headers: { + "Accept": "application/json", + "Authorization": "Bearer " + process.env.LOOPS_API_KEY + }, + }); + } + + // store (refresh) token in httpOnly cookie + res.cookie('jid', tokens.refreshToken, { + httpOnly: true, + path: '/', + sameSite: 'strict', + secure: NODE_ENV === 'production' ? true : false + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to complete account setup' + }); + } + + return res.status(200).send({ + message: 'Successfully set up account', + user, + token + }); +}; + +/** + * Complete setting up user by adding their personal and auth information as part of the + * invite flow + * @param req + * @param res + * @returns + */ +export const completeAccountInvite = async (req: Request, res: Response) => { + let user, token, refreshToken; + try { + const { + email, + firstName, + lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier + } = req.body; + + // get user + user = await User.findOne({ email }); + + if (!user || (user && user?.publicKey)) { + // case 1: user doesn't exist. + // case 2: user has already completed account + return res.status(403).send({ + error: 'Failed to complete account for complete user' + }); + } + + const membershipOrg = await MembershipOrg.findOne({ + inviteEmail: email, + status: INVITED + }); + + if (!membershipOrg) throw new Error('Failed to find invitations for email'); + + // complete setting up user's account + user = await completeAccount({ + userId: user._id.toString(), + firstName, + lastName, + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier + }); + + if (!user) + throw new Error('Failed to complete account for non-existent user'); + + // update organization membership statuses that are + // invited to completed with user attached + await MembershipOrg.updateMany( + { + inviteEmail: email, + status: INVITED + }, + { + user, + status: ACCEPTED + } + ); + + // issue tokens + const tokens = await issueAuthTokens({ + userId: user._id.toString() + }); + + token = tokens.token; + + // store (refresh) token in httpOnly cookie + res.cookie('jid', tokens.refreshToken, { + httpOnly: true, + path: '/', + sameSite: 'strict', + secure: NODE_ENV === 'production' ? true : false + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to complete account setup' + }); + } + + return res.status(200).send({ + message: 'Successfully set up account', + user, + token + }); +}; \ No newline at end of file diff --git a/backend/src/controllers/v2/usersController.ts b/backend/src/controllers/v2/usersController.ts index 4ec1099d9..74c3b5418 100644 --- a/backend/src/controllers/v2/usersController.ts +++ b/backend/src/controllers/v2/usersController.ts @@ -55,6 +55,44 @@ export const getMe = async (req: Request, res: Response) => { }); } +/** + * Update the current user's MFA-enabled status [isMfaEnabled]. + * Note: Infisical currently only supports email-based 2FA only; this will expand to + * include SMS and authenticator app modes of authentication in the future. + * @param req + * @param res + * @returns + */ +export const updateMyMfaEnabled = async (req: Request, res: Response) => { + let user; + try { + const { isMfaEnabled }: { isMfaEnabled: boolean } = req.body; + req.user.isMfaEnabled = isMfaEnabled; + + if (isMfaEnabled) { + // TODO: adapt this route/controller + // to work for different forms of MFA + req.user.mfaMethods = ['email']; + } else { + req.user.mfaMethods = []; + } + + await req.user.save(); + + user = req.user; + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: "Failed to update current user's MFA status" + }); + } + + return res.status(200).send({ + user + }); +} + /** * Return organizations that the current user is part of. * @param req diff --git a/backend/src/ee/controllers/v1/secretController.ts b/backend/src/ee/controllers/v1/secretController.ts index 562c8aa88..e1aca670f 100644 --- a/backend/src/ee/controllers/v1/secretController.ts +++ b/backend/src/ee/controllers/v1/secretController.ts @@ -158,11 +158,9 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { secretKeyCiphertext, secretKeyIV, secretKeyTag, - secretKeyHash, secretValueCiphertext, secretValueIV, secretValueTag, - secretValueHash } = oldSecretVersion; // update secret @@ -179,11 +177,9 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { secretKeyCiphertext, secretKeyIV, secretKeyTag, - secretKeyHash, secretValueCiphertext, secretValueIV, secretValueTag, - secretValueHash }, { new: true @@ -204,11 +200,9 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { secretKeyCiphertext, secretKeyIV, secretKeyTag, - secretKeyHash, secretValueCiphertext, secretValueIV, - secretValueTag, - secretValueHash + secretValueTag }).save(); // take secret snapshot diff --git a/backend/src/ee/helpers/checkMembershipPermissions.ts b/backend/src/ee/helpers/checkMembershipPermissions.ts index 55155e885..50cd28917 100644 --- a/backend/src/ee/helpers/checkMembershipPermissions.ts +++ b/backend/src/ee/helpers/checkMembershipPermissions.ts @@ -1,5 +1,6 @@ import _ from "lodash"; import { Membership } from "../../models"; +import { ABILITY_READ, ABILITY_WRITE } from "../../variables/organization"; export const userHasWorkspaceAccess = async (userId: any, workspaceId: any, environment: any, action: any) => { const membershipForWorkspace = await Membership.findOne({ workspace: workspaceId, user: userId }) @@ -15,4 +16,39 @@ export const userHasWorkspaceAccess = async (userId: any, workspaceId: any, envi } return true +} + +export const userHasWriteOnlyAbility = async (userId: any, workspaceId: any, environment: any) => { + const membershipForWorkspace = await Membership.findOne({ workspace: workspaceId, user: userId }) + if (!membershipForWorkspace) { + return false + } + + const deniedMembershipPermissions = membershipForWorkspace.deniedPermissions; + const isWriteDisallowed = _.some(deniedMembershipPermissions, { environmentSlug: environment, ability: ABILITY_WRITE }); + const isReadDisallowed = _.some(deniedMembershipPermissions, { environmentSlug: environment, ability: ABILITY_READ }); + + // case: you have write only if read is blocked and write is not + if (isReadDisallowed && !isWriteDisallowed) { + return true + } + + return false +} + +export const userHasNoAbility = async (userId: any, workspaceId: any, environment: any) => { + const membershipForWorkspace = await Membership.findOne({ workspace: workspaceId, user: userId }) + if (!membershipForWorkspace) { + return true + } + + const deniedMembershipPermissions = membershipForWorkspace.deniedPermissions; + const isWriteDisallowed = _.some(deniedMembershipPermissions, { environmentSlug: environment, ability: ABILITY_WRITE }); + const isReadBlocked = _.some(deniedMembershipPermissions, { environmentSlug: environment, ability: ABILITY_READ }); + + if (isReadBlocked && isWriteDisallowed) { + return true + } + + return false } \ No newline at end of file diff --git a/backend/src/ee/models/secretVersion.ts b/backend/src/ee/models/secretVersion.ts index efa042765..3095b52fb 100644 --- a/backend/src/ee/models/secretVersion.ts +++ b/backend/src/ee/models/secretVersion.ts @@ -5,22 +5,19 @@ import { } from '../../variables'; export interface ISecretVersion { - _id: Types.ObjectId; secret: Types.ObjectId; version: number; workspace: Types.ObjectId; // new type: string; // new - user: Types.ObjectId; // new + user?: Types.ObjectId; // new environment: string; // new isDeleted: boolean; secretKeyCiphertext: string; secretKeyIV: string; secretKeyTag: string; - secretKeyHash: string; secretValueCiphertext: string; secretValueIV: string; secretValueTag: string; - secretValueHash: string; tags?: string[]; } @@ -72,9 +69,6 @@ const secretVersionSchema = new Schema( type: String, // symmetric required: true }, - secretKeyHash: { - type: String - }, secretValueCiphertext: { type: String, required: true @@ -87,9 +81,6 @@ const secretVersionSchema = new Schema( type: String, // symmetric required: true }, - secretValueHash: { - type: String - }, tags: { ref: 'Tag', type: [Schema.Types.ObjectId], diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index a8ca48377..f7f88ec0b 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -211,7 +211,7 @@ const getAuthAPIKeyPayload = async ({ * @return {String} obj.token - issued JWT token * @return {String} obj.refreshToken - issued refresh token */ -const issueTokens = async ({ userId }: { userId: string }) => { +const issueAuthTokens = async ({ userId }: { userId: string }) => { let token: string; let refreshToken: string; try { @@ -298,6 +298,6 @@ export { getAuthSTDPayload, getAuthAPIKeyPayload, createToken, - issueTokens, + issueAuthTokens, clearTokens }; diff --git a/backend/src/helpers/signup.ts b/backend/src/helpers/signup.ts index 1ef1219f8..dfca96736 100644 --- a/backend/src/helpers/signup.ts +++ b/backend/src/helpers/signup.ts @@ -1,12 +1,11 @@ import * as Sentry from '@sentry/node'; -import crypto from 'crypto'; -import { Token, IToken, IUser } from '../models'; +import { IUser } from '../models'; import { createOrganization } from './organization'; import { addMembershipsOrg } from './membershipOrg'; -import { createWorkspace } from './workspace'; -import { addMemberships } from './membership'; -import { OWNER, ADMIN, ACCEPTED } from '../variables'; +import { OWNER, ACCEPTED } from '../variables'; import { sendMail } from '../helpers/nodemailer'; +import { TokenService } from '../services'; +import { TOKEN_EMAIL_CONFIRMATION } from '../variables'; /** * Send magic link to verify email to [email] @@ -14,21 +13,13 @@ import { sendMail } from '../helpers/nodemailer'; * @param {Object} obj * @param {String} obj.email - email * @returns {Boolean} success - whether or not operation was successful - * */ const sendEmailVerification = async ({ email }: { email: string }) => { try { - const token = String(crypto.randomInt(Math.pow(10, 5), Math.pow(10, 6) - 1)); - - await Token.findOneAndUpdate( - { email }, - { - email, - token, - createdAt: new Date() - }, - { upsert: true, new: true } - ); + const token = await TokenService.createToken({ + type: TOKEN_EMAIL_CONFIRMATION, + email + }); // send mail await sendMail({ @@ -62,12 +53,11 @@ const checkEmailVerification = async ({ code: string; }) => { try { - const token = await Token.findOneAndDelete({ + await TokenService.validateToken({ + type: TOKEN_EMAIL_CONFIRMATION, email, token: code }); - - if (!token) throw new Error('Failed to find email verification token'); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -103,18 +93,6 @@ const initializeDefaultOrg = async ({ roles: [OWNER], statuses: [ACCEPTED] }); - - // initialize a default workspace inside the new organization - const workspace = await createWorkspace({ - name: `Example Project`, - organizationId: organization._id.toString() - }); - - await addMemberships({ - userIds: [user._id.toString()], - workspaceId: workspace._id.toString(), - roles: [ADMIN] - }); } catch (err) { throw new Error(`Failed to initialize default organization and workspace [err=${err}]`); } diff --git a/backend/src/helpers/token.ts b/backend/src/helpers/token.ts new file mode 100644 index 000000000..ca8838151 --- /dev/null +++ b/backend/src/helpers/token.ts @@ -0,0 +1,217 @@ +import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; +import { TokenData } from '../models'; +import crypto from 'crypto'; +import bcrypt from 'bcrypt'; +import { + TOKEN_EMAIL_CONFIRMATION, + TOKEN_EMAIL_MFA, + TOKEN_EMAIL_ORG_INVITATION, + TOKEN_EMAIL_PASSWORD_RESET +} from '../variables'; +import { + SALT_ROUNDS +} from '../config'; +import { UnauthorizedRequestError } from '../utils/errors'; + +/** + * Create and store a token in the database for purpose [type] + * @param {Object} obj + * @param {String} obj.type + * @param {String} obj.email + * @param {String} obj.phoneNumber + * @param {Types.ObjectId} obj.organizationId + * @returns {String} token - the created token + */ +const createTokenHelper = async ({ + type, + email, + phoneNumber, + organizationId +}: { + type: 'emailConfirmation' | 'emailMfa' | 'organizationInvitation' | 'passwordReset'; + email?: string; + phoneNumber?: string; + organizationId?: Types.ObjectId +}) => { + let token, expiresAt, triesLeft; + try { + // generate random token based on specified token use-case + // type [type] + switch (type) { + case TOKEN_EMAIL_CONFIRMATION: + // generate random 6-digit code + token = String(crypto.randomInt(Math.pow(10, 5), Math.pow(10, 6) - 1)); + expiresAt = new Date((new Date()).getTime() + 86400000); + break; + case TOKEN_EMAIL_MFA: + // generate random 6-digit code + token = String(crypto.randomInt(Math.pow(10, 5), Math.pow(10, 6) - 1)); + triesLeft = 5; + expiresAt = new Date((new Date()).getTime() + 300000); + break; + case TOKEN_EMAIL_ORG_INVITATION: + // generate random hex + token = crypto.randomBytes(16).toString('hex'); + expiresAt = new Date((new Date()).getTime() + 259200000); + break; + case TOKEN_EMAIL_PASSWORD_RESET: + // generate random hex + token = crypto.randomBytes(16).toString('hex'); + expiresAt = new Date((new Date()).getTime() + 86400000); + break; + default: + token = crypto.randomBytes(16).toString('hex'); + expiresAt = new Date(); + break; + } + + interface TokenDataQuery { + type: string; + email?: string; + phoneNumber?: string; + organization?: Types.ObjectId; + } + + interface TokenDataUpdate { + type: string; + email?: string; + phoneNumber?: string; + organization?: Types.ObjectId; + tokenHash: string; + triesLeft?: number; + expiresAt: Date; + } + + const query: TokenDataQuery = { type }; + const update: TokenDataUpdate = { + type, + tokenHash: await bcrypt.hash(token, SALT_ROUNDS), + expiresAt + } + + if (email) { + query.email = email; + update.email = email; + } + if (phoneNumber) { + query.phoneNumber = phoneNumber; + update.phoneNumber = phoneNumber; + } + if (organizationId) { + query.organization = organizationId + update.organization = organizationId + } + + if (triesLeft) { + update.triesLeft = triesLeft; + } + + await TokenData.findOneAndUpdate( + query, + update, + { + new: true, + upsert: true + } + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error( + "Failed to create token" + ); + } + + return token; +} + +/** + * + * @param {Object} obj + * @param {String} obj.email - email associated with the token + * @param {String} obj.token - value of the token + */ +const validateTokenHelper = async ({ + type, + email, + phoneNumber, + organizationId, + token +}: { + type: 'emailConfirmation' | 'emailMfa' | 'organizationInvitation' | 'passwordReset'; + email?: string; + phoneNumber?: string; + organizationId?: Types.ObjectId; + token: string; +}) => { + interface Query { + type: string; + email?: string; + phoneNumber?: string; + organization?: Types.ObjectId; + } + + const query: Query = { type }; + + if (email) { query.email = email; } + if (phoneNumber) { query.phoneNumber = phoneNumber; } + if (organizationId) { query.organization = organizationId; } + + const tokenData = await TokenData.findOne(query).select('+tokenHash'); + + if (!tokenData) throw new Error('Failed to find token to validate'); + + if (tokenData.expiresAt < new Date()) { + // case: token expired + await TokenData.findByIdAndDelete(tokenData._id); + throw UnauthorizedRequestError({ + message: 'MFA session expired. Please log in again', + context: { + code: 'mfa_expired' + } + }); + } + + const isValid = await bcrypt.compare(token, tokenData.tokenHash); + if (!isValid) { + // case: token is not valid + if (tokenData?.triesLeft !== undefined) { + // case: token has a try-limit + if (tokenData.triesLeft === 1) { + // case: token is out of tries + await TokenData.findByIdAndDelete(tokenData._id); + } else { + // case: token has more than 1 try left + await TokenData.findByIdAndUpdate(tokenData._id, { + triesLeft: tokenData.triesLeft - 1 + }, { + new: true + }); + } + + throw UnauthorizedRequestError({ + message: 'MFA code is invalid', + context: { + code: 'mfa_invalid', + triesLeft: tokenData.triesLeft - 1 + } + }); + } + + throw UnauthorizedRequestError({ + message: 'MFA code is invalid', + context: { + code: 'mfa_invalid' + } + }); + } + + // case: token is valid + await TokenData.findByIdAndDelete(tokenData._id); +} + +export { + createTokenHelper, + validateTokenHelper +} \ No newline at end of file diff --git a/backend/src/helpers/user.ts b/backend/src/helpers/user.ts index a89ddc45c..932a4bd81 100644 --- a/backend/src/helpers/user.ts +++ b/backend/src/helpers/user.ts @@ -1,5 +1,6 @@ import * as Sentry from '@sentry/node'; -import { User, IUser } from '../models'; +import { IUser, User } from '../models'; +import { sendMail } from './nodemailer'; /** * Initialize a user under email [email] @@ -28,10 +29,14 @@ const setupAccount = async ({ email }: { email: string }) => { * @param {String} obj.userId - id of user to finish setting up * @param {String} obj.firstName - first name of user * @param {String} obj.lastName - last name of user + * @param {Number} obj.encryptionVersion - version of auth encryption scheme used + * @param {String} obj.protectedKey - protected key in encryption version 2 + * @param {String} obj.protectedKeyIV - IV of protected key in encryption version 2 + * @param {String} obj.protectedKeyTag - tag of protected key in encryption version 2 * @param {String} obj.publicKey - publickey of user * @param {String} obj.encryptedPrivateKey - (encrypted) private key of user - * @param {String} obj.iv - iv for (encrypted) private key of user - * @param {String} obj.tag - tag for (encrypted) private key of user + * @param {String} obj.encryptedPrivateKeyIV - iv for (encrypted) private key of user + * @param {String} obj.encryptedPrivateKeyTag - tag for (encrypted) private key of user * @param {String} obj.salt - salt for auth SRP * @param {String} obj.verifier - verifier for auth SRP * @returns {Object} user - the completed user @@ -40,20 +45,28 @@ const completeAccount = async ({ userId, firstName, lastName, + encryptionVersion, + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, encryptedPrivateKey, - iv, - tag, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, salt, verifier }: { userId: string; firstName: string; lastName: string; + encryptionVersion: number; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; publicKey: string; encryptedPrivateKey: string; - iv: string; - tag: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; salt: string; verifier: string; }) => { @@ -67,10 +80,14 @@ const completeAccount = async ({ { firstName, lastName, + encryptionVersion, + protectedKey, + protectedKeyIV, + protectedKeyTag, publicKey, encryptedPrivateKey, - iv, - tag, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, salt, verifier }, @@ -85,4 +102,48 @@ const completeAccount = async ({ return user; }; -export { setupAccount, completeAccount }; +/** + * Check if device with ip [ip] and user-agent [userAgent] has been seen for user [user]. + * If the device is unseen, then notify the user of the new device + * @param {Object} obj + * @param {String} obj.ip - login ip address + * @param {String} obj.userAgent - login user-agent + */ +const checkUserDevice = async ({ + user, + ip, + userAgent +}: { + user: IUser; + ip: string; + userAgent: string; +}) => { + const isDeviceSeen = user.devices.some((device) => device.ip === ip && device.userAgent === userAgent); + + if (!isDeviceSeen) { + // case: unseen login ip detected for user + // -> notify user about the sign-in from new ip + + user.devices = user.devices.concat([{ + ip: String(ip), + userAgent + }]); + + await user.save(); + + // send MFA code [code] to [email] + await sendMail({ + template: 'newDevice.handlebars', + subjectLine: `Successful login from new device`, + recipients: [user.email], + substitutions: { + email: user.email, + timestamp: new Date().toString(), + ip, + userAgent + } + }); + } +} + +export { setupAccount, completeAccount, checkUserDevice }; diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index 254470356..7a828f332 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -86,7 +86,7 @@ const getApps = async ({ }); break; case INTEGRATION_CIRCLECI: - apps = await getAppsCircleci({ + apps = await getAppsCircleCI({ accessToken, }); break; @@ -151,7 +151,7 @@ const getAppsVercel = async ({ await axios.get(`${INTEGRATION_VERCEL_API_URL}/v9/projects`, { headers: { Authorization: `Bearer ${accessToken}`, - 'Accept-Encoding': 'application/json', + 'Accept-Encoding': 'application/json' }, ...(integrationAuth?.teamId ? { @@ -189,8 +189,8 @@ const getAppsNetlify = async ({ accessToken }: { accessToken: string }) => { await axios.get(`${INTEGRATION_NETLIFY_API_URL}/api/v1/sites`, { headers: { Authorization: `Bearer ${accessToken}`, - 'Accept-Encoding': 'application/json', - }, + 'Accept-Encoding': 'application/json' + } }) ).data; @@ -333,24 +333,19 @@ const getAppsFlyio = async ({ accessToken }: { accessToken: string }) => { return apps; }; -const getAppsCircleci = async ({ accessToken }: { accessToken: string }) => { - // in place of accessToken we have to send Circle-Token i.e. Personal API token from CircleCi +/** + * Return list of projects for CircleCI integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for CircleCI API + * @returns {Object[]} apps - + * @returns {String} apps.name - name of CircleCI apps + */ +const getAppsCircleCI = async ({ accessToken }: { accessToken: string }) => { let apps: any; - try { - const circleciOrganizationDetail = ( - await axios.get(`${INTEGRATION_CIRCLECI_API_URL}/v2/me/collaborations`, { - headers: { - "Circle-Token": accessToken, - "Accept-Encoding": "application/json", - }, - }) - ).data[0]; - - const { slug } = circleciOrganizationDetail; - + try { const res = ( await axios.get( - `${INTEGRATION_CIRCLECI_API_URL}/v2/pipeline/?org-slug=${slug}`, + `${INTEGRATION_CIRCLECI_API_URL}/v1.1/projects`, { headers: { "Circle-Token": accessToken, @@ -358,17 +353,19 @@ const getAppsCircleci = async ({ accessToken }: { accessToken: string }) => { }, } ) - ).data?.items; + ).data - apps = res.map((a: any) => ({ - name: a?.project_slug?.split("/")[2], - })); + apps = res?.map((a: any) => { + return { + name: a?.reponame + } + }); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error("Failed to get Render services"); + throw new Error("Failed to get CircleCI projects"); } - + return apps; }; diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 1e08a701f..301259198 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -138,7 +138,7 @@ const exchangeCodeAzure = async ({ try { res = (await axios.post( INTEGRATION_AZURE_TOKEN_URL, - new URLSearchParams({ + new URLSearchParams({ grant_type: 'authorization_code', code: code, scope: 'https://vault.azure.net/.default openid offline_access', @@ -147,16 +147,16 @@ const exchangeCodeAzure = async ({ redirect_uri: `${SITE_URL}/integrations/azure-key-vault/oauth2/callback` } as any) )).data; - + accessExpiresAt.setSeconds( - accessExpiresAt.getSeconds() + res.expires_in + accessExpiresAt.getSeconds() + res.expires_in ); } catch (err: any) { Sentry.setUser(null); Sentry.captureException(err); throw new Error('Failed OAuth2 code-token exchange with Azure'); } - + return ({ accessToken: res.access_token, refreshToken: res.refresh_token, @@ -175,36 +175,36 @@ const exchangeCodeAzure = async ({ * @returns {Date} obj2.accessExpiresAt - date of expiration for access token */ const exchangeCodeHeroku = async ({ - code + code }: { - code: string; + code: string; }) => { - let res: ExchangeCodeHerokuResponse; - const accessExpiresAt = new Date(); - try { - res = (await axios.post( - INTEGRATION_HEROKU_TOKEN_URL, - new URLSearchParams({ - grant_type: 'authorization_code', - code: code, - client_secret: CLIENT_SECRET_HEROKU - } as any) - )).data; - - accessExpiresAt.setSeconds( - accessExpiresAt.getSeconds() + res.expires_in - ); - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed OAuth2 code-token exchange with Heroku'); - } - - return ({ - accessToken: res.access_token, - refreshToken: res.refresh_token, - accessExpiresAt - }); + let res: ExchangeCodeHerokuResponse; + const accessExpiresAt = new Date(); + try { + res = (await axios.post( + INTEGRATION_HEROKU_TOKEN_URL, + new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + client_secret: CLIENT_SECRET_HEROKU + } as any) + )).data; + + accessExpiresAt.setSeconds( + accessExpiresAt.getSeconds() + res.expires_in + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed OAuth2 code-token exchange with Heroku'); + } + + return ({ + accessToken: res.access_token, + refreshToken: res.refresh_token, + accessExpiresAt + }); } /** @@ -234,7 +234,7 @@ const exchangeCodeVercel = async ({ code }: { code: string }) => { } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error('Failed OAuth2 code-token exchange with Vercel'); + throw new Error(`Failed OAuth2 code-token exchange with Vercel [err=${err}]`); } return { diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index b78070e5a..3309e5813 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -30,7 +30,6 @@ import { INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, } from "../variables"; -import { access, appendFile } from "fs"; /** * Sync/push [secrets] to [app] in integration named [integration] @@ -124,7 +123,7 @@ const syncSecrets = async ({ }); break; case INTEGRATION_CIRCLECI: - await syncSecretsCircleci({ + await syncSecretsCircleCI({ integration, secrets, accessToken, @@ -181,7 +180,8 @@ const syncSecretsAzureKeyVault = async ({ while (url) { const res = await axios.get(url, { headers: { - Authorization: `Bearer ${accessToken}` + Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' } }); @@ -202,7 +202,8 @@ const syncSecretsAzureKeyVault = async ({ const azureKeyVaultSecret = await axios.get(`${getAzureKeyVaultSecret.id}?api-version=7.3`, { headers: { - 'Authorization': `Bearer ${accessToken}` + 'Authorization': `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' } }); @@ -259,7 +260,8 @@ const syncSecretsAzureKeyVault = async ({ }, { headers: { - Authorization: `Bearer ${accessToken}` + Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' } } ); @@ -270,7 +272,8 @@ const syncSecretsAzureKeyVault = async ({ deleteSecrets.forEach(async (secret) => { await axios.delete(`${integration.app}/secrets/${secret.key}?api-version=7.3`, { headers: { - 'Authorization': `Bearer ${accessToken}` + 'Authorization': `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' } }); }); @@ -488,6 +491,7 @@ const syncSecretsHeroku = async ({ headers: { Accept: "application/vnd.heroku+json; version=3", Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ) @@ -506,6 +510,7 @@ const syncSecretsHeroku = async ({ headers: { Accept: "application/vnd.heroku+json; version=3", Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ); @@ -552,7 +557,7 @@ const syncSecretsVercel = async ({ } : {}), }; - + const res = ( await Promise.all( ( @@ -561,7 +566,8 @@ const syncSecretsVercel = async ({ { params, headers: { - Authorization: `Bearer ${accessToken}` + Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' } } )) @@ -573,7 +579,8 @@ const syncSecretsVercel = async ({ { params, headers: { - Authorization: `Bearer ${accessToken}` + Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' } } )).data) @@ -633,6 +640,7 @@ const syncSecretsVercel = async ({ params, headers: { Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ); @@ -649,6 +657,7 @@ const syncSecretsVercel = async ({ params, headers: { Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ); @@ -664,6 +673,7 @@ const syncSecretsVercel = async ({ params, headers: { Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ); @@ -723,6 +733,7 @@ const syncSecretsNetlify = async ({ params: getParams, headers: { Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ) @@ -837,6 +848,7 @@ const syncSecretsNetlify = async ({ params: syncParams, headers: { Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ); @@ -854,6 +866,7 @@ const syncSecretsNetlify = async ({ params: syncParams, headers: { Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ); @@ -868,6 +881,7 @@ const syncSecretsNetlify = async ({ params: syncParams, headers: { Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ); @@ -882,6 +896,7 @@ const syncSecretsNetlify = async ({ params: syncParams, headers: { Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ); @@ -1035,6 +1050,7 @@ const syncSecretsRender = async ({ { headers: { Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'application/json' }, } ); @@ -1088,6 +1104,7 @@ const syncSecretsFlyio = async ({ method: "post", headers: { Authorization: "Bearer " + accessToken, + 'Accept-Encoding': 'application/json' }, data: { query: SetSecrets, @@ -1167,6 +1184,7 @@ const syncSecretsFlyio = async ({ headers: { Authorization: "Bearer " + accessToken, "Content-Type": "application/json", + 'Accept-Encoding': 'application/json' }, data: { query: DeleteSecrets, @@ -1185,7 +1203,14 @@ const syncSecretsFlyio = async ({ } }; -const syncSecretsCircleci = async ({ +/** + * Sync/push [secrets] to CircleCI project + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - access token for CircleCI integration + */ +const syncSecretsCircleCI = async ({ integration, secrets, accessToken, @@ -1206,7 +1231,7 @@ const syncSecretsCircleci = async ({ const { slug } = circleciOrganizationDetail; - // inject secrets to CircleCI (one by one) + // sync secrets to CircleCI Object.keys(secrets).forEach( async (key) => await axios.post( diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index 86b653281..7e014103c 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -1,4 +1,5 @@ import requireAuth from './requireAuth'; +import requireMfaAuth from './requireMfaAuth'; import requireBotAuth from './requireBotAuth'; import requireSignupAuth from './requireSignupAuth'; import requireWorkspaceAuth from './requireWorkspaceAuth'; @@ -15,6 +16,7 @@ import validateRequest from './validateRequest'; export { requireAuth, + requireMfaAuth, requireBotAuth, requireSignupAuth, requireWorkspaceAuth, diff --git a/backend/src/middleware/requestErrorHandler.ts b/backend/src/middleware/requestErrorHandler.ts index 50044387e..653c0df2e 100644 --- a/backend/src/middleware/requestErrorHandler.ts +++ b/backend/src/middleware/requestErrorHandler.ts @@ -1,11 +1,12 @@ import { ErrorRequestHandler } from "express"; import * as Sentry from '@sentry/node'; -import { InternalServerError } from "../utils/errors"; +import { InternalServerError, UnauthorizedRequestError } from "../utils/errors"; import { getLogger } from "../utils/logger"; import RequestError, { LogLevel } from "../utils/requestError"; import { NODE_ENV } from "../config"; +import { TokenExpiredError } from 'jsonwebtoken'; export const requestErrorHandler: ErrorRequestHandler = (error: RequestError | Error, req, res, next) => { if (res.headersSent) return next(); diff --git a/backend/src/middleware/requireMfaAuth.ts b/backend/src/middleware/requireMfaAuth.ts new file mode 100644 index 000000000..8fb914258 --- /dev/null +++ b/backend/src/middleware/requireMfaAuth.ts @@ -0,0 +1,43 @@ +import jwt from 'jsonwebtoken'; +import { Request, Response, NextFunction } from 'express'; +import { User } from '../models'; +import { JWT_MFA_SECRET } from '../config'; +import { BadRequestError, UnauthorizedRequestError } from '../utils/errors'; + +declare module 'jsonwebtoken' { + export interface UserIDJwtPayload extends jwt.JwtPayload { + userId: string; + } +} + +/** + * Validate if (MFA) JWT temporary token on request is valid (e.g. not expired) + * and if there is an associated user. + */ +const requireMfaAuth = async ( + req: Request, + res: Response, + next: NextFunction +) => { + // JWT (temporary) authentication middleware for complete signup + const [ AUTH_TOKEN_TYPE, AUTH_TOKEN_VALUE ] = <[string, string]>req.headers['authorization']?.split(' ', 2) ?? [null, null] + if(AUTH_TOKEN_TYPE === null) return next(BadRequestError({message: `Missing Authorization Header in the request header.`})) + if(AUTH_TOKEN_TYPE.toLowerCase() !== 'bearer') return next(BadRequestError({message: `The provided authentication type '${AUTH_TOKEN_TYPE}' is not supported.`})) + if(AUTH_TOKEN_VALUE === null) return next(BadRequestError({message: 'Missing Authorization Body in the request header'})) + + const decodedToken = ( + jwt.verify(AUTH_TOKEN_VALUE, JWT_MFA_SECRET) + ); + + const user = await User.findOne({ + _id: decodedToken.userId + }).select('+publicKey'); + + if (!user) + return next(UnauthorizedRequestError({message: 'Unable to authenticate for User account completion. Try logging in again'})) + + req.user = user; + return next(); +}; + +export default requireMfaAuth; diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index ac5f102c0..ead32aae4 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -10,7 +10,7 @@ import MembershipOrg, { IMembershipOrg } from './membershipOrg'; import Organization, { IOrganization } from './organization'; import Secret, { ISecret } from './secret'; import ServiceToken, { IServiceToken } from './serviceToken'; -import Token, { IToken } from './token'; +import TokenData, { ITokenData } from './tokenData'; import User, { IUser } from './user'; import UserAction, { IUserAction } from './userAction'; import Workspace, { IWorkspace } from './workspace'; @@ -43,8 +43,8 @@ export { ISecret, ServiceToken, IServiceToken, - Token, - IToken, + TokenData, + ITokenData, User, IUser, UserAction, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index a872023e0..95f8c75af 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -15,19 +15,9 @@ import { export interface IIntegrationAuth { _id: Types.ObjectId; workspace: Types.ObjectId; - integration: - | 'azure-key-vault' - | 'aws-parameter-store' - | 'aws-secret-manager' - | 'heroku' - | 'vercel' - | 'netlify' - | 'github' - | 'render' - | 'flyio' - | 'circleci'; - teamId: string; // TODO: deprecate (vercel) -> move to accessId - accountId: string; // TODO: deprecate (netlify) -> move to accessId + integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'render' | 'flyio' | 'azure-key-vault' | 'circleci' | 'aws-parameter-store' | 'aws-secret-manager'; + teamId: string; + accountId: string; refreshCiphertext?: string; refreshIV?: string; refreshTag?: string; diff --git a/backend/src/models/secretApprovalRequest.ts b/backend/src/models/secretApprovalRequest.ts new file mode 100644 index 000000000..9fa897e25 --- /dev/null +++ b/backend/src/models/secretApprovalRequest.ts @@ -0,0 +1,83 @@ +import mongoose, { Schema, model } from 'mongoose'; +import Secret, { ISecret } from './secret'; + +interface ISecretApprovalRequest { + secret: mongoose.Types.ObjectId; + requestedChanges: ISecret; + requestedBy: mongoose.Types.ObjectId; + approvers: IApprover[]; + status: ApprovalStatus; + timestamp: Date; + requestType: RequestType; + requestId: string; +} + +interface IApprover { + userId: mongoose.Types.ObjectId; + status: ApprovalStatus; +} + +export enum ApprovalStatus { + PENDING = 'pending', + APPROVED = 'approved', + REJECTED = 'rejected' +} + +export enum RequestType { + UPDATE = 'update', + DELETE = 'delete', + CREATE = 'create' +} + +const approverSchema = new mongoose.Schema({ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + status: { + type: String, + enum: [ApprovalStatus], + default: ApprovalStatus.PENDING + } +}); + +const secretApprovalRequestSchema = new Schema( + { + secret: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Secret' + }, + requestedChanges: Secret, + requestedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + approvers: [approverSchema], + status: { + type: String, + enum: ApprovalStatus, + default: ApprovalStatus.PENDING + }, + timestamp: { + type: Date, + default: Date.now + }, + requestType: { + type: String, + enum: RequestType, + required: true + }, + requestId: { + type: String, + required: false + } + }, + { + timestamps: true + } +); + +const SecretApprovalRequest = model('SecretApprovalRequest', secretApprovalRequestSchema); + +export default SecretApprovalRequest; diff --git a/backend/src/models/token.ts b/backend/src/models/token.ts index 9569aee0b..c003e42a2 100644 --- a/backend/src/models/token.ts +++ b/backend/src/models/token.ts @@ -5,6 +5,7 @@ export interface IToken { email: string; token: string; createdAt: Date; + ttl: number; } const tokenSchema = new Schema({ @@ -19,14 +20,13 @@ const tokenSchema = new Schema({ createdAt: { type: Date, default: Date.now + }, + ttl: { + type: Number, } }); -tokenSchema.index({ - createdAt: 1 -}, { - expireAfterSeconds: parseInt(EMAIL_TOKEN_LIFETIME) -}); +tokenSchema.index({ email: 1 }); const Token = model('Token', tokenSchema); diff --git a/backend/src/models/tokenData.ts b/backend/src/models/tokenData.ts new file mode 100644 index 000000000..2ee39c694 --- /dev/null +++ b/backend/src/models/tokenData.ts @@ -0,0 +1,61 @@ +import { Schema, Types, model } from 'mongoose'; + +export interface ITokenData { + type: string; + email?: string; + phoneNumber?: string; + organization?: Types.ObjectId; + tokenHash: string; + triesLeft?: number; + expiresAt: Date; + createdAt: Date; + updatedAt: Date; +} + +const tokenDataSchema = new Schema({ + type: { + type: String, + enum: [ + 'emailConfirmation', + 'emailMfa', + 'organizationInvitation', + 'passwordReset' + ], + required: true + }, + email: { + type: String + }, + phoneNumber: { + type: String + }, + organization: { // organizationInvitation-specific field + type: Schema.Types.ObjectId, + ref: 'Organization' + }, + tokenHash: { + type: String, + select: false, + required: true + }, + triesLeft: { + type: Number + }, + expiresAt: { + type: Date, + expires: 0, + required: true + } +}, { + timestamps: true +}); + +tokenDataSchema.index({ + expiresAt: 1 +}, { + expireAfterSeconds: 0 +}); + +const TokenData = model('TokenData', tokenDataSchema); + +export default TokenData; diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index ba9a59bc5..545c5256d 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -1,10 +1,14 @@ -import { Schema, model, Types } from 'mongoose'; +import { Schema, model, Types, Document } from 'mongoose'; -export interface IUser { +export interface IUser extends Document { _id: Types.ObjectId; email: string; firstName?: string; lastName?: string; + encryptionVersion: number; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; publicKey?: string; encryptedPrivateKey?: string; iv?: string; @@ -12,7 +16,12 @@ export interface IUser { salt?: string; verifier?: string; refreshVersion?: number; - seenIps: [string]; + isMfaEnabled: boolean; + mfaMethods: boolean; + devices: { + ip: string; + userAgent: string; + }[]; } const userSchema = new Schema( @@ -27,6 +36,23 @@ const userSchema = new Schema( lastName: { type: String }, + encryptionVersion: { + type: Number, + select: false, + default: 1 // to resolve backward-compatibility issues + }, + protectedKey: { // introduced as part of encryption version 2 + type: String, + select: false + }, + protectedKeyIV: { // introduced as part of encryption version 2 + type: String, + select: false + }, + protectedKeyTag: { // introduced as part of encryption version 2 + type: String, + select: false + }, publicKey: { type: String, select: false @@ -35,11 +61,11 @@ const userSchema = new Schema( type: String, select: false }, - iv: { + iv: { // iv of [encryptedPrivateKey] type: String, select: false }, - tag: { + tag: { // tag of [encryptedPrivateKey] type: String, select: false }, @@ -56,8 +82,21 @@ const userSchema = new Schema( default: 0, select: false }, - seenIps: [String] - }, + isMfaEnabled: { + type: Boolean, + default: false + }, + mfaMethods: [{ + type: String + }], + devices: { + type: [{ + ip: String, + userAgent: String + }], + default: [] + } + }, { timestamps: true } diff --git a/backend/src/routes/v1/auth.ts b/backend/src/routes/v1/auth.ts index 99a65e4ef..638e4501b 100644 --- a/backend/src/routes/v1/auth.ts +++ b/backend/src/routes/v1/auth.ts @@ -7,7 +7,7 @@ import { authLimiter } from '../../helpers/rateLimiter'; router.post('/token', validateRequest, authController.getNewToken); -router.post( +router.post( // deprecated (moved to api/v2/auth/login1) '/login1', authLimiter, body('email').exists().trim().notEmpty(), @@ -16,7 +16,7 @@ router.post( authController.login1 ); -router.post( +router.post( // deprecated (moved to api/v2/auth/login2) '/login2', authLimiter, body('email').exists().trim().notEmpty(), diff --git a/backend/src/routes/v1/bot.ts b/backend/src/routes/v1/bot.ts index 1b98f48c5..4d3865562 100644 --- a/backend/src/routes/v1/bot.ts +++ b/backend/src/routes/v1/bot.ts @@ -31,7 +31,7 @@ router.patch( requireBotAuth({ acceptedRoles: [ADMIN, MEMBER] }), - body('isActive').isBoolean(), + body('isActive').exists().isBoolean(), body('botKey'), validateRequest, botController.setBotActiveState diff --git a/backend/src/routes/v1/organization.ts b/backend/src/routes/v1/organization.ts index 65a4b373f..314f684ad 100644 --- a/backend/src/routes/v1/organization.ts +++ b/backend/src/routes/v1/organization.ts @@ -156,4 +156,19 @@ router.get( organizationController.getOrganizationSubscriptions ); +router.get( + '/:organizationId/workspace-memberships', + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN, MEMBER], + acceptedStatuses: [ACCEPTED] + }), + param('organizationId').exists().trim(), + validateRequest, + organizationController.getOrganizationMembersAndTheirWorkspaces +); + + export default router; diff --git a/backend/src/routes/v1/password.ts b/backend/src/routes/v1/password.ts index 784ef1813..bc353cf08 100644 --- a/backend/src/routes/v1/password.ts +++ b/backend/src/routes/v1/password.ts @@ -10,7 +10,7 @@ router.post( requireAuth({ acceptedAuthModes: ['jwt'] }), - body('clientPublicKey').exists().trim().notEmpty(), + body('clientPublicKey').exists().isString().trim().notEmpty(), validateRequest, passwordController.srp1 ); @@ -22,11 +22,14 @@ router.post( acceptedAuthModes: ['jwt'] }), body('clientProof').exists().trim().notEmpty(), - body('encryptedPrivateKey').exists().trim().notEmpty().notEmpty(), // private key encrypted under new pwd - body('iv').exists().trim().notEmpty(), // new iv for private key - body('tag').exists().trim().notEmpty(), // new tag for private key - body('salt').exists().trim().notEmpty(), // part of new pwd - body('verifier').exists().trim().notEmpty(), // part of new pwd + body('protectedKey').exists().isString().trim().notEmpty(), + body('protectedKeyIV').exists().isString().trim().notEmpty(), + body('protectedKeyTag').exists().isString().trim().notEmpty(), + body('encryptedPrivateKey').exists().isString().trim().notEmpty(), // private key encrypted under new pwd + body('encryptedPrivateKeyIV').exists().isString().trim().notEmpty(), // new iv for private key + body('encryptedPrivateKeyTag').exists().isString().trim().notEmpty(), // new tag for private key + body('salt').exists().isString().trim().notEmpty(), // part of new pwd + body('verifier').exists().isString().trim().notEmpty(), // part of new pwd validateRequest, passwordController.changePassword ); @@ -34,7 +37,7 @@ router.post( router.post( '/email/password-reset', passwordLimiter, - body('email').exists().trim().notEmpty(), + body('email').exists().isString().trim().notEmpty().isEmail(), validateRequest, passwordController.emailPasswordReset ); @@ -42,8 +45,8 @@ router.post( router.post( '/email/password-reset-verify', passwordLimiter, - body('email').exists().trim().notEmpty().isEmail(), - body('code').exists().trim().notEmpty(), + body('email').exists().isString().trim().notEmpty().isEmail(), + body('code').exists().isString().trim().notEmpty(), validateRequest, passwordController.emailPasswordResetVerify ); @@ -61,12 +64,12 @@ router.post( requireAuth({ acceptedAuthModes: ['jwt'] }), - body('clientProof').exists().trim().notEmpty(), - body('encryptedPrivateKey').exists().trim().notEmpty(), // (backup) private key encrypted under a strong key - body('iv').exists().trim().notEmpty(), // new iv for (backup) private key - body('tag').exists().trim().notEmpty(), // new tag for (backup) private key - body('salt').exists().trim().notEmpty(), // salt generated from strong key - body('verifier').exists().trim().notEmpty(), // salt generated from strong key + body('clientProof').exists().isString().trim().notEmpty(), + body('encryptedPrivateKey').exists().isString().trim().notEmpty(), // (backup) private key encrypted under a strong key + body('iv').exists().isString().trim().notEmpty(), // new iv for (backup) private key + body('tag').exists().isString().trim().notEmpty(), // new tag for (backup) private key + body('salt').exists().isString().trim().notEmpty(), // salt generated from strong key + body('verifier').exists().isString().trim().notEmpty(), // salt generated from strong key validateRequest, passwordController.createBackupPrivateKey ); @@ -74,11 +77,14 @@ router.post( router.post( '/password-reset', requireSignupAuth, - body('encryptedPrivateKey').exists().trim().notEmpty(), // private key encrypted under new pwd - body('iv').exists().trim().notEmpty(), // new iv for private key - body('tag').exists().trim().notEmpty(), // new tag for private key - body('salt').exists().trim().notEmpty(), // part of new pwd - body('verifier').exists().trim().notEmpty(), // part of new pwd + body('protectedKey').exists().isString().trim().notEmpty(), + body('protectedKeyIV').exists().isString().trim().notEmpty(), + body('protectedKeyTag').exists().isString().trim().notEmpty(), + body('encryptedPrivateKey').exists().isString().trim().notEmpty(), // private key encrypted under new pwd + body('encryptedPrivateKeyIV').exists().isString().trim().notEmpty(), // new iv for private key + body('encryptedPrivateKeyTag').exists().isString().trim().notEmpty(), // new tag for private key + body('salt').exists().isString().trim().notEmpty(), // part of new pwd + body('verifier').exists().isString().trim().notEmpty(), // part of new pwd validateRequest, passwordController.resetPassword ); diff --git a/backend/src/routes/v1/signup.ts b/backend/src/routes/v1/signup.ts index d1582474c..35a043c8e 100644 --- a/backend/src/routes/v1/signup.ts +++ b/backend/src/routes/v1/signup.ts @@ -1,7 +1,7 @@ import express from 'express'; const router = express.Router(); import { body } from 'express-validator'; -import { requireSignupAuth, validateRequest } from '../../middleware'; +import { validateRequest } from '../../middleware'; import { signupController } from '../../controllers/v1'; import { authLimiter } from '../../helpers/rateLimiter'; @@ -22,39 +22,4 @@ router.post( signupController.verifyEmailSignup ); -router.post( - '/complete-account/signup', - authLimiter, - requireSignupAuth, - body('email').exists().trim().notEmpty().isEmail(), - body('firstName').exists().trim().notEmpty(), - body('lastName').exists().trim().notEmpty(), - body('publicKey').exists().trim().notEmpty(), - body('encryptedPrivateKey').exists().trim().notEmpty(), - body('iv').exists().trim().notEmpty(), - body('tag').exists().trim().notEmpty(), - body('salt').exists().trim().notEmpty(), - body('verifier').exists().trim().notEmpty(), - body('organizationName').exists().trim().notEmpty(), - validateRequest, - signupController.completeAccountSignup -); - -router.post( - '/complete-account/invite', - authLimiter, - requireSignupAuth, - body('email').exists().trim().notEmpty().isEmail(), - body('firstName').exists().trim().notEmpty(), - body('lastName').exists().trim().notEmpty(), - body('publicKey').exists().trim().notEmpty(), - body('encryptedPrivateKey').exists().trim().notEmpty(), - body('iv').exists().trim().notEmpty(), - body('tag').exists().trim().notEmpty(), - body('salt').exists().trim().notEmpty(), - body('verifier').exists().trim().notEmpty(), - validateRequest, - signupController.completeAccountInvite -); - export default router; diff --git a/backend/src/routes/v2/auth.ts b/backend/src/routes/v2/auth.ts new file mode 100644 index 000000000..128a08fae --- /dev/null +++ b/backend/src/routes/v2/auth.ts @@ -0,0 +1,44 @@ +import express from 'express'; +const router = express.Router(); +import { body } from 'express-validator'; +import { requireMfaAuth, validateRequest } from '../../middleware'; +import { authController } from '../../controllers/v2'; +import { authLimiter } from '../../helpers/rateLimiter'; + +router.post( + '/login1', + authLimiter, + body('email').isString().trim().notEmpty(), + body('clientPublicKey').isString().trim().notEmpty(), + validateRequest, + authController.login1 +); + +router.post( + '/login2', + authLimiter, + body('email').isString().trim().notEmpty(), + body('clientProof').isString().trim().notEmpty(), + validateRequest, + authController.login2 +); + +router.post( + '/mfa/send', + authLimiter, + body('email').isString().trim().notEmpty(), + validateRequest, + authController.sendMfaToken +); + +router.post( + '/mfa/verify', + authLimiter, + requireMfaAuth, + body('email').isString().trim().notEmpty(), + body('mfaToken').isString().trim().notEmpty(), + validateRequest, + authController.verifyMfaToken +); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/v2/index.ts b/backend/src/routes/v2/index.ts index dfc9ee617..538ee4800 100644 --- a/backend/src/routes/v2/index.ts +++ b/backend/src/routes/v2/index.ts @@ -1,3 +1,5 @@ +import auth from './auth'; +import signup from './signup'; import users from './users'; import organizations from './organizations'; import workspace from './workspace'; @@ -9,6 +11,8 @@ import environment from "./environment" import tags from "./tags" export { + auth, + signup, users, organizations, workspace, diff --git a/backend/src/routes/v2/secrets.ts b/backend/src/routes/v2/secrets.ts index 9fca3039a..51629b32d 100644 --- a/backend/src/routes/v2/secrets.ts +++ b/backend/src/routes/v2/secrets.ts @@ -6,14 +6,52 @@ import { requireSecretsAuth, validateRequest } from '../../middleware'; -import { query, check, body } from 'express-validator'; +import { query, body } from 'express-validator'; import { secretsController } from '../../controllers/v2'; +import { validateSecrets } from '../../helpers/secret'; import { ADMIN, MEMBER, SECRET_PERSONAL, SECRET_SHARED } from '../../variables'; +import { + BatchSecretRequest +} from '../../types/secret'; + +router.post( + '/batch', + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + location: 'body' + }), + body('workspaceId').exists().isString().trim(), + body('environment').exists().isString().trim(), + body('requests') + .exists() + .custom(async (requests: BatchSecretRequest[], { req }) => { + if (Array.isArray(requests)) { + const secretIds = requests + .map((request) => request.secret._id) + .filter((secretId) => secretId !== undefined) + + if (secretIds.length > 0) { + const relevantSecrets = await validateSecrets({ + userId: req.user._id.toString(), + secretIds + }); + + req.secrets = relevantSecrets; + } + } + return true; + }), + validateRequest, + secretsController.batchSecrets +); router.post( '/', @@ -74,6 +112,7 @@ router.get( '/', query('workspaceId').exists().trim(), query('environment').exists().trim(), + query('tagSlugs'), validateRequest, requireAuth({ acceptedAuthModes: ['jwt', 'apiKey', 'serviceToken'] diff --git a/backend/src/routes/v2/signup.ts b/backend/src/routes/v2/signup.ts new file mode 100644 index 000000000..138591879 --- /dev/null +++ b/backend/src/routes/v2/signup.ts @@ -0,0 +1,49 @@ +import express from 'express'; +const router = express.Router(); +import { body } from 'express-validator'; +import { requireSignupAuth, validateRequest } from '../../middleware'; +import { signupController } from '../../controllers/v2'; +import { authLimiter } from '../../helpers/rateLimiter'; + +router.post( + '/complete-account/signup', + authLimiter, + requireSignupAuth, + body('email').exists().isString().trim().notEmpty().isEmail(), + body('firstName').exists().isString().trim().notEmpty(), + body('lastName').exists().isString().trim().notEmpty(), + body('protectedKey').exists().isString().trim().notEmpty(), + body('protectedKeyIV').exists().isString().trim().notEmpty(), + body('protectedKeyTag').exists().isString().trim().notEmpty(), + body('publicKey').exists().isString().trim().notEmpty(), + body('encryptedPrivateKey').exists().isString().trim().notEmpty(), + body('encryptedPrivateKeyIV').exists().isString().trim().notEmpty(), + body('encryptedPrivateKeyTag').exists().isString().trim().notEmpty(), + body('salt').exists().isString().trim().notEmpty(), + body('verifier').exists().isString().trim().notEmpty(), + body('organizationName').exists().isString().trim().notEmpty(), + validateRequest, + signupController.completeAccountSignup +); + +router.post( + '/complete-account/invite', + authLimiter, + requireSignupAuth, + body('email').exists().isString().trim().notEmpty().isEmail(), + body('firstName').exists().isString().trim().notEmpty(), + body('lastName').exists().isString().trim().notEmpty(), + body('protectedKey').exists().isString().trim().notEmpty(), + body('protectedKeyIV').exists().isString().trim().notEmpty(), + body('protectedKeyTag').exists().isString().trim().notEmpty(), + body('publicKey').exists().trim().notEmpty(), + body('encryptedPrivateKey').exists().isString().trim().notEmpty(), + body('encryptedPrivateKeyIV').exists().isString().trim().notEmpty(), + body('encryptedPrivateKeyTag').exists().isString().trim().notEmpty(), + body('salt').exists().isString().trim().notEmpty(), + body('verifier').exists().isString().trim().notEmpty(), + validateRequest, + signupController.completeAccountInvite +); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/v2/users.ts b/backend/src/routes/v2/users.ts index 48c015cca..bdf0978e1 100644 --- a/backend/src/routes/v2/users.ts +++ b/backend/src/routes/v2/users.ts @@ -1,8 +1,10 @@ import express from 'express'; const router = express.Router(); import { - requireAuth + requireAuth, + validateRequest } from '../../middleware'; +import { body } from 'express-validator'; import { usersController } from '../../controllers/v2'; router.get( @@ -13,6 +15,16 @@ router.get( usersController.getMe ); +router.patch( + '/me/mfa', + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + body('isMfaEnabled').exists().isBoolean(), + validateRequest, + usersController.updateMyMfaEnabled +); + router.get( '/me/organizations', requireAuth({ diff --git a/backend/src/services/TokenService.ts b/backend/src/services/TokenService.ts new file mode 100644 index 000000000..6299f1d54 --- /dev/null +++ b/backend/src/services/TokenService.ts @@ -0,0 +1,69 @@ +import { Types } from 'mongoose'; +import { createTokenHelper, validateTokenHelper } from '../helpers/token'; + +/** + * Class to handle token actions + * TODO: elaborate more on this class + */ +class TokenService { + /** + * Create a token [token] for type [type] with associated details + * @param {Object} obj + * @param {String} obj.type - type or context of token (e.g. emailConfirmation) + * @param {String} obj.email - email associated with the token + * @param {String} obj.phoneNumber - phone number associated with the token + * @param {Types.ObjectId} obj.organizationId - id of organization associated with the token + * @returns {String} token - the token to create + */ + static async createToken({ + type, + email, + phoneNumber, + organizationId + }: { + type: 'emailConfirmation' | 'emailMfa' | 'organizationInvitation' | 'passwordReset'; + email?: string; + phoneNumber?: string; + organizationId?: Types.ObjectId; + }) { + return await createTokenHelper({ + type, + email, + phoneNumber, + organizationId + }); + } + + /** + * Validate whether or not token [token] and its associated details match a token in the DB + * @param {Object} obj + * @param {String} obj.type - type or context of token (e.g. emailConfirmation) + * @param {String} obj.email - email associated with the token + * @param {String} obj.phoneNumber - phone number associated with the token + * @param {Types.ObjectId} obj.organizationId - id of organization associated with the token + * @param {String} obj.token - the token to validate + */ + static async validateToken({ + type, + email, + phoneNumber, + organizationId, + token + }: { + type: 'emailConfirmation' | 'emailMfa' | 'organizationInvitation' | 'passwordReset'; + email?: string; + phoneNumber?: string; + organizationId?: Types.ObjectId; + token: string; + }) { + return await validateTokenHelper({ + type, + email, + phoneNumber, + organizationId, + token + }); + } +} + +export default TokenService; \ No newline at end of file diff --git a/backend/src/services/index.ts b/backend/src/services/index.ts index c53829922..8ac393cf5 100644 --- a/backend/src/services/index.ts +++ b/backend/src/services/index.ts @@ -3,11 +3,13 @@ import postHogClient from './PostHogClient'; import BotService from './BotService'; import EventService from './EventService'; import IntegrationService from './IntegrationService'; +import TokenService from './TokenService'; export { DatabaseService, postHogClient, BotService, EventService, - IntegrationService + IntegrationService, + TokenService } \ No newline at end of file diff --git a/backend/src/templates/emailMfa.handlebars b/backend/src/templates/emailMfa.handlebars new file mode 100644 index 000000000..489c9dd30 --- /dev/null +++ b/backend/src/templates/emailMfa.handlebars @@ -0,0 +1,19 @@ + + + + + + + MFA Code + + + +

Infisical

+

Sign in attempt requires further verification

+

Your MFA code is below — enter it where you started signing in to Infisical.

+

{{code}}

+

The MFA code will be valid for 2 minutes.

+

Not you? Contact Infisical or your administrator immediately.

+ + + \ No newline at end of file diff --git a/backend/src/templates/emailVerification.handlebars b/backend/src/templates/emailVerification.handlebars index e6e5a925a..fc738d202 100644 --- a/backend/src/templates/emailVerification.handlebars +++ b/backend/src/templates/emailVerification.handlebars @@ -1,14 +1,17 @@ + - Email Verification + Code +

Confirm your email address

Your confirmation code is below — enter it in the browser window where you've started signing up for Infisical.

{{code}}

Questions about setting up Infisical? Email us at support@infisical.com

+ \ No newline at end of file diff --git a/backend/src/templates/newDevice.handlebars b/backend/src/templates/newDevice.handlebars new file mode 100644 index 000000000..654bb1ba3 --- /dev/null +++ b/backend/src/templates/newDevice.handlebars @@ -0,0 +1,19 @@ + + + + + + + Successful login for {{email}} from new device + + + +

Infisical

+

We're verifying a recent login for {{email}}:

+

Timestamp: {{timestamp}}

+

IP address: {{ip}}

+

User agent: {{userAgent}}

+

If you believe that this login is suspicious, please contact Infisical or reset your password immediately.

+ + + \ No newline at end of file diff --git a/backend/src/types/secret/index.d.ts b/backend/src/types/secret/index.d.ts index 177df8c0f..257038f60 100644 --- a/backend/src/types/secret/index.d.ts +++ b/backend/src/types/secret/index.d.ts @@ -1,5 +1,7 @@ +import { Types } from 'mongoose'; import { Assign, Omit } from 'utility-types'; import { ISecret } from '../../models'; +import { mongo } from 'mongoose'; // Everything is required, except the omitted types export type CreateSecretRequestBody = Omit; @@ -12,3 +14,39 @@ export type SanitizedSecretModify = Partial; + +export interface BatchSecretRequest { + id: string; + method: 'POST' | 'PATCH' | 'DELETE'; + secret: Secret; +} + +export interface BatchSecret { + _id: string; + type: 'shared' | 'personal', + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext: string; + secretCommentIV: string; + secretCommentTag: string; + tags: string[]; +} + +export interface BatchSecret { + _id: string; + type: 'shared' | 'personal', + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext: string; + secretCommentIV: string; + secretCommentTag: string; + tags: string[]; +} \ No newline at end of file diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index 52feec126..23854bc58 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -40,10 +40,19 @@ import { ACTION_ADD_SECRETS, ACTION_UPDATE_SECRETS, ACTION_DELETE_SECRETS, - ACTION_READ_SECRETS, -} from "./action"; -import { SMTP_HOST_SENDGRID, SMTP_HOST_MAILGUN } from "./smtp"; -import { PLAN_STARTER, PLAN_PRO } from "./stripe"; + ACTION_READ_SECRETS +} from './action'; +import { SMTP_HOST_SENDGRID, SMTP_HOST_MAILGUN } from './smtp'; +import { PLAN_STARTER, PLAN_PRO } from './stripe'; +import { + MFA_METHOD_EMAIL +} from './user'; +import { + TOKEN_EMAIL_CONFIRMATION, + TOKEN_EMAIL_MFA, + TOKEN_EMAIL_ORG_INVITATION, + TOKEN_EMAIL_PASSWORD_RESET +} from './token'; export { OWNER, @@ -94,4 +103,9 @@ export { SMTP_HOST_MAILGUN, PLAN_STARTER, PLAN_PRO, + MFA_METHOD_EMAIL, + TOKEN_EMAIL_CONFIRMATION, + TOKEN_EMAIL_MFA, + TOKEN_EMAIL_ORG_INVITATION, + TOKEN_EMAIL_PASSWORD_RESET }; diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index de7853ffa..3ef8d6f1e 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -125,6 +125,15 @@ const INTEGRATION_OPTIONS = [ clientId: '', docsLink: '' }, + { + name: 'Circle CI', + slug: 'circleci', + image: 'Circle CI.png', + isAvailable: true, + type: 'pat', + clientId: '', + docsLink: '' + }, { name: 'Azure Key Vault', slug: 'azure-key-vault', @@ -152,15 +161,6 @@ const INTEGRATION_OPTIONS = [ type: '', clientId: '', docsLink: '' - }, - { - name: 'Circle CI', - slug: 'circleci', - image: 'Circle CI.png', - isAvailable: true, - type: 'pat', - clientId: '', - docsLink: '' } ] diff --git a/backend/src/variables/token.ts b/backend/src/variables/token.ts new file mode 100644 index 000000000..ecb63990f --- /dev/null +++ b/backend/src/variables/token.ts @@ -0,0 +1,11 @@ +const TOKEN_EMAIL_CONFIRMATION = 'emailConfirmation'; +const TOKEN_EMAIL_MFA = 'emailMfa'; +const TOKEN_EMAIL_ORG_INVITATION = 'organizationInvitation'; +const TOKEN_EMAIL_PASSWORD_RESET = 'passwordReset'; + +export { + TOKEN_EMAIL_CONFIRMATION, + TOKEN_EMAIL_MFA, + TOKEN_EMAIL_ORG_INVITATION, + TOKEN_EMAIL_PASSWORD_RESET +} \ No newline at end of file diff --git a/backend/src/variables/user.ts b/backend/src/variables/user.ts new file mode 100644 index 000000000..baa27d35d --- /dev/null +++ b/backend/src/variables/user.ts @@ -0,0 +1,5 @@ +const MFA_METHOD_EMAIL = 'email'; + +export { + MFA_METHOD_EMAIL +} \ No newline at end of file diff --git a/cli/docker/Dockerfile b/cli/docker/Dockerfile new file mode 100644 index 000000000..0436d4d8e --- /dev/null +++ b/cli/docker/Dockerfile @@ -0,0 +1,4 @@ +FROM alpine +RUN apk add --no-cache tini +COPY infisical /bin/infisical +ENTRYPOINT ["/sbin/tini", "--", "/bin/infisical"] \ No newline at end of file diff --git a/cli/go.mod b/cli/go.mod index 6b7d92489..fd35082e3 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -7,8 +7,8 @@ require ( github.com/muesli/mango-cobra v1.2.0 github.com/muesli/roff v0.1.0 github.com/spf13/cobra v1.6.1 - golang.org/x/crypto v0.3.0 - golang.org/x/term v0.3.0 + golang.org/x/crypto v0.6.0 + golang.org/x/term v0.5.0 ) require ( @@ -31,8 +31,8 @@ require ( github.com/oklog/ulid v1.3.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect go.mongodb.org/mongo-driver v1.10.0 // indirect - golang.org/x/net v0.2.0 // indirect - golang.org/x/sys v0.3.0 // indirect + golang.org/x/net v0.6.0 // indirect + golang.org/x/sys v0.5.0 // indirect ) require ( diff --git a/cli/go.sum b/cli/go.sum index f7e17c62a..8a3818528 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -106,10 +106,14 @@ go.mongodb.org/mongo-driver v1.10.0/go.mod h1:wsihk0Kdgv8Kqu1Anit4sfK+22vSFbUrAV golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -123,9 +127,13 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index 37bd5f299..f0a347800 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -114,6 +114,7 @@ func CallGetSecretsV2(httpClient *resty.Client, request GetEncryptedSecretsV2Req SetHeader("User-Agent", USER_AGENT). SetQueryParam("environment", request.Environment). SetQueryParam("workspaceId", request.WorkspaceId). + SetQueryParam("tagSlugs", request.TagSlugs). Get(fmt.Sprintf("%v/v2/secrets", config.INFISICAL_URL)) if err != nil { @@ -127,6 +128,68 @@ func CallGetSecretsV2(httpClient *resty.Client, request GetEncryptedSecretsV2Req return secretsResponse, nil } +func CallLogin1V2(httpClient *resty.Client, request GetLoginOneV2Request) (GetLoginOneV2Response, error) { + var loginOneV2Response GetLoginOneV2Response + response, err := httpClient. + R(). + SetResult(&loginOneV2Response). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Post(fmt.Sprintf("%v/v2/auth/login1", config.INFISICAL_URL)) + + if err != nil { + return GetLoginOneV2Response{}, fmt.Errorf("CallLogin1V2: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return GetLoginOneV2Response{}, fmt.Errorf("CallLogin1V2: Unsuccessful response: [response=%s]", response) + } + + return loginOneV2Response, nil +} + +func CallVerifyMfaToken(httpClient *resty.Client, request VerifyMfaTokenRequest) (*VerifyMfaTokenResponse, *VerifyMfaTokenErrorResponse, error) { + var verifyMfaTokenResponse VerifyMfaTokenResponse + var responseError VerifyMfaTokenErrorResponse + response, err := httpClient. + R(). + SetResult(&verifyMfaTokenResponse). + SetHeader("User-Agent", USER_AGENT). + SetError(&responseError). + SetBody(request). + Post(fmt.Sprintf("%v/v2/auth/mfa/verify", config.INFISICAL_URL)) + + if err != nil { + return nil, nil, fmt.Errorf("CallVerifyMfaToken: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return nil, &responseError, nil + } + + return &verifyMfaTokenResponse, nil, nil +} + +func CallLogin2V2(httpClient *resty.Client, request GetLoginTwoV2Request) (GetLoginTwoV2Response, error) { + var loginTwoV2Response GetLoginTwoV2Response + response, err := httpClient. + R(). + SetResult(&loginTwoV2Response). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Post(fmt.Sprintf("%v/v2/auth/login2", config.INFISICAL_URL)) + + if err != nil { + return GetLoginTwoV2Response{}, fmt.Errorf("CallLogin2V2: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return GetLoginTwoV2Response{}, fmt.Errorf("CallLogin2V2: Unsuccessful response: [response=%s]", response) + } + + return loginTwoV2Response, nil +} + func CallGetAllWorkSpacesUserBelongsTo(httpClient *resty.Client) (GetWorkSpacesResponse, error) { var workSpacesResponse GetWorkSpacesResponse response, err := httpClient. @@ -154,13 +217,12 @@ func CallIsAuthenticated(httpClient *resty.Client) bool { SetHeader("User-Agent", USER_AGENT). Post(fmt.Sprintf("%v/v1/auth/checkAuth", config.INFISICAL_URL)) - log.Debugln(fmt.Errorf("CallIsAuthenticated: Unsuccessful response: [response=%v]", response)) - if err != nil { return false } if response.IsError() { + log.Debugln(fmt.Errorf("CallIsAuthenticated: Unsuccessful response: [response=%v]", response)) return false } @@ -175,8 +237,6 @@ func CallGetAccessibleEnvironments(httpClient *resty.Client, request GetAccessib SetHeader("User-Agent", USER_AGENT). Get(fmt.Sprintf("%v/v2/workspace/%s/environments", config.INFISICAL_URL, request.WorkspaceId)) - log.Debugln(fmt.Errorf("CallGetAccessibleEnvironments: Unsuccessful response: [response=%v]", response)) - if err != nil { return GetAccessibleEnvironmentsResponse{}, err } diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index 22f3e4c4a..af8dbc5b4 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -197,6 +197,7 @@ type GetSecretsByWorkspaceIdAndEnvironmentRequest struct { type GetEncryptedSecretsV2Request struct { Environment string `json:"environment"` WorkspaceId string `json:"workspaceId"` + TagSlugs string `json:"tagSlugs"` } type GetEncryptedSecretsV2Response struct { @@ -262,3 +263,63 @@ type GetAccessibleEnvironmentsResponse struct { IsWriteDenied bool `json:"isWriteDenied"` } `json:"accessibleEnvironments"` } + +type GetLoginOneV2Request struct { + Email string `json:"email"` + ClientPublicKey string `json:"clientPublicKey"` +} + +type GetLoginOneV2Response struct { + ServerPublicKey string `json:"serverPublicKey"` + Salt string `json:"salt"` +} + +type GetLoginTwoV2Request struct { + Email string `json:"email"` + ClientProof string `json:"clientProof"` +} + +type GetLoginTwoV2Response struct { + MfaEnabled bool `json:"mfaEnabled"` + EncryptionVersion int `json:"encryptionVersion"` + Token string `json:"token"` + PublicKey string `json:"publicKey"` + EncryptedPrivateKey string `json:"encryptedPrivateKey"` + Iv string `json:"iv"` + Tag string `json:"tag"` + ProtectedKey string `json:"protectedKey"` + ProtectedKeyIV string `json:"protectedKeyIV"` + ProtectedKeyTag string `json:"protectedKeyTag"` +} + +type VerifyMfaTokenRequest struct { + Email string `json:"email"` + MFAToken string `json:"mfaToken"` +} + +type VerifyMfaTokenResponse struct { + EncryptionVersion int `json:"encryptionVersion"` + Token string `json:"token"` + PublicKey string `json:"publicKey"` + EncryptedPrivateKey string `json:"encryptedPrivateKey"` + Iv string `json:"iv"` + Tag string `json:"tag"` + ProtectedKey string `json:"protectedKey"` + ProtectedKeyIV string `json:"protectedKeyIV"` + ProtectedKeyTag string `json:"protectedKeyTag"` +} + +type VerifyMfaTokenErrorResponse struct { + Type string `json:"type"` + Message string `json:"message"` + Context struct { + Code string `json:"code"` + TriesLeft int `json:"triesLeft"` + } `json:"context"` + Level int `json:"level"` + LevelName string `json:"level_name"` + StatusCode int `json:"status_code"` + DatetimeIso time.Time `json:"datetime_iso"` + Application string `json:"application"` + Extra []interface{} `json:"extra"` +} diff --git a/cli/packages/cmd/export.go b/cli/packages/cmd/export.go index c2963f6bd..d1e72ff2d 100644 --- a/cli/packages/cmd/export.go +++ b/cli/packages/cmd/export.go @@ -61,7 +61,12 @@ var exportCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } - secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: envName, InfisicalToken: infisicalToken}) + tagSlugs, err := cmd.Flags().GetString("tags") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: envName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs}) if err != nil { util.HandleError(err, "Unable to fetch secrets") } @@ -97,6 +102,7 @@ func init() { exportCmd.Flags().StringP("format", "f", "dotenv", "Set the format of the output file (dotenv, json, csv)") exportCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") exportCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") + exportCmd.Flags().StringP("tags", "t", "", "filter secrets by tag slugs") } // Format according to the format flag diff --git a/cli/packages/cmd/init.go b/cli/packages/cmd/init.go index 413f61440..d5d9164ba 100644 --- a/cli/packages/cmd/init.go +++ b/cli/packages/cmd/init.go @@ -56,7 +56,7 @@ var initCmd = &cobra.Command{ workspaces := workspaceResponse.Workspaces if len(workspaces) == 0 { message := fmt.Sprintf("You don't have any projects created in Infisical. You must first create a project at %s", util.INFISICAL_TOKEN_NAME) - util.PrintMessageAndExit(message) + util.PrintErrorMessageAndExit(message) } var workspaceNames []string diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index c37bdda08..e09253407 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -13,7 +13,6 @@ import ( "regexp" "github.com/Infisical/infisical-merge/packages/api" - "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/crypto" "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/srp" @@ -23,8 +22,17 @@ import ( "github.com/manifoldco/promptui" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "golang.org/x/crypto/argon2" ) +type params struct { + memory uint32 + iterations uint32 + parallelism uint8 + saltLength uint32 + keyLength uint32 +} + // loginCmd represents the login command var loginCmd = &cobra.Command{ Use: "login", @@ -55,36 +63,146 @@ var loginCmd = &cobra.Command{ util.HandleError(err, "Unable to parse email and password for authentication") } - userCredentials, err := getFreshUserCredentials(email, password) + loginOneResponse, loginTwoResponse, err := getFreshUserCredentials(email, password) if err != nil { log.Infoln("Unable to authenticate with the provided credentials, please try again") log.Debugln(err) return } - encryptedPrivateKey, _ := base64.StdEncoding.DecodeString(userCredentials.EncryptedPrivateKey) - tag, err := base64.StdEncoding.DecodeString(userCredentials.Tag) - if err != nil { - util.HandleError(err) + if loginTwoResponse.MfaEnabled { + i := 1 + for i < 6 { + mfaVerifyCode := askForMFACode() + + httpClient := resty.New() + httpClient.SetAuthToken(loginTwoResponse.Token) + verifyMFAresponse, mfaErrorResponse, requestError := api.CallVerifyMfaToken(httpClient, api.VerifyMfaTokenRequest{ + Email: email, + MFAToken: mfaVerifyCode, + }) + + if requestError != nil { + util.HandleError(err) + break + } else if mfaErrorResponse != nil { + if mfaErrorResponse.Context.Code == "mfa_invalid" { + msg := fmt.Sprintf("Incorrect, MFA code. You have %v attempts left", 5-i) + fmt.Println(msg) + if i == 5 { + util.PrintErrorMessageAndExit("No tries left, please try again in a bit") + break + } + } + + if mfaErrorResponse.Context.Code == "mfa_expired" { + util.PrintErrorMessageAndExit("Your MFA code has expired, please try logging in again") + break + } + i++ + } else { + loginTwoResponse.EncryptedPrivateKey = verifyMFAresponse.EncryptedPrivateKey + loginTwoResponse.EncryptionVersion = verifyMFAresponse.EncryptionVersion + loginTwoResponse.Iv = verifyMFAresponse.Iv + loginTwoResponse.ProtectedKey = verifyMFAresponse.ProtectedKey + loginTwoResponse.ProtectedKeyIV = verifyMFAresponse.ProtectedKeyIV + loginTwoResponse.ProtectedKeyTag = verifyMFAresponse.ProtectedKeyTag + loginTwoResponse.PublicKey = verifyMFAresponse.PublicKey + loginTwoResponse.Tag = verifyMFAresponse.Tag + loginTwoResponse.Token = verifyMFAresponse.Token + loginTwoResponse.EncryptionVersion = verifyMFAresponse.EncryptionVersion + + break + } + } } - IV, err := base64.StdEncoding.DecodeString(userCredentials.IV) - if err != nil { - util.HandleError(err) - } + var decryptedPrivateKey []byte - paddedPassword := fmt.Sprintf("%032s", password) - key := []byte(paddedPassword) + if loginTwoResponse.EncryptionVersion == 1 { + encryptedPrivateKey, _ := base64.StdEncoding.DecodeString(loginTwoResponse.EncryptedPrivateKey) + tag, err := base64.StdEncoding.DecodeString(loginTwoResponse.Tag) + if err != nil { + util.HandleError(err) + } - decryptedPrivateKey, err := crypto.DecryptSymmetric(key, encryptedPrivateKey, tag, IV) - if err != nil || len(decryptedPrivateKey) == 0 { - util.HandleError(err) + IV, err := base64.StdEncoding.DecodeString(loginTwoResponse.Iv) + if err != nil { + util.HandleError(err) + } + + paddedPassword := fmt.Sprintf("%032s", password) + key := []byte(paddedPassword) + + decryptedPrivateKey, err := crypto.DecryptSymmetric(key, encryptedPrivateKey, tag, IV) + if err != nil || len(decryptedPrivateKey) == 0 { + util.HandleError(err) + } + } else if loginTwoResponse.EncryptionVersion == 2 { + protectedKey, err := base64.StdEncoding.DecodeString(loginTwoResponse.ProtectedKey) + if err != nil { + util.HandleError(err) + } + + protectedKeyTag, err := base64.StdEncoding.DecodeString(loginTwoResponse.ProtectedKeyTag) + if err != nil { + util.HandleError(err) + } + + protectedKeyIV, err := base64.StdEncoding.DecodeString(loginTwoResponse.ProtectedKeyIV) + if err != nil { + util.HandleError(err) + } + + nonProtectedTag, err := base64.StdEncoding.DecodeString(loginTwoResponse.Tag) + if err != nil { + util.HandleError(err) + } + + nonProtectedIv, err := base64.StdEncoding.DecodeString(loginTwoResponse.Iv) + if err != nil { + util.HandleError(err) + } + + parameters := ¶ms{ + memory: 64 * 1024, + iterations: 3, + parallelism: 1, + keyLength: 32, + } + + derivedKey, err := generateFromPassword(password, []byte(loginOneResponse.Salt), parameters) + if err != nil { + util.HandleError(fmt.Errorf("unable to generate argon hash from password [err=%s]", err)) + } + + decryptedProtectedKey, err := crypto.DecryptSymmetric(derivedKey, protectedKey, protectedKeyTag, protectedKeyIV) + if err != nil { + util.HandleError(fmt.Errorf("unable to get decrypted protected key [err=%s]", err)) + } + + encryptedPrivateKey, err := base64.StdEncoding.DecodeString(loginTwoResponse.EncryptedPrivateKey) + if err != nil { + util.HandleError(err) + } + + decryptedProtectedKeyInHex, err := hex.DecodeString(string(decryptedProtectedKey)) + if err != nil { + util.HandleError(err) + } + + decryptedPrivateKey, err = crypto.DecryptSymmetric(decryptedProtectedKeyInHex, encryptedPrivateKey, nonProtectedTag, nonProtectedIv) + if err != nil { + util.HandleError(err) + } + } else { + util.PrintErrorMessageAndExit("Insufficient details to decrypt private key") } userCredentialsToBeStored := &models.UserCredentials{ Email: email, PrivateKey: string(decryptedPrivateKey), - JTWToken: userCredentials.JTWToken, + JTWToken: loginTwoResponse.Token, } err = util.StoreUserCredsInKeyRing(userCredentialsToBeStored) @@ -155,7 +273,7 @@ func askForLoginCredentials() (email string, password string, err error) { return userEmail, userPassword, nil } -func getFreshUserCredentials(email string, password string) (*api.LoginTwoResponse, error) { +func getFreshUserCredentials(email string, password string) (*api.GetLoginOneV2Response, *api.GetLoginTwoV2Response, error) { log.Debugln("getFreshUserCredentials:", "email", email, "password", password) httpClient := resty.New() httpClient.SetRetryCount(5) @@ -166,36 +284,24 @@ func getFreshUserCredentials(email string, password string) (*api.LoginTwoRespon srpA := hex.EncodeToString(srpClient.ComputeA()) // ** Login one - loginOneRequest := api.LoginOneRequest{ + loginOneResponseResult, err := api.CallLogin1V2(httpClient, api.GetLoginOneV2Request{ Email: email, ClientPublicKey: srpA, - } - - var loginOneResponseResult api.LoginOneResponse - - loginOneResponse, err := httpClient. - R(). - SetBody(loginOneRequest). - SetResult(&loginOneResponseResult). - Post(fmt.Sprintf("%v/v1/auth/login1", config.INFISICAL_URL)) + }) if err != nil { - return nil, err - } - - if loginOneResponse.StatusCode() > 299 { - return nil, fmt.Errorf("ops, unsuccessful response code. [response=%v]", loginOneResponse) + util.HandleError(err) } // **** Login 2 serverPublicKey_bytearray, err := hex.DecodeString(loginOneResponseResult.ServerPublicKey) if err != nil { - return nil, err + return nil, nil, err } - userSalt, err := hex.DecodeString(loginOneResponseResult.ServerSalt) + userSalt, err := hex.DecodeString(loginOneResponseResult.Salt) if err != nil { - return nil, err + return nil, nil, err } srpClient.SetSalt(userSalt, []byte(email), []byte(password)) @@ -203,27 +309,16 @@ func getFreshUserCredentials(email string, password string) (*api.LoginTwoRespon srpM1 := srpClient.ComputeM1() - LoginTwoRequest := api.LoginTwoRequest{ + loginTwoResponseResult, err := api.CallLogin2V2(httpClient, api.GetLoginTwoV2Request{ Email: email, ClientProof: hex.EncodeToString(srpM1), - } - - var loginTwoResponseResult api.LoginTwoResponse - loginTwoResponse, err := httpClient. - R(). - SetBody(LoginTwoRequest). - SetResult(&loginTwoResponseResult). - Post(fmt.Sprintf("%v/v1/auth/login2", config.INFISICAL_URL)) + }) if err != nil { - return nil, err + util.HandleError(err) } - if loginTwoResponse.StatusCode() > 299 { - return nil, fmt.Errorf("ops, unsuccessful response code. [response=%v]", loginTwoResponse) - } - - return &loginTwoResponseResult, nil + return &loginOneResponseResult, &loginTwoResponseResult, nil } func shouldOverrideLoginPrompt(currentLoggedInUserEmail string) (bool, error) { @@ -237,3 +332,21 @@ func shouldOverrideLoginPrompt(currentLoggedInUserEmail string) (bool, error) { } return result == "Yes", err } + +func generateFromPassword(password string, salt []byte, p *params) (hash []byte, err error) { + hash = argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength) + return hash, nil +} + +func askForMFACode() string { + mfaCodePromptUI := promptui.Prompt{ + Label: "MFA verification code", + } + + mfaVerifyCode, err := mfaCodePromptUI.Run() + if err != nil { + util.HandleError(err) + } + + return mfaVerifyCode +} diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index 265da0aba..70c6e627d 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -74,7 +74,12 @@ var runCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } - secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: envName, InfisicalToken: infisicalToken}) + tagSlugs, err := cmd.Flags().GetString("tags") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: envName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs}) if err != nil { util.HandleError(err, "Could not fetch secrets", "If you are using a service token to fetch secrets, please ensure it is valid") @@ -148,6 +153,7 @@ func init() { runCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") runCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") runCmd.Flags().StringP("command", "c", "", "chained commands to execute (e.g. \"npm install && npm run dev; echo ...\")") + runCmd.Flags().StringP("tags", "t", "", "filter secrets by tag slugs ") } // Will execute a single command and pass in the given secrets into the process diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index a00740809..4bb8d0663 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -46,7 +46,12 @@ var secretsCmd = &cobra.Command{ util.HandleError(err) } - secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken}) + tagSlugs, err := cmd.Flags().GetString("tags") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs}) if err != nil { util.HandleError(err) } @@ -87,15 +92,13 @@ var secretsSetCmd = &cobra.Command{ PreRun: toggleDebug, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { + util.RequireLocalWorkspaceFile() + environmentName, err := cmd.Flags().GetString("env") if err != nil { util.HandleError(err, "Unable to parse flag") } - if !util.IsSecretEnvironmentValid(environmentName) { - util.PrintMessageAndExit("You have entered a invalid environment name", "Environment names can only be prod, dev, test or staging") - } - workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { util.HandleError(err, "Unable to get your local config details") @@ -148,11 +151,11 @@ var secretsSetCmd = &cobra.Command{ for _, arg := range args { splitKeyValueFromArg := strings.SplitN(arg, "=", 2) if splitKeyValueFromArg[0] == "" || splitKeyValueFromArg[1] == "" { - util.PrintMessageAndExit("ensure that each secret has a none empty key and value. Modify the input and try again") + util.PrintErrorMessageAndExit("ensure that each secret has a none empty key and value. Modify the input and try again") } if unicode.IsNumber(rune(splitKeyValueFromArg[0][0])) { - util.PrintMessageAndExit("keys of secrets cannot start with a number. Modify the key name(s) and try again") + util.PrintErrorMessageAndExit("keys of secrets cannot start with a number. Modify the key name(s) and try again") } // Key and value from argument @@ -303,7 +306,7 @@ var secretsDeleteCmd = &cobra.Command{ if len(invalidSecretNamesThatDoNotExist) != 0 { message := fmt.Sprintf("secret name(s) [%v] does not exist in your project. To see which secrets exist run [infisical secrets]", strings.Join(invalidSecretNamesThatDoNotExist, ", ")) - util.PrintMessageAndExit(message) + util.PrintErrorMessageAndExit(message) } request := api.BatchDeleteSecretsBySecretIdsRequest{ @@ -332,17 +335,17 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse flag") } - workspaceFileExists := util.WorkspaceConfigFileExistsInCurrentPath() - if !workspaceFileExists { - util.HandleError(err, "Unable to parse flag") - } - infisicalToken, err := cmd.Flags().GetString("token") if err != nil { util.HandleError(err, "Unable to parse flag") } - secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken}) + tagSlugs, err := cmd.Flags().GetString("tags") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs}) if err != nil { util.HandleError(err, "To fetch all secrets") } @@ -375,22 +378,27 @@ func generateExampleEnv(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse flag") } - workspaceFileExists := util.WorkspaceConfigFileExistsInCurrentPath() - if !workspaceFileExists { - util.HandleError(err, "Unable to parse flag") - } - infisicalToken, err := cmd.Flags().GetString("token") if err != nil { util.HandleError(err, "Unable to parse flag") } - secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken}) + tagSlugs, err := cmd.Flags().GetString("tags") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs}) if err != nil { util.HandleError(err, "To fetch all secrets") } tagsHashToSecretKey := make(map[string]int) + slugsToFilerBy := make(map[string]int) + + for _, slug := range strings.Split(tagSlugs, ",") { + slugsToFilerBy[slug] = 1 + } type TagsAndSecrets struct { Secrets []models.SingleEnvironmentVariable @@ -407,6 +415,25 @@ func generateExampleEnv(cmd *cobra.Command, args []string) { return len(secrets[i].Tags) > len(secrets[j].Tags) }) + for i, secret := range secrets { + filteredTag := []struct { + ID string "json:\"_id\"" + Name string "json:\"name\"" + Slug string "json:\"slug\"" + Workspace string "json:\"workspace\"" + }{} + + for _, secretTag := range secret.Tags { + _, exists := slugsToFilerBy[secretTag.Slug] + if !exists { + filteredTag = append(filteredTag, secretTag) + } + } + + secret.Tags = filteredTag + secrets[i] = secret + } + for _, secret := range secrets { listOfTagSlugs := []string{} @@ -470,6 +497,8 @@ func generateExampleEnv(cmd *cobra.Command, args []string) { return len(listOfsecretDetails[i].Tags) < len(listOfsecretDetails[j].Tags) }) + tableOfContents := []string{} + fullyGeneratedDocuments := []string{} for _, secretDetails := range listOfsecretDetails { listOfKeyValue := []string{} @@ -510,11 +539,22 @@ func generateExampleEnv(cmd *cobra.Command, args []string) { heading := CenterString(strings.Join(listOfTagNames, " & "), 80) if len(listOfTagNames) == 0 { - fmt.Printf("\n%s \n", strings.Join(listOfKeyValue, "\n \n")) + fullyGeneratedDocuments = append(fullyGeneratedDocuments, fmt.Sprintf("\n%s \n", strings.Join(listOfKeyValue, "\n"))) } else { - fmt.Printf("\n\n\n%s \n%s \n", heading, strings.Join(listOfKeyValue, "\n \n")) + fullyGeneratedDocuments = append(fullyGeneratedDocuments, fmt.Sprintf("\n\n\n%s \n%s \n", heading, strings.Join(listOfKeyValue, "\n"))) + tableOfContents = append(tableOfContents, strings.ToUpper(strings.Join(listOfTagNames, " & "))) } } + + dashedList := []string{} + for _, item := range tableOfContents { + dashedList = append(dashedList, fmt.Sprintf("# - %s \n", item)) + } + if len(dashedList) > 0 { + fmt.Println(CenterString("TABLE OF CONTENTS", 80)) + fmt.Println(strings.Join(dashedList, "")) + } + fmt.Println(strings.Join(fullyGeneratedDocuments, "")) } func CenterString(s string, numStars int) string { @@ -567,5 +607,6 @@ func init() { secretsCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") secretsCmd.PersistentFlags().String("env", "dev", "Used to select the environment name on which actions should be taken on") secretsCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") + secretsCmd.PersistentFlags().StringP("tags", "t", "", "filter secrets by tag slugs") rootCmd.AddCommand(secretsCmd) } diff --git a/cli/packages/models/cli.go b/cli/packages/models/cli.go index 3ffc1cc20..fca9ae0f2 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -39,7 +39,8 @@ type Workspace struct { } type WorkspaceConfigFile struct { - WorkspaceId string `json:"workspaceId"` + WorkspaceId string `json:"workspaceId"` + DefaultEnvironment string `json:"defaultEnvironment"` } type SymmetricEncryptionResult struct { @@ -51,4 +52,5 @@ type SymmetricEncryptionResult struct { type GetAllSecretsParameters struct { Environment string InfisicalToken string + TagSlugs string } diff --git a/cli/packages/util/helper.go b/cli/packages/util/helper.go index 3fd8853c4..2a7ac7317 100644 --- a/cli/packages/util/helper.go +++ b/cli/packages/util/helper.go @@ -65,29 +65,29 @@ func RequireLogin() { } if !currentUserDetails.IsUserLoggedIn { - PrintMessageAndExit("You must be logged in to run this command. To login, run [infisical login]") + PrintErrorMessageAndExit("You must be logged in to run this command. To login, run [infisical login]") } if currentUserDetails.LoginExpired { - PrintMessageAndExit("Your login expired, please login in again. To login, run [infisical login]") + PrintErrorMessageAndExit("Your login expired, please login in again. To login, run [infisical login]") } if currentUserDetails.UserCredentials.Email == "" && currentUserDetails.UserCredentials.JTWToken == "" && currentUserDetails.UserCredentials.PrivateKey == "" { - PrintMessageAndExit("One or more of your login details is empty. Please try logging in again via by running [infisical login]") + PrintErrorMessageAndExit("One or more of your login details is empty. Please try logging in again via by running [infisical login]") } } func RequireServiceToken() { serviceToken := os.Getenv(INFISICAL_TOKEN_NAME) if serviceToken == "" { - PrintMessageAndExit("No service token is found in your terminal") + PrintErrorMessageAndExit("No service token is found in your terminal") } } func RequireLocalWorkspaceFile() { workspaceFileExists := WorkspaceConfigFileExistsInCurrentPath() if !workspaceFileExists { - PrintMessageAndExit("It looks you have not yet connected this project to Infisical", "To do so, run [infisical init] then run your command again") + PrintErrorMessageAndExit("It looks you have not yet connected this project to Infisical", "To do so, run [infisical init] then run your command again") } workspaceFile, err := GetWorkSpaceFromFile() @@ -96,7 +96,7 @@ func RequireLocalWorkspaceFile() { } if workspaceFile.WorkspaceId == "" { - PrintMessageAndExit("Your project id is missing in your local config file. Please add it or run again [infisical init]") + PrintErrorMessageAndExit("Your project id is missing in your local config file. Please add it or run again [infisical init]") } } diff --git a/cli/packages/util/log.go b/cli/packages/util/log.go index 68144e074..4c11322c2 100644 --- a/cli/packages/util/log.go +++ b/cli/packages/util/log.go @@ -31,10 +31,10 @@ func PrintSuccessMessage(message string) { color.New(color.FgGreen).Println(message) } -func PrintMessageAndExit(messages ...string) { +func PrintErrorMessageAndExit(messages ...string) { if len(messages) > 0 { for _, message := range messages { - fmt.Println(message) + fmt.Fprintln(os.Stderr, message) } } @@ -42,5 +42,5 @@ func PrintMessageAndExit(messages ...string) { } func printError(e error) { - color.Red("Hmm, we ran into an error: %v", e) + color.New(color.FgRed).Fprintf(os.Stderr, "Hmm, we ran into an error: %v", e) } diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index 5b4cd2480..8caa07de4 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -62,7 +62,7 @@ func GetPlainTextSecretsViaServiceToken(fullServiceToken string) ([]models.Singl return plainTextSecrets, nil } -func GetPlainTextSecretsViaJTW(JTWToken string, receiversPrivateKey string, workspaceId string, environmentName string) ([]models.SingleEnvironmentVariable, error) { +func GetPlainTextSecretsViaJTW(JTWToken string, receiversPrivateKey string, workspaceId string, environmentName string, tagSlugs string) ([]models.SingleEnvironmentVariable, error) { httpClient := resty.New() httpClient.SetAuthToken(JTWToken). SetHeader("Accept", "application/json") @@ -85,6 +85,7 @@ func GetPlainTextSecretsViaJTW(JTWToken string, receiversPrivateKey string, work encryptedSecrets, err := api.CallGetSecretsV2(httpClient, api.GetEncryptedSecretsV2Request{ WorkspaceId: workspaceId, Environment: environmentName, + TagSlugs: tagSlugs, }) if err != nil { @@ -130,13 +131,17 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters) ([]models return nil, err } + if workspaceFile.DefaultEnvironment != "" { + params.Environment = workspaceFile.DefaultEnvironment + } + // Verify environment err = ValidateEnvironmentName(params.Environment, workspaceFile.WorkspaceId, loggedInUserDetails.UserCredentials) if err != nil { return nil, fmt.Errorf("unable to validate environment name because [err=%s]", err) } - secretsToReturn, errorToReturn = GetPlainTextSecretsViaJTW(loggedInUserDetails.UserCredentials.JTWToken, loggedInUserDetails.UserCredentials.PrivateKey, workspaceFile.WorkspaceId, params.Environment) + secretsToReturn, errorToReturn = GetPlainTextSecretsViaJTW(loggedInUserDetails.UserCredentials.JTWToken, loggedInUserDetails.UserCredentials.PrivateKey, workspaceFile.WorkspaceId, params.Environment, params.TagSlugs) log.Debugf("GetAllEnvironmentVariables: Trying to fetch secrets JTW token [err=%s]", errorToReturn) backupSecretsEncryptionKey := []byte(loggedInUserDetails.UserCredentials.PrivateKey)[0:32] diff --git a/docs/getting-started/dashboard/integrations.mdx b/docs/getting-started/dashboard/integrations.mdx index 18a5aa8a8..2414c5c14 100644 --- a/docs/getting-started/dashboard/integrations.mdx +++ b/docs/getting-started/dashboard/integrations.mdx @@ -5,8 +5,6 @@ description: "How to sync your secrets among various 3rd-party services with Inf Integrations allow environment variables to be synced across your entire infrastructure from local development to CI/CD and production. -We're still relatively early with integrations. 6+ integrations are already avaiable but expect more coming very soon. - View all available integrations and their guides diff --git a/docs/getting-started/dashboard/mfa.mdx b/docs/getting-started/dashboard/mfa.mdx new file mode 100644 index 000000000..7629401eb --- /dev/null +++ b/docs/getting-started/dashboard/mfa.mdx @@ -0,0 +1,18 @@ +--- +title: "MFA" +description: "Secure your Infisical account with MFA" +--- + +MFA requires users to provide multiple forms of identification to access their account. Currently, this means logging in with your password and a 6-digit code sent to your email. + +## Email 2FA + +Check the box in Personal Settings > Two-factor Authentication to enable email-based 2FA. + +![Email-based MFA](../../images/mfa-email.png) + + + Infisical currently supports email-based 2FA. We're actively working on + building support for other forms of identification via SMS and Authenticator + App. + diff --git a/docs/images/integrations-circleci-auth.png b/docs/images/integrations-circleci-auth.png new file mode 100644 index 000000000..055ebbf4a Binary files /dev/null and b/docs/images/integrations-circleci-auth.png differ diff --git a/docs/images/integrations-circleci-create.png b/docs/images/integrations-circleci-create.png new file mode 100644 index 000000000..d1f1e3293 Binary files /dev/null and b/docs/images/integrations-circleci-create.png differ diff --git a/docs/images/integrations-circleci-token.png b/docs/images/integrations-circleci-token.png new file mode 100644 index 000000000..3e00744cc Binary files /dev/null and b/docs/images/integrations-circleci-token.png differ diff --git a/docs/images/integrations-circleci.png b/docs/images/integrations-circleci.png new file mode 100644 index 000000000..5ee9a9df0 Binary files /dev/null and b/docs/images/integrations-circleci.png differ diff --git a/docs/images/integrations.png b/docs/images/integrations.png index 031e2ddc6..c9975e4f3 100644 Binary files a/docs/images/integrations.png and b/docs/images/integrations.png differ diff --git a/docs/images/mfa-email.png b/docs/images/mfa-email.png new file mode 100644 index 000000000..f592ee239 Binary files /dev/null and b/docs/images/mfa-email.png differ diff --git a/docs/integrations/cicd/circleci.mdx b/docs/integrations/cicd/circleci.mdx index 7ded52d8a..44f2ecd46 100644 --- a/docs/integrations/cicd/circleci.mdx +++ b/docs/integrations/cicd/circleci.mdx @@ -1,5 +1,36 @@ --- title: "Circle CI" +description: "How to automatically sync secrets from Infisical into your CircleCI project." --- -Coming soon. +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) + +## Navigate to your project's integrations tab + +![integrations](../../images/integrations.png) + +## Authorize Infisical for CircleCI + +Obtain an API token in User Settings > Personal API Tokens + +![integrations circleci token](../../images/integrations-circleci-token.png) + +Press on the CircleCI tile and input your CircleCI API token to grant Infisical access to your CircleCI account. + +![integrations circleci authorization](../../images/integrations-circleci-auth.png) + + + If this is your project's first cloud integration, then you'll have to grant + Infisical access to your project's environment variables. Although this step + breaks E2EE, it's necessary for Infisical to sync the environment variables to + the cloud platform. + + +## Start integration + +Select which Infisical environment secrets you want to sync to which CircleCI project and press create integration to start syncing secrets to CircleCI. + +![integrations fly](../../images/integrations-circleci-create.png) +![integrations fly](../../images/integrations-circleci.png) diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index 56943db3f..439564258 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -21,6 +21,8 @@ Missing an integration? Throw in a [request](https://github.com/Infisical/infisi | [AWS Parameter Store](/integrations/cloud/aws-parameter-store) | Cloud | Available | | [AWS Secret Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | | [GitHub Actions](/integrations/cicd/githubactions) | CI/CD | Available | +| [GitLab Pipeline](/integrations/cicd/gitlab) | CI/CD | Available | +| [CircleCI](/integrations/cicd/circleci) | CI/CD | Available | | [React](/integrations/frameworks/react) | Framework | Available | | [Vue](/integrations/frameworks/vue) | Framework | Available | | [Express](/integrations/frameworks/express) | Framework | Available | @@ -39,8 +41,6 @@ Missing an integration? Throw in a [request](https://github.com/Infisical/infisi | GCP | Cloud | Coming soon | | Azure | Cloud | Coming soon | | DigitalOcean | Cloud | Coming soon | -| [GitLab Pipeline](/integrations/cicd/gitlab) | CI/CD | Available | -| [CircleCI](/integrations/cicd/circleci) | CI/CD | Coming soon | | TravisCI | CI/CD | Coming soon | | GitHub Actions | CI/CD | Coming soon | | Jenkins | CI/CD | Coming soon | diff --git a/docs/mint.json b/docs/mint.json index 2dd45d6ba..59be8b375 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -98,6 +98,7 @@ "getting-started/dashboard/pit-recovery", "getting-started/dashboard/secret-versioning", "getting-started/dashboard/audit-logs", + "getting-started/dashboard/mfa", "getting-started/dashboard/token" ] }, diff --git a/docs/security/data-model.mdx b/docs/security/data-model.mdx index a8f8dd9b7..9f79cd254 100644 --- a/docs/security/data-model.mdx +++ b/docs/security/data-model.mdx @@ -7,22 +7,50 @@ Infisical stores a range of data namely user, secrets, keys, organization, proje ## Users -The `User` model includes the fields `email`, `firstName`, `lastName`, `publicKey`, `encryptedPrivateKey`, `iv`, `tag`, `salt`, `verifier`, and `refreshVersion`. +The `User` model includes the fields `email`, `firstName`, `lastName`, `publicKey`, `encryptionVersion`, `protectedKey`, `protectedKeyIV`, `protectedKeyTag`, `encryptedPrivateKey`, `iv`, `tag`, `salt`, `verifier`, and `refreshVersion`. -Infisical makes a usability-security tradeoff to give users convenient access to public-private key pairs across different devices upon login, solving key-storage and transfer challenges across device and browser mediums, in exchange for it storing `encryptedPrivateKey`. In any case, private keys are symmetrically encrypted locally by user passwords which are not sent to the server — this is done with SRP. +Infisical makes a usability-security tradeoff that is to give users convenient access to public-private key pairs across different devices upon login, solving key-storage and transfer challenges across device and browser mediums, in exchange for it storing `encryptedPrivateKey`. + + + `encryptedPrivateKey` is obtained by symmetrically encrypting the user's + private key locally with a protected key which is encrypted by the key derived + from the user's password and salt. Encryption is done via `AES256-GCM` and key + derivation via `argon2id`. The user's password is not sent to the server — + this is done with SRP. + ## Secrets -The `Secret` model includes the fields `workspace`, `type`, `user`, `environment`, `secretKeyCiphertext`, `secretKeyIV`, `secretKeyTag`, `secretKeyHash`, `secretValueCiphertext`, `secretValueIV`, `secretValueTag`, and `secretValueHash`. +The `Secret` model includes the fields `workspace`, `type`, `user`, `environment`, `secretKeyCiphertext`, `secretKeyIV`, `secretKeyTag`, `secretValueCiphertext`, `secretValueIV`, and `secretValueTag`. Each secret is symmetrically encrypted by the key of the project that it belongs to; that key's encrypted copies are stored in a separate `Key` collection. -## Keys +## Project Keys The `Key` model includes the fields `encryptedKey`, `nonce`, `sender`, `receiver`, and `workspace`. Infisical stores copies of project keys, one for each member of a project, encrypted under each member's public key. +## Bots + +The `Bot` model contains the fields `name`, `workspace`, `isActive`, `publicKey`, `encryptedPrivateKey`, `iv`, and `tag`. + +Each project comes with a bot that has its own public-private key pair; its private key is encrypted by the server's symmetric key. If needed, a user can opt-in to share their project key with the bot (i.e. Infisical) to give the platform access to the project's secrets. + + + Sharing secrets with Infisical so they can be synced to integrations like + Vercel, GitHub, and Netlify is something we make sure users consent to before + opting in. + + ## Organizations and Workspaces The `Organization`, `Workspace`, `MembershipOrg`, and `Membership` models contain enrollment information for organizations and projects; they are used to check if users are authorized to retrieve select secrets. + +## Service Tokens + +The `ServiceTokenData` model contains data for service tokens that enable users to fetch secrets from a particular project and environment; each service token data record includes an (encrypted) copy of the project key that it is bound to as well as a validation hash for `bcrypt`. + +## API Keys + +The `APIKeyData` model contains data for API keys that enable users to interact with [Infisical's Open API](https://infisical.com/docs/api-reference/overview/introduction); each API key data record includes a validation hash for `bcrypt`. diff --git a/docs/security/mechanics.mdx b/docs/security/mechanics.mdx index 0fb25b8b2..325224f90 100644 --- a/docs/security/mechanics.mdx +++ b/docs/security/mechanics.mdx @@ -5,7 +5,11 @@ description: "Quick explanation of how Infisical works." ## Signup -During account signup, a user confirms their email address via OTP, generates a public-private key pair to be stored locally (private keys are symmetrically encrypted by the user's newly-made password), and forwards SRP-related values and user identifier information to the server. This includes `email`, `firstName`, `lastName`, `publicKey`, `encryptedPrivateKey`, `iv`, `tag`, `salt`, `verifier`, and `organizationName`. +During account signup, a user confirms their email address via OTP, generates a public-private key pair to be stored locally, generates a user salt, generates a 256-bit key, and enters their password. + +The 256-bit key is used to encrypt the private key; the 256-bit key itself is then encrypted by a key generated from the user's password and salt with key derivation function `argon2id`. The resulting, 256-bit key the protected key. + +The encrypted private key, protected key, user identifier information, and SRP details are forwarded to the server. Once authenticated via SRP, a user is issued a JWT and refresh token. The JWT token is stored in browser memory under a write-only class `SecurityClient` that appends the token to all future outbound requests requiring authentication. The refresh token is stored in an `HttpOnly` cookie and included in future requests to `/api/token` for JWT token renewal. This design side-steps potential XSS attacks on local storage. diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 39d6542c9..342fa9e96 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -5,37 +5,40 @@ description: "How to configure your environment variables when self-hosting Infi Configuring Infisical requires setting some environment variables. There is a file called [`.env.example`](https://github.com/Infisical/infisical/blob/main/.env.example) at the root directory of our main repo that you can use to create a `.env` file before you start the server. -| Variable | Description | Default Value | -| ---------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------- | -| `ENCRYPTION_KEY` | ❗️ Strong hex encryption key | `None` | -| `JWT_SIGNUP_SECRET` | ❗️ JWT token secret | `None` | -| `JWT_REFRESH_SECRET` | ❗️ JWT token secret | `None` | -| `JWT_AUTH_SECRET` | ❗️ JWT token secret | `None` | -| `JWT_SERVICE_SECRET` | ❗️ JWT token secret | `None` | -| `JWT_SIGNUP_LIFETIME` | JWT token lifetime expressed in seconds or a string describing a time span (e.g. 60, "2 days", "10h", "7d") | `15m` | -| `JWT_REFRESH_LIFETIME` | JWT token lifetime expressed in seconds or a string describing a time span (e.g. 60, "2 days", "10h", "7d") | `90d` | -| `JWT_AUTH_LIFETIME` | JWT token lifetime expressed in seconds or a string describing a time span (e.g. 60, "2 days", "10h", "7d") | `10d` | -| `EMAIL_TOKEN_LIFETIME` | Email OTP/magic-link lifetime expressed in seconds | `86400` | -| `MONGO_URL` | ❗️ MongoDB instance connection string either to container instance or MongoDB Cloud | `None` | -| `MONGO_USERNAME` | MongoDB username if using container | `None` | -| `MONGO_PASSWORD` | MongoDB password if using container | `None` | -| `SITE_URL` | ❗️ Site URL - should be an absolute URL including the protocol (e.g. `https://app.infisical.com`) | `None` | -| `SMTP_HOST` | ❗️ Hostname to connect to for establishing SMTP connections | `None` | -| `SMTP_USERNAME` | ❗️ Credential to connect to host (e.g. `team@infisical.com`) | `None` | -| `SMTP_PASSWORD` | ❗️ Credential to connect to host | `None` | -| `SMTP_PORT` | Port to connect to for establishing SMTP connections | `587` | -| `SMTP_SECURE` | If true, use TLS when connecting to host. If false, TLS will be used if STARTTLS is supported | `false` | -| `SMTP_FROM_ADDRESS` | ❗️ Email address to be used for sending emails (e.g. `team@infisical.com`) | `None` | -| `SMTP_FROM_NAME` | Name label to be used in From field (e.g. `Team`) | `Infisical` | -| `TELEMETRY_ENABLED` | `true` or `false`. [More](../overview). | `true` | -| `LICENSE_KEY` | License key if using Infisical Enterprise Edition | `true` | -| `CLIENT_ID_HEROKU` | OAuth2 client ID for Heroku integration | `None` | -| `CLIENT_ID_VERCEL` | OAuth2 client ID for Vercel integration | `None` | -| `CLIENT_ID_NETLIFY` | OAuth2 client ID for Netlify integration | `None` | -| `CLIENT_ID_GITHUB` | OAuth2 client ID for GitHub integration | `None` | -| `CLIENT_SECRET_HEROKU` | OAuth2 client secret for Heroku integration | `None` | -| `CLIENT_SECRET_VERCEL` | OAuth2 client secret for Vercel integration | `None` | -| `CLIENT_SECRET_NETLIFY` | OAuth2 client secret for Netlify integration | `None` | -| `CLIENT_SECRET_GITHUB` | OAuth2 client secret for GitHub integration | `None` | -| `CLIENT_SLUG_VERCEL` | OAuth2 slug for Netlify integration | `None` | -| `SENTRY_DSN` | DSN for error-monitoring with Sentry | `None` | +| Variable | Description | Default Value | +| ----------------------- | ----------------------------------------------------------------------------------------------------------- | ------------- | +| `ENCRYPTION_KEY` | ❗️ Strong hex encryption key | `None` | +| `JWT_SIGNUP_SECRET` | ❗️ JWT token secret | `None` | +| `JWT_REFRESH_SECRET` | ❗️ JWT token secret | `None` | +| `JWT_AUTH_SECRET` | ❗️ JWT token secret | `None` | +| `JWT_MFA_SECRET` | ❗️ JWT token secret | `None` | +| `JWT_SERVICE_SECRET` | ❗️ JWT token secret | `None` | +| `JWT_SIGNUP_LIFETIME` | JWT token lifetime expressed in seconds or a string describing a time span (e.g. 60, "2 days", "10h", "7d") | `15m` | +| `JWT_REFRESH_LIFETIME` | JWT token lifetime expressed in seconds or a string describing a time span (e.g. 60, "2 days", "10h", "7d") | `90d` | +| `JWT_AUTH_LIFETIME` | JWT token lifetime expressed in seconds or a string describing a time span (e.g. 60, "2 days", "10h", "7d") | `10d` | +| `JWT_MFA_LIFETIME` | JWT token lifetime expressed in seconds or a string describing a time span (e.g. 60, "2 days", "10h", "7d") | `5m` | +| `EMAIL_TOKEN_LIFETIME` | Email OTP/magic-link lifetime expressed in seconds | `86400` | +| `MONGO_URL` | ❗️ MongoDB instance connection string either to container instance or MongoDB Cloud | `None` | +| `MONGO_USERNAME` | MongoDB username if using container | `None` | +| `MONGO_PASSWORD` | MongoDB password if using container | `None` | +| `SITE_URL` | ❗️ Site URL - should be an absolute URL including the protocol (e.g. `https://app.infisical.com`) | `None` | +| `SMTP_HOST` | ❗️ Hostname to connect to for establishing SMTP connections | `None` | +| `SMTP_USERNAME` | ❗️ Credential to connect to host (e.g. `team@infisical.com`) | `None` | +| `SMTP_PASSWORD` | ❗️ Credential to connect to host | `None` | +| `SMTP_PORT` | Port to connect to for establishing SMTP connections | `587` | +| `SMTP_SECURE` | If true, use TLS when connecting to host. If false, TLS will be used if STARTTLS is supported | `false` | +| `SMTP_FROM_ADDRESS` | ❗️ Email address to be used for sending emails (e.g. `team@infisical.com`) | `None` | +| `SMTP_FROM_NAME` | Name label to be used in From field (e.g. `Team`) | `Infisical` | +| `TELEMETRY_ENABLED` | `true` or `false`. [More](../overview). | `true` | +| `LICENSE_KEY` | License key if using Infisical Enterprise Edition | `true` | +| `CLIENT_ID_HEROKU` | OAuth2 client ID for Heroku integration | `None` | +| `CLIENT_ID_VERCEL` | OAuth2 client ID for Vercel integration | `None` | +| `CLIENT_ID_NETLIFY` | OAuth2 client ID for Netlify integration | `None` | +| `CLIENT_ID_GITHUB` | OAuth2 client ID for GitHub integration | `None` | +| `CLIENT_SECRET_HEROKU` | OAuth2 client secret for Heroku integration | `None` | +| `CLIENT_SECRET_VERCEL` | OAuth2 client secret for Vercel integration | `None` | +| `CLIENT_SECRET_NETLIFY` | OAuth2 client secret for Netlify integration | `None` | +| `CLIENT_SECRET_GITHUB` | OAuth2 client secret for GitHub integration | `None` | +| `CLIENT_SLUG_VERCEL` | OAuth2 slug for Netlify integration | `None` | +| `SENTRY_DSN` | DSN for error-monitoring with Sentry | `None` | +| `INVITE_ONLY_SIGNUP` | If true, users can only sign up if they are invited | `false` | diff --git a/docs/self-hosting/deployments/kubernetes.mdx b/docs/self-hosting/deployments/kubernetes.mdx index 28bdc2416..cbba7a946 100644 --- a/docs/self-hosting/deployments/kubernetes.mdx +++ b/docs/self-hosting/deployments/kubernetes.mdx @@ -24,114 +24,88 @@ Before you can deploy the Helm chart, you must fill out the required environment Refer to the available [environment variables](../../self-hosting/configuration/envars) to learn more +[View all available Helm chart values parameters](https://github.com/Infisical/infisical/tree/main/helm-charts/infisical) ```yaml -##### -# INFISICAL K8 DEFAULT VALUES FILE -# PLEASE REPLACE VALUES/EDIT AS REQUIRED -##### - -nameOverride: "" - frontend: + enabled: true name: frontend podAnnotations: {} deploymentAnnotations: {} replicaCount: 2 image: repository: infisical/frontend - pullPolicy: Always - tag: "latest" # It it highly recommended to select a specific tag for prod deployment so it is easy to rollback: https://hub.docker.com/r/infisical/frontend/tags - # kubeSecretRef: some-kube-secret-name + tag: "latest" + pullPolicy: IfNotPresent + kubeSecretRef: "" service: - # type of the frontend service - type: ClusterIP - # define the nodePort if service type is NodePort - # nodePort: annotations: {} + type: ClusterIP + nodePort: "" + +frontendEnvironmentVariables: + SITE_URL: infisical.local backend: + enabled: true name: backend podAnnotations: {} deploymentAnnotations: {} replicaCount: 2 image: repository: infisical/backend - pullPolicy: Always - tag: "latest" # It it highly recommended to select a specific tag for prod deployment so it is easy to rollback: https://hub.docker.com/r/infisical/backend/tags - # kubeSecretRef: some-kube-secret-name - service: - annotations: {} - -mongodb: - name: mongodb - podAnnotations: {} - image: - repository: mongo + tag: "latest" pullPolicy: IfNotPresent - tag: "latest" + kubeSecretRef: "" service: annotations: {} + type: ClusterIP + nodePort: "" -# By default the backend will be connected to a Mongo instance in the cluster. -# However, it is recommended to add a managed document DB connection string because the DB instance in the cluster does not have persistence yet ( data will be deleted on next deploy). -# Learn about connection string type here https://www.mongodb.com/docs/manual/reference/connection-string/ -mongodbConnection: {} - # externalMongoDBConnectionString: <> +backendEnvironmentVariables: + ENCRYPTION_KEY: MUST_REPLACE + JWT_SIGNUP_SECRET: MUST_REPLACE + JWT_REFRESH_SECRET: MUST_REPLACE + JWT_AUTH_SECRET: MUST_REPLACE + JWT_SERVICE_SECRET: MUST_REPLACE + SMTP_HOST: MUST_REPLACE + SMTP_PORT: 587 + SMTP_SECURE: false + SMTP_FROM_NAME: Infisical + SMTP_FROM_ADDRESS: MUST_REPLACE + SMTP_USERNAME: MUST_REPLACE + SMTP_PASSWORD: MUST_REPLACE + SITE_URL: infisical.local + +## Mongo DB persistence +mongodb: + enabled: true + +## By default the backend will be connected to a Mongo instance within the cluster +## However, it is recommended to add a managed document DB connection string for production-use (DBaaS) +## Learn about connection string type here https://www.mongodb.com/docs/manual/reference/connection-string/ +## e.g. "mongodb://:@:/" +mongodbConnection: + externalMongoDBConnectionString: "" ingress: enabled: true annotations: kubernetes.io/ingress.class: "nginx" - hostName: example.com # replace with your domain - frontend: + # cert-manager.io/issuer: letsencrypt-nginx + hostName: infisical.local ## <- Replace with your own domain + frontend: path: / pathType: Prefix backend: path: /api pathType: Prefix tls: [] + # - secretName: letsencrypt-nginx + # hosts: + # - infisical.local - -## Complete Ingress example -# ingress: -# enabled: true -# annotations: -# kubernetes.io/ingress.class: "nginx" -# cert-manager.io/issuer: letsencrypt-nginx -# hostName: k8.infisical.com -# frontend: -# path: / -# pathType: Prefix -# backend: -# path: /api -# pathType: Prefix -# tls: -# - secretName: letsencrypt-nginx -# hosts: -# - k8.infisical.com - -### -### YOU MUST FILL IN ALL SECRETS BELOW -### -backendEnvironmentVariables: - # Required keys for platform encryption/decryption ops. Replace with nacl sk keys - ENCRYPTION_KEY: MUST_REPLACE - - # JWT - # Required secrets to sign JWT tokens - JWT_SIGNUP_SECRET: MUST_REPLACE - JWT_REFRESH_SECRET: MUST_REPLACE - JWT_AUTH_SECRET: MUST_REPLACE - - # Mail/SMTP - # Required to send emails - SMTP_HOST: MUST_REPLACE - SMTP_NAME: MUST_REPLACE - SMTP_USERNAME: MUST_REPLACE - SMTP_PASSWORD: MUST_REPLACE - -frontendEnvironmentVariables: {} - +mailhog: + enabled: false ``` diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 7c471fdc6..155e6d6e8 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -1,4 +1,9 @@ module.exports = { + overrides: [ + { + files: ["next.config.js"] + } + ], root: true, env: { browser: true, @@ -87,6 +92,7 @@ module.exports = { } ] }, + ignorePatterns: ['next.config.js'], settings: { 'import/resolver': { typescript: { diff --git a/frontend/next.config.js b/frontend/next.config.js index 06c56dd78..5e7c81270 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,9 +1,11 @@ + // @ts-check /** * @type {import('next').NextConfig} **/ const { i18n } = require("./next-i18next.config.js"); +const path = require('path'); const ContentSecurityPolicy = ` default-src 'self'; @@ -65,7 +67,33 @@ module.exports = { }, ]; }, - webpack: (config, { isServer, webpack }) => { + webpack: (config, { isServer, webpack }) => { // config + config.module.rules.push({ + test: /\.wasm$/, + loader: "base64-loader", + type: "javascript/auto", + }); + + config.module.noParse = /\.wasm$/; + + config.module.rules.forEach((rule) => { + (rule.oneOf || []).forEach((oneOf) => { + if (oneOf.loader && oneOf.loader.indexOf("file-loader") >= 0) { + oneOf.exclude.push(/\.wasm$/); + } + }); + }); + + if (!isServer) { + config.resolve.fallback.fs = false; + } + + // Perform customizations to webpack config + config.plugins.push( + new webpack.IgnorePlugin({ resourceRegExp: /\/__tests__\// }) + ); + + // Important: return the modified config return config; }, i18n, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b0317ff7c..59a788cb0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,14 +27,18 @@ "@radix-ui/react-progress": "^1.0.1", "@radix-ui/react-select": "^1.2.0", "@radix-ui/react-switch": "^1.0.1", + "@radix-ui/react-tabs": "^1.0.2", "@radix-ui/react-toast": "^1.1.2", "@reduxjs/toolkit": "^1.8.3", "@stripe/react-stripe-js": "^1.10.0", "@stripe/stripe-js": "^1.46.0", "@tanstack/react-query": "^4.23.0", + "@types/argon2-browser": "^1.18.1", "add": "^2.0.6", + "argon2-browser": "^1.18.0", "axios": "^0.27.2", "axios-auth-refresh": "^3.3.3", + "base64-loader": "^1.0.0", "classnames": "^2.3.1", "cookies": "^0.8.0", "cva": "npm:class-variance-authority@^0.4.0", @@ -2338,54 +2342,6 @@ "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.0.tgz", "integrity": "sha512-AHPmaAx+RYfZz0eYu6Gviiagpmiyw98ySSlQvCUhVGDRtDFe4DBS0x1bSjdF3gqUDYOczB+yYvBTtEylYSdRhg==" }, - "node_modules/@esbuild/android-arm": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.16.17.tgz", - "integrity": "sha512-N9x1CMXVhtWEAMS7pNNONyA14f71VPQN9Cnavj1XQh6T7bskqiLLrSca4O0Vr8Wdcga943eThxnVp3JLnBMYtw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.16.17.tgz", - "integrity": "sha512-MIGl6p5sc3RDTLLkYL1MyL8BMRN4tLMRCn+yRJJmEDvYZ2M7tmAf80hx1kbNEUX2KJ50RRtxZ4JHLvCfuB6kBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.16.17.tgz", - "integrity": "sha512-a3kTv3m0Ghh4z1DaFEuEDfz3OLONKuFvI4Xqczqx4BqLyuFaFkuaG4j2MtA6fuWEFeC5x9IvqnX7drmRq/fyAQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@esbuild/darwin-arm64": { "version": "0.16.17", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.16.17.tgz", @@ -2402,294 +2358,6 @@ "node": ">=12" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.16.17.tgz", - "integrity": "sha512-2By45OBHulkd9Svy5IOCZt376Aa2oOkiE9QWUK9fe6Tb+WDr8hXL3dpqi+DeLiMed8tVXspzsTAvd0jUl96wmg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.16.17.tgz", - "integrity": "sha512-mt+cxZe1tVx489VTb4mBAOo2aKSnJ33L9fr25JXpqQqzbUIw/yzIzi+NHwAXK2qYV1lEFp4OoVeThGjUbmWmdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.16.17.tgz", - "integrity": "sha512-8ScTdNJl5idAKjH8zGAsN7RuWcyHG3BAvMNpKOBaqqR7EbUhhVHOqXRdL7oZvz8WNHL2pr5+eIT5c65kA6NHug==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.16.17.tgz", - "integrity": "sha512-iihzrWbD4gIT7j3caMzKb/RsFFHCwqqbrbH9SqUSRrdXkXaygSZCZg1FybsZz57Ju7N/SHEgPyaR0LZ8Zbe9gQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.16.17.tgz", - "integrity": "sha512-7S8gJnSlqKGVJunnMCrXHU9Q8Q/tQIxk/xL8BqAP64wchPCTzuM6W3Ra8cIa1HIflAvDnNOt2jaL17vaW+1V0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.16.17.tgz", - "integrity": "sha512-kiX69+wcPAdgl3Lonh1VI7MBr16nktEvOfViszBSxygRQqSpzv7BffMKRPMFwzeJGPxcio0pdD3kYQGpqQ2SSg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.16.17.tgz", - "integrity": "sha512-dTzNnQwembNDhd654cA4QhbS9uDdXC3TKqMJjgOWsC0yNCbpzfWoXdZvp0mY7HU6nzk5E0zpRGGx3qoQg8T2DQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.16.17.tgz", - "integrity": "sha512-ezbDkp2nDl0PfIUn0CsQ30kxfcLTlcx4Foz2kYv8qdC6ia2oX5Q3E/8m6lq84Dj/6b0FrkgD582fJMIfHhJfSw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.16.17.tgz", - "integrity": "sha512-dzS678gYD1lJsW73zrFhDApLVdM3cUF2MvAa1D8K8KtcSKdLBPP4zZSLy6LFZ0jYqQdQ29bjAHJDgz0rVbLB3g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.16.17.tgz", - "integrity": "sha512-ylNlVsxuFjZK8DQtNUwiMskh6nT0vI7kYl/4fZgV1llP5d6+HIeL/vmmm3jpuoo8+NuXjQVZxmKuhDApK0/cKw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.16.17.tgz", - "integrity": "sha512-gzy7nUTO4UA4oZ2wAMXPNBGTzZFP7mss3aKR2hH+/4UUkCOyqmjXiKpzGrY2TlEUhbbejzXVKKGazYcQTZWA/w==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.16.17.tgz", - "integrity": "sha512-mdPjPxfnmoqhgpiEArqi4egmBAMYvaObgn4poorpUaqmvzzbvqbowRllQ+ZgzGVMGKaPkqUmPDOOFQRUFDmeUw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.16.17.tgz", - "integrity": "sha512-/PzmzD/zyAeTUsduZa32bn0ORug+Jd1EGGAUJvqfeixoEISYpGnAezN6lnJoskauoai0Jrs+XSyvDhppCPoKOA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.16.17.tgz", - "integrity": "sha512-2yaWJhvxGEz2RiftSk0UObqJa/b+rIAjnODJgv2GbGGpRwAfpgzyrg1WLK8rqA24mfZa9GvpjLcBBg8JHkoodg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.16.17.tgz", - "integrity": "sha512-xtVUiev38tN0R3g8VhRfN7Zl42YCJvyBhRKw1RJjwE1d2emWTVToPLNEQj/5Qxc6lVFATDiy6LjVHYhIPrLxzw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.16.17.tgz", - "integrity": "sha512-ga8+JqBDHY4b6fQAmOgtJJue36scANy4l/rL97W+0wYmijhxKetzZdKOJI7olaBaMhWt8Pac2McJdZLxXWUEQw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.16.17.tgz", - "integrity": "sha512-WnsKaf46uSSF/sZhwnqE4L/F89AYNMiD4YtEcYekBt9Q7nj0DiId2XH2Ng2PHM54qi5oPrQ8luuzGszqi/veig==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.16.17.tgz", - "integrity": "sha512-y+EHuSchhL7FjHgvQL/0fnnFmO4T1bhvWANX6gcnqTjtnKWbTvUMCpGnv2+t+31d7RzyEAYAd4u2fnIhHL6N/Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@eslint/eslintrc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz", @@ -3292,36 +2960,6 @@ "integrity": "sha512-NXGXGFGiOKEnvBIHq9cdFTKbHO2/4B3Zd9K27M7j1DioIQVar7oVRqZMYs0h3XMVEZLwjjkdAtqRPCzzd3RtXg==", "dev": true }, - "node_modules/@next/swc-android-arm-eabi": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.3.4.tgz", - "integrity": "sha512-cM42Cw6V4Bz/2+j/xIzO8nK/Q3Ly+VSlZJTa1vHzsocJRYz8KT6MrreXaci2++SIZCF1rVRCDgAg5PpqRibdIA==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-android-arm64": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-android-arm64/-/swc-android-arm64-12.3.4.tgz", - "integrity": "sha512-5jf0dTBjL+rabWjGj3eghpLUxCukRhBcEJgwLedewEA/LJk2HyqCvGIwj5rH+iwmq1llCWbOky2dO3pVljrapg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@next/swc-darwin-arm64": { "version": "12.3.4", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.3.4.tgz", @@ -3337,156 +2975,6 @@ "node": ">= 10" } }, - "node_modules/@next/swc-darwin-x64": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-12.3.4.tgz", - "integrity": "sha512-PPF7tbWD4k0dJ2EcUSnOsaOJ5rhT3rlEt/3LhZUGiYNL8KvoqczFrETlUx0cUYaXe11dRA3F80Hpt727QIwByQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-freebsd-x64": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.3.4.tgz", - "integrity": "sha512-KM9JXRXi/U2PUM928z7l4tnfQ9u8bTco/jb939pdFUHqc28V43Ohd31MmZD1QzEK4aFlMRaIBQOWQZh4D/E5lQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm-gnueabihf": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.3.4.tgz", - "integrity": "sha512-3zqD3pO+z5CZyxtKDTnOJ2XgFFRUBciOox6EWkoZvJfc9zcidNAQxuwonUeNts6Xbm8Wtm5YGIRC0x+12YH7kw==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.3.4.tgz", - "integrity": "sha512-kiX0vgJGMZVv+oo1QuObaYulXNvdH/IINmvdZnVzMO/jic/B8EEIGlZ8Bgvw8LCjH3zNVPO3mGrdMvnEEPEhKA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.3.4.tgz", - "integrity": "sha512-EETZPa1juczrKLWk5okoW2hv7D7WvonU+Cf2CgsSoxgsYbUCZ1voOpL4JZTOb6IbKMDo6ja+SbY0vzXZBUMvkQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.3.4.tgz", - "integrity": "sha512-4csPbRbfZbuWOk3ATyWcvVFdD9/Rsdq5YHKvRuEni68OCLkfy4f+4I9OBpyK1SKJ00Cih16NJbHE+k+ljPPpag==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.3.4.tgz", - "integrity": "sha512-YeBmI+63Ro75SUiL/QXEVXQ19T++58aI/IINOyhpsRL1LKdyfK/35iilraZEFz9bLQrwy1LYAR5lK200A9Gjbg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.3.4.tgz", - "integrity": "sha512-Sd0qFUJv8Tj0PukAYbCCDbmXcMkbIuhnTeHm9m4ZGjCf6kt7E/RMs55Pd3R5ePjOkN7dJEuxYBehawTR/aPDSQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.3.4.tgz", - "integrity": "sha512-rt/vv/vg/ZGGkrkKcuJ0LyliRdbskQU+91bje+PgoYmxTZf/tYs6IfbmgudBJk6gH3QnjHWbkphDdRQrseRefQ==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.3.4.tgz", - "integrity": "sha512-DQ20JEfTBZAgF8QCjYfJhv2/279M6onxFjdG/+5B0Cyj00/EdBxiWb2eGGFgQhrBbNv/lsvzFbbi0Ptf8Vw/bg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4271,6 +3759,26 @@ "react-dom": "^16.8 || ^17.0 || ^18.0" } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.2.tgz", + "integrity": "sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-direction": "1.0.0", + "@radix-ui/react-id": "1.0.0", + "@radix-ui/react-presence": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-roving-focus": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, "node_modules/@radix-ui/react-toast": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.1.2.tgz", @@ -6935,6 +6443,11 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@types/argon2-browser": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/@types/argon2-browser/-/argon2-browser-1.18.1.tgz", + "integrity": "sha512-PZffP/CqH9m2kovDSRQMfMMxUC3V98I7i7/caa0RB0/nvsXzYbL9bKyqZpNMFmLFGZslROlG1R60ONt7abrwlA==" + }, "node_modules/@types/aria-query": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.1.tgz", @@ -8343,6 +7856,11 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "dev": true }, + "node_modules/argon2-browser": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/argon2-browser/-/argon2-browser-1.18.0.tgz", + "integrity": "sha512-ImVAGIItnFnvET1exhsQB7apRztcoC5TnlSqernMJDUjbc/DLq3UEYeXFrLPrlaIl8cVfwnXb6wX2KpFf2zxHw==" + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -8937,6 +8455,11 @@ } ] }, + "node_modules/base64-loader": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base64-loader/-/base64-loader-1.0.0.tgz", + "integrity": "sha512-p32+F8dg+ANGx7s8QsZS74ZPHfIycmC2yZcoerzFgbersIYWitPbbF39G6SBx3gyvzyLH5nt1ooocxr0IHuWKA==" + }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", @@ -24279,27 +23802,6 @@ "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.0.tgz", "integrity": "sha512-AHPmaAx+RYfZz0eYu6Gviiagpmiyw98ySSlQvCUhVGDRtDFe4DBS0x1bSjdF3gqUDYOczB+yYvBTtEylYSdRhg==" }, - "@esbuild/android-arm": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.16.17.tgz", - "integrity": "sha512-N9x1CMXVhtWEAMS7pNNONyA14f71VPQN9Cnavj1XQh6T7bskqiLLrSca4O0Vr8Wdcga943eThxnVp3JLnBMYtw==", - "dev": true, - "optional": true - }, - "@esbuild/android-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.16.17.tgz", - "integrity": "sha512-MIGl6p5sc3RDTLLkYL1MyL8BMRN4tLMRCn+yRJJmEDvYZ2M7tmAf80hx1kbNEUX2KJ50RRtxZ4JHLvCfuB6kBg==", - "dev": true, - "optional": true - }, - "@esbuild/android-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.16.17.tgz", - "integrity": "sha512-a3kTv3m0Ghh4z1DaFEuEDfz3OLONKuFvI4Xqczqx4BqLyuFaFkuaG4j2MtA6fuWEFeC5x9IvqnX7drmRq/fyAQ==", - "dev": true, - "optional": true - }, "@esbuild/darwin-arm64": { "version": "0.16.17", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.16.17.tgz", @@ -24307,132 +23809,6 @@ "dev": true, "optional": true }, - "@esbuild/darwin-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.16.17.tgz", - "integrity": "sha512-2By45OBHulkd9Svy5IOCZt376Aa2oOkiE9QWUK9fe6Tb+WDr8hXL3dpqi+DeLiMed8tVXspzsTAvd0jUl96wmg==", - "dev": true, - "optional": true - }, - "@esbuild/freebsd-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.16.17.tgz", - "integrity": "sha512-mt+cxZe1tVx489VTb4mBAOo2aKSnJ33L9fr25JXpqQqzbUIw/yzIzi+NHwAXK2qYV1lEFp4OoVeThGjUbmWmdw==", - "dev": true, - "optional": true - }, - "@esbuild/freebsd-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.16.17.tgz", - "integrity": "sha512-8ScTdNJl5idAKjH8zGAsN7RuWcyHG3BAvMNpKOBaqqR7EbUhhVHOqXRdL7oZvz8WNHL2pr5+eIT5c65kA6NHug==", - "dev": true, - "optional": true - }, - "@esbuild/linux-arm": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.16.17.tgz", - "integrity": "sha512-iihzrWbD4gIT7j3caMzKb/RsFFHCwqqbrbH9SqUSRrdXkXaygSZCZg1FybsZz57Ju7N/SHEgPyaR0LZ8Zbe9gQ==", - "dev": true, - "optional": true - }, - "@esbuild/linux-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.16.17.tgz", - "integrity": "sha512-7S8gJnSlqKGVJunnMCrXHU9Q8Q/tQIxk/xL8BqAP64wchPCTzuM6W3Ra8cIa1HIflAvDnNOt2jaL17vaW+1V0g==", - "dev": true, - "optional": true - }, - "@esbuild/linux-ia32": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.16.17.tgz", - "integrity": "sha512-kiX69+wcPAdgl3Lonh1VI7MBr16nktEvOfViszBSxygRQqSpzv7BffMKRPMFwzeJGPxcio0pdD3kYQGpqQ2SSg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-loong64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.16.17.tgz", - "integrity": "sha512-dTzNnQwembNDhd654cA4QhbS9uDdXC3TKqMJjgOWsC0yNCbpzfWoXdZvp0mY7HU6nzk5E0zpRGGx3qoQg8T2DQ==", - "dev": true, - "optional": true - }, - "@esbuild/linux-mips64el": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.16.17.tgz", - "integrity": "sha512-ezbDkp2nDl0PfIUn0CsQ30kxfcLTlcx4Foz2kYv8qdC6ia2oX5Q3E/8m6lq84Dj/6b0FrkgD582fJMIfHhJfSw==", - "dev": true, - "optional": true - }, - "@esbuild/linux-ppc64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.16.17.tgz", - "integrity": "sha512-dzS678gYD1lJsW73zrFhDApLVdM3cUF2MvAa1D8K8KtcSKdLBPP4zZSLy6LFZ0jYqQdQ29bjAHJDgz0rVbLB3g==", - "dev": true, - "optional": true - }, - "@esbuild/linux-riscv64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.16.17.tgz", - "integrity": "sha512-ylNlVsxuFjZK8DQtNUwiMskh6nT0vI7kYl/4fZgV1llP5d6+HIeL/vmmm3jpuoo8+NuXjQVZxmKuhDApK0/cKw==", - "dev": true, - "optional": true - }, - "@esbuild/linux-s390x": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.16.17.tgz", - "integrity": "sha512-gzy7nUTO4UA4oZ2wAMXPNBGTzZFP7mss3aKR2hH+/4UUkCOyqmjXiKpzGrY2TlEUhbbejzXVKKGazYcQTZWA/w==", - "dev": true, - "optional": true - }, - "@esbuild/linux-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.16.17.tgz", - "integrity": "sha512-mdPjPxfnmoqhgpiEArqi4egmBAMYvaObgn4poorpUaqmvzzbvqbowRllQ+ZgzGVMGKaPkqUmPDOOFQRUFDmeUw==", - "dev": true, - "optional": true - }, - "@esbuild/netbsd-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.16.17.tgz", - "integrity": "sha512-/PzmzD/zyAeTUsduZa32bn0ORug+Jd1EGGAUJvqfeixoEISYpGnAezN6lnJoskauoai0Jrs+XSyvDhppCPoKOA==", - "dev": true, - "optional": true - }, - "@esbuild/openbsd-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.16.17.tgz", - "integrity": "sha512-2yaWJhvxGEz2RiftSk0UObqJa/b+rIAjnODJgv2GbGGpRwAfpgzyrg1WLK8rqA24mfZa9GvpjLcBBg8JHkoodg==", - "dev": true, - "optional": true - }, - "@esbuild/sunos-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.16.17.tgz", - "integrity": "sha512-xtVUiev38tN0R3g8VhRfN7Zl42YCJvyBhRKw1RJjwE1d2emWTVToPLNEQj/5Qxc6lVFATDiy6LjVHYhIPrLxzw==", - "dev": true, - "optional": true - }, - "@esbuild/win32-arm64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.16.17.tgz", - "integrity": "sha512-ga8+JqBDHY4b6fQAmOgtJJue36scANy4l/rL97W+0wYmijhxKetzZdKOJI7olaBaMhWt8Pac2McJdZLxXWUEQw==", - "dev": true, - "optional": true - }, - "@esbuild/win32-ia32": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.16.17.tgz", - "integrity": "sha512-WnsKaf46uSSF/sZhwnqE4L/F89AYNMiD4YtEcYekBt9Q7nj0DiId2XH2Ng2PHM54qi5oPrQ8luuzGszqi/veig==", - "dev": true, - "optional": true - }, - "@esbuild/win32-x64": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.16.17.tgz", - "integrity": "sha512-y+EHuSchhL7FjHgvQL/0fnnFmO4T1bhvWANX6gcnqTjtnKWbTvUMCpGnv2+t+31d7RzyEAYAd4u2fnIhHL6N/Q==", - "dev": true, - "optional": true - }, "@eslint/eslintrc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz", @@ -24883,84 +24259,12 @@ "integrity": "sha512-NXGXGFGiOKEnvBIHq9cdFTKbHO2/4B3Zd9K27M7j1DioIQVar7oVRqZMYs0h3XMVEZLwjjkdAtqRPCzzd3RtXg==", "dev": true }, - "@next/swc-android-arm-eabi": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-12.3.4.tgz", - "integrity": "sha512-cM42Cw6V4Bz/2+j/xIzO8nK/Q3Ly+VSlZJTa1vHzsocJRYz8KT6MrreXaci2++SIZCF1rVRCDgAg5PpqRibdIA==", - "optional": true - }, - "@next/swc-android-arm64": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-android-arm64/-/swc-android-arm64-12.3.4.tgz", - "integrity": "sha512-5jf0dTBjL+rabWjGj3eghpLUxCukRhBcEJgwLedewEA/LJk2HyqCvGIwj5rH+iwmq1llCWbOky2dO3pVljrapg==", - "optional": true - }, "@next/swc-darwin-arm64": { "version": "12.3.4", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.3.4.tgz", "integrity": "sha512-DqsSTd3FRjQUR6ao0E1e2OlOcrF5br+uegcEGPVonKYJpcr0MJrtYmPxd4v5T6UCJZ+XzydF7eQo5wdGvSZAyA==", "optional": true }, - "@next/swc-darwin-x64": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-12.3.4.tgz", - "integrity": "sha512-PPF7tbWD4k0dJ2EcUSnOsaOJ5rhT3rlEt/3LhZUGiYNL8KvoqczFrETlUx0cUYaXe11dRA3F80Hpt727QIwByQ==", - "optional": true - }, - "@next/swc-freebsd-x64": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-freebsd-x64/-/swc-freebsd-x64-12.3.4.tgz", - "integrity": "sha512-KM9JXRXi/U2PUM928z7l4tnfQ9u8bTco/jb939pdFUHqc28V43Ohd31MmZD1QzEK4aFlMRaIBQOWQZh4D/E5lQ==", - "optional": true - }, - "@next/swc-linux-arm-gnueabihf": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.3.4.tgz", - "integrity": "sha512-3zqD3pO+z5CZyxtKDTnOJ2XgFFRUBciOox6EWkoZvJfc9zcidNAQxuwonUeNts6Xbm8Wtm5YGIRC0x+12YH7kw==", - "optional": true - }, - "@next/swc-linux-arm64-gnu": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.3.4.tgz", - "integrity": "sha512-kiX0vgJGMZVv+oo1QuObaYulXNvdH/IINmvdZnVzMO/jic/B8EEIGlZ8Bgvw8LCjH3zNVPO3mGrdMvnEEPEhKA==", - "optional": true - }, - "@next/swc-linux-arm64-musl": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.3.4.tgz", - "integrity": "sha512-EETZPa1juczrKLWk5okoW2hv7D7WvonU+Cf2CgsSoxgsYbUCZ1voOpL4JZTOb6IbKMDo6ja+SbY0vzXZBUMvkQ==", - "optional": true - }, - "@next/swc-linux-x64-gnu": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.3.4.tgz", - "integrity": "sha512-4csPbRbfZbuWOk3ATyWcvVFdD9/Rsdq5YHKvRuEni68OCLkfy4f+4I9OBpyK1SKJ00Cih16NJbHE+k+ljPPpag==", - "optional": true - }, - "@next/swc-linux-x64-musl": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.3.4.tgz", - "integrity": "sha512-YeBmI+63Ro75SUiL/QXEVXQ19T++58aI/IINOyhpsRL1LKdyfK/35iilraZEFz9bLQrwy1LYAR5lK200A9Gjbg==", - "optional": true - }, - "@next/swc-win32-arm64-msvc": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.3.4.tgz", - "integrity": "sha512-Sd0qFUJv8Tj0PukAYbCCDbmXcMkbIuhnTeHm9m4ZGjCf6kt7E/RMs55Pd3R5ePjOkN7dJEuxYBehawTR/aPDSQ==", - "optional": true - }, - "@next/swc-win32-ia32-msvc": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.3.4.tgz", - "integrity": "sha512-rt/vv/vg/ZGGkrkKcuJ0LyliRdbskQU+91bje+PgoYmxTZf/tYs6IfbmgudBJk6gH3QnjHWbkphDdRQrseRefQ==", - "optional": true - }, - "@next/swc-win32-x64-msvc": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.3.4.tgz", - "integrity": "sha512-DQ20JEfTBZAgF8QCjYfJhv2/279M6onxFjdG/+5B0Cyj00/EdBxiWb2eGGFgQhrBbNv/lsvzFbbi0Ptf8Vw/bg==", - "optional": true - }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -25552,6 +24856,22 @@ "@radix-ui/react-use-size": "1.0.0" } }, + "@radix-ui/react-tabs": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.2.tgz", + "integrity": "sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-direction": "1.0.0", + "@radix-ui/react-id": "1.0.0", + "@radix-ui/react-presence": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-roving-focus": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.0" + } + }, "@radix-ui/react-toast": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.1.2.tgz", @@ -27446,6 +26766,11 @@ "@babel/runtime": "^7.12.5" } }, + "@types/argon2-browser": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/@types/argon2-browser/-/argon2-browser-1.18.1.tgz", + "integrity": "sha512-PZffP/CqH9m2kovDSRQMfMMxUC3V98I7i7/caa0RB0/nvsXzYbL9bKyqZpNMFmLFGZslROlG1R60ONt7abrwlA==" + }, "@types/aria-query": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.1.tgz", @@ -28577,6 +27902,11 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "dev": true }, + "argon2-browser": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/argon2-browser/-/argon2-browser-1.18.0.tgz", + "integrity": "sha512-ImVAGIItnFnvET1exhsQB7apRztcoC5TnlSqernMJDUjbc/DLq3UEYeXFrLPrlaIl8cVfwnXb6wX2KpFf2zxHw==" + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -29012,6 +28342,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "base64-loader": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base64-loader/-/base64-loader-1.0.0.tgz", + "integrity": "sha512-p32+F8dg+ANGx7s8QsZS74ZPHfIycmC2yZcoerzFgbersIYWitPbbF39G6SBx3gyvzyLH5nt1ooocxr0IHuWKA==" + }, "before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4e2e3e585..824dfdcd5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,14 +34,18 @@ "@radix-ui/react-progress": "^1.0.1", "@radix-ui/react-select": "^1.2.0", "@radix-ui/react-switch": "^1.0.1", + "@radix-ui/react-tabs": "^1.0.2", "@radix-ui/react-toast": "^1.1.2", "@reduxjs/toolkit": "^1.8.3", "@stripe/react-stripe-js": "^1.10.0", "@stripe/stripe-js": "^1.46.0", + "@types/argon2-browser": "^1.18.1", "@tanstack/react-query": "^4.23.0", "add": "^2.0.6", + "argon2-browser": "^1.18.0", "axios": "^0.27.2", "axios-auth-refresh": "^3.3.3", + "base64-loader": "^1.0.0", "classnames": "^2.3.1", "cookies": "^0.8.0", "cva": "npm:class-variance-authority@^0.4.0", diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 7519acc28..f2e3acf70 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -11,7 +11,8 @@ const integrationSlugNameMapping: Mapping = { 'netlify': 'Netlify', 'github': 'GitHub', 'render': 'Render', - 'flyio': 'Fly.io' + 'flyio': 'Fly.io', + "circleci": 'CircleCI' } const envMapping: Mapping = { diff --git a/frontend/public/data/frequentInterfaces.ts b/frontend/public/data/frequentInterfaces.ts index 9865d9909..953096e1d 100644 --- a/frontend/public/data/frequentInterfaces.ts +++ b/frontend/public/data/frequentInterfaces.ts @@ -10,9 +10,10 @@ export interface Tag { export interface SecretDataProps { pos: number; key: string; - value: string; + value: string | undefined; valueOverride: string | undefined; id: string; + idOverride?: string; comment: string; tags: Tag[]; } \ No newline at end of file diff --git a/frontend/public/images/dragon-book.svg b/frontend/public/images/dragon-book.svg new file mode 100644 index 000000000..31f51eb8d --- /dev/null +++ b/frontend/public/images/dragon-book.svg @@ -0,0 +1,235 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/locales/en/mfa.json b/frontend/public/locales/en/mfa.json new file mode 100644 index 000000000..2cc50c509 --- /dev/null +++ b/frontend/public/locales/en/mfa.json @@ -0,0 +1,28 @@ +{ + "title": "Sign Up", + "og-title": "Replace .env files with 1 line of code. Sign Up for Infisical in 3 minutes.", + "og-description": "Infisical a simple end-to-end encrypted platform that enables teams to sync and manage API-keys and environemntal variables. Works with Node.js, Next.js, Gatsby, Nest.js...", + "signup": "Sign Up", + "already-have-account": "Have an account? Log in", + "forgot-password": "Forgot your password?", + "verify": "Verify", + "step1-start": "Let's get started", + "step1-privacy": "By creating an account, you agree to our Terms and have read and acknowledged the Privacy Policy.", + "step1-submit": "Get Started", + "step2-message": "We've sent a verification code to", + "step2-code-error": "Oops. Your code is wrong. Tries left:", + "step2-resend-alert": "Don't see the code?", + "step2-resend-submit": "Resend", + "step2-resend-progress": "Resending...", + "step2-spam-alert": "Make sure to check your spam inbox.", + "step3-message": "Almost there!", + "step4-message": "Save your Emergency Kit", + "step4-description1": "If you get locked out of your account, your Emergency Kit is the only way to sign in.", + "step4-description2": "We recommend you download it and keep it somewhere safe.", + "step4-description3": "It contains your Secret Key which we cannot access or recover for you if you lose it.", + "step4-download": "Download PDF", + "step5-send-invites": "Send Invites", + "step5-invite-team": "Invite your team", + "step5-subtitle": "Infisical is meant to be used with your teammates. Invite them to test it out.", + "step5-skip": "Skip" +} diff --git a/frontend/public/locales/en/signup.json b/frontend/public/locales/en/signup.json index bee993727..5fa5742e3 100644 --- a/frontend/public/locales/en/signup.json +++ b/frontend/public/locales/en/signup.json @@ -9,9 +9,9 @@ "step1-start": "Let's get started", "step1-privacy": "By creating an account, you agree to our Terms and have read and acknowledged the Privacy Policy.", "step1-submit": "Get Started", - "step2-message": "We've sent a verification email to", + "step2-message": "We've sent a verification code to", "step2-code-error": "Oops. Your code is wrong. Please try again.", - "step2-resend-alert": "Don't see the email?", + "step2-resend-alert": "Don't see the code?", "step2-resend-submit": "Resend", "step2-resend-progress": "Resending...", "step2-spam-alert": "Make sure to check your spam inbox.", diff --git a/frontend/public/locales/fr/mfa.json b/frontend/public/locales/fr/mfa.json new file mode 100644 index 000000000..60e3201ed --- /dev/null +++ b/frontend/public/locales/fr/mfa.json @@ -0,0 +1,28 @@ +{ + "title": "S'inscrire", + "og-title": "Remplacez les fichiers .env par 1 ligne de code. Inscrivez-vous à Infisical en 3 minutes.", + "og-description": "Infisical, une plate-forme simple et chiffré de bout en bout qui permet aux équipes de synchroniser et de gérer des clefs API et des variables d'environnement. Fonctionne avec Node.js, Next.js, Gatsby, Nest.js ...", + "signup": "S'inscrire", + "already-have-account": "Déjà inscris? Se connecter", + "forgot-password": "Mot de passe oublié?", + "verify": "Vérifier", + "step1-start": "Bon, on commence!", + "step1-privacy": "En créant votre compte, vous acceptez nos conditions et avez lu et reconnu notre politique de confidentialité.", + "step1-submit": "C'est parti", + "step2-message": "Nous avons envoyé un email de vérification à", + "step2-code-error": "Oops. Votre code est faux. Essais restants:", + "step2-resend-alert": "Vous ne voyez pas le code?", + "step2-resend-submit": "Renvoyer", + "step2-resend-progress": "Envoie en cours...", + "step2-spam-alert": "Assurez-vous de vérifier vos spams.", + "step3-message": "Nous y sommes presque!", + "step4-message": "Enregistrez votre kit d'urgence", + "step4-description1": "Si vous n'arrivez plus à vous connecter à votre compte, votre kit d'urgence est le seul moyen d'y arriver.", + "step4-description2": "Nous vous recommandons de le télécharger et de le garder en sécurité.", + "step4-description3": "Il contient votre clef secrète que nous ne pouvons pas récupérer pour vous si vous la perdez.", + "step4-download": "Téléchargez le PDF", + "step5-send-invites": "Envoyer les invitations", + "step5-invite-team": "Invitez votre équipe", + "step5-subtitle": "Infisical a pour but d'être utilisé avec vos coéquipiers. Invitez-les à le tester.", + "step5-skip": "Passer" +} diff --git a/frontend/public/locales/fr/signup.json b/frontend/public/locales/fr/signup.json index 48b36e203..63d869193 100644 --- a/frontend/public/locales/fr/signup.json +++ b/frontend/public/locales/fr/signup.json @@ -11,7 +11,7 @@ "step1-submit": "C'est parti", "step2-message": "Nous avons envoyé un email de vérification à", "step2-code-error": "Oops. Votre code est faux. Veuillez réessayer.", - "step2-resend-alert": "Vous ne voyez pas l'email?", + "step2-resend-alert": "Vous ne voyez pas le code?", "step2-resend-submit": "Renvoyer", "step2-resend-progress": "Envoie en cours...", "step2-spam-alert": "Assurez-vous de vérifier vos spams.", diff --git a/frontend/public/locales/ko/mfa.json b/frontend/public/locales/ko/mfa.json new file mode 100644 index 000000000..6c17a68cf --- /dev/null +++ b/frontend/public/locales/ko/mfa.json @@ -0,0 +1,21 @@ +{ + "title": "회원가입", + "og-title": "한줄의 코드르 .env파일을 교체하세요. 3분이면 가입할 수 있어요.", + "og-description": "Infisical은 팀원과 .env파일을 공유하고 연동할 수 있는 심플한 end-to-end 암호화 플렛폼입니다. Node.js, Next.js, Gatsby, Nest.js 와 같은 다양한 플렛폼에서 작동해요.", + "signup": "회원가입", + "already-have-account": "이미 계정이 있나요? 로그인하기", + "forgot-password": "비밀번호를 잊으셨나요?", + "verify": "인증", + "step1-start": "시작하기", + "step1-privacy": "회원가입시 약관과 개인 정보 보호 정책을 읽고 동의한 것으로 간주합니다.", + "step1-submit": "시작하기", + "step2-message": "{{email}}로 인증 메일을 전송하였습니다{{email}}", + "step2-code-error": "코드가 잘못된 것 같아요. 남은 시도:", + "step2-spam-alert": "스팸함에 메일이 있지는 않은지 확인하세요", + "step3-message": "거의다 끝났어요!", + "step4-message": "긴급복구 키트 저장하기", + "step4-description1": "계정이 잠겼을 경우 비상 키트를 사용하여 로그인할 수 있어요.", + "step4-description2": "다운로드 후 안전한 곳에 보관하는 것을 추천합니다.", + "step4-description3": "분실시 접근하거나 복구할 수 없는 시크릿 키가 포함되어 있어요.", + "step4-download": "PDF 다운로드" +} diff --git a/frontend/public/locales/pt-BR/login.json b/frontend/public/locales/pt-BR/login.json index 9510d1a22..e06a59acd 100644 --- a/frontend/public/locales/pt-BR/login.json +++ b/frontend/public/locales/pt-BR/login.json @@ -4,5 +4,6 @@ "og-description": "Infisical é uma plataforma simples e criptografada de ponta a ponta que permite que as equipes sincronizem e gerenciem seus arquivos .env.", "login": "Entrar", "need-account": "Precisa de uma conta Infisical?", + "forgot-password": "Esqueceu sua senha?", "create-account": "Criar uma conta" -} \ No newline at end of file +} diff --git a/frontend/public/locales/pt-BR/mfa.json b/frontend/public/locales/pt-BR/mfa.json new file mode 100644 index 000000000..59acb3055 --- /dev/null +++ b/frontend/public/locales/pt-BR/mfa.json @@ -0,0 +1,21 @@ +{ + "title": "Inscrever-se", + "og-title": "Substitua os arquivos .env por 1 linha de código. Cadastre-se no Infisical em 3 minutos.", + "og-description": "Infisical é uma plataforma criptografada de ponta a ponta simples que permite que as equipes sincronizem e gerenciem chaves de API e variáveis ambientais. Funciona com Node.js, Next.js, Gatsby, Nest.js...", + "signup": "Inscrever-se", + "already-have-account": "Possui uma conta? Conecte-se", + "forgot-password": "Esqueceu sua senha?", + "verify": "Verificar", + "step1-start": "Vamos começar", + "step1-privacy": "Ao criar uma conta, você concorda com nossos Termos e leu e reconheceu a Política de Privacidade.", + "step1-submit": "Iniciar", + "step2-message": "Enviamos um e-mail de verificação para{{email}}", + "step2-code-error": "Ops. Seu código está errado. Tentativas restantes:", + "step2-spam-alert": "Certifique-se de verificar sua caixa de entrada de spam.", + "step3-message": "Quase lá!", + "step4-message": "Guarde o seu Kit de Emergência", + "step4-description1": "Se sua conta for bloqueada, seu Kit de emergência é a única maneira de fazer login.", + "step4-description2": "Recomendamos que você faça o download e guarde-o em algum lugar seguro.", + "step4-description3": "Ele contém sua chave secreta que não podemos acessar ou recuperar para você se você a perder.", + "step4-download": "Baixar PDF" +} \ No newline at end of file diff --git a/frontend/public/locales/tr/mfa.json b/frontend/public/locales/tr/mfa.json new file mode 100644 index 000000000..fe4eb1654 --- /dev/null +++ b/frontend/public/locales/tr/mfa.json @@ -0,0 +1,28 @@ +{ + "title": "Kayıt olun", + "og-title": "Tek satır kodla .env dosyalarını değiştirin. 3 dakika içerinde Infisical'a kayıt olun.", + "og-description": "Infisical takımların API anahtarlarını ve ortam değişkenlerini yönetmelerini ve senkronize etmelerini sağlayan basit, uçtan uca şifrelenmiş bir platformdur. Node.js, Next.js, Gatsby, Nest.js ve daha fazlası ile çalışır.", + "signup": "Kayıt ol", + "already-have-account": "Hesabın var mı? Giriş yap", + "forgot-password": "Şifreni mi unuttun?", + "verify": "Doğrula", + "step1-start": "Hadi başlayalım", + "step1-privacy": "Hesap oluşturarak, Şartlarımızı ve Gizlilik Politikasını okuyup kabul etmiş olursunuz.", + "step1-submit": "Başla", + "step2-message": "Şu adrese bir doğrulama maili yolladık", + "step2-code-error": "Tüh. Kodun hatalı. Kalan denemeler:", + "step2-resend-alert": "Kodu ulaşamadı mı?", + "step2-resend-submit": "Tekrar Yolla", + "step2-resend-progress": "Tekrar yollanıyor...", + "step2-spam-alert": "Spam kutunuzu kontrol ettiğinizden emin olun.", + "step3-message": "Çok az kaldı!", + "step4-message": "Acil Durum Kitinizi kayıt edin", + "step4-description1": "Eğer hesabınıza erişemezseniz, Acil Durum Kitiniz giriş yapmanın tek yoludur.", + "step4-description2": "Bunu indirmenizi ve güvenli bir ortamda saklamanızı öneriyoruz.", + "step4-description3": "Kaybetmeniz durumunda bizim dahi erişemeyeceğimiz veya kurtaramayacağımız Gizli Anahtarınızı barındırır.", + "step4-download": "PDF'yi indir", + "step5-send-invites": "Davetleri yolla", + "step5-invite-team": "Takımını davet et", + "step5-subtitle": "Infisical takım arkadaşlarınız ile kullanılmak üzere yapılmıştır. Birlikte test etmek için onları davet edin.", + "step5-skip": "Atla" +} \ No newline at end of file diff --git a/frontend/public/locales/tr/signup.json b/frontend/public/locales/tr/signup.json index 4a0778335..663b6b72b 100644 --- a/frontend/public/locales/tr/signup.json +++ b/frontend/public/locales/tr/signup.json @@ -11,7 +11,7 @@ "step1-submit": "Başla", "step2-message": "Şu adrese bir doğrulama maili yolladık", "step2-code-error": "Tüh. Kodun hatalı. Lütfen tekrar dene.", - "step2-resend-alert": "Mail ulaşamadı mı?", + "step2-resend-alert": "Kodu ulaşamadı mı?", "step2-resend-submit": "Tekrar Yolla", "step2-resend-progress": "Tekrar yollanıyor...", "step2-spam-alert": "Spam kutunuzu kontrol ettiğinizden emin olun.", diff --git a/frontend/src/components/basic/Toggle.tsx b/frontend/src/components/basic/Toggle.tsx index 9c6dcf155..313d7009c 100644 --- a/frontend/src/components/basic/Toggle.tsx +++ b/frontend/src/components/basic/Toggle.tsx @@ -3,8 +3,8 @@ import { Switch } from '@headlessui/react'; interface ToggleProps { enabled: boolean; setEnabled: (value: boolean) => void; - addOverride: (value: string | undefined, pos: number) => void; - pos: number; + addOverride: (value: string | undefined, id: string) => void; + id: string; } /** @@ -13,18 +13,18 @@ interface ToggleProps { * @param {boolean} obj.enabled - whether the toggle is turned on or off * @param {function} obj.setEnabled - change the state of the toggle * @param {function} obj.addOverride - a function that adds an override to a certain secret - * @param {number} obj.pos - position of a certain secret + * @param {number} obj.id - id of a certain secret * @returns */ -const Toggle = ({ enabled, setEnabled, addOverride, pos }: ToggleProps): JSX.Element => { +const Toggle = ({ enabled, setEnabled, addOverride, id }: ToggleProps): JSX.Element => { return ( { if (enabled === false) { - addOverride('', pos); + addOverride('', id); } else { - addOverride(undefined, pos); + addOverride(undefined, id); } setEnabled(!enabled); }} diff --git a/frontend/src/components/basic/dialog/AddUserDialog.tsx b/frontend/src/components/basic/dialog/AddUserDialog.tsx index 100ed3025..68ce24696 100644 --- a/frontend/src/components/basic/dialog/AddUserDialog.tsx +++ b/frontend/src/components/basic/dialog/AddUserDialog.tsx @@ -1,7 +1,5 @@ import { Fragment } from 'react'; -import { useRouter } from 'next/router'; import { Dialog, Transition } from '@headlessui/react'; -import { plans } from 'public/data/frequentConstants'; import Button from '../buttons/Button'; import InputField from '../InputField'; @@ -12,7 +10,6 @@ type Props = { submitModal: (email: string) => void; email: string; setEmail: (email: string) => void; - currentPlan: string; orgName: string; }; @@ -22,13 +19,11 @@ const AddUserDialog = ({ submitModal, email, setEmail, - currentPlan, orgName, }: Props) => { const submit = () => { submitModal(email); }; - const router = useRouter(); return (
@@ -81,28 +76,6 @@ const AddUserDialog = ({ isRequired />
- {currentPlan === plans.starter && ( -
- - -
- )}