diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 000000000..d19c3a6f7 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,26 @@ +--- +type: docker +kind: pipeline +name: "Main" + +steps: + - name: Docker build Git SHA + image: plugins/docker:20 + pull: if-not-exists + environment: + DOCKER_BUILDKIT: 1 + settings: + username: + from_secret: quay_username + password: + from_secret: quay_password + repo: quay.io/openware/gotrue + registry: quay.io + tag: ${DRONE_COMMIT:0:7} + purge: false + when: + event: + - push + branch: + - stable/ow + - feature/asymmetric-auth diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bcdc78303..e445886cd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,6 +33,8 @@ jobs: go-version: ${{ matrix.go-version }} - name: Checkout code uses: actions/checkout@v2 + - name: Formatting checks + run: if [ ! -z $(gofmt -l .) ]; then echo 'Make sure to run "gofmt -s -w ." before commit!' && exit 1; fi - name: Init Database run: psql -f hack/init_postgres.sql postgresql://postgres:root@localhost:5432/postgres - name: Run migrations diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..cb9b3cac9 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @supabase/auth diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3003d492c..b196dbe2b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,48 @@ Docs aren't perfect and so we're here to help. If you're stuck on setup for more Please help us keep all our projects open and inclusive. Kindly follow our [Code of Conduct](<(CODE_OF_CONDUCT.md)>) to keep the ecosystem healthy and friendly for all. +## Quick Start + +GoTrue has a development container setup that makes it easy to get started contributing. This setup only requires that [Docker](https://www.docker.com/get-started) is setup on your system. The development container setup includes a PostgreSQL container with migrations already applied and a container running GoTrue that will perform a hot reload when changes to the source code are detected. + +If you would like to run GoTrue locally or learn more about what these containers are doing for you, continue reading the [Setup and Tooling](#setup-and-tooling) section below. Otherwise, you can skip ahead to the [How To Verify that GoTrue is Available](#how-to-verify-that-gotrue-is-available) section to learn about working with and developing GoTrue. + +Before using the containers, you will need to make sure an `.env.docker` file exists by making a copy of `example.docker.env` and configuring it for your needs. The set of env vars in `example.docker.env` only contain the necessary env vars for gotrue to start in a docker environment. For the full list of env vars, please refer to `example.env` and copy over the necessary ones into your `.env.docker` file. + +The following are some basic commands. A full and up to date list of commands can be found in the project's `Makefile` or by running `make help`. + +### Starting the containers + +Start the containers as described above in an attached state with log output. + +``` bash +make dev +``` + +### Running tests in the containers + +Start the containers with a fresh database and run the project's tests. + +``` bash +make docker-test +``` + +### Removing the containers + +Remove both containers and their volumes. This removes any data associated with the containers. + +``` bash +make docker-clean +``` + +### Rebuild the containers + +Fully rebuild the containers without using any cached layers. + +``` bash +make docker-build +``` + ## Setup and Tooling GoTrue -- as the name implies -- is a user registration and authentication API developed in [Go](https://go.dev). diff --git a/Dockerfile b/Dockerfile index 56c222b23..c1eafc6da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ENV GO111MODULE=on ENV CGO_ENABLED=0 ENV GOOS=linux -RUN apk add --no-cache make git +RUN apk add --no-cache make git curl WORKDIR /go/src/github.com/netlify/gotrue @@ -15,11 +15,16 @@ RUN make deps COPY . /go/src/github.com/netlify/gotrue RUN make build +ARG KAIGARA_VERSION=v1.0.10 +RUN curl -Lo ./kaigara https://github.com/openware/kaigara/releases/download/${KAIGARA_VERSION}/kaigara \ + && chmod +x ./kaigara + FROM alpine:3.15 RUN adduser -D -u 1000 netlify RUN apk add --no-cache ca-certificates COPY --from=build /go/src/github.com/netlify/gotrue/gotrue /usr/local/bin/gotrue +COPY --from=build /go/src/github.com/netlify/gotrue/kaigara /usr/local/bin/kaigara COPY --from=build /go/src/github.com/netlify/gotrue/migrations /usr/local/etc/gotrue/migrations/ ENV GOTRUE_DB_MIGRATIONS_PATH /usr/local/etc/gotrue/migrations diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 000000000..de4e07791 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,18 @@ +FROM golang:1.17-alpine +ENV GO111MODULE=on +ENV CGO_ENABLED=0 +ENV GOOS=linux + +RUN apk add --no-cache make git bash + +WORKDIR /go/src/github.com/netlify/gotrue + +# Pulling dependencies +COPY ./Makefile ./go.* ./ + +# Production dependencies +RUN make deps + +# Development dependences +RUN go get github.com/githubnemo/CompileDaemon +RUN go install github.com/githubnemo/CompileDaemon diff --git a/Makefile b/Makefile index c59f4fb4b..4e212bff3 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ .PHONY: all build deps image lint migrate test vet CHECK_FILES?=$$(go list ./... | grep -v /vendor/) FLAGS?=-ldflags "-X github.com/netlify/gotrue/cmd.Version=`git describe --tags`" +DEV_DOCKER_COMPOSE:=docker-compose-dev.yml help: ## Show this help. @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {sub("\\\\n",sprintf("\n%22c"," "), $$2);printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) @@ -12,13 +13,10 @@ build: ## Build the binary. GOOS=linux GOARCH=arm64 go build $(FLAGS) -o gotrue-arm64 deps: ## Install dependencies. - @go install github.com/gobuffalo/pop/soda@latest + @go install github.com/gobuffalo/pop/soda@latest @go install golang.org/x/lint/golint@latest @go mod download -image: ## Build the Docker image. - docker build . - lint: ## Lint the code. golint $(CHECK_FILES) @@ -33,3 +31,28 @@ test: ## Run tests. vet: # Vet the code go vet $(CHECK_FILES) + +dev: ## Run the development containers + docker-compose -f $(DEV_DOCKER_COMPOSE) up + +down: ## Shutdown the development containers + # Start postgres first and apply migrations + docker-compose -f $(DEV_DOCKER_COMPOSE) down + +docker-test: ## Run the tests using the development containers + docker-compose -f $(DEV_DOCKER_COMPOSE) up -d postgres + docker-compose -f $(DEV_DOCKER_COMPOSE) run gotrue sh -c "make migrate_test" + docker-compose -f $(DEV_DOCKER_COMPOSE) run gotrue sh -c "make test" + docker-compose -f $(DEV_DOCKER_COMPOSE) down -v + +docker-build: ## Force a full rebuild of the development containers + docker-compose -f $(DEV_DOCKER_COMPOSE) build --no-cache + docker-compose -f $(DEV_DOCKER_COMPOSE) up -d postgres + docker-compose -f $(DEV_DOCKER_COMPOSE) run gotrue sh -c "make migrate_dev" + docker-compose -f $(DEV_DOCKER_COMPOSE) down + +docker-clean: ## Remove the development containers and volumes + docker-compose -f $(DEV_DOCKER_COMPOSE) rm -fsv + +format: + gofmt -s -w . diff --git a/README.md b/README.md index 63f5c25b0..0225ea7f1 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,15 @@ Create a `.env` file to store your own custom env vars. See [`example.env`](exam go build -ldflags "-X github.com/supabase/gotrue/cmd.Version=`git rev-parse HEAD`" GOOS=linux GOARCH=arm64 go build -ldflags "-X github.com/supabase/gotrue/cmd.Version=`git rev-parse HEAD`" -o gotrue-arm64 ``` -3. Execute the gotrue binary: `./gotrue` (if you're on x86) `./gotrue-arm64` (if you're on arm) +3. Execute the gotrue binary: `./gotrue` + +### If you have docker installed... +Create a `.env.docker` file to store your own custom env vars. See [`example.docker.env`](example.docker.env) + +1. `make build` +2. `make dev` +3. `docker ps` should show 2 docker containers (`gotrue_postgresql` and `gotrue_gotrue`) +4. That's it! Visit the [health checkendpoint](http://localhost:9999/health) to confirm that gotrue is running. ## Configuration @@ -35,7 +43,9 @@ The base URL your site is located at. Currently used in combination with other s `URI_ALLOW_LIST` - `string` -A comma separated list of URIs (e.g. "https://supabase.io/welcome,io.supabase.gotruedemo://logincallback") which are permitted as valid `redirect_to` destinations, in addition to SITE_URL. Defaults to []. +A comma separated list of URIs (e.g. `"https://foo.example.com,https://*.foo.example.com,https://bar.example.com"`) which are permitted as valid `redirect_to` destinations. Defaults to []. Supports wildcard matching through globbing. e.g. `https://*.foo.example.com` will allow `https://a.foo.example.com` and `https://b.foo.example.com` to be accepted. Globbing is also supported on subdomains. e.g. `https://foo.example.com/*` will allow `https://foo.example.com/page1` and `https://foo.example.com/page2` to be accepted. + +For more common glob patterns, check out the [following link](https://pkg.go.dev/github.com/gobwas/glob#Compile). `OPERATOR_TOKEN` - `string` _Multi-instance mode only_ @@ -66,6 +76,15 @@ Rate limit the number of emails sent per hr on the following endpoints: `/signup Minimum password length, defaults to 6. +`GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED` - `bool` + +If refresh token rotation is enabled, gotrue will automatically detect malicious attempts to reuse a revoked refresh token. When a malicious attempt is detected, gotrue immediately revokes all tokens that descended from the offending token. + +`GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL` - `string` + +This setting is only applicable if `GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED` is enabled. The reuse interval for a refresh token allows for exchanging the refresh token multiple times during the interval to support concurrency or offline issues. During the reuse interval, gotrue will not consider using a revoked token as a malicious attempt and will simply return the child refresh token. + +Only the previous revoked token can be reused. Using an old refresh token way before the current valid refresh token will trigger the reuse detection. ### API ```properties @@ -104,14 +123,17 @@ Chooses what dialect of database you want. Must be `mysql`. Connection string for the database. +`GOTRUE_DB_MAX_POOL_SIZE` - `int` + +Sets the maximum number of open connections to the database. Defaults to 0 which is equivalent to an "unlimited" number of connections. + `DB_NAMESPACE` - `string` Adds a prefix to all table names. **Migrations Note** -Migrations are not applied automatically, so you will need to run them after -you've built gotrue. +Migrations are applied automatically when you run `./gotrue`. However, you also have the option to rerun the migrations via the following methods: - If built locally: `./gotrue migrate` - Using Docker: `docker run --rm gotrue gotrue migrate` @@ -167,13 +189,17 @@ The name to use for the service. ```properties GOTRUE_JWT_SECRET=supersecretvalue +GOTRUE_JWT_ALGORITHM=RS256 GOTRUE_JWT_EXP=3600 GOTRUE_JWT_AUD=netlify ``` +`JWT_ALGORITHM` - `string` + +The signing algorithm for the JWT. Defaults to HS256. `JWT_SECRET` - `string` **required** -The secret used to sign JWT tokens with. +The secret used to sign JWT tokens with. If signing alogrithm is RS256, secret has to be Base64 encoded RSA private key. `JWT_EXP` - `number` @@ -193,7 +219,7 @@ The default group to assign all new users to. ### External Authentication Providers -We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `github`, `gitlab`, `google`, `linkedin`, `notion`, `spotify`, `slack`, `twitch` and `twitter` for external authentication. +We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `spotify`, `slack`, `twitch`, `twitter` and `workos` for external authentication. Use the names as the keys underneath `external` to configure each separately. @@ -224,7 +250,7 @@ The URI a OAuth2 provider will redirect to with the `code` and `state` values. `EXTERNAL_X_URL` - `string` -The base URL used for constructing the URLs to request authorization and access tokens. Used by `gitlab` only. Defaults to `https://gitlab.com`. +The base URL used for constructing the URLs to request authorization and access tokens. Used by `gitlab` and `keycloak`. For `gitlab` it defaults to `https://gitlab.com`. For `keycloak` you need to set this to your instance, for example: `https://keycloak.example.com/realms/myrealm` #### Apple OAuth @@ -486,10 +512,17 @@ Whether captcha middleware is enabled for now the only option supported is: `hcaptcha` -`SECURITY_CAPTCHA_SECRET` - `string` +- `SECURITY_CAPTCHA_SECRET` - `string` +- `SECURITY_CAPTCHA_TIMEOUT` - `string` Retrieve from hcaptcha account +### Reauthentication + +`SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION` - `bool` + +Enforce reauthentication on password update. + ## Endpoints GoTrue exposes the following endpoints: @@ -509,12 +542,14 @@ Returns the publicly available settings for this gotrue instance. "github": true, "gitlab": true, "google": true, + "keycloak": true, "linkedin": true, "notion": true, "slack": true, "spotify": true, "twitch": true, - "twitter": true + "twitter": true, + "workos": true, }, "disable_signup": false, "autoconfirm": false @@ -908,6 +943,25 @@ Returns: } ``` +If `GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION` is enabled, the user will need to reauthenticate first. + +```json +{ + "password": "new-password", + "nonce": "123456", +} +``` + +### **GET /reauthenticate** + +Sends a nonce to the user's email (preferred) or phone. This endpoint requires the user to be logged in / authenticated first. The user needs to have either an email or phone number for the nonce to be sent successfully. + +```json +headers: { + "Authorization" : "Bearer eyJhbGciOiJI...M3A90LCkxxtX9oNP9KZO" +} +``` + ### **POST /logout** Logout a user (Requires authentication). @@ -922,7 +976,8 @@ Get access_token from external oauth provider query params: ``` -provider=apple | azure | bitbucket | discord | facebook | github | gitlab | google | linkedin | notion | slack | spotify | twitch | twitter +provider=apple | azure | bitbucket | discord | facebook | github | gitlab | google | keycloak | linkedin | notion | slack | spotify | twitch | twitter | workos + scopes= ``` @@ -936,3 +991,52 @@ External provider should redirect to here Redirects to `#access_token=&refresh_token=&provider_token=&expires_in=3600&provider=` If additional scopes were requested then `provider_token` will be populated, you can use this to fetch additional data from the provider or interact with their services +### **POST /sign_challenge** + + This is an endpoint for user sign up with Asymmetric key. + Currently implemets only sign up with Ethereum address( not public key). + + body: + ```json + // Sign up with Metamask browser extension + { + "key": "0x6BE46d7D863666546b77951D5dfffcF075F36E68", + "algorithm": "ETH" + } + ``` + + Returns: + ```json + { + "challenge_token": "d188f5a4-f9d6-4ede-8cfd-2a45927b0edc" + } + ``` + Returned challenge token has to be signed with Metamask and sent back to /asymmetric_login + +### **POST /asymmetric_login** + + This is an endpoint for user sign in with Asymmetric key. + Accepts signed challenge token from `/sign_challenge` endpoint + + body: + ```json + // Login with with Metamask browser extension + { + "key": "0x6BE46d7D863666546b77951D5dfffcF075F36E68", + "challenge_token_signature": "0x3129682f92a0f3f6ef648623c3256ae39ab16de4fefcc50c60a375c8dd224dde291f750d0fd3d475b403a00a631dd8979583b8d036d2e3b2408668a1b4ea6b321c" + } + ``` + + Returns: + ```json + { + "access_token": "jwt-token-representing-the-user", + "token_type": "bearer", + "expires_in": 3600, + "refresh_token": "a-refresh-token" + } + ``` + + Once you have an access token, you can access the methods requiring authentication + by settings the `Authorization: Bearer YOUR_ACCESS_TOKEN_HERE` header. + diff --git a/api/admin.go b/api/admin.go index 8022cda8f..2134ce2d3 100644 --- a/api/admin.go +++ b/api/admin.go @@ -68,7 +68,7 @@ func (a *API) adminUsers(w http.ResponseWriter, r *http.Request) error { return badRequestError("Bad Pagination Parameters: %v", err) } - sortParams, err := sort(r, map[string]bool{models.CreatedAt: true}, []models.SortField{models.SortField{Name: models.CreatedAt, Dir: models.Descending}}) + sortParams, err := sort(r, map[string]bool{models.CreatedAt: true}, []models.SortField{{Name: models.CreatedAt, Dir: models.Descending}}) if err != nil { return badRequestError("Bad Sort Parameters: %v", err) } @@ -175,7 +175,7 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error { } } - if terr := models.NewAuditLogEntry(tx, instanceID, adminUser, models.UserModifiedAction, map[string]interface{}{ + if terr := models.NewAuditLogEntry(tx, instanceID, adminUser, models.UserModifiedAction, "", map[string]interface{}{ "user_id": user.ID, "user_email": user.Email, "user_phone": user.Phone, @@ -231,9 +231,9 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error { } if params.Phone != "" { - params.Phone = a.formatPhoneNumber(params.Phone) - if isValid := a.validateE164Format(params.Phone); !isValid { - return unprocessableEntityError("Invalid phone format") + params.Phone, err = a.validatePhone(params.Phone) + if err != nil { + return err } if exists, err := models.IsDuplicatedPhone(a.db, instanceID, params.Phone, aud); err != nil { return internalServerError("Database error checking phone").WithInternalError(err) @@ -250,13 +250,10 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error { params.Password = &password } - user, err := models.NewUser(instanceID, params.Email, *params.Password, aud, params.UserMetaData) + user, err := models.NewUser(instanceID, params.Phone, params.Email, *params.Password, aud, params.UserMetaData) if err != nil { return internalServerError("Error creating user").WithInternalError(err) } - if params.Phone != "" { - user.Phone = storage.NullString(params.Phone) - } if user.AppMetaData == nil { user.AppMetaData = make(map[string]interface{}) } @@ -273,7 +270,7 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error { } err = a.db.Transaction(func(tx *storage.Connection) error { - if terr := models.NewAuditLogEntry(tx, instanceID, adminUser, models.UserSignedUpAction, map[string]interface{}{ + if terr := models.NewAuditLogEntry(tx, instanceID, adminUser, models.UserSignedUpAction, "", map[string]interface{}{ "user_id": user.ID, "user_email": user.Email, "user_phone": user.Phone, @@ -326,7 +323,7 @@ func (a *API) adminUserDelete(w http.ResponseWriter, r *http.Request) error { adminUser := getAdminUser(ctx) err := a.db.Transaction(func(tx *storage.Connection) error { - if terr := models.NewAuditLogEntry(tx, instanceID, adminUser, models.UserDeletedAction, map[string]interface{}{ + if terr := models.NewAuditLogEntry(tx, instanceID, adminUser, models.UserDeletedAction, "", map[string]interface{}{ "user_id": user.ID, "user_email": user.Email, "user_phone": user.Phone, diff --git a/api/admin_test.go b/api/admin_test.go index 9c421630a..40471924c 100644 --- a/api/admin_test.go +++ b/api/admin_test.go @@ -49,17 +49,20 @@ func (ts *AdminTestSuite) SetupTest() { } func (ts *AdminTestSuite) makeSuperAdmin(email string) string { - u, err := models.NewUser(ts.instanceID, email, "test", ts.Config.JWT.Aud, map[string]interface{}{"full_name": "Test User"}) + u, err := models.NewUser(ts.instanceID, "9123456", email, "test", ts.Config.JWT.Aud, map[string]interface{}{"full_name": "Test User"}) require.NoError(ts.T(), err, "Error making new user") u.Role = "supabase_admin" - token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret) + key, err := models.FindMainAsymmetricKeyByUser(ts.API.db, u) + require.NoError(ts.T(), err, "Error finding keys") + + token, err := generateAccessToken(u, key, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.GetSigningMethod(), ts.Config.JWT.GetSigningKey()) require.NoError(ts.T(), err, "Error generating access token") p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} _, err = p.Parse(token, func(token *jwt.Token) (interface{}, error) { - return []byte(ts.Config.JWT.Secret), nil + return ts.Config.JWT.GetVerificationKey(), nil }) require.NoError(ts.T(), err, "Error parsing token") @@ -70,12 +73,15 @@ func (ts *AdminTestSuite) makeSystemUser() string { u := models.NewSystemUser(uuid.Nil, ts.Config.JWT.Aud) u.Role = "service_role" - token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret) + key, err := models.FindMainAsymmetricKeyByUser(ts.API.db, u) + require.NoError(ts.T(), err, "Error finding keys") + + token, err := generateAccessToken(u, key, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.GetSigningMethod(), ts.Config.JWT.GetSigningKey()) require.NoError(ts.T(), err, "Error generating access token") p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} _, err = p.Parse(token, func(token *jwt.Token) (interface{}, error) { - return []byte(ts.Config.JWT.Secret), nil + return ts.Config.JWT.GetVerificationKey(), nil }) require.NoError(ts.T(), err, "Error parsing token") @@ -108,11 +114,11 @@ func (ts *AdminTestSuite) TestAdminUsers() { // TestAdminUsers tests API /admin/users route func (ts *AdminTestSuite) TestAdminUsers_Pagination() { - u, err := models.NewUser(ts.instanceID, "test1@example.com", "test", ts.Config.JWT.Aud, nil) + u, err := models.NewUser(ts.instanceID, "12345678", "test1@example.com", "test", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") - u, err = models.NewUser(ts.instanceID, "test2@example.com", "test", ts.Config.JWT.Aud, nil) + u, err = models.NewUser(ts.instanceID, "987654321", "test2@example.com", "test", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") @@ -137,13 +143,13 @@ func (ts *AdminTestSuite) TestAdminUsers_Pagination() { // TestAdminUsers tests API /admin/users route func (ts *AdminTestSuite) TestAdminUsers_SortAsc() { - u, err := models.NewUser(ts.instanceID, "test1@example.com", "test", ts.Config.JWT.Aud, nil) + u, err := models.NewUser(ts.instanceID, "", "test1@example.com", "test", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") // if the created_at times are the same, then the sort order is not guaranteed time.Sleep(1 * time.Second) - u, err = models.NewUser(ts.instanceID, "test2@example.com", "test", ts.Config.JWT.Aud, nil) + u, err = models.NewUser(ts.instanceID, "", "test2@example.com", "test", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") @@ -172,13 +178,13 @@ func (ts *AdminTestSuite) TestAdminUsers_SortAsc() { // TestAdminUsers tests API /admin/users route func (ts *AdminTestSuite) TestAdminUsers_SortDesc() { - u, err := models.NewUser(ts.instanceID, "test1@example.com", "test", ts.Config.JWT.Aud, nil) + u, err := models.NewUser(ts.instanceID, "12345678", "test1@example.com", "test", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") // if the created_at times are the same, then the sort order is not guaranteed time.Sleep(1 * time.Second) - u, err = models.NewUser(ts.instanceID, "test2@example.com", "test", ts.Config.JWT.Aud, nil) + u, err = models.NewUser(ts.instanceID, "987654321", "test2@example.com", "test", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") @@ -204,7 +210,7 @@ func (ts *AdminTestSuite) TestAdminUsers_SortDesc() { // TestAdminUsers tests API /admin/users route func (ts *AdminTestSuite) TestAdminUsers_FilterEmail() { - u, err := models.NewUser(ts.instanceID, "test1@example.com", "test", ts.Config.JWT.Aud, nil) + u, err := models.NewUser(ts.instanceID, "", "test1@example.com", "test", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") @@ -229,11 +235,11 @@ func (ts *AdminTestSuite) TestAdminUsers_FilterEmail() { // TestAdminUsers tests API /admin/users route func (ts *AdminTestSuite) TestAdminUsers_FilterName() { - u, err := models.NewUser(ts.instanceID, "test1@example.com", "test", ts.Config.JWT.Aud, map[string]interface{}{"full_name": "Test User"}) + u, err := models.NewUser(ts.instanceID, "", "test1@example.com", "test", ts.Config.JWT.Aud, map[string]interface{}{"full_name": "Test User"}) require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") - u, err = models.NewUser(ts.instanceID, "test2@example.com", "test", ts.Config.JWT.Aud, nil) + u, err = models.NewUser(ts.instanceID, "", "test2@example.com", "test", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") @@ -354,7 +360,7 @@ func (ts *AdminTestSuite) TestAdminUserCreate() { // TestAdminUserGet tests API /admin/user route (GET) func (ts *AdminTestSuite) TestAdminUserGet() { - u, err := models.NewUser(ts.instanceID, "test1@example.com", "test", ts.Config.JWT.Aud, map[string]interface{}{"full_name": "Test Get User"}) + u, err := models.NewUser(ts.instanceID, "12345678", "test1@example.com", "test", ts.Config.JWT.Aud, map[string]interface{}{"full_name": "Test Get User"}) require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") @@ -380,7 +386,7 @@ func (ts *AdminTestSuite) TestAdminUserGet() { // TestAdminUserUpdate tests API /admin/user route (UPDATE) func (ts *AdminTestSuite) TestAdminUserUpdate() { - u, err := models.NewUser(ts.instanceID, "test1@example.com", "test", ts.Config.JWT.Aud, nil) + u, err := models.NewUser(ts.instanceID, "12345678", "test1@example.com", "test", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") @@ -421,7 +427,7 @@ func (ts *AdminTestSuite) TestAdminUserUpdate() { // TestAdminUserUpdate tests API /admin/user route (UPDATE) as system user func (ts *AdminTestSuite) TestAdminUserUpdateAsSystemUser() { - u, err := models.NewUser(ts.instanceID, "test1@example.com", "test", ts.Config.JWT.Aud, nil) + u, err := models.NewUser(ts.instanceID, "12345678", "test1@example.com", "test", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") @@ -466,7 +472,7 @@ func (ts *AdminTestSuite) TestAdminUserUpdateAsSystemUser() { } func (ts *AdminTestSuite) TestAdminUserUpdatePasswordFailed() { - u, err := models.NewUser(ts.instanceID, "test1@example.com", "test", ts.Config.JWT.Aud, nil) + u, err := models.NewUser(ts.instanceID, "12345678", "test1@example.com", "test", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") @@ -489,7 +495,7 @@ func (ts *AdminTestSuite) TestAdminUserUpdatePasswordFailed() { } func (ts *AdminTestSuite) TestAdminUserUpdateBannedUntilFailed() { - u, err := models.NewUser(ts.instanceID, "test1@example.com", "test", ts.Config.JWT.Aud, nil) + u, err := models.NewUser(ts.instanceID, "", "test1@example.com", "test", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") @@ -513,7 +519,7 @@ func (ts *AdminTestSuite) TestAdminUserUpdateBannedUntilFailed() { // TestAdminUserDelete tests API /admin/user route (DELETE) func (ts *AdminTestSuite) TestAdminUserDelete() { - u, err := models.NewUser(ts.instanceID, "test-delete@example.com", "test", ts.Config.JWT.Aud, nil) + u, err := models.NewUser(ts.instanceID, "123456789", "test-delete@example.com", "test", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") diff --git a/api/api.go b/api/api.go index 6ea197856..eae132a08 100644 --- a/api/api.go +++ b/api/api.go @@ -117,30 +117,37 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati sharedLimiter := api.limitEmailSentHandler() r.With(sharedLimiter).With(api.requireAdminCredentials).Post("/invite", api.Invite) r.With(sharedLimiter).With(api.verifyCaptcha).Post("/signup", api.Signup) + r.With(sharedLimiter).With(api.verifyCaptcha).Post("/sign_challenge", api.GetChallengeToken) + r.With(sharedLimiter).With(api.verifyCaptcha).Post("/asymmetric_login", api.SignInWithAsymmetricKey) r.With(sharedLimiter).With(api.verifyCaptcha).With(api.requireEmailProvider).Post("/recover", api.Recover) r.With(sharedLimiter).With(api.verifyCaptcha).Post("/magiclink", api.MagicLink) r.With(sharedLimiter).With(api.verifyCaptcha).Post("/otp", api.Otp) r.With(api.limitHandler( - // Allow requests at a rate of 30 per 5 minutes. - tollbooth.NewLimiter(30.0/(60*5), &limiter.ExpirableOptions{ + // Allow requests at the specified rate per 5 minutes. + tollbooth.NewLimiter(api.config.RateLimitTokenRefresh/(60*5), &limiter.ExpirableOptions{ DefaultExpirationTTL: time.Hour, }).SetBurst(30), - )).Post("/token", api.Token) + )).With(api.verifyCaptcha).Post("/token", api.Token) r.With(api.limitHandler( - // Allow requests at a rate of 30 per 5 minutes. - tollbooth.NewLimiter(30.0/(60*5), &limiter.ExpirableOptions{ + // Allow requests at the specified rate per 5 minutes. + tollbooth.NewLimiter(api.config.RateLimitVerify/(60*5), &limiter.ExpirableOptions{ DefaultExpirationTTL: time.Hour, }).SetBurst(30), )).Route("/verify", func(r *router) { r.Get("/", api.Verify) - r.Post("/", api.Verify) + r.With(api.verifyCaptcha).Post("/", api.Verify) }) r.With(api.requireAuthentication).Post("/logout", api.Logout) + r.Route("/reauthenticate", func(r *router) { + r.Use(api.requireAuthentication) + r.Get("/", api.Reauthenticate) + }) + r.Route("/user", func(r *router) { r.Use(api.requireAuthentication) r.Get("/", api.UserGet) diff --git a/api/asymmetric_login_test.go b/api/asymmetric_login_test.go new file mode 100644 index 000000000..05ba7441e --- /dev/null +++ b/api/asymmetric_login_test.go @@ -0,0 +1,424 @@ +package api + +import ( + "bytes" + "crypto/ecdsa" + "encoding/json" + "fmt" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" + "github.com/golang-jwt/jwt" + "github.com/netlify/gotrue/conf" + "github.com/netlify/gotrue/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" +) + +type ChallengeTokenTestSuite struct { + suite.Suite + API *API + Config *conf.Configuration +} + +func TestGetChallengeToken(t *testing.T) { + api, config, _, err := setupAPIForTestForInstance() + require.NoError(t, err) + + ts := &ChallengeTokenTestSuite{ + API: api, + Config: config, + } + defer api.db.Close() + + suite.Run(t, ts) +} + +func (ts *ChallengeTokenTestSuite) SetupTest() { + models.TruncateAll(ts.API.db) +} + +// TestSignup tests API /signup route +func (ts *SignupTestSuite) TestSuccessfulGetChallengeToken() { + // Request body + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "key": "0x6BE46d7D863666546b77951D5dfffcF075F36E68", + "algorithm": "ETH", + })) + + // Setup request + req := httptest.NewRequest(http.MethodPost, "/sign_challenge", &buffer) + req.Header.Set("Content-Type", "application/json") + + // Setup response recorder + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusOK, w.Code) + + jsonData := GetChallengeTokenResponse{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&jsonData)) + require.NotEmpty(ts.T(), jsonData) + + user, key, err := models.FindUserWithAsymmetrickey(ts.API.db, "0x6BE46d7D863666546b77951D5dfffcF075F36E68") + require.NoError(ts.T(), err) + require.NotEmpty(ts.T(), user) + require.NotEmpty(ts.T(), key) + assert.Equal(ts.T(), key.ChallengeToken.String(), jsonData.ChallengeToken) + +} + +func (ts *SignupTestSuite) TestWrongAlgorithmGetChallengeToken() { + // Request body + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "key": "0x6BE46d7D863666546b77951D5dfffcF075F36E68", + "algorithm": "test", + })) + + // Setup request + req := httptest.NewRequest(http.MethodPost, "/sign_challenge", &buffer) + req.Header.Set("Content-Type", "application/json") + + // Setup response recorder + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusUnprocessableEntity, w.Code) + + msg, err := ioutil.ReadAll(w.Body) + require.NoError(ts.T(), err) + require.Equal(ts.T(), []byte(`{"code":422,"msg":"Key verification failed: Provided algorithm is not supported"}`), msg) +} + +func (ts *SignupTestSuite) TestWrongKeyFormatGetChallengeToken() { + // Request body + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "key": "testtest", + "algorithm": "ETH", + })) + + // Setup request + req := httptest.NewRequest(http.MethodPost, "/sign_challenge", &buffer) + req.Header.Set("Content-Type", "application/json") + + // Setup response recorder + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusUnprocessableEntity, w.Code) + + msg, err := ioutil.ReadAll(w.Body) + require.NoError(ts.T(), err) + require.Equal(ts.T(), []byte(`{"code":422,"msg":"Key verification failed: Provided key cannot be ETH address"}`), msg) +} + +func (ts *SignupTestSuite) TestFirstSignInSuperAdmin() { + //FirstUser is SuperAdmin config on true + ts.Config.FirstUserSuperAdmin = true + + // Request body + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "key": "0x6BE46d7D863666546b77951D5dfffcF075F36E68", + "algorithm": "ETH", + })) + + // Setup request + req := httptest.NewRequest(http.MethodPost, "/sign_challenge", &buffer) + req.Header.Set("Content-Type", "application/json") + + // Setup response recorder + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusOK, w.Code) + + jsonData := GetChallengeTokenResponse{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&jsonData)) + require.NotEmpty(ts.T(), jsonData) + + user, key, err := models.FindUserWithAsymmetrickey(ts.API.db, "0x6BE46d7D863666546b77951D5dfffcF075F36E68") + require.NoError(ts.T(), err) + require.NotEmpty(ts.T(), user) + require.NotEmpty(ts.T(), key) + assert.Equal(ts.T(), key.ChallengeToken.String(), jsonData.ChallengeToken) + assert.Equal(ts.T(), user.Role, "superadmin") + assert.Equal(ts.T(), user.IsSuperAdmin, true) +} + +func (ts *SignupTestSuite) TestFirstSignInConfigFalse() { + //FirstUser is SuperAdmin config on false + ts.Config.FirstUserSuperAdmin = false + + // Request body + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "key": "0x6BE46d7D863666546b77951D5dfffcF075F36E68", + "algorithm": "ETH", + })) + + // Setup request + req := httptest.NewRequest(http.MethodPost, "/sign_challenge", &buffer) + req.Header.Set("Content-Type", "application/json") + + // Setup response recorder + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusOK, w.Code) + + jsonData := GetChallengeTokenResponse{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&jsonData)) + require.NotEmpty(ts.T(), jsonData) + + user, key, err := models.FindUserWithAsymmetrickey(ts.API.db, "0x6BE46d7D863666546b77951D5dfffcF075F36E68") + require.NoError(ts.T(), err) + require.NotEmpty(ts.T(), user) + require.NotEmpty(ts.T(), key) + assert.Equal(ts.T(), key.ChallengeToken.String(), jsonData.ChallengeToken) + assert.Equal(ts.T(), user.Role, "authenticated") + assert.Equal(ts.T(), user.IsSuperAdmin, false) +} + +func (ts *SignupTestSuite) TestNotFirstSignIn() { + //FirstUser is SuperAdmin config on true + ts.Config.FirstUserSuperAdmin = true + + // Request body + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "key": "0x6BE46d7D863666546b77951D5dfffcF075F36E68", + "algorithm": "ETH", + })) + + var buffer2 bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer2).Encode(map[string]interface{}{ + "key": "0x6BE46d7D863666546677951D5dfffcF075F36E68", + "algorithm": "ETH", + })) + + // Setup request + req := httptest.NewRequest(http.MethodPost, "/sign_challenge", &buffer) + req.Header.Set("Content-Type", "application/json") + + // Setup response recorder + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + req = httptest.NewRequest(http.MethodPost, "/sign_challenge", &buffer2) + req.Header.Set("Content-Type", "application/json") + + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusOK, w.Code) + + jsonData := GetChallengeTokenResponse{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&jsonData)) + require.NotEmpty(ts.T(), jsonData) + + user, key, err := models.FindUserWithAsymmetrickey(ts.API.db, "0x6BE46d7D863666546677951D5dfffcF075F36E68") + require.NoError(ts.T(), err) + require.NotEmpty(ts.T(), user) + require.NotEmpty(ts.T(), key) + assert.Equal(ts.T(), user.Role, "authenticated") + assert.Equal(ts.T(), user.IsSuperAdmin, false) +} + +type AsymmetricSignInTestSuite struct { + suite.Suite + API *API + Config *conf.Configuration + + privateKey *ecdsa.PrivateKey + address string + challengeToken string +} + +func TestSignInWithAsymmetricKey(t *testing.T) { + api, config, _, err := setupAPIForTestForInstance() + require.NoError(t, err) + + ts := &AsymmetricSignInTestSuite{ + API: api, + Config: config, + } + defer api.db.Close() + + suite.Run(t, ts) +} + +func (ts *AsymmetricSignInTestSuite) SetupTest() { + models.TruncateAll(ts.API.db) + + privateKey, err := crypto.GenerateKey() + require.NoError(ts.T(), err) + ts.privateKey = privateKey + ts.address = crypto.PubkeyToAddress(privateKey.PublicKey).Hex() + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "key": ts.address, + "algorithm": "ETH", + })) + + // Setup request + req := httptest.NewRequest(http.MethodPost, "/sign_challenge", &buffer) + req.Header.Set("Content-Type", "application/json") + + // Setup response recorder + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + jsonData := GetChallengeTokenResponse{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&jsonData)) + require.NotEmpty(ts.T(), jsonData) + ts.challengeToken = jsonData.ChallengeToken +} + +func (ts *AsymmetricSignInTestSuite) TestSuccessfulSignIn() { + hash := models.SignEthMessageHash([]byte(ts.challengeToken)) + signature, err := crypto.Sign(hash, ts.privateKey) + require.NoError(ts.T(), err) + fmt.Println("Signature1:", hexutil.Encode(signature)) + signature[64] += 27 + + fmt.Println("Signature2:", hexutil.Encode(signature)) + + // Request body + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "key": ts.address, + "challenge_token_signature": hexutil.Encode(signature), + })) + + // Setup request + req := httptest.NewRequest(http.MethodPost, "/asymmetric_login", &buffer) + req.Header.Set("Content-Type", "application/json") + + // Setup response recorder + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusOK, w.Code) + + jsonData := AccessTokenResponse{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&jsonData)) + require.NotEmpty(ts.T(), jsonData) + + jwtToken, err := jwt.ParseWithClaims(jsonData.Token, &GoTrueClaims{}, func(token *jwt.Token) (interface{}, error) { + return ts.Config.JWT.GetVerificationKey(), nil + }) + require.NoError(ts.T(), err) + require.True(ts.T(), jwtToken.Valid) + + claims, ok := jwtToken.Claims.(*GoTrueClaims) + require.True(ts.T(), ok) + + require.Equal(ts.T(), ts.address, claims.MainAsymmetricKey) + require.Equal(ts.T(), "ETH", claims.MainAsymmetricKeyAlgorithm) +} + +func (ts *AsymmetricSignInTestSuite) TestSignatureWithout0xPrefixSignIn() { + // Request body + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "key": ts.address, + "challenge_token_signature": "testest", + })) + + // Setup request + req := httptest.NewRequest(http.MethodPost, "/asymmetric_login", &buffer) + req.Header.Set("Content-Type", "application/json") + + // Setup response recorder + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusUnprocessableEntity, w.Code) + + msg, err := ioutil.ReadAll(w.Body) + require.NoError(ts.T(), err) + require.Equal(ts.T(), []byte(`{"code":422,"msg":"Signature verification failed:hex string without 0x prefix"}`), msg) +} + +func (ts *AsymmetricSignInTestSuite) TestWrongSignatureFormatSignIn() { + // Request body + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "key": ts.address, + "challenge_token_signature": "0x39c95778651840beee168d95577abe5e42d83bf88ba6e39569de2d2bd674da6f2844a42d45206f09f945fb1768e9c7045e818ea5bee0dce1258005e43855b50601", + })) + + // Setup request + req := httptest.NewRequest(http.MethodPost, "/asymmetric_login", &buffer) + req.Header.Set("Content-Type", "application/json") + + // Setup response recorder + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusUnprocessableEntity, w.Code) + + msg, err := ioutil.ReadAll(w.Body) + require.NoError(ts.T(), err) + require.Equal(ts.T(), []byte(`{"code":422,"msg":"Signature verification failed:Provided signature has wrong format"}`), msg) +} + +func (ts *AsymmetricSignInTestSuite) TestAnotherKeySignatureSignIn() { + // Request body + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "key": ts.address, + "challenge_token_signature": "0x89568ade6b6f87652de7832b83652176788862bf6b2EB4260ef8d7f98dc067475e2d0fdb2aee6c5630d94e3c4a596acd8c62ce97bce2946f2003908c375116da1c", + })) + + // Setup request + req := httptest.NewRequest(http.MethodPost, "/asymmetric_login", &buffer) + req.Header.Set("Content-Type", "application/json") + + // Setup response recorder + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusUnprocessableEntity, w.Code) + + msg, err := ioutil.ReadAll(w.Body) + require.NoError(ts.T(), err) + require.Equal(ts.T(), []byte(`{"code":422,"msg":"Signature verification failed:Provided signature does not match with Key"}`), msg) +} + +func (ts *AsymmetricSignInTestSuite) TestMissingKeySignIn() { + // Request body + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "key": "0x6BE46d7D863666546b77951D5dfffcF075F36E68", + "challenge_token_signature": "0x89568ade6b6f87652de7832b83652176788862bf6b2EB4260ef8d7f98dc067475e2d0fdb2aee6c5630d94e3c4a596acd8c62ce97bce2946f2003908c375116da1c", + })) + + // Setup request + req := httptest.NewRequest(http.MethodPost, "/asymmetric_login", &buffer) + req.Header.Set("Content-Type", "application/json") + + // Setup response recorder + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusUnauthorized, w.Code) + + msg, err := ioutil.ReadAll(w.Body) + require.NoError(ts.T(), err) + require.Equal(ts.T(), []byte(`{"code":401,"msg":"Unauthorized"}`), msg) +} diff --git a/api/asymmetric_signin.go b/api/asymmetric_signin.go new file mode 100644 index 000000000..8f1748370 --- /dev/null +++ b/api/asymmetric_signin.go @@ -0,0 +1,160 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/gofrs/uuid" + "github.com/netlify/gotrue/models" + + "github.com/netlify/gotrue/storage" +) + +// GetChallengeTokenParams are the parameters the Signup endpoint accepts +type GetChallengeTokenParams struct { + Key string `json:"key"` + Algorithm string `json:"algorithm"` +} + +// GetChallengeTokenResponse is the response struct from Signup endpoint +type GetChallengeTokenResponse struct { + ChallengeToken string `json:"challenge_token"` +} + +func (a *API) GetChallengeToken(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + config := a.getConfig(ctx) + + params := &GetChallengeTokenParams{} + jsonDecoder := json.NewDecoder(r.Body) + err := jsonDecoder.Decode(params) + if err != nil { + return badRequestError("Could not read GetChallengeTokenParams params: %v", err) + } + + err = models.VerifyKeyAndAlgorithm(params.Key, params.Algorithm) + if err != nil { + return unprocessableEntityError("Key verification failed: %v", err) + } + + user, key, err := models.FindUserWithAsymmetrickey(a.db, params.Key) + var challengeToken uuid.UUID + + if err != nil && !models.IsNotFoundError(err) { + return internalServerError("Database error finding user").WithInternalError(err) + } + + aud := a.requestAud(ctx, r) + err = a.db.Transaction(func(tx *storage.Connection) error { + var terr error + if user != nil && key != nil { + challengeToken, terr = key.GetChallengeToken(tx) + if terr != nil { + return terr + } + } else if user == nil && key == nil { + if config.DisableSignup { + return forbiddenError("Signups not allowed for this instance") + } + + user, terr = a.signupNewUser(ctx, tx, &SignupParams{ + Email: "", + Phone: "", + Password: "", + Data: nil, + Provider: "AsymmetricKey", + Aud: aud, + }) + + if terr != nil { + return terr + } + + key, terr = models.NewAsymmetricKey(user.ID, params.Key, params.Algorithm, true) + if terr != nil { + return terr + } + + if terr := tx.Create(key); terr != nil { + return terr + } + + challengeToken, terr = key.GetChallengeToken(tx) + if terr != nil { + return terr + } + } else { + return internalServerError("Impossible case") + } + return nil + }) + + if err != nil { + return err + } + + return sendJSON(w, http.StatusOK, GetChallengeTokenResponse{ChallengeToken: challengeToken.String()}) +} + +// AsymmetricSignInParams are the parameters the Signin endpoint accepts +type AsymmetricSignInParams struct { + Key string `json:"key"` + ChallengeTokenSignature string `json:"challenge_token_signature"` +} + +func (a *API) SignInWithAsymmetricKey(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + config := a.getConfig(ctx) + cookie := r.Header.Get(useCookieHeader) + + params := &AsymmetricSignInParams{} + jsonDecoder := json.NewDecoder(r.Body) + err := jsonDecoder.Decode(params) + if err != nil { + return badRequestError("Could not read AsymmetricSignInParams params: %v", err) + } + + user, key, err := models.FindUserWithAsymmetrickey(a.db, params.Key) + if err != nil && models.IsNotFoundError(err) { + return unauthorizedError("Unauthorized") + } + if err != nil && !models.IsNotFoundError(err) { + return internalServerError("Database error finding key").WithInternalError(err) + } + + if key.IsChallengeTokenExpired() { + return unprocessableEntityError("Key challenge token has been expired") + } + + if err = key.VerifySignature(params.ChallengeTokenSignature); err != nil { + return unprocessableEntityError("Signature verification failed:%v", err) + } + + var token *AccessTokenResponse + err = a.db.Transaction(func(tx *storage.Connection) error { + var terr error + terr = tx.UpdateOnly(key, "challenge_passed") + if terr != nil { + return terr + } + + token, terr = a.issueRefreshToken(ctx, tx, user) + if terr != nil { + return terr + } + + if cookie != "" && config.Cookie.Duration > 0 { + if terr = a.setCookieTokens(config, token, cookie == useSessionCookie, w); terr != nil { + return internalServerError("Failed to set JWT cookie. %s", terr) + } + } + return nil + }) + + if err != nil { + return err + } + + token.User = user + return sendJSON(w, http.StatusOK, token) +} diff --git a/api/audit.go b/api/audit.go index ea1236798..b7b954512 100644 --- a/api/audit.go +++ b/api/audit.go @@ -8,16 +8,15 @@ import ( ) var filterColumnMap = map[string][]string{ - "author": []string{"actor_username", "actor_name"}, - "action": []string{"action"}, - "type": []string{"log_type"}, + "author": {"actor_username", "actor_name"}, + "action": {"action"}, + "type": {"log_type"}, } func (a *API) adminAuditLog(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() instanceID := getInstanceID(ctx) // aud := a.requestAud(ctx, r) - pageParams, err := paginate(r) if err != nil { return badRequestError("Bad Pagination Parameters: %v", err) diff --git a/api/audit_test.go b/api/audit_test.go index b6a3fdd08..9a9d679ff 100644 --- a/api/audit_test.go +++ b/api/audit_test.go @@ -46,17 +46,20 @@ func (ts *AuditTestSuite) SetupTest() { } func (ts *AuditTestSuite) makeSuperAdmin(email string) string { - u, err := models.NewUser(ts.instanceID, email, "test", ts.Config.JWT.Aud, map[string]interface{}{"full_name": "Test User"}) + u, err := models.NewUser(ts.instanceID, "", email, "test", ts.Config.JWT.Aud, map[string]interface{}{"full_name": "Test User"}) require.NoError(ts.T(), err, "Error making new user") u.Role = "supabase_admin" - token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret) + key, err := models.FindMainAsymmetricKeyByUser(ts.API.db, u) + require.NoError(ts.T(), err, "Error finding keys") + + token, err := generateAccessToken(u, key, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.GetSigningMethod(), ts.Config.JWT.GetSigningKey()) require.NoError(ts.T(), err, "Error generating access token") p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} _, err = p.Parse(token, func(token *jwt.Token) (interface{}, error) { - return []byte(ts.Config.JWT.Secret), nil + return ts.Config.JWT.GetVerificationKey(), nil }) require.NoError(ts.T(), err, "Error parsing token") @@ -121,7 +124,7 @@ func (ts *AuditTestSuite) TestAuditFilters() { func (ts *AuditTestSuite) prepareDeleteEvent() { // DELETE USER - u, err := models.NewUser(ts.instanceID, "test-delete@example.com", "test", ts.Config.JWT.Aud, nil) + u, err := models.NewUser(ts.instanceID, "12345678", "test-delete@example.com", "test", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error making new user") require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user") diff --git a/api/auth.go b/api/auth.go index 35d82444b..be569592a 100644 --- a/api/auth.go +++ b/api/auth.go @@ -60,9 +60,9 @@ func (a *API) parseJWTClaims(bearer string, r *http.Request, w http.ResponseWrit ctx := r.Context() config := a.getConfig(ctx) - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.Parser{ValidMethods: []string{config.JWT.Algorithm}} token, err := p.ParseWithClaims(bearer, &GoTrueClaims{}, func(token *jwt.Token) (interface{}, error) { - return []byte(config.JWT.Secret), nil + return config.JWT.GetVerificationKey(), nil }) if err != nil { a.clearCookieTokens(config, w) diff --git a/api/errors.go b/api/errors.go index f839357de..335dc4e7d 100644 --- a/api/errors.go +++ b/api/errors.go @@ -8,12 +8,14 @@ import ( "runtime/debug" "github.com/netlify/gotrue/conf" + "github.com/netlify/gotrue/utilities" "github.com/pkg/errors" ) // Common error messages during signup flow var ( DuplicateEmailMsg = "A user with this email address has already been registered" + DuplicatePhoneMsg = "A user with this phone number has already been registered" UserExistsError error = errors.New("User already exists") ) @@ -100,7 +102,7 @@ func acceptedTokenError(fmtString string, args ...interface{}) *HTTPError { } func expiredTokenError(fmtString string, args ...interface{}) *HTTPError { - return httpError(http.StatusGone, fmtString, args...) + return httpError(http.StatusUnauthorized, fmtString, args...) } func unauthorizedError(fmtString string, args ...interface{}) *HTTPError { @@ -248,6 +250,15 @@ func handleError(err error, w http.ResponseWriter, r *http.Request) { } else { log.WithError(e.Cause()).Info(e.Error()) } + + // Provide better error messages for certain user-triggered Postgres errors. + if pgErr := utilities.NewPostgresError(e.InternalError); pgErr != nil { + if jsonErr := sendJSON(w, pgErr.HttpStatusCode, pgErr); jsonErr != nil { + handleError(jsonErr, w, r) + } + return + } + if jsonErr := sendJSON(w, e.Code, e); jsonErr != nil { handleError(jsonErr, w, r) } diff --git a/api/external.go b/api/external.go index 4d4a96c3b..117e7e538 100644 --- a/api/external.go +++ b/api/external.go @@ -16,6 +16,7 @@ import ( "github.com/netlify/gotrue/conf" "github.com/netlify/gotrue/models" "github.com/netlify/gotrue/storage" + "github.com/netlify/gotrue/utilities" "github.com/sirupsen/logrus" ) @@ -38,15 +39,16 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e ctx := r.Context() config := a.getConfig(ctx) - providerType := r.URL.Query().Get("provider") - scopes := r.URL.Query().Get("scopes") + query := r.URL.Query() + providerType := query.Get("provider") + scopes := query.Get("scopes") - p, err := a.Provider(ctx, providerType, scopes) + p, err := a.Provider(ctx, providerType, scopes, &query) if err != nil { return badRequestError("Unsupported provider: %+v", err).WithInternalError(err) } - inviteToken := r.URL.Query().Get("invite_token") + inviteToken := query.Get("invite_token") if inviteToken != "" { _, userErr := models.FindUserByConfirmationToken(a.db, inviteToken) if userErr != nil { @@ -57,11 +59,11 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e } } - redirectURL := a.getRedirectURLOrReferrer(r, r.URL.Query().Get("redirect_to")) + redirectURL := a.getRedirectURLOrReferrer(r, query.Get("redirect_to")) log := getLogEntry(r) log.WithField("provider", providerType).Info("Redirecting to external provider") - token := jwt.NewWithClaims(jwt.SigningMethodHS256, ExternalProviderClaims{ + token := jwt.NewWithClaims(config.JWT.GetSigningMethod(), ExternalProviderClaims{ NetlifyMicroserviceClaims: NetlifyMicroserviceClaims{ StandardClaims: jwt.StandardClaims{ ExpiresAt: time.Now().Add(5 * time.Minute).Unix(), @@ -74,7 +76,7 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e InviteToken: inviteToken, Referrer: redirectURL, }) - tokenString, err := token.SignedString([]byte(config.JWT.Secret)) + tokenString, err := token.SignedString(config.JWT.GetVerificationKey()) if err != nil { return internalServerError("Error creating state").WithInternalError(err) } @@ -233,7 +235,7 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re if !emailData.Verified && !config.Mailer.Autoconfirm { mailer := a.Mailer(ctx) referrer := a.getReferrer(r) - if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer); terr != nil { + if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength); terr != nil { if errors.Is(terr, MaxFrequencyLimitError) { return tooManyRequestsError("For security purposes, you can only request this once every minute") } @@ -243,7 +245,7 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re return nil } - if terr := models.NewAuditLogEntry(tx, instanceID, user, models.UserSignedUpAction, map[string]interface{}{ + if terr := models.NewAuditLogEntry(tx, instanceID, user, models.UserSignedUpAction, "", map[string]interface{}{ "provider": providerType, }); terr != nil { return terr @@ -257,7 +259,7 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re return internalServerError("Error updating user").WithInternalError(terr) } } else { - if terr := models.NewAuditLogEntry(tx, instanceID, user, models.LoginAction, map[string]interface{}{ + if terr := models.NewAuditLogEntry(tx, instanceID, user, models.LoginAction, "", map[string]interface{}{ "provider": providerType, }); terr != nil { return terr @@ -345,7 +347,7 @@ func (a *API) processInvite(ctx context.Context, tx *storage.Connection, userDat return nil, internalServerError("Database error updating user").WithInternalError(err) } - if err := models.NewAuditLogEntry(tx, instanceID, user, models.InviteAcceptedAction, map[string]interface{}{ + if err := models.NewAuditLogEntry(tx, instanceID, user, models.InviteAcceptedAction, "", map[string]interface{}{ "provider": providerType, }); err != nil { return nil, err @@ -364,9 +366,9 @@ func (a *API) processInvite(ctx context.Context, tx *storage.Connection, userDat func (a *API) loadExternalState(ctx context.Context, state string) (context.Context, error) { config := a.getConfig(ctx) claims := ExternalProviderClaims{} - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + p := jwt.Parser{ValidMethods: []string{config.JWT.Algorithm}} _, err := p.ParseWithClaims(state, &claims, func(token *jwt.Token) (interface{}, error) { - return []byte(config.JWT.Secret), nil + return config.JWT.GetVerificationKey(), nil }) if err != nil || claims.Provider == "" { return nil, badRequestError("OAuth state is invalid: %v", err) @@ -383,7 +385,7 @@ func (a *API) loadExternalState(ctx context.Context, state string) (context.Cont } // Provider returns a Provider interface for the given name. -func (a *API) Provider(ctx context.Context, name string, scopes string) (provider.Provider, error) { +func (a *API) Provider(ctx context.Context, name string, scopes string, query *url.Values) (provider.Provider, error) { config := a.getConfig(ctx) name = strings.ToLower(name) @@ -402,6 +404,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide return provider.NewGitlabProvider(config.External.Gitlab, scopes) case "google": return provider.NewGoogleProvider(config.External.Google, scopes) + case "keycloak": + return provider.NewKeycloakProvider(config.External.Keycloak, scopes) case "linkedin": return provider.NewLinkedinProvider(config.External.Linkedin, scopes) case "facebook": @@ -416,6 +420,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide return provider.NewTwitchProvider(config.External.Twitch, scopes) case "twitter": return provider.NewTwitterProvider(config.External.Twitter, scopes) + case "workos": + return provider.NewWorkOSProvider(config.External.WorkOS, query) case "saml": return provider.NewSamlProvider(config.External.Saml, a.db, getInstanceID(ctx)) case "zoom": @@ -460,8 +466,18 @@ func getErrorQueryString(err error, errorID string, log logrus.FieldLogger) *url case ErrorCause: return getErrorQueryString(e.Cause(), errorID, log) default: - q.Set("error", "server_error") - q.Set("error_description", err.Error()) + error_type, error_description := "server_error", err.Error() + + // Provide better error messages for certain user-triggered Postgres errors. + if pgErr := utilities.NewPostgresError(e); pgErr != nil { + error_description = pgErr.Message + if oauthErrorType, ok := oauthErrorMap[pgErr.HttpStatusCode]; ok { + error_type = oauthErrorType + } + } + + q.Set("error", error_type) + q.Set("error_description", error_description) } return &q } diff --git a/api/external_apple_test.go b/api/external_apple_test.go index 8b2e99a98..5e385fcc9 100644 --- a/api/external_apple_test.go +++ b/api/external_apple_test.go @@ -24,7 +24,7 @@ func (ts *ExternalTestSuite) TestSignupExternalApple() { claims := ExternalProviderClaims{} p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { - return []byte(ts.Config.JWT.Secret), nil + return ts.Config.JWT.GetVerificationKey(), nil }) ts.Require().NoError(err) diff --git a/api/external_azure_test.go b/api/external_azure_test.go index b0599c45f..8c57e4061 100644 --- a/api/external_azure_test.go +++ b/api/external_azure_test.go @@ -30,7 +30,7 @@ func (ts *ExternalTestSuite) TestSignupExternalAzure() { claims := ExternalProviderClaims{} p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { - return []byte(ts.Config.JWT.Secret), nil + return ts.Config.JWT.GetVerificationKey(), nil }) ts.Require().NoError(err) @@ -67,7 +67,6 @@ func AzureTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int func (ts *ExternalTestSuite) TestSignupExternalAzure_AuthorizationCode() { ts.Config.DisableSignup = false - ts.createUser("azuretestid", "azure@example.com", "Azure Test", "", "") tokenCount, userCount := 0, 0 code := "authcode" server := AzureTestSignupSetup(ts, &tokenCount, &userCount, code, azureUser) @@ -106,7 +105,7 @@ func (ts *ExternalTestSuite) TestSignupExternalAzureDisableSignupErrorWhenNoEmai func (ts *ExternalTestSuite) TestSignupExternalAzureDisableSignupSuccessWithPrimaryEmail() { ts.Config.DisableSignup = true - ts.createUser("azuretestid", "azure@example.com", "Azure Test", "", "") + ts.createUser("azuretestid", "azure@example.com", "Azure Test", "http://example.com/avatar", "") tokenCount, userCount := 0, 0 code := "authcode" @@ -115,12 +114,12 @@ func (ts *ExternalTestSuite) TestSignupExternalAzureDisableSignupSuccessWithPrim u := performAuthorization(ts, "azure", code, "") - assertAuthorizationSuccess(ts, u, tokenCount, userCount, "azure@example.com", "Azure Test", "azuretestid", "") + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "azure@example.com", "Azure Test", "azuretestid", "http://example.com/avatar") } func (ts *ExternalTestSuite) TestInviteTokenExternalAzureSuccessWhenMatchingToken() { - // name and avatar should be populated from Azure API - ts.createUser("azuretestid", "azure@example.com", "", "", "invite_token") + // name should be populated from Azure API + ts.createUser("azuretestid", "azure@example.com", "", "http://example.com/avatar", "invite_token") tokenCount, userCount := 0, 0 code := "authcode" @@ -129,7 +128,7 @@ func (ts *ExternalTestSuite) TestInviteTokenExternalAzureSuccessWhenMatchingToke u := performAuthorization(ts, "azure", code, "invite_token") - assertAuthorizationSuccess(ts, u, tokenCount, userCount, "azure@example.com", "Azure Test", "azuretestid", "") + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "azure@example.com", "Azure Test", "azuretestid", "http://example.com/avatar") } func (ts *ExternalTestSuite) TestInviteTokenExternalAzureErrorWhenNoMatchingToken() { diff --git a/api/external_bitbucket_test.go b/api/external_bitbucket_test.go index ab48a1e1b..1c193fec5 100644 --- a/api/external_bitbucket_test.go +++ b/api/external_bitbucket_test.go @@ -29,7 +29,7 @@ func (ts *ExternalTestSuite) TestSignupExternalBitbucket() { claims := ExternalProviderClaims{} p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { - return []byte(ts.Config.JWT.Secret), nil + return ts.Config.JWT.GetVerificationKey(), nil }) ts.Require().NoError(err) diff --git a/api/external_discord_test.go b/api/external_discord_test.go index 057cd9c59..7a6b05985 100644 --- a/api/external_discord_test.go +++ b/api/external_discord_test.go @@ -10,7 +10,7 @@ import ( ) const ( - discordUser string = `{"id":"discordTestId","avatar":"abc","email":"discord@example.com","username":"Discord Test","verified":true}}` + discordUser string = `{"id":"discordTestId","avatar":"abc","email":"discord@example.com","username":"Discord Test","verified":true,"discriminator":"0001"}}` discordUserWrongEmail string = `{"id":"discordTestId","avatar":"abc","email":"other@example.com","username":"Discord Test","verified":true}}` discordUserNoEmail string = `{"id":"discordTestId","avatar":"abc","username":"Discord Test","verified":true}}` ) @@ -31,7 +31,7 @@ func (ts *ExternalTestSuite) TestSignupExternalDiscord() { claims := ExternalProviderClaims{} p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { - return []byte(ts.Config.JWT.Secret), nil + return ts.Config.JWT.GetVerificationKey(), nil }) ts.Require().NoError(err) diff --git a/api/external_facebook_test.go b/api/external_facebook_test.go index 253715438..6d041c891 100644 --- a/api/external_facebook_test.go +++ b/api/external_facebook_test.go @@ -31,7 +31,7 @@ func (ts *ExternalTestSuite) TestSignupExternalFacebook() { claims := ExternalProviderClaims{} p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { - return []byte(ts.Config.JWT.Secret), nil + return ts.Config.JWT.GetVerificationKey(), nil }) ts.Require().NoError(err) diff --git a/api/external_github_test.go b/api/external_github_test.go index 9ebcda49b..a65ef806f 100644 --- a/api/external_github_test.go +++ b/api/external_github_test.go @@ -28,7 +28,7 @@ func (ts *ExternalTestSuite) TestSignupExternalGithub() { claims := ExternalProviderClaims{} p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { - return []byte(ts.Config.JWT.Secret), nil + return ts.Config.JWT.GetVerificationKey(), nil }) ts.Require().NoError(err) diff --git a/api/external_gitlab_test.go b/api/external_gitlab_test.go index 8a8b0fbf0..322ca258f 100644 --- a/api/external_gitlab_test.go +++ b/api/external_gitlab_test.go @@ -31,7 +31,7 @@ func (ts *ExternalTestSuite) TestSignupExternalGitlab() { claims := ExternalProviderClaims{} p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { - return []byte(ts.Config.JWT.Secret), nil + return ts.Config.JWT.GetVerificationKey(), nil }) ts.Require().NoError(err) diff --git a/api/external_google_test.go b/api/external_google_test.go index 8992d0a65..abe8f6928 100644 --- a/api/external_google_test.go +++ b/api/external_google_test.go @@ -31,7 +31,7 @@ func (ts *ExternalTestSuite) TestSignupExternalGoogle() { claims := ExternalProviderClaims{} p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { - return []byte(ts.Config.JWT.Secret), nil + return ts.Config.JWT.GetVerificationKey(), nil }) ts.Require().NoError(err) diff --git a/api/external_keycloak_test.go b/api/external_keycloak_test.go new file mode 100644 index 000000000..81072049f --- /dev/null +++ b/api/external_keycloak_test.go @@ -0,0 +1,182 @@ +package api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + + jwt "github.com/golang-jwt/jwt" +) + +const ( + keycloakUser string = `{"sub": "keycloaktestid", "name": "Keycloak Test", "email": "keycloak@example.com", "preferred_username": "keycloak", "email_verified": true}` + keycloakUserNoEmail string = `{"sub": "keycloaktestid", "name": "Keycloak Test", "preferred_username": "keycloak", "email_verified": false}` +) + +func (ts *ExternalTestSuite) TestSignupExternalKeycloak() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=keycloak", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.Require().Equal(http.StatusFound, w.Code) + u, err := url.Parse(w.Header().Get("Location")) + ts.Require().NoError(err, "redirect url parse failed") + q := u.Query() + ts.Equal(ts.Config.External.Keycloak.RedirectURI, q.Get("redirect_uri")) + ts.Equal(ts.Config.External.Keycloak.ClientID, q.Get("client_id")) + ts.Equal("code", q.Get("response_type")) + ts.Equal("profile email", q.Get("scope")) + + claims := ExternalProviderClaims{} + p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { + return []byte(ts.Config.JWT.Secret), nil + }) + ts.Require().NoError(err) + + ts.Equal("keycloak", claims.Provider) + ts.Equal(ts.Config.SiteURL, claims.SiteURL) +} + +func KeycloakTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/protocol/openid-connect/token": + *tokenCount++ + ts.Equal(code, r.FormValue("code")) + ts.Equal("authorization_code", r.FormValue("grant_type")) + ts.Equal(ts.Config.External.Keycloak.RedirectURI, r.FormValue("redirect_uri")) + + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, `{"access_token":"keycloak_token","expires_in":100000}`) + case "/protocol/openid-connect/userinfo": + *userCount++ + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, user) + default: + w.WriteHeader(500) + ts.Fail("unknown keycloak oauth call %s", r.URL.Path) + } + })) + + ts.Config.External.Keycloak.URL = server.URL + + return server +} + +func (ts *ExternalTestSuite) TestSignupExternalKeycloakWithoutURLSetup() { + ts.createUser("keycloaktestid", "keycloak@example.com", "Keycloak Test", "", "") + tokenCount, userCount := 0, 0 + code := "authcode" + server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser) + ts.Config.External.Keycloak.URL = "" + defer server.Close() + + w := performAuthorizationRequest(ts, "keycloak", code) + ts.Equal(w.Code, http.StatusBadRequest) +} + +func (ts *ExternalTestSuite) TestSignupExternalKeycloak_AuthorizationCode() { + ts.Config.DisableSignup = false + ts.createUser("keycloaktestid", "keycloak@example.com", "Keycloak Test", "http://example.com/avatar", "") + tokenCount, userCount := 0, 0 + code := "authcode" + server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser) + defer server.Close() + + u := performAuthorization(ts, "keycloak", code, "") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "keycloak@example.com", "Keycloak Test", "keycloaktestid", "http://example.com/avatar") +} + +func (ts *ExternalTestSuite) TestSignupExternalKeycloakDisableSignupErrorWhenNoUser() { + ts.Config.DisableSignup = true + tokenCount, userCount := 0, 0 + code := "authcode" + server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser) + defer server.Close() + + u := performAuthorization(ts, "keycloak", code, "") + + assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "keycloak@example.com") +} + +func (ts *ExternalTestSuite) TestSignupExternalKeycloakDisableSignupErrorWhenNoEmail() { + ts.Config.DisableSignup = true + tokenCount, userCount := 0, 0 + code := "authcode" + server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUserNoEmail) + defer server.Close() + + u := performAuthorization(ts, "keycloak", code, "") + + assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "keycloak@example.com") + +} + +func (ts *ExternalTestSuite) TestSignupExternalKeycloakDisableSignupSuccessWithPrimaryEmail() { + ts.Config.DisableSignup = true + + ts.createUser("keycloaktestid", "keycloak@example.com", "Keycloak Test", "http://example.com/avatar", "") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser) + defer server.Close() + + u := performAuthorization(ts, "keycloak", code, "") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "keycloak@example.com", "Keycloak Test", "keycloaktestid", "http://example.com/avatar") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalKeycloakSuccessWhenMatchingToken() { + // name and avatar should be populated from Keycloak API + ts.createUser("keycloaktestid", "keycloak@example.com", "", "http://example.com/avatar", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser) + defer server.Close() + + u := performAuthorization(ts, "keycloak", code, "invite_token") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "keycloak@example.com", "Keycloak Test", "keycloaktestid", "http://example.com/avatar") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalKeycloakErrorWhenNoMatchingToken() { + tokenCount, userCount := 0, 0 + code := "authcode" + keycloakUser := `{"name":"Keycloak Test","avatar":{"href":"http://example.com/avatar"}}` + server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser) + defer server.Close() + + w := performAuthorizationRequest(ts, "keycloak", "invite_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalKeycloakErrorWhenWrongToken() { + ts.createUser("keycloaktestid", "keycloak@example.com", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + keycloakUser := `{"name":"Keycloak Test","avatar":{"href":"http://example.com/avatar"}}` + server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser) + defer server.Close() + + w := performAuthorizationRequest(ts, "keycloak", "wrong_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalKeycloakErrorWhenEmailDoesntMatch() { + ts.createUser("keycloaktestid", "keycloak@example.com", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + keycloakUser := `{"name":"Keycloak Test", "email":"other@example.com", "avatar":{"href":"http://example.com/avatar"}}` + server := KeycloakTestSignupSetup(ts, &tokenCount, &userCount, code, keycloakUser) + defer server.Close() + + u := performAuthorization(ts, "keycloak", code, "invite_token") + + assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "") +} diff --git a/api/external_oauth.go b/api/external_oauth.go index 3a4899efc..0e7e72a44 100644 --- a/api/external_oauth.go +++ b/api/external_oauth.go @@ -141,7 +141,7 @@ func (a *API) oAuth1Callback(ctx context.Context, r *http.Request, providerType // OAuthProvider returns the corresponding oauth provider as an OAuthProvider interface func (a *API) OAuthProvider(ctx context.Context, name string) (provider.OAuthProvider, error) { - providerCandidate, err := a.Provider(ctx, name, "") + providerCandidate, err := a.Provider(ctx, name, "", nil) if err != nil { return nil, err } diff --git a/api/external_test.go b/api/external_test.go index 9aee2c30c..bbef2fb2c 100644 --- a/api/external_test.go +++ b/api/external_test.go @@ -47,7 +47,8 @@ func (ts *ExternalTestSuite) createUser(providerId string, email string, name st require.NoError(ts.T(), ts.API.db.Destroy(u), "Error deleting user") } - u, err := models.NewUser(ts.instanceID, email, "test", ts.Config.JWT.Aud, map[string]interface{}{"provider_id": providerId, "full_name": name, "avatar_url": avatar}) + // TODO: [Joel] -- refactor to take in phone + u, err := models.NewUser(ts.instanceID, "", email, "test", ts.Config.JWT.Aud, map[string]interface{}{"provider_id": providerId, "full_name": name, "avatar_url": avatar}) if confirmationToken != "" { u.ConfirmationToken = confirmationToken @@ -120,7 +121,11 @@ func assertAuthorizationSuccess(ts *ExternalTestSuite, u *url.URL, tokenCount in ts.Require().NoError(err) ts.Equal(providerId, user.UserMetaData["provider_id"]) ts.Equal(name, user.UserMetaData["full_name"]) - ts.Equal(avatar, user.UserMetaData["avatar_url"]) + if avatar == "" { + ts.Equal(nil, user.UserMetaData["avatar_url"]) + } else { + ts.Equal(avatar, user.UserMetaData["avatar_url"]) + } } func assertAuthorizationFailure(ts *ExternalTestSuite, u *url.URL, errorDescription string, errorType string, email string) { diff --git a/api/external_twitch_test.go b/api/external_twitch_test.go index a4e473cae..066aa8ce4 100644 --- a/api/external_twitch_test.go +++ b/api/external_twitch_test.go @@ -30,7 +30,7 @@ func (ts *ExternalTestSuite) TestSignupExternalTwitch() { claims := ExternalProviderClaims{} p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { - return []byte(ts.Config.JWT.Secret), nil + return ts.Config.JWT.GetVerificationKey(), nil }) ts.Require().NoError(err) diff --git a/api/external_workos_test.go b/api/external_workos_test.go new file mode 100644 index 000000000..b2e1e599e --- /dev/null +++ b/api/external_workos_test.go @@ -0,0 +1,221 @@ +package api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + + jwt "github.com/golang-jwt/jwt" +) + +const ( + workosUser string = `{"id":"test_prof_workos","first_name":"John","last_name":"Doe","email":"workos@example.com","connection_id":"test_conn_1","organization_id":"test_org_1","connection_type":"test","idp_id":"test_idp_1","object": "profile","raw_attributes": {}}` + workosUserWrongEmail string = `{"id":"test_prof_workos","first_name":"John","last_name":"Doe","email":"other@example.com","connection_id":"test_conn_1","organization_id":"test_org_1","connection_type":"test","idp_id":"test_idp_1","object": "profile","raw_attributes": {}}` + workosUserNoEmail string = `{"id":"test_prof_workos","first_name":"John","last_name":"Doe","connection_id":"test_conn_1","organization_id":"test_org_1","connection_type":"test","idp_id":"test_idp_1","object": "profile","raw_attributes": {}}` +) + +func (ts *ExternalTestSuite) TestSignupExternalWorkOSWithConnection() { + connection := "test_connection_id" + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost/authorize?provider=workos&connection=%s", connection), nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.Require().Equal(http.StatusFound, w.Code) + u, err := url.Parse(w.Header().Get("Location")) + ts.Require().NoError(err, "redirect url parse failed") + q := u.Query() + ts.Equal(ts.Config.External.WorkOS.RedirectURI, q.Get("redirect_uri")) + ts.Equal(ts.Config.External.WorkOS.ClientID, q.Get("client_id")) + ts.Equal("code", q.Get("response_type")) + ts.Equal("", q.Get("scope")) + ts.Equal(connection, q.Get("connection")) + + claims := ExternalProviderClaims{} + p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { + return []byte(ts.Config.JWT.Secret), nil + }) + ts.Require().NoError(err) + + ts.Equal("workos", claims.Provider) + ts.Equal(ts.Config.SiteURL, claims.SiteURL) +} + +func (ts *ExternalTestSuite) TestSignupExternalWorkOSWithOrganization() { + organization := "test_organization_id" + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost/authorize?provider=workos&organization=%s", organization), nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.Require().Equal(http.StatusFound, w.Code) + u, err := url.Parse(w.Header().Get("Location")) + ts.Require().NoError(err, "redirect url parse failed") + q := u.Query() + ts.Equal(ts.Config.External.WorkOS.RedirectURI, q.Get("redirect_uri")) + ts.Equal(ts.Config.External.WorkOS.ClientID, q.Get("client_id")) + ts.Equal("code", q.Get("response_type")) + ts.Equal("", q.Get("scope")) + ts.Equal(organization, q.Get("organization")) + + claims := ExternalProviderClaims{} + p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { + return []byte(ts.Config.JWT.Secret), nil + }) + ts.Require().NoError(err) + + ts.Equal("workos", claims.Provider) + ts.Equal(ts.Config.SiteURL, claims.SiteURL) +} + +func (ts *ExternalTestSuite) TestSignupExternalWorkOSWithProvider() { + provider := "test_provider" + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost/authorize?provider=workos&workos_provider=%s", provider), nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.Require().Equal(http.StatusFound, w.Code) + u, err := url.Parse(w.Header().Get("Location")) + ts.Require().NoError(err, "redirect url parse failed") + q := u.Query() + ts.Equal(ts.Config.External.WorkOS.RedirectURI, q.Get("redirect_uri")) + ts.Equal(ts.Config.External.WorkOS.ClientID, q.Get("client_id")) + ts.Equal("code", q.Get("response_type")) + ts.Equal("", q.Get("scope")) + ts.Equal(provider, q.Get("provider")) + + claims := ExternalProviderClaims{} + p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} + _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { + return []byte(ts.Config.JWT.Secret), nil + }) + ts.Require().NoError(err) + + ts.Equal("workos", claims.Provider) + ts.Equal(ts.Config.SiteURL, claims.SiteURL) +} + +func WorkosTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/sso/token": + // WorkOS returns the user data along with the token. + *tokenCount++ + *userCount++ + ts.Equal(code, r.FormValue("code")) + ts.Equal("authorization_code", r.FormValue("grant_type")) + ts.Equal(ts.Config.External.WorkOS.RedirectURI, r.FormValue("redirect_uri")) + + w.Header().Add("Content-Type", "application/json") + fmt.Fprintf(w, `{"access_token":"workos_token","expires_in":100000,"profile":%s}`, user) + default: + fmt.Printf("%s", r.URL.Path) + w.WriteHeader(500) + ts.Fail("unknown workos oauth call %s", r.URL.Path) + } + })) + + ts.Config.External.WorkOS.URL = server.URL + + return server +} + +func (ts *ExternalTestSuite) TestSignupExternalWorkosAuthorizationCode() { + ts.Config.DisableSignup = false + + tokenCount, userCount := 0, 0 + code := "authcode" + server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUser) + defer server.Close() + + u := performAuthorization(ts, "workos", code, "") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "workos@example.com", "John Doe", "test_prof_workos", "") +} + +func (ts *ExternalTestSuite) TestSignupExternalWorkosDisableSignupErrorWhenNoUser() { + ts.Config.DisableSignup = true + + tokenCount, userCount := 0, 0 + code := "authcode" + server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUser) + defer server.Close() + + u := performAuthorization(ts, "workos", code, "") + + assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "workos@example.com") +} + +func (ts *ExternalTestSuite) TestSignupExternalWorkosDisableSignupErrorWhenEmptyEmail() { + ts.Config.DisableSignup = true + + tokenCount, userCount := 0, 0 + code := "authcode" + server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUserNoEmail) + defer server.Close() + + u := performAuthorization(ts, "workos", code, "") + + assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "workos@example.com") +} + +func (ts *ExternalTestSuite) TestSignupExternalWorkosDisableSignupSuccessWithPrimaryEmail() { + ts.Config.DisableSignup = true + + ts.createUser("test_prof_workos", "workos@example.com", "John Doe", "http://example.com/avatar", "") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUser) + defer server.Close() + + u := performAuthorization(ts, "workos", code, "") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "workos@example.com", "John Doe", "test_prof_workos", "http://example.com/avatar") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalWorkosSuccessWhenMatchingToken() { + ts.createUser("test_prof_workos", "workos@example.com", "", "http://example.com/avatar", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUser) + defer server.Close() + + u := performAuthorization(ts, "workos", code, "invite_token") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "workos@example.com", "John Doe", "test_prof_workos", "http://example.com/avatar") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalWorkosErrorWhenNoMatchingToken() { + tokenCount, userCount := 0, 0 + code := "authcode" + server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUser) + defer server.Close() + + w := performAuthorizationRequest(ts, "workos", "invite_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalWorkosErrorWhenWrongToken() { + ts.createUser("test_prof_workos", "workos@example.com", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUser) + defer server.Close() + + w := performAuthorizationRequest(ts, "workos", "wrong_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalWorkosErrorWhenEmailDoesntMatch() { + ts.createUser("test_prof_workos", "workos@example.com", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUserWrongEmail) + defer server.Close() + + u := performAuthorization(ts, "workos", code, "invite_token") + + assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "") +} diff --git a/api/helpers.go b/api/helpers.go index c8cfc2f35..d62628710 100644 --- a/api/helpers.go +++ b/api/helpers.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptrace" "net/url" + "strings" "github.com/gofrs/uuid" "github.com/netlify/gotrue/conf" @@ -124,8 +125,13 @@ func isRedirectURLValid(config *conf.Configuration, redirectURL string) bool { } // For case when user came from mobile app or other permitted resource - redirect back - for _, uri := range config.URIAllowList { - if redirectURL == uri { + for uri, g := range config.URIAllowListMap { + // Only allow wildcard matching if url scheme is http(s) + if strings.HasPrefix(uri, "http") || strings.HasPrefix(uri, "https") { + if g.Match(redirectURL) { + return true + } + } else if redirectURL == uri { return true } } diff --git a/api/hook_test.go b/api/hook_test.go index f60a0218c..fb6a35932 100644 --- a/api/hook_test.go +++ b/api/hook_test.go @@ -25,7 +25,7 @@ func TestSignupHookSendInstanceID(t *testing.T) { require.NoError(t, err) iid := uuid.Must(uuid.NewV4()) - user, err := models.NewUser(iid, "test@truth.com", "thisisapassword", "", nil) + user, err := models.NewUser(iid, "81234567", "test@truth.com", "thisisapassword", "", nil) require.NoError(t, err) var callCount int @@ -68,7 +68,7 @@ func TestSignupHookFromClaims(t *testing.T) { require.NoError(t, err) iid := uuid.Must(uuid.NewV4()) - user, err := models.NewUser(iid, "test@truth.com", "thisisapassword", "", nil) + user, err := models.NewUser(iid, "", "test@truth.com", "thisisapassword", "", nil) require.NoError(t, err) var callCount int @@ -99,7 +99,7 @@ func TestSignupHookFromClaims(t *testing.T) { ctx := context.Background() ctx = withFunctionHooks(ctx, map[string][]string{ - "signup": []string{svr.URL}, + "signup": {svr.URL}, }) require.NoError(t, triggerEventHooks(ctx, conn, SignupEvent, user, iid, config)) diff --git a/api/instance_test.go b/api/instance_test.go index 17d720d2a..ab30899e0 100644 --- a/api/instance_test.go +++ b/api/instance_test.go @@ -132,7 +132,7 @@ func (ts *InstanceTestSuite) TestUpdate() { i, err := models.GetInstanceByUUID(ts.API.db, testUUID) require.NoError(ts.T(), err) - require.Equal(ts.T(), i.BaseConfig.JWT.Secret, "testsecret") + require.Equal(ts.T(), []byte("testsecret"), i.BaseConfig.JWT.GetVerificationKey()) require.Equal(ts.T(), i.BaseConfig.SiteURL, "https://test.mysite.com") } diff --git a/api/invite.go b/api/invite.go index 6c13861c8..53da7f204 100644 --- a/api/invite.go +++ b/api/invite.go @@ -17,6 +17,7 @@ type InviteParams struct { // Invite is the endpoint for inviting a new user func (a *API) Invite(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() + config := a.getConfig(ctx) instanceID := getInstanceID(ctx) adminUser := getAdminUser(ctx) params := &InviteParams{} @@ -55,7 +56,7 @@ func (a *API) Invite(w http.ResponseWriter, r *http.Request) error { } } - if terr := models.NewAuditLogEntry(tx, instanceID, adminUser, models.UserInvitedAction, map[string]interface{}{ + if terr := models.NewAuditLogEntry(tx, instanceID, adminUser, models.UserInvitedAction, "", map[string]interface{}{ "user_id": user.ID, "user_email": user.Email, }); terr != nil { @@ -64,7 +65,7 @@ func (a *API) Invite(w http.ResponseWriter, r *http.Request) error { mailer := a.Mailer(ctx) referrer := a.getReferrer(r) - if err := sendInvite(tx, user, mailer, referrer); err != nil { + if err := sendInvite(tx, user, mailer, referrer, config.Mailer.OtpLength); err != nil { return internalServerError("Error inviting user").WithInternalError(err) } return nil diff --git a/api/invite_test.go b/api/invite_test.go index 8d105213c..eaa3ccf0b 100644 --- a/api/invite_test.go +++ b/api/invite_test.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "crypto/sha256" "encoding/json" "fmt" "net/http" @@ -55,17 +56,20 @@ func (ts *InviteTestSuite) makeSuperAdmin(email string) string { require.NoError(ts.T(), ts.API.db.Destroy(u), "Error deleting user") } - u, err := models.NewUser(ts.instanceID, email, "test", ts.Config.JWT.Aud, map[string]interface{}{"full_name": "Test User"}) + u, err := models.NewUser(ts.instanceID, "123456789", email, "test", ts.Config.JWT.Aud, map[string]interface{}{"full_name": "Test User"}) require.NoError(ts.T(), err, "Error making new user") u.Role = "supabase_admin" - token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret) + key, err := models.FindMainAsymmetricKeyByUser(ts.API.db, u) + require.NoError(ts.T(), err, "Error finding keys") + + token, err := generateAccessToken(u, key, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.GetSigningMethod(), ts.Config.JWT.GetSigningKey()) require.NoError(ts.T(), err, "Error generating access token") p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} _, err = p.Parse(token, func(token *jwt.Token) (interface{}, error) { - return []byte(ts.Config.JWT.Secret), nil + return ts.Config.JWT.GetVerificationKey(), nil }) require.NoError(ts.T(), err, "Error parsing token") @@ -126,6 +130,7 @@ func (ts *InviteTestSuite) TestVerifyInvite() { "Verify invite with password", "test@example.com", map[string]interface{}{ + "email": "test@example.com", "type": "invite", "token": "asdf", "password": "testing", @@ -136,6 +141,7 @@ func (ts *InviteTestSuite) TestVerifyInvite() { "Verify invite with no password", "test1@example.com", map[string]interface{}{ + "email": "test1@example.com", "type": "invite", "token": "asdf", }, @@ -145,12 +151,12 @@ func (ts *InviteTestSuite) TestVerifyInvite() { for _, c := range cases { ts.Run(c.desc, func() { - user, err := models.NewUser(ts.instanceID, c.email, "", ts.Config.JWT.Aud, nil) + user, err := models.NewUser(ts.instanceID, "", c.email, "", ts.Config.JWT.Aud, nil) now := time.Now() user.InvitedAt = &now user.ConfirmationSentAt = &now user.EncryptedPassword = "" - user.ConfirmationToken = c.requestBody["token"].(string) + user.ConfirmationToken = fmt.Sprintf("%x", sha256.Sum224([]byte(c.email+c.requestBody["token"].(string)))) require.NoError(ts.T(), err) require.NoError(ts.T(), ts.API.db.Create(user)) diff --git a/api/logout.go b/api/logout.go index 77b4f2fa5..38e298cf4 100644 --- a/api/logout.go +++ b/api/logout.go @@ -21,7 +21,7 @@ func (a *API) Logout(w http.ResponseWriter, r *http.Request) error { } err = a.db.Transaction(func(tx *storage.Connection) error { - if terr := models.NewAuditLogEntry(tx, instanceID, u, models.LogoutAction, nil); terr != nil { + if terr := models.NewAuditLogEntry(tx, instanceID, u, models.LogoutAction, "", nil); terr != nil { return terr } return models.Logout(tx, instanceID, u.ID) diff --git a/api/magic_link.go b/api/magic_link.go index c4d6b58e0..ff7e275fa 100644 --- a/api/magic_link.go +++ b/api/magic_link.go @@ -76,13 +76,13 @@ func (a *API) MagicLink(w http.ResponseWriter, r *http.Request) error { } err = a.db.Transaction(func(tx *storage.Connection) error { - if terr := models.NewAuditLogEntry(tx, instanceID, user, models.UserRecoveryRequestedAction, nil); terr != nil { + if terr := models.NewAuditLogEntry(tx, instanceID, user, models.UserRecoveryRequestedAction, "", nil); terr != nil { return terr } mailer := a.Mailer(ctx) referrer := a.getReferrer(r) - return a.sendMagicLink(tx, user, mailer, config.SMTP.MaxFrequency, referrer) + return a.sendMagicLink(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength) }) if err != nil { if errors.Is(err, MaxFrequencyLimitError) { diff --git a/api/mail.go b/api/mail.go index edf8b0f67..973183e35 100644 --- a/api/mail.go +++ b/api/mail.go @@ -2,11 +2,13 @@ package api import ( "context" + "crypto/sha256" "encoding/json" "fmt" "net/http" "time" + "github.com/netlify/gotrue/conf" "github.com/netlify/gotrue/crypto" "github.com/netlify/gotrue/mailer" "github.com/netlify/gotrue/models" @@ -67,14 +69,18 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error { var url string referrer := a.getRedirectURLOrReferrer(r, params.RedirectTo) now := time.Now() + otp, err := crypto.GenerateOtp(config.Mailer.OtpLength) + if err != nil { + return err + } err = a.db.Transaction(func(tx *storage.Connection) error { var terr error switch params.Type { case "magiclink", "recovery": - if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserRecoveryRequestedAction, nil); terr != nil { + if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserRecoveryRequestedAction, "", nil); terr != nil { return terr } - user.RecoveryToken = crypto.SecureToken() + user.RecoveryToken = fmt.Sprintf("%x", sha256.Sum224([]byte(user.GetEmail()+otp))) user.RecoverySentAt = &now terr = errors.Wrap(tx.UpdateOnly(user, "recovery_token", "recovery_sent_at"), "Database error updating user for recovery") case "invite": @@ -94,13 +100,13 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error { return terr } } - if terr = models.NewAuditLogEntry(tx, instanceID, adminUser, models.UserInvitedAction, map[string]interface{}{ + if terr = models.NewAuditLogEntry(tx, instanceID, adminUser, models.UserInvitedAction, "", map[string]interface{}{ "user_id": user.ID, "user_email": user.Email, }); terr != nil { return terr } - user.ConfirmationToken = crypto.SecureToken() + user.ConfirmationToken = fmt.Sprintf("%x", sha256.Sum224([]byte(user.GetEmail()+otp))) user.ConfirmationSentAt = &now user.InvitedAt = &now terr = errors.Wrap(tx.UpdateOnly(user, "confirmation_token", "confirmation_sent_at", "invited_at"), "Database error updating user for invite") @@ -131,7 +137,7 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error { return terr } } - user.ConfirmationToken = crypto.SecureToken() + user.ConfirmationToken = fmt.Sprintf("%x", sha256.Sum224([]byte(user.GetEmail()+otp))) user.ConfirmationSentAt = &now terr = errors.Wrap(tx.UpdateOnly(user, "confirmation_token", "confirmation_sent_at"), "Database error updating user for confirmation") default: @@ -166,15 +172,19 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error { return sendJSON(w, http.StatusOK, resp) } -func sendConfirmation(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string) error { +func sendConfirmation(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string, otpLength int) error { + var err error if u.ConfirmationSentAt != nil && !u.ConfirmationSentAt.Add(maxFrequency).Before(time.Now()) { return MaxFrequencyLimitError } - oldToken := u.ConfirmationToken - u.ConfirmationToken = crypto.SecureToken() + otp, err := crypto.GenerateOtp(otpLength) + if err != nil { + return err + } + u.ConfirmationToken = fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetEmail()+otp))) now := time.Now() - if err := mailer.ConfirmationMail(u, referrerURL); err != nil { + if err := mailer.ConfirmationMail(u, otp, referrerURL); err != nil { u.ConfirmationToken = oldToken return errors.Wrap(err, "Error sending confirmation email") } @@ -182,11 +192,16 @@ func sendConfirmation(tx *storage.Connection, u *models.User, mailer mailer.Mail return errors.Wrap(tx.UpdateOnly(u, "confirmation_token", "confirmation_sent_at"), "Database error updating user for confirmation") } -func sendInvite(tx *storage.Connection, u *models.User, mailer mailer.Mailer, referrerURL string) error { +func sendInvite(tx *storage.Connection, u *models.User, mailer mailer.Mailer, referrerURL string, otpLength int) error { + var err error oldToken := u.ConfirmationToken - u.ConfirmationToken = crypto.SecureToken() + otp, err := crypto.GenerateOtp(otpLength) + if err != nil { + return err + } + u.ConfirmationToken = fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetEmail()+otp))) now := time.Now() - if err := mailer.InviteMail(u, referrerURL); err != nil { + if err := mailer.InviteMail(u, otp, referrerURL); err != nil { u.ConfirmationToken = oldToken return errors.Wrap(err, "Error sending invite email") } @@ -195,15 +210,20 @@ func sendInvite(tx *storage.Connection, u *models.User, mailer mailer.Mailer, re return errors.Wrap(tx.UpdateOnly(u, "confirmation_token", "confirmation_sent_at", "invited_at"), "Database error updating user for invite") } -func (a *API) sendPasswordRecovery(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string) error { +func (a *API) sendPasswordRecovery(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string, otpLength int) error { + var err error if u.RecoverySentAt != nil && !u.RecoverySentAt.Add(maxFrequency).Before(time.Now()) { return MaxFrequencyLimitError } oldToken := u.RecoveryToken - u.RecoveryToken = crypto.SecureToken() + otp, err := crypto.GenerateOtp(otpLength) + if err != nil { + return err + } + u.RecoveryToken = fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetEmail()+otp))) now := time.Now() - if err := mailer.RecoveryMail(u, referrerURL); err != nil { + if err := mailer.RecoveryMail(u, otp, referrerURL); err != nil { u.RecoveryToken = oldToken return errors.Wrap(err, "Error sending recovery email") } @@ -211,17 +231,45 @@ func (a *API) sendPasswordRecovery(tx *storage.Connection, u *models.User, maile return errors.Wrap(tx.UpdateOnly(u, "recovery_token", "recovery_sent_at"), "Database error updating user for recovery") } -func (a *API) sendMagicLink(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string) error { +func (a *API) sendReauthenticationOtp(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, otpLength int) error { + var err error + if u.ReauthenticationSentAt != nil && !u.ReauthenticationSentAt.Add(maxFrequency).Before(time.Now()) { + return MaxFrequencyLimitError + } + + oldToken := u.ReauthenticationToken + otp, err := crypto.GenerateOtp(otpLength) + if err != nil { + return err + } + u.ReauthenticationToken = fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetEmail()+otp))) + if err != nil { + return err + } + now := time.Now() + if err := mailer.ReauthenticateMail(u, otp); err != nil { + u.ReauthenticationToken = oldToken + return errors.Wrap(err, "Error sending reauthentication email") + } + u.ReauthenticationSentAt = &now + return errors.Wrap(tx.UpdateOnly(u, "reauthentication_token", "reauthentication_sent_at"), "Database error updating user for reauthentication") +} + +func (a *API) sendMagicLink(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string, otpLength int) error { + var err error // since Magic Link is just a recovery with a different template and behaviour // around new users we will reuse the recovery db timer to prevent potential abuse if u.RecoverySentAt != nil && !u.RecoverySentAt.Add(maxFrequency).Before(time.Now()) { return MaxFrequencyLimitError } - oldToken := u.RecoveryToken - u.RecoveryToken = crypto.SecureToken() + otp, err := crypto.GenerateOtp(otpLength) + if err != nil { + return err + } + u.RecoveryToken = fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetEmail()+otp))) now := time.Now() - if err := mailer.MagicLinkMail(u, referrerURL); err != nil { + if err := mailer.MagicLinkMail(u, otp, referrerURL); err != nil { u.RecoveryToken = oldToken return errors.Wrap(err, "Error sending magic link email") } @@ -229,40 +277,37 @@ func (a *API) sendMagicLink(tx *storage.Connection, u *models.User, mailer maile return errors.Wrap(tx.UpdateOnly(u, "recovery_token", "recovery_sent_at"), "Database error updating user for recovery") } -// sendSecureEmailChange sends out an email change token each to the old and new emails. -func (a *API) sendSecureEmailChange(tx *storage.Connection, u *models.User, mailer mailer.Mailer, email string, referrerURL string) error { - u.EmailChangeTokenCurrent, u.EmailChangeTokenNew = crypto.SecureToken(), crypto.SecureToken() - u.EmailChange = email - u.EmailChangeConfirmStatus = zeroConfirmation - now := time.Now() - if err := mailer.EmailChangeMail(u, referrerURL); err != nil { +// sendEmailChange sends out an email change token to the new email. +func (a *API) sendEmailChange(tx *storage.Connection, config *conf.Configuration, u *models.User, mailer mailer.Mailer, email string, referrerURL string, otpLength int) error { + var err error + otpNew, err := crypto.GenerateOtp(otpLength) + if err != nil { return err } + u.EmailChangeTokenNew = fmt.Sprintf("%x", sha256.Sum224([]byte(u.EmailChange+otpNew))) - u.EmailChangeSentAt = &now - return errors.Wrap(tx.UpdateOnly( - u, - "email_change_token_current", - "email_change_token_new", - "email_change", - "email_change_sent_at", - "email_change_confirm_status", - ), "Database error updating user for email change") -} - -// sendEmailChange sends out an email change token to the new email. -func (a *API) sendEmailChange(tx *storage.Connection, u *models.User, mailer mailer.Mailer, email string, referrerURL string) error { - u.EmailChangeTokenNew = crypto.SecureToken() + otpCurrent := "" + if config.Mailer.SecureEmailChangeEnabled && u.GetEmail() != "" { + otpCurrent, err = crypto.GenerateOtp(otpLength) + if err != nil { + return err + } + u.EmailChangeTokenCurrent = fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetEmail()+otpCurrent))) + if err != nil { + return err + } + } u.EmailChange = email u.EmailChangeConfirmStatus = zeroConfirmation now := time.Now() - if err := mailer.EmailChangeMail(u, referrerURL); err != nil { + if err := mailer.EmailChangeMail(u, otpNew, otpCurrent, referrerURL); err != nil { return err } u.EmailChangeSentAt = &now return errors.Wrap(tx.UpdateOnly( u, + "email_change_token_current", "email_change_token_new", "email_change", "email_change_sent_at", diff --git a/api/middleware.go b/api/middleware.go index 54e6fdc07..14d9dadca 100644 --- a/api/middleware.go +++ b/api/middleware.go @@ -97,7 +97,7 @@ func (a *API) loadInstanceConfig(w http.ResponseWriter, r *http.Request) (contex claims := NetlifyMicroserviceClaims{} p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} _, err := p.ParseWithClaims(signature, &claims, func(token *jwt.Token) (interface{}, error) { - return []byte(config.JWT.Secret), nil + return config.JWT.GetVerificationKey(), nil }) if err != nil { return nil, badRequestError("Operator microservice signature is invalid: %v", err) @@ -234,6 +234,7 @@ func (a *API) verifyCaptcha(w http.ResponseWriter, req *http.Request) (context.C if secret == "" { return nil, internalServerError("server misconfigured") } + verificationResult, err := security.VerifyRequest(req, secret) if err != nil { logrus.WithField("err", err).Infof("failed to validate result") diff --git a/api/middleware_test.go b/api/middleware_test.go index 1c99037c3..dde8a4cdc 100644 --- a/api/middleware_test.go +++ b/api/middleware_test.go @@ -48,7 +48,7 @@ func (ts *MiddlewareTestSuite) TestVerifyCaptchaValid() { "email": "test@example.com", "password": "secret", "gotrue_meta_security": map[string]interface{}{ - "hcaptcha_token": HCaptchaResponse, + "captcha_token": HCaptchaResponse, }, })) @@ -75,7 +75,7 @@ func (ts *MiddlewareTestSuite) TestVerifyCaptchaValid() { "email": "test@example.com", "password": "secret", "gotrue_meta_security": map[string]interface{}{ - "hcaptcha_token": HCaptchaResponse, + "captcha_token": HCaptchaResponse, }, })) @@ -129,7 +129,7 @@ func (ts *MiddlewareTestSuite) TestVerifyCaptchaInvalid() { "email": "test@example.com", "password": "secret", "gotrue_meta_security": map[string]interface{}{ - "hcaptcha_token": HCaptchaResponse, + "captcha_token": HCaptchaResponse, }, })) req := httptest.NewRequest(http.MethodPost, "http://localhost", &buffer) diff --git a/api/otp.go b/api/otp.go index 6b8a86b81..b9cbea984 100644 --- a/api/otp.go +++ b/api/otp.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" + "github.com/netlify/gotrue/api/sms_provider" "github.com/netlify/gotrue/models" "github.com/netlify/gotrue/storage" "github.com/sethvargo/go-password/password" @@ -30,6 +31,9 @@ func (a *API) Otp(w http.ResponseWriter, r *http.Request) error { CreateUser: true, } body, err := ioutil.ReadAll(r.Body) + if err != nil { + return err + } jsonDecoder := json.NewDecoder(bytes.NewReader(body)) if err = jsonDecoder.Decode(params); err != nil { return badRequestError("Could not read verification params: %v", err) @@ -40,8 +44,10 @@ func (a *API) Otp(w http.ResponseWriter, r *http.Request) error { r.Body = ioutil.NopCloser(strings.NewReader(string(body))) - if !a.shouldCreateUser(r, params) { + if ok, err := a.shouldCreateUser(r, params); !ok { return badRequestError("Signups not allowed for otp") + } else if err != nil { + return err } if params.Email != "" { @@ -69,10 +75,10 @@ func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error { return badRequestError("Could not read sms otp params: %v", err) } - params.Phone = a.formatPhoneNumber(params.Phone) - - if isValid := a.validateE164Format(params.Phone); !isValid { - return badRequestError("Invalid format: Phone number should follow the E.164 format") + var err error + params.Phone, err = a.validatePhone(params.Phone) + if err != nil { + return err } aud := a.requestAud(ctx, r) @@ -91,6 +97,17 @@ func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error { fakeResponse := &responseStub{} + if config.Sms.Autoconfirm { + // signups are autoconfirmed, send otp after signup + if err := a.Signup(fakeResponse, r); err != nil { + return err + } + newBodyContent := `{"phone":"` + params.Phone + `"}` + r.Body = ioutil.NopCloser(strings.NewReader(newBodyContent)) + r.ContentLength = int64(len(newBodyContent)) + return a.SmsOtp(w, r) + } + if err := a.Signup(fakeResponse, r); err != nil { return err } @@ -99,12 +116,15 @@ func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error { return internalServerError("Database error finding user").WithInternalError(uerr) } - err := a.db.Transaction(func(tx *storage.Connection) error { - if err := models.NewAuditLogEntry(tx, instanceID, user, models.UserRecoveryRequestedAction, nil); err != nil { + err = a.db.Transaction(func(tx *storage.Connection) error { + if err := models.NewAuditLogEntry(tx, instanceID, user, models.UserRecoveryRequestedAction, "", nil); err != nil { return err } - - if err := a.sendPhoneConfirmation(ctx, tx, user, params.Phone); err != nil { + smsProvider, terr := sms_provider.GetSmsProvider(*config) + if terr != nil { + return badRequestError("Error sending sms: %v", terr) + } + if err := a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneConfirmationOtp, smsProvider); err != nil { return badRequestError("Error sending sms otp: %v", err) } return nil @@ -117,21 +137,28 @@ func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error { return sendJSON(w, http.StatusOK, make(map[string]string)) } -func (a *API) shouldCreateUser(r *http.Request, params *OtpParams) bool { +func (a *API) shouldCreateUser(r *http.Request, params *OtpParams) (bool, error) { if !params.CreateUser { ctx := r.Context() instanceID := getInstanceID(ctx) aud := a.requestAud(ctx, r) var err error if params.Email != "" { + if err := a.validateEmail(ctx, params.Email); err != nil { + return false, err + } _, err = models.FindUserByEmailAndAudience(a.db, instanceID, params.Email, aud) } else if params.Phone != "" { + params.Phone, err = a.validatePhone(params.Phone) + if err != nil { + return false, err + } _, err = models.FindUserByPhoneAndAudience(a.db, instanceID, params.Phone, aud) } if err != nil && models.IsNotFoundError(err) { - return false + return false, nil } } - return true + return true, nil } diff --git a/api/phone.go b/api/phone.go index c36397331..e72e10bf4 100644 --- a/api/phone.go +++ b/api/phone.go @@ -2,6 +2,7 @@ package api import ( "context" + "crypto/sha256" "fmt" "regexp" "strings" @@ -17,7 +18,20 @@ import ( const e164Format = `^[1-9]\d{1,14}$` const defaultSmsMessage = "Your code is %v" -// validateE165Format checks if phone number follows the E.164 format +const ( + phoneConfirmationOtp = "confirmation" + phoneReauthenticationOtp = "reauthentication" +) + +func (a *API) validatePhone(phone string) (string, error) { + phone = a.formatPhoneNumber(phone) + if isValid := a.validateE164Format(phone); !isValid { + return "", unprocessableEntityError("Invalid phone number format") + } + return phone, nil +} + +// validateE164Format checks if phone number follows the E.164 format func (a *API) validateE164Format(phone string) bool { // match should never fail as long as regexp is valid matched, _ := regexp.Match(e164Format, []byte(phone)) @@ -29,39 +43,65 @@ func (a *API) formatPhoneNumber(phone string) string { return strings.ReplaceAll(strings.Trim(phone, "+"), " ", "") } -func (a *API) sendPhoneConfirmation(ctx context.Context, tx *storage.Connection, user *models.User, phone string) error { +// sendPhoneConfirmation sends an otp to the user's phone number +func (a *API) sendPhoneConfirmation(ctx context.Context, tx *storage.Connection, user *models.User, phone, otpType string, smsProvider sms_provider.SmsProvider) error { config := a.getConfig(ctx) - if user.ConfirmationSentAt != nil && !user.ConfirmationSentAt.Add(config.Sms.MaxFrequency).Before(time.Now()) { + var token *string + var sentAt *time.Time + + includeFields := []string{} + switch otpType { + case phoneChangeVerification: + token = &user.PhoneChangeToken + sentAt = user.PhoneChangeSentAt + user.PhoneChange = phone + includeFields = append(includeFields, "phone_change", "phone_change_token", "phone_change_sent_at") + case phoneConfirmationOtp: + token = &user.ConfirmationToken + sentAt = user.ConfirmationSentAt + includeFields = append(includeFields, "confirmation_token", "confirmation_sent_at") + case phoneReauthenticationOtp: + token = &user.ReauthenticationToken + sentAt = user.ReauthenticationSentAt + includeFields = append(includeFields, "reauthentication_token", "reauthentication_sent_at") + default: + return internalServerError("invalid otp type") + } + + if sentAt != nil && !sentAt.Add(config.Sms.MaxFrequency).Before(time.Now()) { return MaxFrequencyLimitError } - oldToken := user.ConfirmationToken + oldToken := *token otp, err := crypto.GenerateOtp(config.Sms.OtpLength) if err != nil { return internalServerError("error generating otp").WithInternalError(err) } - user.ConfirmationToken = otp - - smsProvider, err := sms_provider.GetSmsProvider(*config) - if err != nil { - return err - } + *token = fmt.Sprintf("%x", sha256.Sum224([]byte(phone+otp))) var message string if config.Sms.Template == "" { - message = fmt.Sprintf(defaultSmsMessage, user.ConfirmationToken) + message = fmt.Sprintf(defaultSmsMessage, otp) } else { - message = strings.Replace(config.Sms.Template, "{{ .Code }}", user.ConfirmationToken, -1) + message = strings.Replace(config.Sms.Template, "{{ .Code }}", otp, -1) } if serr := smsProvider.SendSms(phone, message); serr != nil { - user.ConfirmationToken = oldToken + *token = oldToken return serr } now := time.Now() - user.ConfirmationSentAt = &now - return errors.Wrap(tx.UpdateOnly(user, "confirmation_token", "confirmation_sent_at"), "Database error updating user for confirmation") + switch otpType { + case phoneConfirmationOtp: + user.ConfirmationSentAt = &now + case phoneChangeVerification: + user.PhoneChangeSentAt = &now + case phoneReauthenticationOtp: + user.ReauthenticationSentAt = &now + } + + return errors.Wrap(tx.UpdateOnly(user, includeFields...), "Database error updating user for confirmation") } diff --git a/api/phone_test.go b/api/phone_test.go new file mode 100644 index 000000000..37d94bb6c --- /dev/null +++ b/api/phone_test.go @@ -0,0 +1,225 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gofrs/uuid" + "github.com/netlify/gotrue/conf" + "github.com/netlify/gotrue/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type PhoneTestSuite struct { + suite.Suite + API *API + Config *conf.Configuration + + instanceID uuid.UUID +} + +type TestSmsProvider struct { + mock.Mock +} + +func (t *TestSmsProvider) SendSms(phone string, message string) error { + return nil +} + +func TestPhone(t *testing.T) { + api, config, instanceID, err := setupAPIForTestForInstance() + require.NoError(t, err) + + ts := &PhoneTestSuite{ + API: api, + Config: config, + instanceID: instanceID, + } + defer api.db.Close() + + suite.Run(t, ts) +} + +func (ts *PhoneTestSuite) SetupTest() { + models.TruncateAll(ts.API.db) + + // Create user + u, err := models.NewUser(ts.instanceID, "123456789", "", "password", ts.Config.JWT.Aud, nil) + require.NoError(ts.T(), err, "Error creating test user model") + require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user") +} + +func (ts *PhoneTestSuite) TestValidateE164Format() { + isValid := ts.API.validateE164Format("0123456789") + assert.Equal(ts.T(), false, isValid) +} + +func (ts *PhoneTestSuite) TestFormatPhoneNumber() { + actual := ts.API.formatPhoneNumber("+1 23456789 ") + assert.Equal(ts.T(), "123456789", actual) +} + +func (ts *PhoneTestSuite) TestSendPhoneConfirmation() { + u, err := models.FindUserByPhoneAndAudience(ts.API.db, ts.instanceID, "123456789", ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + ctx, err := WithInstanceConfig(context.Background(), ts.Config, ts.instanceID) + require.NoError(ts.T(), err) + cases := []struct { + desc string + otpType string + expected error + }{ + { + "send confirmation otp", + phoneConfirmationOtp, + nil, + }, + { + "send phone_change otp", + phoneChangeVerification, + nil, + }, + { + "send recovery otp", + phoneReauthenticationOtp, + nil, + }, + { + "send invalid otp type ", + "invalid otp type", + internalServerError("invalid otp type"), + }, + } + + for _, c := range cases { + ts.Run(c.desc, func() { + err = ts.API.sendPhoneConfirmation(ctx, ts.API.db, u, "123456789", c.otpType, &TestSmsProvider{}) + require.Equal(ts.T(), c.expected, err) + u, err = models.FindUserByPhoneAndAudience(ts.API.db, ts.instanceID, "123456789", ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + + switch c.otpType { + case phoneConfirmationOtp: + require.NotEmpty(ts.T(), u.ConfirmationToken) + require.NotEmpty(ts.T(), u.ConfirmationSentAt) + case phoneChangeVerification: + require.NotEmpty(ts.T(), u.PhoneChangeToken) + require.NotEmpty(ts.T(), u.PhoneChangeSentAt) + case phoneReauthenticationOtp: + require.NotEmpty(ts.T(), u.ReauthenticationToken) + require.NotEmpty(ts.T(), u.ReauthenticationSentAt) + default: + } + }) + } +} + +func (ts *PhoneTestSuite) TestMissingSmsProviderConfig() { + u, err := models.FindUserByPhoneAndAudience(ts.API.db, ts.instanceID, "123456789", ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + now := time.Now() + u.PhoneConfirmedAt = &now + require.NoError(ts.T(), ts.API.db.Update(u), "Error updating new test user") + + token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret) + require.NoError(ts.T(), err) + + cases := []struct { + desc string + endpoint string + method string + header string + body map[string]string + expected map[string]interface{} + }{ + { + "Signup", + "/signup", + http.MethodPost, + "", + map[string]string{ + "phone": "1234567890", + "password": "testpassword", + }, + map[string]interface{}{ + "code": http.StatusBadRequest, + "message": "Error sending confirmation sms:", + }, + }, + { + "Sms OTP", + "/otp", + http.MethodPost, + "", + map[string]string{ + "phone": "123456789", + }, + map[string]interface{}{ + "code": http.StatusBadRequest, + "message": "Error sending sms:", + }, + }, + { + "Phone change", + "/user", + http.MethodPut, + token, + map[string]string{ + "phone": "111111111", + }, + map[string]interface{}{ + "code": http.StatusBadRequest, + "message": "Error sending sms:", + }, + }, + { + "Reauthenticate", + "/reauthenticate", + http.MethodGet, + "", + nil, + map[string]interface{}{ + "code": http.StatusBadRequest, + "message": "Error sending sms:", + }, + }, + } + + smsProviders := []string{"twilio", "messagebird", "textlocal", "vonage"} + ts.Config.External.Phone.Enabled = true + ts.Config.Sms.Twilio.AccountSid = "" + ts.Config.Sms.Messagebird.AccessKey = "" + ts.Config.Sms.Textlocal.ApiKey = "" + ts.Config.Sms.Vonage.ApiKey = "" + for _, c := range cases { + for _, provider := range smsProviders { + ts.Config.Sms.Provider = provider + desc := fmt.Sprintf("[%v] %v", provider, c.desc) + ts.Run(desc, func() { + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body)) + + req := httptest.NewRequest(c.method, "http://localhost"+c.endpoint, &buffer) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), c.expected["code"], w.Code) + + body := w.Body.String() + require.True(ts.T(), strings.Contains(body, c.expected["message"].(string))) + }) + } + } +} diff --git a/api/provider/apple.go b/api/provider/apple.go index 148841af9..e04585a4a 100644 --- a/api/provider/apple.go +++ b/api/provider/apple.go @@ -32,7 +32,6 @@ const ( // AppleProvider stores the custom config for apple provider type AppleProvider struct { *oauth2.Config - httpClient *http.Client UserInfoURL string } diff --git a/api/provider/discord.go b/api/provider/discord.go index d7344eb67..301181431 100644 --- a/api/provider/discord.go +++ b/api/provider/discord.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strconv" "strings" "github.com/netlify/gotrue/conf" @@ -21,7 +22,7 @@ type discordProvider struct { type discordUser struct { Avatar string `json:"avatar"` - Discriminator int `json:"discriminator,string"` + Discriminator string `json:"discriminator"` Email string `json:"email"` ID string `json:"id"` Name string `json:"username"` @@ -76,11 +77,15 @@ func (g discordProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*U var avatarURL string extension := "png" - // https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints: - // In the case of the Default User Avatar endpoint, the value for - // user_discriminator in the path should be the user's discriminator modulo 5 if u.Avatar == "" { - avatarURL = fmt.Sprintf("https://cdn.discordapp.com/embed/avatars/%d.%s", u.Discriminator%5, extension) + if intDiscriminator, err := strconv.Atoi(u.Discriminator); err != nil { + return nil, err + } else { + // https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints: + // In the case of the Default User Avatar endpoint, the value for + // user_discriminator in the path should be the user's discriminator modulo 5 + avatarURL = fmt.Sprintf("https://cdn.discordapp.com/embed/avatars/%d.%s", intDiscriminator%5, extension) + } } else { // https://discord.com/developers/docs/reference#image-formatting: // "In the case of endpoints that support GIFs, the hash will begin with a_ diff --git a/api/provider/keycloak.go b/api/provider/keycloak.go new file mode 100644 index 000000000..997fc73c9 --- /dev/null +++ b/api/provider/keycloak.go @@ -0,0 +1,98 @@ +package provider + +import ( + "context" + "errors" + "strings" + + "github.com/netlify/gotrue/conf" + "golang.org/x/oauth2" +) + +// Keycloak +type keycloakProvider struct { + *oauth2.Config + Host string +} + +type keycloakUser struct { + Name string `json:"name"` + Sub string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` +} + +// NewKeycloakProvider creates a Keycloak account provider. +func NewKeycloakProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { + if err := ext.Validate(); err != nil { + return nil, err + } + + oauthScopes := []string{ + "profile", + "email", + } + + if scopes != "" { + oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...) + } + + if ext.URL == "" { + return nil, errors.New("Unable to find URL for the Keycloak provider") + } + + extURLlen := len(ext.URL) + if ext.URL[extURLlen-1] == '/' { + ext.URL = ext.URL[:extURLlen-1] + } + + return &keycloakProvider{ + Config: &oauth2.Config{ + ClientID: ext.ClientID, + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: ext.URL + "/protocol/openid-connect/auth", + TokenURL: ext.URL + "/protocol/openid-connect/token", + }, + RedirectURL: ext.RedirectURI, + Scopes: oauthScopes, + }, + Host: ext.URL, + }, nil +} + +func (g keycloakProvider) GetOAuthToken(code string) (*oauth2.Token, error) { + return g.Exchange(oauth2.NoContext, code) +} + +func (g keycloakProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + var u keycloakUser + + if err := makeRequest(ctx, tok, g.Config, g.Host+"/protocol/openid-connect/userinfo", &u); err != nil { + return nil, err + } + + if u.Email == "" { + return nil, errors.New("Unable to find email with Keycloak provider") + } + + return &UserProvidedData{ + Metadata: &Claims{ + Issuer: g.Host, + Subject: u.Sub, + Name: u.Name, + Email: u.Email, + EmailVerified: u.EmailVerified, + + // To be deprecated + FullName: u.Name, + ProviderId: u.Sub, + }, + Emails: []Email{{ + Email: u.Email, + Verified: u.EmailVerified, + Primary: true, + }}, + }, nil + +} diff --git a/api/provider/notion.go b/api/provider/notion.go index 877fb6711..db8f84d02 100644 --- a/api/provider/notion.go +++ b/api/provider/notion.go @@ -77,7 +77,7 @@ func (g notionProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Us req.Header.Set("Notion-Version", notionApiVersion) req.Header.Set("Authorization", "Bearer "+tok.AccessToken) - client := &http.Client{} + client := &http.Client{Timeout: defaultTimeout} resp, err := client.Do(req) if err != nil { diff --git a/api/provider/provider.go b/api/provider/provider.go index d06659e72..426f1875d 100644 --- a/api/provider/provider.go +++ b/api/provider/provider.go @@ -5,11 +5,27 @@ import ( "context" "encoding/json" "io/ioutil" + "log" "net/http" + "os" + "time" "golang.org/x/oauth2" ) +var defaultTimeout time.Duration = time.Second * 10 + +func init() { + timeoutStr := os.Getenv("GOTRUE_INTERNAL_HTTP_TIMEOUT") + if timeoutStr != "" { + if timeout, err := time.ParseDuration(timeoutStr); err != nil { + log.Fatalf("error loading GOTRUE_INTERNAL_HTTP_TIMEOUT: %v", err.Error()) + } else if timeout != 0 { + defaultTimeout = timeout + } + } +} + type Claims struct { // Reserved claims Issuer string `json:"iss,omitempty"` @@ -103,6 +119,7 @@ func chooseHost(base, defaultHost string) string { func makeRequest(ctx context.Context, tok *oauth2.Token, g *oauth2.Config, url string, dst interface{}) error { client := g.Client(ctx, tok) + client.Timeout = defaultTimeout res, err := client.Get(url) if err != nil { return err diff --git a/api/provider/saml.go b/api/provider/saml.go index 9b5af7b6e..807438aa0 100644 --- a/api/provider/saml.go +++ b/api/provider/saml.go @@ -20,11 +20,11 @@ import ( "github.com/netlify/gotrue/models" "github.com/netlify/gotrue/storage" + "github.com/gofrs/uuid" "github.com/netlify/gotrue/conf" saml2 "github.com/russellhaering/gosaml2" "github.com/russellhaering/gosaml2/types" dsig "github.com/russellhaering/goxmldsig" - "github.com/gofrs/uuid" "golang.org/x/oauth2" ) diff --git a/api/provider/twitch.go b/api/provider/twitch.go index f4c52d7a0..517109c76 100644 --- a/api/provider/twitch.go +++ b/api/provider/twitch.go @@ -92,7 +92,7 @@ func (t twitchProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Us req.Header.Set("Client-Id", t.Config.ClientID) req.Header.Set("Authorization", "Bearer "+tok.AccessToken) - client := &http.Client{} + client := &http.Client{Timeout: defaultTimeout} resp, err := client.Do(req) if err != nil { diff --git a/api/provider/workos.go b/api/provider/workos.go new file mode 100644 index 000000000..91ff8dd33 --- /dev/null +++ b/api/provider/workos.go @@ -0,0 +1,126 @@ +package provider + +import ( + "context" + "errors" + "net/url" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/netlify/gotrue/conf" + "golang.org/x/oauth2" +) + +const ( + defaultWorkOSAPIBase = "api.workos.com" +) + +type workosProvider struct { + *oauth2.Config + APIPath string + AuthCodeOptions []oauth2.AuthCodeOption +} + +// See https://workos.com/docs/reference/sso/profile. +type workosUser struct { + ID string `mapstructure:"id"` + ConnectionID string `mapstructure:"connection_id"` + OrganizationID string `mapstructure:"organization_id"` + ConnectionType string `mapstructure:"connection_type"` + Email string `mapstructure:"email"` + FirstName string `mapstructure:"first_name"` + LastName string `mapstructure:"last_name"` + Object string `mapstructure:"object"` + IdpID string `mapstructure:"idp_id"` + RawAttributes map[string]interface{} `mapstructure:"raw_attributes"` +} + +// NewWorkOSProvider creates a WorkOS account provider. +func NewWorkOSProvider(ext conf.OAuthProviderConfiguration, query *url.Values) (OAuthProvider, error) { + if err := ext.Validate(); err != nil { + return nil, err + } + apiPath := chooseHost(ext.URL, defaultWorkOSAPIBase) + + // Attach custom query parameters to the WorkOS authorization URL. + // See https://workos.com/docs/reference/sso/authorize/get. + var authCodeOptions []oauth2.AuthCodeOption + if query != nil { + if connection := query.Get("connection"); connection != "" { + authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("connection", connection)) + } else if organization := query.Get("organization"); organization != "" { + authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("organization", organization)) + } else if provider := query.Get("workos_provider"); provider != "" { + authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("provider", provider)) + } + + if login_hint := query.Get("login_hint"); login_hint != "" { + authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("login_hint", login_hint)) + } + } + + return &workosProvider{ + Config: &oauth2.Config{ + ClientID: ext.ClientID, + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: apiPath + "/sso/authorize", + TokenURL: apiPath + "/sso/token", + }, + RedirectURL: ext.RedirectURI, + }, + APIPath: apiPath, + AuthCodeOptions: authCodeOptions, + }, nil +} + +func (g workosProvider) AuthCodeURL(state string, args ...oauth2.AuthCodeOption) string { + opts := append(args, g.AuthCodeOptions...) + return g.Config.AuthCodeURL(state, opts...) +} + +func (g workosProvider) GetOAuthToken(code string) (*oauth2.Token, error) { + return g.Exchange(oauth2.NoContext, code) +} + +func (g workosProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + if tok.AccessToken == "" { + return &UserProvidedData{}, nil + } + + // WorkOS API returns the user's profile data along with the OAuth2 token, so + // we can just convert from `map[string]interface{}` to `workosUser` without + // an additional network request. + var u workosUser + err := mapstructure.Decode(tok.Extra("profile"), &u) + if err != nil { + return nil, err + } + + if u.Email == "" { + return nil, errors.New("Unable to find email with WorkOS provider") + } + + return &UserProvidedData{ + Metadata: &Claims{ + Issuer: g.APIPath, + Subject: u.ID, + Name: strings.TrimSpace(u.FirstName + " " + u.LastName), + Email: u.Email, + EmailVerified: true, + CustomClaims: map[string]interface{}{ + "connection_id": u.ConnectionID, + "organization_id": u.OrganizationID, + }, + + // To be deprecated + FullName: strings.TrimSpace(u.FirstName + " " + u.LastName), + ProviderId: u.ID, + }, + Emails: []Email{{ + Email: u.Email, + Verified: true, + Primary: true, + }}, + }, nil +} diff --git a/api/reauthenticate.go b/api/reauthenticate.go new file mode 100644 index 000000000..6debc67ea --- /dev/null +++ b/api/reauthenticate.go @@ -0,0 +1,101 @@ +package api + +import ( + "crypto/sha256" + "errors" + "fmt" + "net/http" + + "github.com/gofrs/uuid" + "github.com/netlify/gotrue/api/sms_provider" + "github.com/netlify/gotrue/conf" + "github.com/netlify/gotrue/models" + "github.com/netlify/gotrue/storage" +) + +const InvalidNonceMessage = "Nonce has expired or is invalid" + +// Reauthenticate sends a reauthentication otp to either the user's email or phone +func (a *API) Reauthenticate(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + config := a.getConfig(ctx) + instanceID := getInstanceID(ctx) + + claims := getClaims(ctx) + userID, err := uuid.FromString(claims.Subject) + if err != nil { + return badRequestError("Could not read User ID claim") + } + user, err := models.FindUserByID(a.db, userID) + if err != nil { + if models.IsNotFoundError(err) { + return notFoundError(err.Error()) + } + return internalServerError("Database error finding user").WithInternalError(err) + } + + email, phone := user.GetEmail(), user.GetPhone() + + if email == "" && phone == "" { + return unprocessableEntityError("Reauthentication requires the user to have an email or a phone number") + } + + if email != "" { + if !user.IsConfirmed() { + return badRequestError("Please verify your email first.") + } + } else if phone != "" { + if !user.IsPhoneConfirmed() { + return badRequestError("Please verify your phone first.") + } + } + + err = a.db.Transaction(func(tx *storage.Connection) error { + if terr := models.NewAuditLogEntry(tx, instanceID, user, models.UserReauthenticateAction, "", nil); terr != nil { + return terr + } + if email != "" { + mailer := a.Mailer(ctx) + return a.sendReauthenticationOtp(tx, user, mailer, config.SMTP.MaxFrequency, config.Mailer.OtpLength) + } else if phone != "" { + smsProvider, terr := sms_provider.GetSmsProvider(*config) + if terr != nil { + return badRequestError("Error sending sms: %v", terr) + } + return a.sendPhoneConfirmation(ctx, tx, user, phone, phoneReauthenticationOtp, smsProvider) + } + return nil + }) + if err != nil { + if errors.Is(err, MaxFrequencyLimitError) { + return tooManyRequestsError("For security purposes, you can only request this once every 60 seconds") + } + return err + } + + return sendJSON(w, http.StatusOK, make(map[string]string)) +} + +// verifyReauthentication checks if the nonce provided is valid +func (a *API) verifyReauthentication(nonce string, tx *storage.Connection, config *conf.Configuration, user *models.User) error { + if user.ReauthenticationToken == "" || user.ReauthenticationSentAt == nil { + return badRequestError(InvalidNonceMessage) + } + var isValid bool + if user.GetEmail() != "" { + tokenHash := fmt.Sprintf("%x", sha256.Sum224([]byte(user.GetEmail()+nonce))) + isValid = isOtpValid(tokenHash, user.ReauthenticationToken, user.ReauthenticationSentAt, config.Mailer.OtpExp) + } else if user.GetPhone() != "" { + tokenHash := fmt.Sprintf("%x", sha256.Sum224([]byte(user.GetPhone()+nonce))) + isValid = isOtpValid(tokenHash, user.ReauthenticationToken, user.ReauthenticationSentAt, config.Sms.OtpExp) + } else { + return unprocessableEntityError("Reauthentication requires an email or a phone number") + } + if !isValid { + return badRequestError(InvalidNonceMessage) + } + if err := user.ConfirmReauthentication(tx); err != nil { + return internalServerError("Error during reauthentication").WithInternalError(err) + } + return nil +} diff --git a/api/recover.go b/api/recover.go index 2853e7542..17cc36763 100644 --- a/api/recover.go +++ b/api/recover.go @@ -30,9 +30,14 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error { return unprocessableEntityError("Password recovery requires an email") } + var user *models.User aud := a.requestAud(ctx, r) - user, err := models.FindUserByEmailAndAudience(a.db, instanceID, params.Email, aud) recoverErrorMessage := "If a user exists, you will receive an email with instructions on how to reset your password." + if err := a.validateEmail(ctx, params.Email); err != nil { + return err + } + user, err = models.FindUserByEmailAndAudience(a.db, instanceID, params.Email, aud) + if err != nil { if models.IsNotFoundError(err) { return notFoundError(err.Error()) @@ -41,13 +46,12 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error { } err = a.db.Transaction(func(tx *storage.Connection) error { - if terr := models.NewAuditLogEntry(tx, instanceID, user, models.UserRecoveryRequestedAction, nil); terr != nil { + if terr := models.NewAuditLogEntry(tx, instanceID, user, models.UserRecoveryRequestedAction, "", nil); terr != nil { return terr } - mailer := a.Mailer(ctx) referrer := a.getReferrer(r) - return a.sendPasswordRecovery(tx, user, mailer, config.SMTP.MaxFrequency, referrer) + return a.sendPasswordRecovery(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength) }) if err != nil { if errors.Is(err, MaxFrequencyLimitError) { diff --git a/api/recover_test.go b/api/recover_test.go index 548d77278..9838e3f2d 100644 --- a/api/recover_test.go +++ b/api/recover_test.go @@ -42,7 +42,7 @@ func (ts *RecoverTestSuite) SetupTest() { models.TruncateAll(ts.API.db) // Create user - u, err := models.NewUser(ts.instanceID, "test@example.com", "password", ts.Config.JWT.Aud, nil) + u, err := models.NewUser(ts.instanceID, "", "test@example.com", "password", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error creating test user model") require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user") } diff --git a/api/settings.go b/api/settings.go index 56e760efb..df76dd1d1 100644 --- a/api/settings.go +++ b/api/settings.go @@ -9,12 +9,14 @@ type ProviderSettings struct { Discord bool `json:"discord"` GitHub bool `json:"github"` GitLab bool `json:"gitlab"` + Keycloak bool `json:"keycloak"` Google bool `json:"google"` Linkedin bool `json:"linkedin"` Facebook bool `json:"facebook"` Notion bool `json:"notion"` Spotify bool `json:"spotify"` Slack bool `json:"slack"` + WorkOS bool `json:"workos"` Twitch bool `json:"twitch"` Twitter bool `json:"twitter"` Email bool `json:"email"` @@ -48,6 +50,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error { GitHub: config.External.Github.Enabled, GitLab: config.External.Gitlab.Enabled, Google: config.External.Google.Enabled, + Keycloak: config.External.Keycloak.Enabled, Linkedin: config.External.Linkedin.Enabled, Facebook: config.External.Facebook.Enabled, Notion: config.External.Notion.Enabled, @@ -55,6 +58,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error { Slack: config.External.Slack.Enabled, Twitch: config.External.Twitch.Enabled, Twitter: config.External.Twitter.Enabled, + WorkOS: config.External.WorkOS.Enabled, Email: config.External.Email.Enabled, Phone: config.External.Phone.Enabled, SAML: config.External.Saml.Enabled, diff --git a/api/settings_test.go b/api/settings_test.go index 922b5ff01..ebadc3d5f 100644 --- a/api/settings_test.go +++ b/api/settings_test.go @@ -36,11 +36,13 @@ func TestSettings_DefaultProviders(t *testing.T) { require.True(t, p.Spotify) require.True(t, p.Slack) require.True(t, p.Google) + require.True(t, p.Keycloak) require.True(t, p.Linkedin) require.True(t, p.GitHub) require.True(t, p.GitLab) require.True(t, p.SAML) require.True(t, p.Twitch) + require.True(t, p.WorkOS) require.True(t, p.Zoom) } diff --git a/api/signup.go b/api/signup.go index a35d1b716..a6fbbf757 100644 --- a/api/signup.go +++ b/api/signup.go @@ -8,6 +8,7 @@ import ( "time" "github.com/gofrs/uuid" + "github.com/netlify/gotrue/api/sms_provider" "github.com/netlify/gotrue/metering" "github.com/netlify/gotrue/models" "github.com/netlify/gotrue/storage" @@ -75,9 +76,9 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { if !config.External.Phone.Enabled { return badRequestError("Phone signups are disabled") } - params.Phone = a.formatPhoneNumber(params.Phone) - if isValid := a.validateE164Format(params.Phone); !isValid { - return unprocessableEntityError("Invalid phone number format") + params.Phone, err = a.validatePhone(params.Phone) + if err != nil { + return err } user, err = models.FindUserByPhoneAndAudience(a.db, instanceID, params.Phone, params.Aud) default: @@ -112,7 +113,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { if params.Provider == "email" && !user.IsConfirmed() { if config.Mailer.Autoconfirm { - if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserSignedUpAction, map[string]interface{}{ + if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserSignedUpAction, "", map[string]interface{}{ "provider": params.Provider, }); terr != nil { return terr @@ -126,12 +127,12 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { } else { mailer := a.Mailer(ctx) referrer := a.getReferrer(r) - if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserConfirmationRequestedAction, map[string]interface{}{ + if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserConfirmationRequestedAction, "", map[string]interface{}{ "provider": params.Provider, }); terr != nil { return terr } - if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer); terr != nil { + if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength); terr != nil { if errors.Is(terr, MaxFrequencyLimitError) { now := time.Now() left := user.ConfirmationSentAt.Add(config.SMTP.MaxFrequency).Sub(now) / time.Second @@ -142,7 +143,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { } } else if params.Provider == "phone" && !user.IsPhoneConfirmed() { if config.Sms.Autoconfirm { - if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserSignedUpAction, map[string]interface{}{ + if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserSignedUpAction, "", map[string]interface{}{ "provider": params.Provider, }); terr != nil { return terr @@ -154,12 +155,16 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { return internalServerError("Database error updating user").WithInternalError(terr) } } else { - if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserConfirmationRequestedAction, map[string]interface{}{ + if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserConfirmationRequestedAction, "", map[string]interface{}{ "provider": params.Provider, }); terr != nil { return terr } - if terr = a.sendPhoneConfirmation(ctx, tx, user, params.Phone); terr != nil { + smsProvider, terr := sms_provider.GetSmsProvider(*config) + if terr != nil { + return badRequestError("Error sending confirmation sms: %v", terr) + } + if terr = a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneConfirmationOtp, smsProvider); terr != nil { return badRequestError("Error sending confirmation sms: %v", terr) } } @@ -174,7 +179,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { } if errors.Is(err, UserExistsError) { err = a.db.Transaction(func(tx *storage.Connection) error { - if terr := models.NewAuditLogEntry(tx, instanceID, user, models.UserRepeatedSignUpAction, map[string]interface{}{ + if terr := models.NewAuditLogEntry(tx, instanceID, user, models.UserRepeatedSignUpAction, "", map[string]interface{}{ "provider": params.Provider, }); terr != nil { return terr @@ -201,7 +206,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { var token *AccessTokenResponse err = a.db.Transaction(func(tx *storage.Connection) error { var terr error - if terr = models.NewAuditLogEntry(tx, instanceID, user, models.LoginAction, map[string]interface{}{ + if terr = models.NewAuditLogEntry(tx, instanceID, user, models.LoginAction, "", map[string]interface{}{ "provider": params.Provider, }); terr != nil { return terr @@ -273,13 +278,12 @@ func (a *API) signupNewUser(ctx context.Context, conn *storage.Connection, param var err error switch params.Provider { case "email": - user, err = models.NewUser(instanceID, params.Email, params.Password, params.Aud, params.Data) + user, err = models.NewUser(instanceID, "", params.Email, params.Password, params.Aud, params.Data) case "phone": - user, err = models.NewUser(instanceID, "", params.Password, params.Aud, params.Data) - user.Phone = storage.NullString(params.Phone) + user, err = models.NewUser(instanceID, params.Phone, "", params.Password, params.Aud, params.Data) default: // handles external provider case - user, err = models.NewUser(instanceID, params.Email, params.Password, params.Aud, params.Data) + user, err = models.NewUser(instanceID, "", params.Email, params.Password, params.Aud, params.Data) } if err != nil { @@ -301,12 +305,27 @@ func (a *API) signupNewUser(ctx context.Context, conn *storage.Connection, param err = conn.Transaction(func(tx *storage.Connection) error { var terr error + userExist, terr := models.AnyUser(tx) + + if terr != nil { + return terr + } + if terr = tx.Create(user); terr != nil { return internalServerError("Database error saving new user").WithInternalError(terr) } - if terr = user.SetRole(tx, config.JWT.DefaultGroupName); terr != nil { - return internalServerError("Database error updating user").WithInternalError(terr) + + if config.FirstUserSuperAdmin && !userExist { + terr = user.SetSuperAdmin(tx) + if terr != nil { + return terr + } + } else { + if terr = user.SetRole(tx, config.JWT.DefaultGroupName); terr != nil { + return internalServerError("Database error updating user").WithInternalError(terr) + } } + if terr = triggerEventHooks(ctx, tx, ValidateEvent, user, instanceID, config); terr != nil { return terr } diff --git a/api/signup_test.go b/api/signup_test.go index f06756bc9..1faa53158 100644 --- a/api/signup_test.go +++ b/api/signup_test.go @@ -3,9 +3,11 @@ package api import ( "bytes" "encoding/json" + "fmt" "io/ioutil" "net/http" "net/http/httptest" + "net/url" "testing" "time" @@ -115,7 +117,7 @@ func (ts *SignupTestSuite) TestWebhookTriggered() { assert.Len(u, 10) // assert.Equal(t, user.ID, u["id"]) TODO assert.Equal("authenticated", u["aud"]) - assert.Equal("authenticated", u["role"]) + assert.Equal("superadmin", u["role"]) assert.Equal("test@example.com", u["email"]) appmeta, ok := u["app_metadata"].(map[string]interface{}) @@ -233,7 +235,7 @@ func (ts *SignupTestSuite) TestSignupTwice() { } func (ts *SignupTestSuite) TestVerifySignup() { - user, err := models.NewUser(ts.instanceID, "test@example.com", "testing", ts.Config.JWT.Aud, nil) + user, err := models.NewUser(ts.instanceID, "123456789", "test@example.com", "testing", ts.Config.JWT.Aud, nil) user.ConfirmationToken = "asdf3" now := time.Now() user.ConfirmationSentAt = &now @@ -244,21 +246,20 @@ func (ts *SignupTestSuite) TestVerifySignup() { u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) - // Request body - var buffer bytes.Buffer - require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ - "type": "signup", - "token": u.ConfirmationToken, - })) - // Setup request - req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer) - req.Header.Set("Content-Type", "application/json") + reqUrl := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", signupVerification, u.ConfirmationToken) + req := httptest.NewRequest(http.MethodGet, reqUrl, nil) // Setup response recorder w := httptest.NewRecorder() - ts.API.handler.ServeHTTP(w, req) + assert.Equal(ts.T(), http.StatusSeeOther, w.Code) - assert.Equal(ts.T(), http.StatusOK, w.Code, w.Body.String()) + urlVal, err := url.Parse(w.Result().Header.Get("Location")) + require.NoError(ts.T(), err) + v, err := url.ParseQuery(urlVal.Fragment) + require.NoError(ts.T(), err) + require.NotEmpty(ts.T(), v.Get("access_token")) + require.NotEmpty(ts.T(), v.Get("expires_in")) + require.NotEmpty(ts.T(), v.Get("refresh_token")) } diff --git a/api/sms_provider/messagebird.go b/api/sms_provider/messagebird.go index 0055bad28..4520f2896 100644 --- a/api/sms_provider/messagebird.go +++ b/api/sms_provider/messagebird.go @@ -55,7 +55,7 @@ func NewMessagebirdProvider(config conf.MessagebirdProviderConfiguration) (SmsPr } // Send an SMS containing the OTP with Messagebird's API -func (t MessagebirdProvider) SendSms(phone string, message string) error { +func (t *MessagebirdProvider) SendSms(phone string, message string) error { body := url.Values{ "originator": {t.Config.Originator}, "body": {message}, @@ -64,7 +64,7 @@ func (t MessagebirdProvider) SendSms(phone string, message string) error { "datacoding": {"unicode"}, } - client := &http.Client{} + client := &http.Client{Timeout: defaultTimeout} r, err := http.NewRequest("POST", t.APIPath, strings.NewReader(body.Encode())) if err != nil { return err diff --git a/api/sms_provider/sms_provider.go b/api/sms_provider/sms_provider.go index 11b3b3f2b..7d535133e 100644 --- a/api/sms_provider/sms_provider.go +++ b/api/sms_provider/sms_provider.go @@ -2,10 +2,26 @@ package sms_provider import ( "fmt" + "log" + "os" + "time" "github.com/netlify/gotrue/conf" ) +var defaultTimeout time.Duration = time.Second * 10 + +func init() { + timeoutStr := os.Getenv("GOTRUE_INTERNAL_HTTP_TIMEOUT") + if timeoutStr != "" { + if timeout, err := time.ParseDuration(timeoutStr); err != nil { + log.Fatalf("error loading GOTRUE_INTERNAL_HTTP_TIMEOUT: %v", err.Error()) + } else if timeout != 0 { + defaultTimeout = timeout + } + } +} + type SmsProvider interface { SendSms(phone, message string) error } diff --git a/api/sms_provider/textlocal.go b/api/sms_provider/textlocal.go index c45cab703..ac0f4b164 100644 --- a/api/sms_provider/textlocal.go +++ b/api/sms_provider/textlocal.go @@ -44,7 +44,7 @@ func NewTextlocalProvider(config conf.TextlocalProviderConfiguration) (SmsProvid } // Send an SMS containing the OTP with Textlocal's API -func (t TextlocalProvider) SendSms(phone string, message string) error { +func (t *TextlocalProvider) SendSms(phone string, message string) error { body := url.Values{ "sender": {t.Config.Sender}, "apikey": {t.Config.ApiKey}, @@ -52,7 +52,7 @@ func (t TextlocalProvider) SendSms(phone string, message string) error { "numbers": {phone}, } - client := &http.Client{} + client := &http.Client{Timeout: defaultTimeout} r, err := http.NewRequest("POST", t.APIPath, strings.NewReader(body.Encode())) if err != nil { return err @@ -70,7 +70,7 @@ func (t TextlocalProvider) SendSms(phone string, message string) error { if derr != nil { return derr } - + if len(resp.Errors) == 0 { return errors.New("Textlocal error: Internal Error") } diff --git a/api/sms_provider/twilio.go b/api/sms_provider/twilio.go index c9f4cc6fe..ce29db825 100644 --- a/api/sms_provider/twilio.go +++ b/api/sms_provider/twilio.go @@ -54,7 +54,7 @@ func NewTwilioProvider(config conf.TwilioProviderConfiguration) (SmsProvider, er } // Send an SMS containing the OTP with Twilio's API -func (t TwilioProvider) SendSms(phone string, message string) error { +func (t *TwilioProvider) SendSms(phone string, message string) error { body := url.Values{ "To": {"+" + phone}, // twilio api requires "+" extension to be included "Channel": {"sms"}, @@ -62,7 +62,7 @@ func (t TwilioProvider) SendSms(phone string, message string) error { "Body": {message}, } - client := &http.Client{} + client := &http.Client{Timeout: defaultTimeout} r, err := http.NewRequest("POST", t.APIPath, strings.NewReader(body.Encode())) if err != nil { return err @@ -73,7 +73,7 @@ func (t TwilioProvider) SendSms(phone string, message string) error { if err != nil { return err } - if res.StatusCode == http.StatusBadRequest || res.StatusCode == http.StatusForbidden { + if res.StatusCode/100 != 2 { resp := &twilioErrResponse{} if err := json.NewDecoder(res.Body).Decode(resp); err != nil { return err diff --git a/api/sms_provider/vonage.go b/api/sms_provider/vonage.go index e266dbcc5..75ed3577a 100644 --- a/api/sms_provider/vonage.go +++ b/api/sms_provider/vonage.go @@ -43,7 +43,7 @@ func NewVonageProvider(config conf.VonageProviderConfiguration) (SmsProvider, er } // Send an SMS containing the OTP with Vonage's API -func (t VonageProvider) SendSms(phone string, message string) error { +func (t *VonageProvider) SendSms(phone string, message string) error { body := url.Values{ "from": {t.Config.From}, "to": {phone}, @@ -52,7 +52,7 @@ func (t VonageProvider) SendSms(phone string, message string) error { "api_secret": {t.Config.ApiSecret}, } - client := &http.Client{} + client := &http.Client{Timeout: defaultTimeout} r, err := http.NewRequest("POST", t.APIPath, strings.NewReader(body.Encode())) if err != nil { return err diff --git a/api/token.go b/api/token.go index f2853f171..a50db5c10 100644 --- a/api/token.go +++ b/api/token.go @@ -11,7 +11,7 @@ import ( "time" "github.com/coreos/go-oidc/v3/oidc" - jwt "github.com/golang-jwt/jwt" + "github.com/golang-jwt/jwt" "github.com/netlify/gotrue/conf" "github.com/netlify/gotrue/metering" "github.com/netlify/gotrue/models" @@ -26,6 +26,9 @@ type GoTrueClaims struct { AppMetaData map[string]interface{} `json:"app_metadata"` UserMetaData map[string]interface{} `json:"user_metadata"` Role string `json:"role"` + + MainAsymmetricKey string `json:"asymmetric_key"` + MainAsymmetricKeyAlgorithm string `json:"asymmetric_key_algorithm"` } // AccessTokenResponse represents an OAuth2 success response @@ -58,6 +61,16 @@ type IdTokenGrantParams struct { Issuer string `json:"issuer"` } +type tokenType string + +const ( + confirmationToken tokenType = "confirmation_token" + recoveryToken tokenType = "recovery_token" + emailChangeTokenNew tokenType = "email_change_token_new" + emailChangeTokenCurrent tokenType = "email_change_token_current" + reauthenticationToken tokenType = "reauthentication_token" +) + const useCookieHeader = "x-use-cookie" const useSessionCookie = "session" const InvalidLoginMessage = "Invalid login credentials" @@ -90,6 +103,10 @@ func (p *IdTokenGrantParams) getVerifier(ctx context.Context) (*oidc.IDTokenVeri oAuthProvider = config.External.Google oAuthProviderClientId = oAuthProvider.ClientID provider, err = oidc.NewProvider(ctx, "https://accounts.google.com") + case "keycloak": + oAuthProvider = config.External.Keycloak + oAuthProviderClientId = oAuthProvider.ClientID + provider, err = oidc.NewProvider(ctx, oAuthProvider.URL) default: return nil, fmt.Errorf("Provider %s doesn't support the id_token grant flow", p.Provider) } @@ -205,7 +222,7 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri var token *AccessTokenResponse err = a.db.Transaction(func(tx *storage.Connection) error { var terr error - if terr = models.NewAuditLogEntry(tx, instanceID, user, models.LoginAction, map[string]interface{}{ + if terr = models.NewAuditLogEntry(tx, instanceID, user, models.LoginAction, "", map[string]interface{}{ "provider": provider, }); terr != nil { return terr @@ -259,55 +276,71 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h return oauthError("invalid_grant", "Invalid Refresh Token") } - if !(config.External.Email.Enabled && config.External.Phone.Enabled) { - providers, err := models.FindProvidersByUser(a.db, user) - if err != nil { - return internalServerError(err.Error()) - } - for _, provider := range providers { - if provider == "email" && !config.External.Email.Enabled { - return badRequestError("Email logins are disabled") + var newToken *models.RefreshToken + if token.Revoked { + a.clearCookieTokens(config, w) + err = a.db.Transaction(func(tx *storage.Connection) error { + validToken, terr := models.GetValidChildToken(tx, token) + if terr != nil { + if errors.Is(terr, models.RefreshTokenNotFoundError{}) { + // revoked token has no descendants + return nil + } + return terr } - if provider == "phone" && !config.External.Phone.Enabled { - return badRequestError("Phone logins are disabled") + // check if token is the last previous revoked token + if validToken.Parent == storage.NullString(token.Token) { + refreshTokenReuseWindow := token.UpdatedAt.Add(time.Second * time.Duration(config.Security.RefreshTokenReuseInterval)) + if time.Now().Before(refreshTokenReuseWindow) { + newToken = validToken + } } + return nil + }) + if err != nil { + return internalServerError("Error validating reuse interval").WithInternalError(err) } - } - if token.Revoked { - a.clearCookieTokens(config, w) - if config.Security.RefreshTokenRotationEnabled { - // Revoke all tokens in token family - err = a.db.Transaction(func(tx *storage.Connection) error { - var terr error - if terr = models.RevokeTokenFamily(tx, token); terr != nil { - return terr + if newToken == nil { + if config.Security.RefreshTokenRotationEnabled { + // Revoke all tokens in token family + err = a.db.Transaction(func(tx *storage.Connection) error { + var terr error + if terr = models.RevokeTokenFamily(tx, token); terr != nil { + return terr + } + return nil + }) + if err != nil { + return internalServerError(err.Error()) } - return nil - }) - if err != nil { - return internalServerError(err.Error()) } + return oauthError("invalid_grant", "Invalid Refresh Token").WithInternalMessage("Possible abuse attempt: %v", r) } - return oauthError("invalid_grant", "Invalid Refresh Token").WithInternalMessage("Possible abuse attempt: %v", r) } var tokenString string - var newToken *models.RefreshToken var newTokenResponse *AccessTokenResponse err = a.db.Transaction(func(tx *storage.Connection) error { var terr error - if terr = models.NewAuditLogEntry(tx, instanceID, user, models.TokenRefreshedAction, nil); terr != nil { + if terr = models.NewAuditLogEntry(tx, instanceID, user, models.TokenRefreshedAction, "", nil); terr != nil { return terr } - newToken, terr = models.GrantRefreshTokenSwap(tx, user, token) + if newToken == nil { + newToken, terr = models.GrantRefreshTokenSwap(tx, user, token) + if terr != nil { + return internalServerError(terr.Error()) + } + } + + key, terr := models.FindMainAsymmetricKeyByUser(tx, user) if terr != nil { - return internalServerError(terr.Error()) + return internalServerError("Database error granting user").WithInternalError(terr) } - tokenString, terr = generateAccessToken(user, time.Second*time.Duration(config.JWT.Exp), config.JWT.Secret) + tokenString, terr = generateAccessToken(user, key, time.Second*time.Duration(config.JWT.Exp), config.JWT.GetSigningMethod(), config.JWT.GetSigningKey()) if terr != nil { return internalServerError("error generating jwt token").WithInternalError(terr) } @@ -344,8 +377,8 @@ func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.R return badRequestError("Could not read id token grant params: %v", err) } - if params.IdToken == "" || params.Nonce == "" { - return oauthError("invalid request", "id_token and nonce required") + if params.IdToken == "" { + return oauthError("invalid request", "id_token required") } if params.Provider == "" && (params.ClientID == "" || params.Issuer == "") { @@ -375,14 +408,17 @@ func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.R return err } - // verify nonce to mitigate replay attacks hashedNonce, ok := claims["nonce"] - if !ok { - return oauthError("invalid request", "missing nonce in id_token") + if (!ok && params.Nonce != "") || (ok && params.Nonce == "") { + return oauthError("invalid request", "Passed nonce and nonce in id_token should either both exist or not.") } - hash := fmt.Sprintf("%x", sha256.Sum256([]byte(params.Nonce))) - if hash != hashedNonce.(string) { - return oauthError("invalid nonce", "").WithInternalMessage("Possible abuse attempt: %v", r) + + if ok && params.Nonce != "" { + // verify nonce to mitigate replay attacks + hash := fmt.Sprintf("%x", sha256.Sum256([]byte(params.Nonce))) + if hash != hashedNonce.(string) { + return oauthError("invalid nonce", "").WithInternalMessage("Possible abuse attempt: %v", r) + } } sub, ok := claims["sub"].(string) @@ -453,13 +489,13 @@ func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.R if (!ok || !isEmailVerified) && !config.Mailer.Autoconfirm { mailer := a.Mailer(ctx) referrer := a.getReferrer(r) - if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer); terr != nil { + if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength); terr != nil { return internalServerError("Error sending confirmation mail").WithInternalError(terr) } return unauthorizedError("Error unverified email") } - if terr := models.NewAuditLogEntry(tx, instanceID, user, models.UserSignedUpAction, map[string]interface{}{ + if terr := models.NewAuditLogEntry(tx, instanceID, user, models.UserSignedUpAction, "", map[string]interface{}{ "provider": params.Provider, }); terr != nil { return terr @@ -473,7 +509,7 @@ func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.R return internalServerError("Error updating user").WithInternalError(terr) } } else { - if terr := models.NewAuditLogEntry(tx, instanceID, user, models.LoginAction, map[string]interface{}{ + if terr := models.NewAuditLogEntry(tx, instanceID, user, models.LoginAction, "", map[string]interface{}{ "provider": params.Provider, }); terr != nil { return terr @@ -502,7 +538,7 @@ func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.R return sendJSON(w, http.StatusOK, token) } -func generateAccessToken(user *models.User, expiresIn time.Duration, secret string) (string, error) { +func generateAccessToken(user *models.User, key *models.AsymmetricKey, expiresIn time.Duration, algorithm jwt.SigningMethod, secret interface{}) (string, error) { claims := &GoTrueClaims{ StandardClaims: jwt.StandardClaims{ Subject: user.ID.String(), @@ -516,8 +552,13 @@ func generateAccessToken(user *models.User, expiresIn time.Duration, secret stri Role: user.Role, } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString([]byte(secret)) + if key != nil { + claims.MainAsymmetricKey = key.Key + claims.MainAsymmetricKeyAlgorithm = key.Algorithm + } + + token := jwt.NewWithClaims(algorithm, claims) + return token.SignedString(secret) } func (a *API) issueRefreshToken(ctx context.Context, conn *storage.Connection, user *models.User) (*AccessTokenResponse, error) { @@ -536,7 +577,12 @@ func (a *API) issueRefreshToken(ctx context.Context, conn *storage.Connection, u return internalServerError("Database error granting user").WithInternalError(terr) } - tokenString, terr = generateAccessToken(user, time.Second*time.Duration(config.JWT.Exp), config.JWT.Secret) + key, terr := models.FindMainAsymmetricKeyByUser(tx, user) + if terr != nil { + return internalServerError("Database error granting user").WithInternalError(terr) + } + + tokenString, terr = generateAccessToken(user, key, time.Second*time.Duration(config.JWT.Exp), config.JWT.GetSigningMethod(), config.JWT.GetSigningKey()) if terr != nil { return internalServerError("error generating jwt token").WithInternalError(terr) } diff --git a/api/token_test.go b/api/token_test.go index eaa5dcb5f..79dec3b2b 100644 --- a/api/token_test.go +++ b/api/token_test.go @@ -2,13 +2,23 @@ package api import ( "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" "encoding/json" + "encoding/pem" + "fmt" "net/http" "net/http/httptest" "os" "testing" "time" + "github.com/golang-jwt/jwt" + "github.com/netlify/gotrue/storage" + "github.com/gofrs/uuid" "github.com/netlify/gotrue/conf" "github.com/netlify/gotrue/models" @@ -46,7 +56,7 @@ func (ts *TokenTestSuite) SetupTest() { models.TruncateAll(ts.API.db) // Create user & refresh token - u, err := models.NewUser(ts.instanceID, "test@example.com", "password", ts.Config.JWT.Aud, nil) + u, err := models.NewUser(ts.instanceID, "12345678", "test@example.com", "password", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error creating test user model") t := time.Now() u.EmailConfirmedAt = &t @@ -57,7 +67,7 @@ func (ts *TokenTestSuite) SetupTest() { require.NoError(ts.T(), err, "Error creating refresh token") } -func (ts *TokenTestSuite) TestRateLimitToken() { +func (ts *TokenTestSuite) TestRateLimitTokenRefresh() { var buffer bytes.Buffer req := httptest.NewRequest(http.MethodPost, "http://localhost/token", &buffer) req.Header.Set("Content-Type", "application/json") @@ -150,8 +160,97 @@ func (ts *TokenTestSuite) TestTokenRefreshTokenGrantFailure() { assert.Equal(ts.T(), http.StatusBadRequest, w.Code) } +func (ts *TokenTestSuite) TestTokenRefreshTokenRotation() { + u, err := models.NewUser(ts.instanceID, "", "foo@example.com", "password", ts.Config.JWT.Aud, nil) + require.NoError(ts.T(), err, "Error creating test user model") + t := time.Now() + u.EmailConfirmedAt = &t + require.NoError(ts.T(), ts.API.db.Create(u), "Error saving foo user") + first, err := models.GrantAuthenticatedUser(ts.API.db, u) + require.NoError(ts.T(), err) + second, err := models.GrantRefreshTokenSwap(ts.API.db, u, first) + require.NoError(ts.T(), err) + third, err := models.GrantRefreshTokenSwap(ts.API.db, u, second) + require.NoError(ts.T(), err) + + cases := []struct { + desc string + refreshTokenRotationEnabled bool + reuseInterval int + refreshToken string + expectedCode int + expectedBody map[string]interface{} + }{ + { + "Valid refresh within reuse interval", + true, + 30, + second.Token, + http.StatusOK, + map[string]interface{}{ + "refresh_token": third.Token, + }, + }, + { + "Invalid refresh, first token is not the previous revoked token", + true, + 0, + first.Token, + http.StatusBadRequest, + map[string]interface{}{ + "error": "invalid_grant", + "error_description": "Invalid Refresh Token", + }, + }, + { + "Invalid refresh, revoked third token", + true, + 0, + second.Token, + http.StatusBadRequest, + map[string]interface{}{ + "error": "invalid_grant", + "error_description": "Invalid Refresh Token", + }, + }, + { + "Invalid refresh, third token revoked by previous case", + true, + 30, + third.Token, + http.StatusBadRequest, + map[string]interface{}{ + "error": "invalid_grant", + "error_description": "Invalid Refresh Token", + }, + }, + } + + for _, c := range cases { + ts.Run(c.desc, func() { + ts.Config.Security.RefreshTokenRotationEnabled = c.refreshTokenRotationEnabled + ts.Config.Security.RefreshTokenReuseInterval = c.reuseInterval + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "refresh_token": c.refreshToken, + })) + req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=refresh_token", &buffer) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + assert.Equal(ts.T(), c.expectedCode, w.Code) + + data := make(map[string]interface{}) + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data)) + for k, v := range c.expectedBody { + require.Equal(ts.T(), v, data[k]) + } + }) + } +} + func (ts *TokenTestSuite) createBannedUser() *models.User { - u, err := models.NewUser(ts.instanceID, "banned@example.com", "password", ts.Config.JWT.Aud, nil) + u, err := models.NewUser(ts.instanceID, "", "banned@example.com", "password", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error creating test user model") t := time.Now() u.EmailConfirmedAt = &t @@ -164,3 +263,61 @@ func (ts *TokenTestSuite) createBannedUser() *models.User { return u } + +func (ts *TokenTestSuite) TestRSAToken() { + privatekey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(ts.T(), err) + + privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(privatekey) + require.NoError(ts.T(), err) + + keyPEM := pem.EncodeToMemory( + &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privateKeyBytes, + }, + ) + + ts.Config.JWT.Secret = base64.URLEncoding.EncodeToString(keyPEM) + ts.Config.JWT.Algorithm = "RS256" + ts.Config.JWT.InitializeSigningSecret() + + ctx, err := WithInstanceConfig(context.Background(), ts.Config, uuid.Nil) + + var token *AccessTokenResponse + err = ts.API.db.Transaction(func(tx *storage.Connection) error { + user, terr := ts.API.signupNewUser(ctx, ts.API.db, &SignupParams{ + Aud: "authenticated", + }) + if terr != nil { + return terr + } + + token, err = ts.API.issueRefreshToken(ctx, tx, user) + return err + }) + require.NoError(ts.T(), err) + + jwtToken, err := jwt.ParseWithClaims(token.Token, &GoTrueClaims{}, func(token *jwt.Token) (interface{}, error) { + return ts.Config.JWT.GetVerificationKey(), nil + }) + + require.NoError(ts.T(), err) + require.True(ts.T(), jwtToken.Valid) + require.Equal(ts.T(), jwt.SigningMethodRS256, jwtToken.Method) + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "password": "newpass", + })) + + // Setup request + req := httptest.NewRequest(http.MethodGet, "http://localhost/user", &buffer) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.Token)) + + // Setup response recorder + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), w.Code, http.StatusOK) +} diff --git a/api/user.go b/api/user.go index 4ef51525a..7a89eea66 100644 --- a/api/user.go +++ b/api/user.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/gofrs/uuid" + "github.com/netlify/gotrue/api/sms_provider" "github.com/netlify/gotrue/models" "github.com/netlify/gotrue/storage" ) @@ -13,6 +14,7 @@ import ( type UserUpdateParams struct { Email string `json:"email"` Password *string `json:"password"` + Nonce string `json:"nonce"` Data map[string]interface{} `json:"data"` AppData map[string]interface{} `json:"app_metadata,omitempty"` Phone string `json:"phone"` @@ -84,8 +86,19 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { return invalidPasswordLengthError(config) } - if terr = user.UpdatePassword(tx, *params.Password); terr != nil { - return internalServerError("Error during password storage").WithInternalError(terr) + if !config.Security.UpdatePasswordRequireReauthentication { + if terr = user.UpdatePassword(tx, *params.Password); terr != nil { + return internalServerError("Error during password storage").WithInternalError(terr) + } + } else if params.Nonce == "" { + return unauthorizedError("Password update requires reauthentication.") + } else { + if terr = a.verifyReauthentication(params.Nonce, tx, config, user); terr != nil { + return terr + } + if terr = user.UpdatePassword(tx, *params.Password); terr != nil { + return internalServerError("Error during password storage").WithInternalError(terr) + } } } @@ -119,18 +132,36 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { mailer := a.Mailer(ctx) referrer := a.getReferrer(r) - if config.Mailer.SecureEmailChangeEnabled { - if terr = a.sendSecureEmailChange(tx, user, mailer, params.Email, referrer); terr != nil { - return internalServerError("Error sending change email").WithInternalError(terr) - } + if terr = a.sendEmailChange(tx, config, user, mailer, params.Email, referrer, config.Mailer.OtpLength); terr != nil { + return internalServerError("Error sending change email").WithInternalError(terr) + } + } + + if params.Phone != "" && params.Phone != user.GetPhone() { + params.Phone, err = a.validatePhone(params.Phone) + if err != nil { + return err + } + var exists bool + if exists, terr = models.IsDuplicatedPhone(tx, instanceID, params.Phone, user.Aud); terr != nil { + return internalServerError("Database error checking phone").WithInternalError(terr) + } else if exists { + return unprocessableEntityError(DuplicatePhoneMsg) + } + if config.Sms.Autoconfirm { + return user.UpdatePhone(tx, params.Phone) } else { - if terr = a.sendEmailChange(tx, user, mailer, params.Email, referrer); terr != nil { - return internalServerError("Error sending change email").WithInternalError(terr) + smsProvider, terr := sms_provider.GetSmsProvider(*config) + if terr != nil { + return badRequestError("Error sending sms: %v", terr) + } + if terr := a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneChangeVerification, smsProvider); terr != nil { + return internalServerError("Error sending phone change otp").WithInternalError(terr) } } } - if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserModifiedAction, nil); terr != nil { + if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserModifiedAction, "", nil); terr != nil { return internalServerError("Error recording audit log entry").WithInternalError(terr) } diff --git a/api/user_test.go b/api/user_test.go index baae1f35b..e31ad9268 100644 --- a/api/user_test.go +++ b/api/user_test.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "crypto/sha256" "encoding/json" "fmt" "net/http" @@ -42,62 +43,283 @@ func (ts *UserTestSuite) SetupTest() { models.TruncateAll(ts.API.db) // Create user - u, err := models.NewUser(ts.instanceID, "test@example.com", "password", ts.Config.JWT.Aud, nil) + u, err := models.NewUser(ts.instanceID, "123456789", "test@example.com", "password", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error creating test user model") require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user") } -func (ts *UserTestSuite) TestUser_UpdatePassword() { +func (ts *UserTestSuite) TestUserGet() { u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud) - require.NoError(ts.T(), err) + require.NoError(ts.T(), err, "Error finding user") + token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret) + require.NoError(ts.T(), err, "Error generating access token") - var cases = []struct { - desc string - update map[string]interface{} - expectedCode int - isAuthenticated bool + req := httptest.NewRequest(http.MethodGet, "http://localhost/user", nil) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) +} + +func (ts *UserTestSuite) TestUserUpdateEmail() { + cases := []struct { + desc string + userData map[string]string + isSecureEmailChangeEnabled bool + expectedCode int }{ { - "Valid password length", - map[string]interface{}{ - "password": "newpass", + "User doesn't have an existing email", + map[string]string{ + "email": "", + "phone": "", }, + false, http.StatusOK, + }, + { + "User doesn't have an existing email and double email confirmation required", + map[string]string{ + "email": "", + "phone": "234567890", + }, true, + http.StatusOK, }, { - "Invalid password length", - map[string]interface{}{ - "password": "", + "User has an existing email", + map[string]string{ + "email": "foo@example.com", + "phone": "", + }, + false, + http.StatusOK, + }, + { + "User has an existing email and double email confirmation required", + map[string]string{ + "email": "bar@example.com", + "phone": "", + }, + true, + http.StatusOK, + }, + } + + for _, c := range cases { + ts.Run(c.desc, func() { + u, err := models.NewUser(ts.instanceID, "", "", "", ts.Config.JWT.Aud, nil) + require.NoError(ts.T(), err, "Error creating test user model") + require.NoError(ts.T(), u.SetEmail(ts.API.db, c.userData["email"]), "Error setting user email") + require.NoError(ts.T(), u.SetPhone(ts.API.db, c.userData["phone"]), "Error setting user phone") + require.NoError(ts.T(), ts.API.db.Create(u), "Error saving test user") + + token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret) + require.NoError(ts.T(), err, "Error generating access token") + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "email": "new@example.com", + })) + req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + w := httptest.NewRecorder() + ts.Config.Mailer.SecureEmailChangeEnabled = c.isSecureEmailChangeEnabled + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), c.expectedCode, w.Code) + }) + } + +} +func (ts *UserTestSuite) TestUserUpdatePhoneAutoconfirmEnabled() { + u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + + existingUser, err := models.NewUser(ts.instanceID, "22222222", "", "", ts.Config.JWT.Aud, nil) + require.NoError(ts.T(), err) + require.NoError(ts.T(), ts.API.db.Create(existingUser)) + + cases := []struct { + desc string + userData map[string]string + expectedCode int + }{ + { + "New phone number is the same as current phone number", + map[string]string{ + "phone": "123456789", + }, + http.StatusOK, + }, + { + "New phone number exists already", + map[string]string{ + "phone": "22222222", }, http.StatusUnprocessableEntity, + }, + { + "New phone number is different from current phone number", + map[string]string{ + "phone": "234567890", + }, + http.StatusOK, + }, + } + + ts.Config.Sms.Autoconfirm = true + + for _, c := range cases { + ts.Run(c.desc, func() { + token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret) + require.NoError(ts.T(), err, "Error generating access token") + + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "phone": c.userData["phone"], + })) + req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), c.expectedCode, w.Code) + }) + } + +} + +func (ts *UserTestSuite) TestUserUpdatePassword() { + u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + + type expected struct { + code int + isAuthenticated bool + } + + var cases = []struct { + desc string + newPassword string + nonce string + requireReauthentication bool + expected expected + }{ + { + "Valid password length", + "newpassword", + "", + false, + expected{code: http.StatusOK, isAuthenticated: true}, + }, + { + "Invalid password length", + "", + "", false, + expected{code: http.StatusUnprocessableEntity, isAuthenticated: false}, + }, + { + "No reauthentication provided", + "newpassword123", + "", + true, + expected{code: http.StatusUnauthorized, isAuthenticated: false}, + }, + { + "Invalid nonce", + "newpassword123", + "123456", + true, + expected{code: http.StatusBadRequest, isAuthenticated: false}, }, } for _, c := range cases { ts.Run(c.desc, func() { + ts.Config.Security.UpdatePasswordRequireReauthentication = c.requireReauthentication var buffer bytes.Buffer - require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.update)) + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]string{"password": c.newPassword, "nonce": c.nonce})) req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) req.Header.Set("Content-Type", "application/json") - token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret) + key, err := models.FindMainAsymmetricKeyByUser(ts.API.db, u) + require.NoError(ts.T(), err, "error finding keys") + + token, err := generateAccessToken(u, key, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.GetSigningMethod(), ts.Config.JWT.GetSigningKey()) require.NoError(ts.T(), err) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) // Setup response recorder w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) - require.Equal(ts.T(), w.Code, c.expectedCode) + require.Equal(ts.T(), c.expected.code, w.Code) // Request body u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) - passwordUpdate, _ := c.update["password"].(string) - require.Equal(ts.T(), c.isAuthenticated, u.Authenticate(passwordUpdate)) + require.Equal(ts.T(), c.expected.isAuthenticated, u.Authenticate(c.newPassword)) }) } } + +func (ts *UserTestSuite) TestUserUpdatePasswordReauthentication() { + ts.Config.Security.UpdatePasswordRequireReauthentication = true + + // create a confirmed user + u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + now := time.Now() + u.EmailConfirmedAt = &now + require.NoError(ts.T(), ts.API.db.Update(u), "Error updating new test user") + + token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret) + require.NoError(ts.T(), err) + + // request for reauthentication nonce + req := httptest.NewRequest(http.MethodGet, "http://localhost/reauthenticate", nil) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), w.Code, http.StatusOK) + + u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + require.NotEmpty(ts.T(), u.ReauthenticationToken) + require.NotEmpty(ts.T(), u.ReauthenticationSentAt) + + // update reauthentication token to a known token + u.ReauthenticationToken = fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetEmail()+"123456"))) + require.NoError(ts.T(), ts.API.db.Update(u)) + + // update password with reauthentication token + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ + "password": "newpass", + "nonce": "123456", + })) + + req = httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) + req.Header.Set("Content-Type", "application/json") + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), w.Code, http.StatusOK) + + // Request body + u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + + require.True(ts.T(), u.Authenticate("newpass")) + require.Empty(ts.T(), u.ReauthenticationToken) + require.NotEmpty(ts.T(), u.ReauthenticationSentAt) +} diff --git a/api/verify.go b/api/verify.go index c6fa4cc40..f84b765ee 100644 --- a/api/verify.go +++ b/api/verify.go @@ -2,11 +2,14 @@ package api import ( "context" + "crypto/sha256" "encoding/json" "errors" + "fmt" "net/http" "net/url" "strconv" + "strings" "time" "github.com/netlify/gotrue/models" @@ -26,6 +29,7 @@ const ( magicLinkVerification = "magiclink" emailChangeVerification = "email_change" smsVerification = "sms" + phoneChangeVerification = "phone_change" ) const ( @@ -33,11 +37,19 @@ const ( singleConfirmation ) +const ( + // v1 uses crypto.SecureToken() + v1OtpLength = 22 + sum224HashLength = 28 +) + +// Only applicable when SECURE_EMAIL_CHANGE_ENABLED +const singleConfirmationAccepted = "Confirmation link accepted. Please proceed to confirm link sent to the other email" + // VerifyParams are the parameters the Verify endpoint accepts type VerifyParams struct { Type string `json:"type"` Token string `json:"token"` - Password string `json:"password"` Email string `json:"email"` Phone string `json:"phone"` RedirectTo string `json:"redirect_to"` @@ -45,31 +57,23 @@ type VerifyParams struct { // Verify exchanges a confirmation or recovery token to a refresh token func (a *API) Verify(w http.ResponseWriter, r *http.Request) error { - ctx := r.Context() - config := a.getConfig(ctx) - - params := &VerifyParams{} - switch r.Method { - // GET only supports signup type - case "GET": - params.Token = r.FormValue("token") - params.Password = "" - params.Type = r.FormValue("type") - params.RedirectTo = a.getRedirectURLOrReferrer(r, r.FormValue("redirect_to")) - case "POST": - jsonDecoder := json.NewDecoder(r.Body) - if err := jsonDecoder.Decode(params); err != nil { - return badRequestError("Could not read verification params: %v", err) - } - params.RedirectTo = a.getRedirectURLOrReferrer(r, params.RedirectTo) + case http.MethodGet: + return a.verifyGet(w, r) + case http.MethodPost: + return a.verifyPost(w, r) default: - unprocessableEntityError("Only GET and POST methods are supported.") + return unprocessableEntityError("Only GET and POST methods are supported.") } +} - if params.Token == "" { - return unprocessableEntityError("Verify requires a token") - } +func (a *API) verifyGet(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + config := a.getConfig(ctx) + params := &VerifyParams{} + params.Token = r.FormValue("token") + params.Type = r.FormValue("type") + params.RedirectTo = a.getRedirectURLOrReferrer(r, r.FormValue("redirect_to")) var ( user *models.User @@ -79,8 +83,18 @@ func (a *API) Verify(w http.ResponseWriter, r *http.Request) error { err = a.db.Transaction(func(tx *storage.Connection) error { var terr error + if params.Token == "" { + return badRequestError("Verify requires a token") + } + if len(params.Token) > v1OtpLength { + // token follows the v2 format and includes "-" + params.Token = strings.ReplaceAll(params.Token, "-", "") + } + if params.Type == "" { + return badRequestError("Verify requires a verification type") + } aud := a.requestAud(ctx, r) - user, terr = a.verifyUserAndToken(ctx, tx, params, aud) + user, terr = a.verifyEmailLink(ctx, tx, params, aud) if terr != nil { return terr } @@ -94,14 +108,12 @@ func (a *API) Verify(w http.ResponseWriter, r *http.Request) error { user, terr = a.emailChangeVerify(ctx, tx, params, user) if user == nil && terr == nil { // when double confirmation is required - rurl := a.prepRedirectURL("Confirmation link accepted. Please proceed to confirm link sent to the other email", params.RedirectTo) + rurl := a.prepRedirectURL(singleConfirmationAccepted, params.RedirectTo) http.Redirect(w, r, rurl, http.StatusSeeOther) return nil } - case smsVerification: - user, terr = a.smsVerify(ctx, tx, user) default: - return unprocessableEntityError("Verify requires a verification type") + return unprocessableEntityError("Unsupported verification type") } if terr != nil { @@ -120,41 +132,103 @@ func (a *API) Verify(w http.ResponseWriter, r *http.Request) error { }) if err != nil { - if r.Method == http.MethodPost { - // do not redirect POST requests - return err - } var herr *HTTPError if errors.As(err, &herr) { - if errors.Is(herr.InternalError, redirectWithQueryError) { - rurl := a.prepErrorRedirectURL(herr, r, params.RedirectTo) - http.Redirect(w, r, rurl, http.StatusSeeOther) - return nil - } + rurl := a.prepErrorRedirectURL(herr, r, params.RedirectTo) + http.Redirect(w, r, rurl, http.StatusSeeOther) + return nil } } - // GET requests should return to the app site after confirmation - switch r.Method { - case "GET": - rurl := params.RedirectTo - if token != nil { - q := url.Values{} - q.Set("access_token", token.Token) - q.Set("token_type", token.TokenType) - q.Set("expires_in", strconv.Itoa(token.ExpiresIn)) - q.Set("refresh_token", token.RefreshToken) - q.Set("type", params.Type) - rurl += "#" + q.Encode() - } - http.Redirect(w, r, rurl, http.StatusSeeOther) - case "POST": - return sendJSON(w, http.StatusOK, token) + rurl := params.RedirectTo + if token != nil { + q := url.Values{} + q.Set("access_token", token.Token) + q.Set("token_type", token.TokenType) + q.Set("expires_in", strconv.Itoa(token.ExpiresIn)) + q.Set("refresh_token", token.RefreshToken) + q.Set("type", params.Type) + rurl += "#" + q.Encode() } - + http.Redirect(w, r, rurl, http.StatusSeeOther) return nil } +func (a *API) verifyPost(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + config := a.getConfig(ctx) + params := &VerifyParams{} + jsonDecoder := json.NewDecoder(r.Body) + if err := jsonDecoder.Decode(params); err != nil { + return badRequestError("Could not read verification params: %v", err) + } + + if params.Token == "" { + return badRequestError("Verify requires a token") + } + if len(params.Token) > v1OtpLength { + // token follows the v2 format and includes "-" + params.Token = strings.ReplaceAll(params.Token, "-", "") + } + + if params.Type == "" { + return badRequestError("Verify requires a verification type") + } + + var ( + user *models.User + err error + token *AccessTokenResponse + ) + + err = a.db.Transaction(func(tx *storage.Connection) error { + var terr error + aud := a.requestAud(ctx, r) + user, terr = a.verifyUserAndToken(ctx, tx, params, aud) + if terr != nil { + return terr + } + + switch params.Type { + case signupVerification, inviteVerification: + user, terr = a.signupVerify(ctx, tx, user) + case recoveryVerification, magicLinkVerification: + user, terr = a.recoverVerify(ctx, tx, user) + case emailChangeVerification: + user, terr = a.emailChangeVerify(ctx, tx, params, user) + if user == nil && terr == nil { + return sendJSON(w, http.StatusOK, map[string]string{ + "msg": singleConfirmationAccepted, + "code": strconv.Itoa(http.StatusOK), + }) + } + case smsVerification, phoneChangeVerification: + user, terr = a.smsVerify(ctx, tx, user, params.Type) + default: + return unprocessableEntityError("Unsupported verification type") + } + + if terr != nil { + return terr + } + + token, terr = a.issueRefreshToken(ctx, tx, user) + if terr != nil { + return terr + } + + if terr = a.setCookieTokens(config, token, false, w); terr != nil { + return internalServerError("Failed to set JWT cookie. %s", terr) + } + return nil + }) + + if err != nil { + return err + } + return sendJSON(w, http.StatusOK, token) +} + func (a *API) signupVerify(ctx context.Context, conn *storage.Connection, user *models.User) (*models.User, error) { instanceID := getInstanceID(ctx) config := a.getConfig(ctx) @@ -175,7 +249,7 @@ func (a *API) signupVerify(ctx context.Context, conn *storage.Connection, user * } } - if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserSignedUpAction, nil); terr != nil { + if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserSignedUpAction, "", nil); terr != nil { return terr } @@ -204,7 +278,7 @@ func (a *API) recoverVerify(ctx context.Context, conn *storage.Connection, user return terr } if !user.IsConfirmed() { - if terr = models.NewAuditLogEntry(tx, instanceID, user, models.LoginAction, nil); terr != nil { + if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserSignedUpAction, "", nil); terr != nil { return terr } @@ -215,7 +289,7 @@ func (a *API) recoverVerify(ctx context.Context, conn *storage.Connection, user return terr } } else { - if terr = models.NewAuditLogEntry(tx, instanceID, user, models.LoginAction, nil); terr != nil { + if terr = models.NewAuditLogEntry(tx, instanceID, user, models.LoginAction, "", nil); terr != nil { return terr } if terr = triggerEventHooks(ctx, tx, LoginEvent, user, instanceID, config); terr != nil { @@ -231,13 +305,13 @@ func (a *API) recoverVerify(ctx context.Context, conn *storage.Connection, user return user, nil } -func (a *API) smsVerify(ctx context.Context, conn *storage.Connection, user *models.User) (*models.User, error) { +func (a *API) smsVerify(ctx context.Context, conn *storage.Connection, user *models.User, otpType string) (*models.User, error) { instanceID := getInstanceID(ctx) config := a.getConfig(ctx) err := conn.Transaction(func(tx *storage.Connection) error { var terr error - if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserSignedUpAction, nil); terr != nil { + if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserSignedUpAction, "", nil); terr != nil { return terr } @@ -245,8 +319,14 @@ func (a *API) smsVerify(ctx context.Context, conn *storage.Connection, user *mod return terr } - if terr = user.ConfirmPhone(tx); terr != nil { - return internalServerError("Error confirming user").WithInternalError(terr) + if otpType == smsVerification { + if terr = user.ConfirmPhone(tx); terr != nil { + return internalServerError("Error confirming user").WithInternalError(terr) + } + } else if otpType == phoneChangeVerification { + if terr = user.ConfirmPhoneChange(tx); terr != nil { + return internalServerError("Error confirming user").WithInternalError(terr) + } } return nil }) @@ -280,8 +360,8 @@ func (a *API) emailChangeVerify(ctx context.Context, conn *storage.Connection, p instanceID := getInstanceID(ctx) config := a.getConfig(ctx) - if config.Mailer.SecureEmailChangeEnabled && user.EmailChangeConfirmStatus == zeroConfirmation { - err := a.db.Transaction(func(tx *storage.Connection) error { + if config.Mailer.SecureEmailChangeEnabled && user.EmailChangeConfirmStatus == zeroConfirmation && user.GetEmail() != "" { + err := conn.Transaction(func(tx *storage.Connection) error { user.EmailChangeConfirmStatus = singleConfirmation if params.Token == user.EmailChangeTokenCurrent { user.EmailChangeTokenCurrent = "" @@ -300,10 +380,10 @@ func (a *API) emailChangeVerify(ctx context.Context, conn *storage.Connection, p } // one email is confirmed at this point - err := a.db.Transaction(func(tx *storage.Connection) error { + err := conn.Transaction(func(tx *storage.Connection) error { var terr error - if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserModifiedAction, nil); terr != nil { + if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserModifiedAction, "", nil); terr != nil { return terr } @@ -324,6 +404,50 @@ func (a *API) emailChangeVerify(ctx context.Context, conn *storage.Connection, p return user, nil } +func (a *API) verifyEmailLink(ctx context.Context, conn *storage.Connection, params *VerifyParams, aud string) (*models.User, error) { + config := getConfig(ctx) + + var user *models.User + var err error + + switch params.Type { + case signupVerification, inviteVerification: + user, err = models.FindUserByConfirmationToken(conn, params.Token) + case recoveryVerification, magicLinkVerification: + user, err = models.FindUserByRecoveryToken(conn, params.Token) + case emailChangeVerification: + user, err = models.FindUserByEmailChangeToken(conn, params.Token) + default: + return nil, badRequestError("Invalid email verification type") + } + + if err != nil { + if models.IsNotFoundError(err) { + return nil, expiredTokenError("Email link is invalid or has expired").WithInternalError(redirectWithQueryError) + } + return nil, internalServerError("Database error finding user from email link").WithInternalError(err) + } + + if user.IsBanned() { + return nil, unauthorizedError("Error confirming user").WithInternalError(redirectWithQueryError) + } + + var isExpired bool + switch params.Type { + case signupVerification, inviteVerification: + isExpired = isOtpExpired(user.ConfirmationSentAt, config.Mailer.OtpExp) + case recoveryVerification, magicLinkVerification: + isExpired = isOtpExpired(user.RecoverySentAt, config.Mailer.OtpExp) + case emailChangeVerification: + isExpired = isOtpExpired(user.EmailChangeSentAt, config.Mailer.OtpExp) + } + + if isExpired { + return nil, expiredTokenError("Email link is invalid or has expired").WithInternalError(redirectWithQueryError) + } + return user, nil +} + // verifyUserAndToken verifies the token associated to the user based on the verify type func (a *API) verifyUserAndToken(ctx context.Context, conn *storage.Connection, params *VerifyParams, aud string) (*models.User, error) { instanceID := getInstanceID(ctx) @@ -331,29 +455,34 @@ func (a *API) verifyUserAndToken(ctx context.Context, conn *storage.Connection, var user *models.User var err error - if isUrlVerification(params) { - switch params.Type { - case signupVerification, inviteVerification: - user, err = models.FindUserByConfirmationToken(conn, params.Token) - case recoveryVerification, magicLinkVerification: - user, err = models.FindUserByRecoveryToken(conn, params.Token) - case emailChangeVerification: - user, err = models.FindUserByEmailChangeToken(conn, params.Token) - } - } else if params.Type == smsVerification { - if params.Phone == "" { - return nil, unprocessableEntityError("Sms Verification requires a phone number") + var tokenHash string + if isPhoneOtpVerification(params) { + params.Phone, err = a.validatePhone(params.Phone) + if err != nil { + return nil, err } - params.Phone = a.formatPhoneNumber(params.Phone) - if ok := a.validateE164Format(params.Phone); !ok { - return nil, unprocessableEntityError("Invalid phone number format") + tokenHash = fmt.Sprintf("%x", sha256.Sum224([]byte(string(params.Phone)+params.Token))) + switch params.Type { + case phoneChangeVerification: + user, err = models.FindUserByPhoneChangeAndAudience(conn, instanceID, params.Phone, aud) + case smsVerification: + user, err = models.FindUserByPhoneAndAudience(conn, instanceID, params.Phone, aud) + default: + return nil, badRequestError("Invalid sms verification type") } - user, err = models.FindUserByPhoneAndAudience(conn, instanceID, params.Phone, aud) - } else if params.Email != "" { + } else if isEmailOtpVerification(params) { if err := a.validateEmail(ctx, params.Email); err != nil { return nil, unprocessableEntityError("Invalid email format").WithInternalError(err) } - user, err = models.FindUserByEmailAndAudience(conn, instanceID, params.Email, aud) + tokenHash = fmt.Sprintf("%x", sha256.Sum224([]byte(string(params.Email)+params.Token))) + switch params.Type { + case emailChangeVerification: + user, err = models.FindUserForEmailChange(conn, instanceID, params.Email, tokenHash, aud, config.Mailer.SecureEmailChangeEnabled) + default: + user, err = models.FindUserByEmailAndAudience(conn, instanceID, params.Email, aud) + } + } else { + return nil, badRequestError("Only an email address or phone number should be provided on verify") } if err != nil { @@ -368,23 +497,40 @@ func (a *API) verifyUserAndToken(ctx context.Context, conn *storage.Connection, } var isValid bool - mailerOtpExpiresAt := time.Second * time.Duration(config.Mailer.OtpExp) - smsOtpExpiresAt := time.Second * time.Duration(config.Sms.OtpExp) switch params.Type { case signupVerification, inviteVerification: - isValid = isOtpValid(params.Token, user.ConfirmationToken, user.ConfirmationSentAt.Add(mailerOtpExpiresAt)) + // TODO(km): remove when old token format is deprecated + // the new token format is represented by a MD5 hash which is 32 characters (128 bits) long + // anything shorter than 32 characters can safely be assumed to be using the old token format + if len(user.ConfirmationToken) < sum224HashLength { + tokenHash = params.Token + } + isValid = isOtpValid(tokenHash, user.ConfirmationToken, user.ConfirmationSentAt, config.Mailer.OtpExp) case recoveryVerification, magicLinkVerification: - isValid = isOtpValid(params.Token, user.RecoveryToken, user.RecoverySentAt.Add(mailerOtpExpiresAt)) + // TODO(km): remove when old token format is deprecated + if len(user.RecoveryToken) < sum224HashLength { + tokenHash = params.Token + } + isValid = isOtpValid(tokenHash, user.RecoveryToken, user.RecoverySentAt, config.Mailer.OtpExp) case emailChangeVerification: - expiresAt := user.EmailChangeSentAt.Add(mailerOtpExpiresAt) - isValid = isOtpValid(params.Token, user.EmailChangeTokenCurrent, expiresAt) || isOtpValid(params.Token, user.EmailChangeTokenNew, expiresAt) - if !isValid { - // reset email confirmation status - user.EmailChangeConfirmStatus = zeroConfirmation - err = conn.UpdateOnly(user, "email_change_confirm_status") + // TODO(km): remove when old token format is deprecated + if len(user.EmailChangeTokenCurrent) < sum224HashLength && len(user.EmailChangeTokenNew) < sum224HashLength { + tokenHash = params.Token + } + isValid = isOtpValid(tokenHash, user.EmailChangeTokenCurrent, user.EmailChangeSentAt, config.Mailer.OtpExp) || + isOtpValid(tokenHash, user.EmailChangeTokenNew, user.EmailChangeSentAt, config.Mailer.OtpExp) + case phoneChangeVerification: + // TODO(km): remove when old token format is deprecated + if len(user.PhoneChangeToken) < sum224HashLength { + tokenHash = params.Token } + isValid = isOtpValid(tokenHash, user.PhoneChangeToken, user.PhoneChangeSentAt, config.Sms.OtpExp) case smsVerification: - isValid = isOtpValid(params.Token, user.ConfirmationToken, user.ConfirmationSentAt.Add(smsOtpExpiresAt)) + // TODO(km): remove when old token format is deprecated + if len(user.ConfirmationToken) < sum224HashLength { + tokenHash = params.Token + } + isValid = isOtpValid(tokenHash, user.ConfirmationToken, user.ConfirmationSentAt, config.Sms.OtpExp) } if !isValid || err != nil { @@ -394,10 +540,23 @@ func (a *API) verifyUserAndToken(ctx context.Context, conn *storage.Connection, } // isOtpValid checks the actual otp sent against the expected otp and ensures that it's within the valid window -func isOtpValid(actual, expected string, expiresAt time.Time) bool { - return time.Now().Before(expiresAt) && (actual == expected) +func isOtpValid(actual, expected string, sentAt *time.Time, otpExp uint) bool { + if expected == "" || sentAt == nil { + return false + } + return !isOtpExpired(sentAt, otpExp) && (actual == expected) +} + +func isOtpExpired(sentAt *time.Time, otpExp uint) bool { + return time.Now().After(sentAt.Add(time.Second * time.Duration(otpExp))) +} + +// isPhoneOtpVerification checks if the verification came from a phone otp +func isPhoneOtpVerification(params *VerifyParams) bool { + return params.Phone != "" && params.Email == "" } -func isUrlVerification(params *VerifyParams) bool { - return params.Type != smsVerification && params.Email == "" +// isEmailOtpVerification checks if the verification came from an email otp +func isEmailOtpVerification(params *VerifyParams) bool { + return params.Phone == "" && params.Email != "" } diff --git a/api/verify_test.go b/api/verify_test.go index 65c4dc7fd..664e22710 100644 --- a/api/verify_test.go +++ b/api/verify_test.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "crypto/sha256" "encoding/json" "fmt" "io/ioutil" @@ -45,8 +46,7 @@ func (ts *VerifyTestSuite) SetupTest() { models.TruncateAll(ts.API.db) // Create user - u, err := models.NewUser(ts.instanceID, "test@example.com", "password", ts.Config.JWT.Aud, nil) - u.Phone = "12345678" + u, err := models.NewUser(ts.instanceID, "12345678", "test@example.com", "password", ts.Config.JWT.Aud, nil) require.NoError(ts.T(), err, "Error creating test user model") require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user") } @@ -78,19 +78,12 @@ func (ts *VerifyTestSuite) TestVerifyPasswordRecovery() { assert.WithinDuration(ts.T(), time.Now(), *u.RecoverySentAt, 1*time.Second) assert.False(ts.T(), u.IsConfirmed()) - // Send Verify request - var vbuffer bytes.Buffer - require.NoError(ts.T(), json.NewEncoder(&vbuffer).Encode(map[string]interface{}{ - "type": "recovery", - "token": u.RecoveryToken, - })) - - req = httptest.NewRequest(http.MethodPost, "http://localhost/verify", &vbuffer) - req.Header.Set("Content-Type", "application/json") + reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", recoveryVerification, u.RecoveryToken) + req = httptest.NewRequest(http.MethodGet, reqURL, nil) w = httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) - assert.Equal(ts.T(), http.StatusOK, w.Code) + assert.Equal(ts.T(), http.StatusSeeOther, w.Code) u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) @@ -113,8 +106,11 @@ func (ts *VerifyTestSuite) TestVerifySecureEmailChange() { req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) req.Header.Set("Content-Type", "application/json") + key, err := models.FindMainAsymmetricKeyByUser(ts.API.db, u) + require.NoError(ts.T(), err, "Error finding keys") + // Generate access token for request - token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret) + token, err := generateAccessToken(u, key, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.GetSigningMethod(), ts.Config.JWT.GetSigningKey()) require.NoError(ts.T(), err) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) @@ -130,40 +126,43 @@ func (ts *VerifyTestSuite) TestVerifySecureEmailChange() { assert.False(ts.T(), u.IsConfirmed()) // Verify new email - var vbuffer bytes.Buffer - require.NoError(ts.T(), json.NewEncoder(&vbuffer).Encode(map[string]interface{}{ - "type": "email_change", - "token": u.EmailChangeTokenNew, - })) - - req = httptest.NewRequest(http.MethodPost, "http://localhost/verify", &vbuffer) - req.Header.Set("Content-Type", "application/json") + reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", emailChangeVerification, u.EmailChangeTokenNew) + req = httptest.NewRequest(http.MethodGet, reqURL, nil) w = httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) - assert.Equal(ts.T(), http.StatusSeeOther, w.Code) + + require.Equal(ts.T(), http.StatusSeeOther, w.Code) + urlVal, err := url.Parse(w.Result().Header.Get("Location")) + ts.Require().NoError(err, "redirect url parse failed") + v, err := url.ParseQuery(urlVal.Fragment) + ts.Require().NoError(err) + ts.Require().NotEmpty(v.Get("message")) u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) assert.Equal(ts.T(), singleConfirmation, u.EmailChangeConfirmStatus) // Verify old email - require.NoError(ts.T(), json.NewEncoder(&vbuffer).Encode(map[string]interface{}{ - "type": "email_change", - "token": u.EmailChangeTokenCurrent, - })) - - req = httptest.NewRequest(http.MethodPost, "http://localhost/verify", &vbuffer) - req.Header.Set("Content-Type", "application/json") + reqURL = fmt.Sprintf("http://localhost/verify?type=%s&token=%s", emailChangeVerification, u.EmailChangeTokenCurrent) + req = httptest.NewRequest(http.MethodGet, reqURL, nil) w = httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) - assert.Equal(ts.T(), http.StatusOK, w.Code) + require.Equal(ts.T(), http.StatusSeeOther, w.Code) + + urlVal, err = url.Parse(w.Header().Get("Location")) + ts.Require().NoError(err, "redirect url parse failed") + v, err = url.ParseQuery(urlVal.Fragment) + ts.Require().NoError(err) + ts.Require().NotEmpty(v.Get("access_token")) + ts.Require().NotEmpty(v.Get("expires_in")) + ts.Require().NotEmpty(v.Get("refresh_token")) // user's email should've been updated to new@example.com u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "new@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) - assert.Equal(ts.T(), zeroConfirmation, u.EmailChangeConfirmStatus) + require.Equal(ts.T(), zeroConfirmation, u.EmailChangeConfirmStatus) } func (ts *VerifyTestSuite) TestExpiredConfirmationToken() { @@ -184,17 +183,26 @@ func (ts *VerifyTestSuite) TestExpiredConfirmationToken() { ts.API.handler.ServeHTTP(w, req) assert.Equal(ts.T(), http.StatusSeeOther, w.Code) - url, err := w.Result().Location() + rurl, err := url.Parse(w.Header().Get("Location")) + require.NoError(ts.T(), err, "redirect url parse failed") + + f, err := url.ParseQuery(rurl.Fragment) require.NoError(ts.T(), err) - assert.Equal(ts.T(), "error_code=410&error_description=Token+has+expired+or+is+invalid", url.Fragment) + fmt.Println(f) + assert.Equal(ts.T(), "401", f.Get("error_code")) + assert.Equal(ts.T(), "Email link is invalid or has expired", f.Get("error_description")) + assert.Equal(ts.T(), "unauthorized_client", f.Get("error")) } func (ts *VerifyTestSuite) TestInvalidOtp() { u, err := models.FindUserByPhoneAndAudience(ts.API.db, ts.instanceID, "12345678", ts.Config.JWT.Aud) require.NoError(ts.T(), err) - u.ConfirmationToken = "123456" sentTime := time.Now().Add(-48 * time.Hour) + u.ConfirmationToken = "123456" u.ConfirmationSentAt = &sentTime + u.PhoneChange = "22222222" + u.PhoneChangeToken = "123456" + u.PhoneChangeSentAt = &sentTime require.NoError(ts.T(), ts.API.db.Update(u)) type ResponseBody struct { @@ -203,7 +211,7 @@ func (ts *VerifyTestSuite) TestInvalidOtp() { } expectedResponse := ResponseBody{ - Code: http.StatusGone, + Code: http.StatusUnauthorized, Msg: "Token has expired or is invalid", } @@ -233,6 +241,16 @@ func (ts *VerifyTestSuite) TestInvalidOtp() { }, expected: expectedResponse, }, + { + desc: "Invalid Phone Change OTP", + sentTime: time.Now(), + body: map[string]interface{}{ + "type": phoneChangeVerification, + "token": "invalid_otp", + "phone": u.PhoneChange, + }, + expected: expectedResponse, + }, { desc: "Invalid Email OTP", sentTime: time.Now(), @@ -411,6 +429,69 @@ func (ts *VerifyTestSuite) TestVerifySignupWithredirectURLContainedPath() { requestredirectURL: "http://localhost:3000/docs", expectedredirectURL: "https://someapp-something.codemagic.app/#/", }, + { + desc: "same wildcard site url and redirect url in allow list", + siteURL: "http://sub.test.dev:3000/#/", + uriAllowList: []string{"http://*.test.dev:3000"}, + requestredirectURL: "http://sub.test.dev:3000/#/", + expectedredirectURL: "http://sub.test.dev:3000/#/", + }, + { + desc: "different wildcard site url and redirect url in allow list", + siteURL: "http://sub.test.dev/#/", + uriAllowList: []string{"http://*.other.dev:3000"}, + requestredirectURL: "http://sub.other.dev:3000", + expectedredirectURL: "http://sub.other.dev:3000", + }, + { + desc: "different wildcard site url and redirect url not in allow list", + siteURL: "http://test.dev:3000/#/", + uriAllowList: []string{"http://*.allowed.dev:3000"}, + requestredirectURL: "http://sub.test.dev:3000/#/", + expectedredirectURL: "http://test.dev:3000/#/", + }, + { + desc: "exact mobile deep link redirect url in allow list", + siteURL: "http://test.dev:3000/#/", + uriAllowList: []string{"twitter://timeline"}, + requestredirectURL: "twitter://timeline", + expectedredirectURL: "twitter://timeline", + }, + { + desc: "wildcard mobile deep link redirect url in allow list", + siteURL: "http://test.dev:3000/#/", + uriAllowList: []string{"com.mobile.*"}, + requestredirectURL: "com.mobile.app", + expectedredirectURL: "http://test.dev:3000/#/", + }, + { + desc: "redirect respects . separator", + siteURL: "http://localhost:3000", + uriAllowList: []string{"http://*.*.dev:3000"}, + requestredirectURL: "http://foo.bar.dev:3000", + expectedredirectURL: "http://foo.bar.dev:3000", + }, + { + desc: "redirect does not respect . separator", + siteURL: "http://localhost:3000", + uriAllowList: []string{"http://*.dev:3000"}, + requestredirectURL: "http://foo.bar.dev:3000", + expectedredirectURL: "http://localhost:3000", + }, + { + desc: "redirect respects / separator in url subdirectory", + siteURL: "http://localhost:3000", + uriAllowList: []string{"http://test.dev:3000/*/*"}, + requestredirectURL: "http://test.dev:3000/bar/foo", + expectedredirectURL: "http://test.dev:3000/bar/foo", + }, + { + desc: "redirect does not respect / separator in url subdirectory", + siteURL: "http://localhost:3000", + uriAllowList: []string{"http://test.dev:3000/*"}, + requestredirectURL: "http://test.dev:3000/bar/foo", + expectedredirectURL: "http://localhost:3000", + }, } for _, tC := range testCases { @@ -419,6 +500,7 @@ func (ts *VerifyTestSuite) TestVerifySignupWithredirectURLContainedPath() { ts.Config.SiteURL = tC.siteURL redirectURL := tC.requestredirectURL ts.Config.URIAllowList = tC.uriAllowList + ts.Config.ApplyDefaults() // set verify token to user as it actual do in magic link method u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud) @@ -478,14 +560,6 @@ func (ts *VerifyTestSuite) TestVerifyBannedUser() { Token: u.ConfirmationToken, }, }, - { - "Verify banned phone user on sms", - &VerifyParams{ - Type: "sms", - Token: u.ConfirmationToken, - Phone: u.GetPhone(), - }, - }, { "Verify banned user on recover", &VerifyParams{ @@ -514,16 +588,20 @@ func (ts *VerifyTestSuite) TestVerifyBannedUser() { var buffer bytes.Buffer require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.payload)) - req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer) + requestUrl := fmt.Sprintf("http://localhost/verify?type=%v&token=%v", c.payload.Type, c.payload.Token) + req := httptest.NewRequest(http.MethodGet, requestUrl, &buffer) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) - assert.Equal(ts.T(), http.StatusUnauthorized, w.Code) + assert.Equal(ts.T(), http.StatusSeeOther, w.Code) - b, err := ioutil.ReadAll(w.Body) + rurl, err := url.Parse(w.Header().Get("Location")) + require.NoError(ts.T(), err, "redirect url parse failed") + + f, err := url.ParseQuery(rurl.Fragment) require.NoError(ts.T(), err) - assert.Equal(ts.T(), "{\"code\":401,\"msg\":\"Error confirming user\"}", string(b)) + assert.Equal(ts.T(), "401", f.Get("error_code")) }) } } @@ -531,6 +609,10 @@ func (ts *VerifyTestSuite) TestVerifyBannedUser() { func (ts *VerifyTestSuite) TestVerifyValidOtp() { u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) + u.EmailChange = "new@example.com" + u.Phone = "12345678" + u.PhoneChange = "1234567890" + require.NoError(ts.T(), ts.API.db.Update(u)) type expected struct { code int @@ -550,9 +632,10 @@ func (ts *VerifyTestSuite) TestVerifyValidOtp() { desc: "Valid SMS OTP", sentTime: time.Now(), body: map[string]interface{}{ - "type": smsVerification, - "token": "123456", - "phone": "12345678", + "type": smsVerification, + "tokenHash": fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetPhone()+"123456"))), + "token": "123456", + "phone": u.GetPhone(), }, expected: expectedResponse, }, @@ -560,9 +643,10 @@ func (ts *VerifyTestSuite) TestVerifyValidOtp() { desc: "Valid Confirmation OTP", sentTime: time.Now(), body: map[string]interface{}{ - "type": signupVerification, - "token": "123456", - "email": u.GetEmail(), + "type": signupVerification, + "tokenHash": fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetEmail()+"123456"))), + "token": "123456", + "email": u.GetEmail(), }, expected: expectedResponse, }, @@ -570,9 +654,10 @@ func (ts *VerifyTestSuite) TestVerifyValidOtp() { desc: "Valid Recovery OTP", sentTime: time.Now(), body: map[string]interface{}{ - "type": recoveryVerification, - "token": "123456", - "email": u.GetEmail(), + "type": recoveryVerification, + "tokenHash": fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetEmail()+"123456"))), + "token": "123456", + "email": u.GetEmail(), }, expected: expectedResponse, }, @@ -580,13 +665,23 @@ func (ts *VerifyTestSuite) TestVerifyValidOtp() { desc: "Valid Email Change OTP", sentTime: time.Now(), body: map[string]interface{}{ - "type": emailChangeVerification, - "token": "123456", - "email": u.GetEmail(), + "type": emailChangeVerification, + "tokenHash": fmt.Sprintf("%x", sha256.Sum224([]byte(u.EmailChange+"123456"))), + "token": "123456", + "email": u.EmailChange, }, - expected: expected{ - code: http.StatusSeeOther, + expected: expectedResponse, + }, + { + desc: "Valid Phone Change OTP", + sentTime: time.Now(), + body: map[string]interface{}{ + "type": phoneChangeVerification, + "tokenHash": fmt.Sprintf("%x", sha256.Sum224([]byte(u.PhoneChange+"123456"))), + "token": "123456", + "phone": u.PhoneChange, }, + expected: expectedResponse, }, } @@ -596,9 +691,11 @@ func (ts *VerifyTestSuite) TestVerifyValidOtp() { u.ConfirmationSentAt = &c.sentTime u.RecoverySentAt = &c.sentTime u.EmailChangeSentAt = &c.sentTime - u.ConfirmationToken, _ = c.body["token"].(string) - u.RecoveryToken, _ = c.body["token"].(string) - u.EmailChangeTokenCurrent, _ = c.body["token"].(string) + u.PhoneChangeSentAt = &c.sentTime + u.ConfirmationToken = c.body["tokenHash"].(string) + u.RecoveryToken = c.body["tokenHash"].(string) + u.EmailChangeTokenNew = c.body["tokenHash"].(string) + u.PhoneChangeToken = c.body["tokenHash"].(string) require.NoError(ts.T(), ts.API.db.Update(u)) var buffer bytes.Buffer diff --git a/cmd/admin_cmd.go b/cmd/admin_cmd.go index 24a2f3066..d14d22cc2 100644 --- a/cmd/admin_cmd.go +++ b/cmd/admin_cmd.go @@ -1,10 +1,10 @@ package cmd import ( + "github.com/gofrs/uuid" "github.com/netlify/gotrue/conf" "github.com/netlify/gotrue/models" "github.com/netlify/gotrue/storage" - "github.com/gofrs/uuid" "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -83,7 +83,7 @@ func adminCreateUser(globalConfig *conf.GlobalConfiguration, config *conf.Config logrus.Fatalf("Error checking user email: %+v", err) } - user, err := models.NewUser(iid, args[0], args[1], aud, nil) + user, err := models.NewUser(iid, "", args[0], args[1], aud, nil) if err != nil { logrus.Fatalf("Error creating new user: %+v", err) } diff --git a/cmd/migrate_cmd.go b/cmd/migrate_cmd.go index e0a5b90d5..3b355c9f2 100644 --- a/cmd/migrate_cmd.go +++ b/cmd/migrate_cmd.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "net/url" "os" @@ -53,9 +54,16 @@ func migrate(cmd *cobra.Command, args []string) { } } + u, err := url.Parse(globalConfig.DB.URL) + processedUrl := globalConfig.DB.URL + if len(u.Query()) != 0 { + processedUrl = fmt.Sprintf("%s&application_name=gotrue_migrations", processedUrl) + } else { + processedUrl = fmt.Sprintf("%s?application_name=gotrue_migrations", processedUrl) + } deets := &pop.ConnectionDetails{ Dialect: globalConfig.DB.Driver, - URL: globalConfig.DB.URL, + URL: processedUrl, } deets.Options = map[string]string{ "migration_table_name": "schema_migrations", diff --git a/cmd/serve_cmd.go b/cmd/serve_cmd.go index 4b5cbc0d1..c76f8909e 100644 --- a/cmd/serve_cmd.go +++ b/cmd/serve_cmd.go @@ -4,10 +4,10 @@ import ( "context" "fmt" + "github.com/gofrs/uuid" "github.com/netlify/gotrue/api" "github.com/netlify/gotrue/conf" "github.com/netlify/gotrue/storage" - "github.com/gofrs/uuid" "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) diff --git a/conf/configuration.go b/conf/configuration.go index 9b00b4c7b..96df370ee 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -1,12 +1,16 @@ package conf import ( + "crypto/rsa" "database/sql/driver" + "encoding/base64" "encoding/json" "errors" "os" "time" + "github.com/gobwas/glob" + jwt "github.com/golang-jwt/jwt" "github.com/joho/godotenv" "github.com/kelseyhightower/envconfig" ) @@ -19,7 +23,7 @@ type OAuthProviderConfiguration struct { Secret string `json:"secret"` RedirectURI string `json:"redirect_uri" split_words:"true"` URL string `json:"url"` - ApiURL string `json:"api_url"` + ApiURL string `json:"api_url" split_words:"true"` Enabled bool `json:"enabled"` } @@ -38,19 +42,24 @@ type SamlProviderConfiguration struct { // DBConfiguration holds all the database related configuration. type DBConfiguration struct { - Driver string `json:"driver" required:"true"` - URL string `json:"url" envconfig:"DATABASE_URL" required:"true"` + Driver string `json:"driver" required:"true"` + URL string `json:"url" envconfig:"DATABASE_URL" required:"true"` + + // MaxPoolSize defaults to 0 (unlimited). + MaxPoolSize int `json:"max_pool_size" split_words:"true"` MigrationsPath string `json:"migrations_path" split_words:"true" default:"./migrations"` } // JWTConfiguration holds all the JWT related configuration. type JWTConfiguration struct { + Algorithm string `json:"algorithm" default:"HS256"` Secret string `json:"secret" required:"true"` Exp int `json:"exp"` Aud string `json:"aud"` AdminGroupName string `json:"admin_group_name" split_words:"true"` AdminRoles []string `json:"admin_roles" split_words:"true"` DefaultGroupName string `json:"default_group_name" split_words:"true"` + pKey *rsa.PrivateKey } // GlobalConfiguration holds all the configuration that applies to all instances. @@ -62,24 +71,27 @@ type GlobalConfiguration struct { RequestIDHeader string `envconfig:"REQUEST_ID_HEADER"` ExternalURL string `json:"external_url" envconfig:"API_EXTERNAL_URL"` } - DB DBConfiguration - External ProviderConfiguration - Logging LoggingConfig `envconfig:"LOG"` - OperatorToken string `split_words:"true" required:"false"` - MultiInstanceMode bool - Tracing TracingConfig - SMTP SMTPConfiguration - RateLimitHeader string `split_words:"true"` - RateLimitEmailSent float64 `split_words:"true" default:"30"` + DB DBConfiguration + External ProviderConfiguration + Logging LoggingConfig `envconfig:"LOG"` + OperatorToken string `split_words:"true" required:"false"` + MultiInstanceMode bool + Tracing TracingConfig + SMTP SMTPConfiguration + RateLimitHeader string `split_words:"true"` + RateLimitEmailSent float64 `split_words:"true" default:"30"` + RateLimitVerify float64 `split_words:"true" default:"30"` + RateLimitTokenRefresh float64 `split_words:"true" default:"30"` } // EmailContentConfiguration holds the configuration for emails, both subjects and template URLs. type EmailContentConfiguration struct { - Invite string `json:"invite"` - Confirmation string `json:"confirmation"` - Recovery string `json:"recovery"` - EmailChange string `json:"email_change" split_words:"true"` - MagicLink string `json:"magic_link" split_words:"true"` + Invite string `json:"invite"` + Confirmation string `json:"confirmation"` + Recovery string `json:"recovery"` + EmailChange string `json:"email_change" split_words:"true"` + MagicLink string `json:"magic_link" split_words:"true"` + Reauthentication string `json:"reauthentication"` } type ProviderConfiguration struct { @@ -92,11 +104,13 @@ type ProviderConfiguration struct { Gitlab OAuthProviderConfiguration `json:"gitlab"` Google OAuthProviderConfiguration `json:"google"` Notion OAuthProviderConfiguration `json:"notion"` + Keycloak OAuthProviderConfiguration `json:"keycloak"` Linkedin OAuthProviderConfiguration `json:"linkedin"` Spotify OAuthProviderConfiguration `json:"spotify"` Slack OAuthProviderConfiguration `json:"slack"` Twitter OAuthProviderConfiguration `json:"twitter"` Twitch OAuthProviderConfiguration `json:"twitch"` + WorkOS OAuthProviderConfiguration `json:"workos"` Email EmailProviderConfiguration `json:"email"` Phone PhoneProviderConfiguration `json:"phone"` Saml SamlProviderConfiguration `json:"saml"` @@ -122,6 +136,7 @@ type MailerConfiguration struct { URLPaths EmailContentConfiguration `json:"url_paths"` SecureEmailChangeEnabled bool `json:"secure_email_change_enabled" split_words:"true" default:"true"` OtpExp uint `json:"otp_exp" split_words:"true"` + OtpLength int `json:"otp_length" split_words:"true"` } type PhoneProviderConfiguration struct { @@ -170,24 +185,28 @@ type CaptchaConfiguration struct { } type SecurityConfiguration struct { - Captcha CaptchaConfiguration `json:"captcha"` - RefreshTokenRotationEnabled bool `json:"refresh_token_rotation_enabled" split_words:"true" default:"true"` + Captcha CaptchaConfiguration `json:"captcha"` + RefreshTokenRotationEnabled bool `json:"refresh_token_rotation_enabled" split_words:"true" default:"true"` + RefreshTokenReuseInterval int `json:"refresh_token_reuse_interval" split_words:"true"` + UpdatePasswordRequireReauthentication bool `json:"update_password_require_reauthentication" split_words:"true"` } // Configuration holds all the per-instance configuration. type Configuration struct { - SiteURL string `json:"site_url" split_words:"true" required:"true"` - URIAllowList []string `json:"uri_allow_list" split_words:"true"` - PasswordMinLength int `json:"password_min_length" split_words:"true"` - JWT JWTConfiguration `json:"jwt"` - SMTP SMTPConfiguration `json:"smtp"` - Mailer MailerConfiguration `json:"mailer"` - External ProviderConfiguration `json:"external"` - Sms SmsProviderConfiguration `json:"sms"` - DisableSignup bool `json:"disable_signup" split_words:"true"` - Webhook WebhookConfig `json:"webhook" split_words:"true"` - Security SecurityConfiguration `json:"security"` - Cookie struct { + SiteURL string `json:"site_url" split_words:"true" required:"true"` + URIAllowList []string `json:"uri_allow_list" split_words:"true"` + URIAllowListMap map[string]glob.Glob + PasswordMinLength int `json:"password_min_length" split_words:"true"` + JWT JWTConfiguration `json:"jwt"` + SMTP SMTPConfiguration `json:"smtp"` + Mailer MailerConfiguration `json:"mailer"` + External ProviderConfiguration `json:"external"` + Sms SmsProviderConfiguration `json:"sms"` + DisableSignup bool `json:"disable_signup" split_words:"true"` + FirstUserSuperAdmin bool `json:"first_user_super_admin" split_words:"true"` + Webhook WebhookConfig `json:"webhook" split_words:"true"` + Security SecurityConfiguration `json:"security"` + Cookie struct { Key string `json:"key"` Domain string `json:"domain"` Duration int `json:"duration"` @@ -197,7 +216,7 @@ type Configuration struct { func loadEnvironment(filename string) error { var err error if filename != "" { - err = godotenv.Load(filename) + err = godotenv.Overload(filename) } else { err = godotenv.Load() // handle if .env file does not exist, this is OK @@ -296,6 +315,11 @@ func (config *Configuration) ApplyDefaults() { config.Mailer.OtpExp = 86400 // 1 day } + if config.Mailer.OtpLength == 0 || config.Mailer.OtpLength < 6 || config.Mailer.OtpLength > 10 { + // 6-digit otp by default + config.Mailer.OtpLength = 6 + } + if config.SMTP.MaxFrequency == 0 { config.SMTP.MaxFrequency = 1 * time.Minute } @@ -332,10 +356,18 @@ func (config *Configuration) ApplyDefaults() { if config.URIAllowList == nil { config.URIAllowList = []string{} } - + if config.URIAllowList != nil { + config.URIAllowListMap = make(map[string]glob.Glob) + for _, uri := range config.URIAllowList { + g := glob.MustCompile(uri, '.', '/') + config.URIAllowListMap[uri] = g + } + } if config.PasswordMinLength < defaultMinPasswordLength { config.PasswordMinLength = defaultMinPasswordLength } + + config.JWT.InitializeSigningSecret() } func (config *Configuration) Value() (driver.Value, error) { @@ -424,3 +456,44 @@ func (t *VonageProviderConfiguration) Validate() error { } return nil } + +func (j *JWTConfiguration) InitializeSigningSecret() { + if j.Algorithm == "RS256" { + pemPrivateKey, err := base64.URLEncoding.DecodeString(j.Secret) + if err != nil { + panic(err) + } + + key, err := jwt.ParseRSAPrivateKeyFromPEM(pemPrivateKey) + if err != nil { + panic(err) + } + + j.pKey = key + } +} + +func (j *JWTConfiguration) GetSigningKey() interface{} { + if j.Algorithm == "RS256" { + return j.pKey + } + + return []byte(j.Secret) +} + +func (j *JWTConfiguration) GetVerificationKey() interface{} { + if j.Algorithm == "RS256" { + return j.pKey.Public() + } + + return []byte(j.Secret) +} + +func (j *JWTConfiguration) GetSigningMethod() jwt.SigningMethod { + switch j.Algorithm { + case "RS256": + return jwt.SigningMethodRS256 + default: + return jwt.SigningMethodHS256 + } +} diff --git a/crypto/crypto.go b/crypto/crypto.go index 3c788f95c..a6af6d8c5 100644 --- a/crypto/crypto.go +++ b/crypto/crypto.go @@ -8,7 +8,6 @@ import ( "math" "math/big" "strconv" - "strings" "github.com/pkg/errors" ) @@ -19,11 +18,7 @@ func SecureToken() string { if _, err := io.ReadFull(rand.Reader, b); err != nil { panic(err.Error()) // rand should never fail } - return removePadding(base64.URLEncoding.EncodeToString(b)) -} - -func removePadding(token string) string { - return strings.TrimRight(token, "=") + return base64.RawURLEncoding.EncodeToString(b) } // GenerateOtp generates a random n digit otp @@ -33,7 +28,27 @@ func GenerateOtp(digits int) (string, error) { if err != nil { return "", errors.WithMessage(err, "Error generating otp") } + // adds a variable zero-padding to the left to ensure otp is uniformly random expr := "%0" + strconv.Itoa(digits) + "v" otp := fmt.Sprintf(expr, val.String()) return otp, nil } + +// GenerateOtpFromCharset generates a random n-length otp from a charset +func GenerateOtpFromCharset(length int, charset string) (string, error) { + b := make([]byte, length) + for i := range b { + val, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return "", errors.WithMessage(err, "Error generating otp from charset") + } + b[i] = charset[val.Int64()] + } + return string(b), nil +} + +// GenerateEmailOtp generates a random n-length alphanumeric otp +func GenerateEmailOtp(length int) (string, error) { + const charset = "abcdefghijklmnopqrstuvwxyz" + return GenerateOtpFromCharset(length, charset) +} diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml new file mode 100644 index 000000000..831d8eb63 --- /dev/null +++ b/docker-compose-dev.yml @@ -0,0 +1,31 @@ +version: "3.9" +services: + gotrue: + container_name: gotrue + depends_on: + - postgres + build: + context: ./ + dockerfile: Dockerfile.dev + ports: + - '9999:9999' + environment: + - GOTRUE_DB_MIGRATIONS_PATH=/go/src/github.com/netlify/gotrue/migrations + volumes: + - ./:/go/src/github.com/netlify/gotrue + command: CompileDaemon --build="make build" --directory=/go/src/github.com/netlify/gotrue --recursive=true -pattern="(.+\.go|.+\.env)" -exclude=gotrue -exclude=gotrue-arm64 -exclude=.env --command="/go/src/github.com/netlify/gotrue/gotrue -c=.env.docker" + postgres: + image: postgres:13 + container_name: postgres + ports: + - '5432:5432' + volumes: + - postgres_data:/var/lib/postgresql/data + - ${PWD}/hack/init_postgres.sql:/docker-entrypoint-initdb.d/init.sql + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=root + - POSTGRES_DB=postgres + +volumes: + postgres_data: diff --git a/example.docker.env b/example.docker.env new file mode 100644 index 000000000..89eefe062 --- /dev/null +++ b/example.docker.env @@ -0,0 +1,7 @@ +GOTRUE_SITE_URL="http://localhost:3000" +GOTRUE_JWT_SECRET="" +GOTRUE_DB_MIGRATIONS_PATH=/go/src/github.com/netlify/gotrue/migrations +GOTRUE_DB_DRIVER=postgres +DATABASE_URL=postgres://supabase_auth_admin:root@postgres:5432/postgres +GOTRUE_API_HOST=0.0.0.0 +PORT=9999 diff --git a/example.env b/example.env index 44cc6899a..f4147f276 100644 --- a/example.env +++ b/example.env @@ -50,6 +50,7 @@ GOTRUE_SITE_URL="http://localhost:3000" GOTRUE_EXTERNAL_EMAIL_ENABLED="true" GOTRUE_EXTERNAL_PHONE_ENABLED="true" GOTRUE_EXTERNAL_IOS_BUNDLE_ID="com.supabase.gotrue" +GOTRUE_FIRST_USER_SUPER_ADMIN="true" # Whitelist redirect to URLs here GOTRUE_URI_ALLOW_LIST=["http://localhost:3000"] @@ -126,6 +127,13 @@ GOTRUE_EXTERNAL_SPOTIFY_CLIENT_ID="" GOTRUE_EXTERNAL_SPOTIFY_SECRET="" GOTRUE_EXTERNAL_SPOTIFY_REDIRECT_URI="http://localhost:9999/callback" +# Keycloak OAuth config +GOTRUE_EXTERNAL_KEYCLOAK_ENABLED="false" +GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID="" +GOTRUE_EXTERNAL_KEYCLOAK_SECRET="" +GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI="http://localhost:9999/callback" +GOTRUE_EXTERNAL_KEYCLOAK_URL="https://keycloak.example.com/auth/realms/myrealm" + # Linkedin OAuth config GOTRUE_EXTERNAL_LINKEDIN_ENABLED="true" GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID="" @@ -135,13 +143,19 @@ GOTRUE_EXTERNAL_LINKEDIN_SECRET="" GOTRUE_EXTERNAL_SLACK_ENABLED="false" GOTRUE_EXTERNAL_SLACK_CLIENT_ID="" GOTRUE_EXTERNAL_SLACK_SECRET="" -GOTRUE_EXTERNAL_SLACK_REDIRECT_URI="https://localhost:9999/callback" +GOTRUE_EXTERNAL_SLACK_REDIRECT_URI="http://localhost:9999/callback" + +# WorkOS OAuth config +GOTRUE_EXTERNAL_WORKOS_ENABLED="true" +GOTRUE_EXTERNAL_WORKOS_CLIENT_ID="" +GOTRUE_EXTERNAL_WORKOS_SECRET="" +GOTRUE_EXTERNAL_WORKOS_REDIRECT_URI="http://localhost:9999/callback" # Zoom OAuth config GOTRUE_EXTERNAL_ZOOM_ENABLED="false" GOTRUE_EXTERNAL_ZOOM_CLIENT_ID="" GOTRUE_EXTERNAL_ZOOM_SECRET="" -GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI="https://localhost:9999/callback" +GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI="http://localhost:9999/callback" # Phone provider config GOTRUE_SMS_AUTOCONFIRM="false" @@ -165,6 +179,7 @@ GOTRUE_SMS_VONAGE_FROM="" GOTRUE_SECURITY_CAPTCHA_ENABLED="false" GOTRUE_SECURITY_CAPTCHA_PROVIDER="hcaptcha" GOTRUE_SECURITY_CAPTCHA_SECRET="0x0000000000000000000000000000000000000000" +GOTRUE_SECURITY_CAPTCHA_TIMEOUT="10s" GOTRUE_SESSION_KEY="" # SAML config @@ -177,7 +192,9 @@ GOTRUE_EXTERNAL_SAML_SIGNING_KEY="" # Additional Security config GOTRUE_LOG_LEVEL="debug" -GOTRUE_REFRESH_TOKEN_ROTATION_ENABLED="false" +GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED="false" +GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL="0" +GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION="false" GOTRUE_OPERATOR_TOKEN="unused-operator-token" GOTRUE_RATE_LIMIT_HEADER="X-Forwarded-For" GOTRUE_RATE_LIMIT_EMAIL_SENT="100" @@ -191,4 +208,4 @@ GOTRUE_WEBHOOK_EVENTS=validate,signup,login # Cookie config GOTRUE_COOKIE_KEY: "sb" -GOTRUE_COOKIE_DOMAIN: "localhost" \ No newline at end of file +GOTRUE_COOKIE_DOMAIN: "localhost" diff --git a/go.mod b/go.mod index 1563512c6..689c0e77c 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/beevik/etree v1.1.0 github.com/coreos/go-oidc/v3 v3.0.0 github.com/didip/tollbooth/v5 v5.1.1 + github.com/ethereum/go-ethereum v1.10.9 github.com/fatih/color v1.10.0 // indirect github.com/go-chi/chi v4.0.2+incompatible github.com/go-sql-driver/mysql v1.5.0 @@ -19,11 +20,14 @@ require ( github.com/gobuffalo/plush/v4 v4.1.0 // indirect github.com/gobuffalo/pop/v5 v5.3.3 github.com/gobuffalo/validate/v3 v3.3.0 // indirect + github.com/gobwas/glob v0.2.3 github.com/gofrs/uuid v4.0.0+incompatible github.com/golang-jwt/jwt v3.2.1+incompatible github.com/gorilla/securecookie v1.1.1 github.com/gorilla/sessions v1.1.1 github.com/imdario/mergo v0.0.0-20160216103600-3e95a51e0639 + github.com/jackc/pgconn v1.8.0 + github.com/jackc/pgerrcode v0.0.0-20201024163028-a0d42d470451 github.com/jackc/pgproto3/v2 v2.0.7 // indirect github.com/jmoiron/sqlx v1.3.1 // indirect github.com/joho/godotenv v1.3.0 @@ -31,27 +35,27 @@ require ( github.com/lestrrat-go/jwx v0.9.0 github.com/lib/pq v1.9.0 // indirect github.com/microcosm-cc/bluemonday v1.0.16 // indirect + github.com/mitchellh/mapstructure v1.4.1 github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 github.com/netlify/mailme v1.1.1 github.com/opentracing/opentracing-go v1.1.0 github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pkg/errors v0.9.1 - github.com/rs/cors v1.6.0 + github.com/rs/cors v1.7.0 github.com/russellhaering/gosaml2 v0.6.1-0.20210916051624-757d23f1bc28 github.com/russellhaering/goxmldsig v1.1.1 github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35 github.com/sethvargo/go-password v0.2.0 github.com/sirupsen/logrus v1.7.0 github.com/spf13/cobra v1.1.3 - github.com/stretchr/testify v1.6.1 - golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad + github.com/stretchr/testify v1.7.0 + golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba // indirect golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 - golang.org/x/sync v0.0.0-20201207232520-09787c993a3a // indirect - golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect gopkg.in/DataDog/dd-trace-go.v1 v1.12.1 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 // indirect + gopkg.in/yaml.v3 v3.0.0 // indirect ) go 1.13 diff --git a/go.sum b/go.sum index 6ca10922b..2433eb759 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,13 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.43.0/go.mod h1:BOSR3VbTLkk6FDC/TcffxP4NF/FFBGA5ku+jvKOP7pg= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.51.0/go.mod h1:hWtGJ6gnXH+KgDv+V0zFGDvpi07n3z8ZNj3T1RW0Gcw= cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= @@ -21,6 +23,7 @@ cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvf cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/bigtable v1.2.0/go.mod h1:JcVAOl45lrTmQfLj7T6TxyMzIN/3FGGcFm+2xVAli2o= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= @@ -33,9 +36,24 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +collectd.org v0.3.0/go.mod h1:A/8DzQBkF6abtvrT2j/AU/4tiBgJWYyh0y/oB/4MlWE= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/Azure/azure-pipeline-go v0.2.1/go.mod h1:UGSo8XybXnIGZ3epmeBw7Jdz+HiUVpqIlpz/HKHylF4= +github.com/Azure/azure-pipeline-go v0.2.2/go.mod h1:4rQ/NZncSvGqNkkOsNpOU1tgoNuIlp9AfUH5G1tvCHc= +github.com/Azure/azure-storage-blob-go v0.7.0/go.mod h1:f9YQKtsG1nMisotuTPpO0tjNuEjKRYAcJU8/ydDI++4= +github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= +github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0= +github.com/Azure/go-autorest/autorest/adal v0.8.0/go.mod h1:Z6vX6WXXuyieHAXwMj0S6HY6e6wcHn37qQMBQlvY3lc= +github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA= +github.com/Azure/go-autorest/autorest/date v0.2.0/go.mod h1:vcORJHLJEh643/Ioh9+vPmf1Ij9AEBM5FuBIXLmIy0g= +github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/mocks v0.3.0/go.mod h1:a8FDP3DYzQ4RYfVAxAN3SVSiiO77gL2j2ronKKP0syM= +github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc= +github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/DataDog/datadog-go v2.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/GoogleCloudPlatform/cloudsql-proxy v0.0.0-20170623214735-571947b0f240 h1:bCOIpv1VinSRhS5ezZeCEGG82gib2WtXfiJOHmMSuls= github.com/GoogleCloudPlatform/cloudsql-proxy v0.0.0-20170623214735-571947b0f240/go.mod h1:aJ4qN3TfrelA6NZ6AXsXRfmEVaYin3EDbSPJrKS8OXo= @@ -43,13 +61,29 @@ github.com/Masterminds/semver/v3 v3.0.3/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0 github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= +github.com/VictoriaMetrics/fastcache v1.6.0/go.mod h1:0qHz5QP0GMX4pfmMA/zt5RgfNuXJrTP0zS7DqpHGGTw= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/apache/arrow/go/arrow v0.0.0-20191024131854-af6fa24be0db/go.mod h1:VTxUBvSJ3s3eHAg65PNgrsn5BtqCRPdmyXh6rAfdxN0= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878/go.mod h1:3AMJUQhVx52RsWOnlkpikZr01T/yAVN2gn0861vByNg= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aws/aws-sdk-go-v2 v1.2.0/go.mod h1:zEQs02YRBw1DjK0PoJv3ygDYOFTre1ejlJWl8FwAuQo= +github.com/aws/aws-sdk-go-v2/config v1.1.1/go.mod h1:0XsVy9lBI/BCXm+2Tuvt39YmdHwS5unDQmxZOYe8F5Y= +github.com/aws/aws-sdk-go-v2/credentials v1.1.1/go.mod h1:mM2iIjwl7LULWtS6JCACyInboHirisUUdkBPoTHMOUo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.0.2/go.mod h1:3hGg3PpiEjHnrkrlasTfxFqUsZ2GCk/fMUn4CbKgSkM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.0.2/go.mod h1:45MfaXZ0cNbeuT0KQ1XJylq8A6+OpVV2E5kvY/Kq+u8= +github.com/aws/aws-sdk-go-v2/service/route53 v1.1.1/go.mod h1:rLiOUrPLW/Er5kRcQ7NkwbjlijluLsrIbu/iyl35RO4= +github.com/aws/aws-sdk-go-v2/service/sso v1.1.1/go.mod h1:SuZJxklHxLAXgLTc1iFXbEWkXs7QRTQpCLGaKIprQW0= +github.com/aws/aws-sdk-go-v2/service/sts v1.1.1/go.mod h1:Wi0EBZwiz/K44YliU0EKxqTCJGUfYTWXrrBwkq736bM= +github.com/aws/smithy-go v1.1.0/go.mod h1:EzMw8dbp/YJL4A5/sbhGddag+NPT7q084agLbB9LgIw= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/badoux/checkmail v0.0.0-20170203135005-d0a759655d62 h1:vMqcPzLT1/mbYew0gM6EJy4/sCNy9lY9rmlFO+pPwhY= @@ -62,19 +96,36 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40/go.mod h1:8rLXio+WjiTceGBHIoTvn60HIbs7Hm7bcHjyrSqYB9c= +github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= +github.com/btcsuite/btcd v0.20.1-beta h1:Ik4hyJqN8Jfyv3S4AGBOmyouMsYE3EdYODkMbQjwPGw= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/bugsnag/bugsnag-go v1.5.3/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/c-bata/go-prompt v0.2.2/go.mod h1:VzqtzE2ksDBcdln8G7mk2RX9QyGjH+OVqOCSiVIqS34= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/cloudflare-go v0.14.0/go.mod h1:EnwdgGMaFOruiPZRFSgn+TsQ3hQ7C/YWzIGLeu5c304= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/consensys/bavard v0.1.8-0.20210406032232-f3452dc9b572/go.mod h1:Bpd0/3mZuaj6Sj+PqrmIquiOKy397AKGThQPaGzNXAQ= +github.com/consensys/gnark-crypto v0.4.1-0.20210426202927-39ac3d4b3f1f/go.mod h1:815PAHg3wvysy0SyIqanF8gZ0Y1wjk/hrDHD/iT88+Q= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= @@ -87,37 +138,65 @@ github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7 github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4= +github.com/dave/jennifer v1.2.0/go.mod h1:fIb+770HOpJ2fmN9EPPKOqm1vMGhB+TwXKMZhrIygKg= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckarep/golang-set v0.0.0-20180603214616-504e848d77ea/go.mod h1:93vsz/8Wt4joVM7c2AVqh+YRMiUSc14yDtF28KmMOgQ= +github.com/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M= +github.com/deepmap/oapi-codegen v1.8.2/go.mod h1:YLgSKSDv/bZQB7N4ws6luhozi3cEdRktEqrX88CvjIw= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-bitstream v0.0.0-20180413035011-3522498ce2c8/go.mod h1:VMaSuZ+SZcx/wljOQKvp5srsbCiKDEb6K2wC4+PiBmQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/didip/tollbooth/v5 v5.1.1 h1:QpKFg56jsbNuQ6FFj++Z1gn2fbBsvAc1ZPLUaDOYW5k= github.com/didip/tollbooth/v5 v5.1.1/go.mod h1:d9rzwOULswrD3YIrAQmP3bfjxab32Df4IaO6+D25l9g= +github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/docker/docker v1.4.2-0.20180625184442-8e610b2b55bf/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/dop251/goja v0.0.0-20200721192441-a695b0cdd498/go.mod h1:Mw6PkjjMXWbTj+nnj4s3QPXq1jaT0s5pC0iFD4+BOAA= +github.com/eclipse/paho.mqtt.golang v1.2.0/go.mod h1:H9keYFcgq3Qr5OUJm/JZI/i6U7joQ8SYLhZwfeOo6Ts= +github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/ethereum/go-ethereum v1.10.9 h1:uMSWt0qDhaqqCk0PWqfDFOMUExmk4Tnbma6c6oXW+Pk= +github.com/ethereum/go-ethereum v1.10.9/go.mod h1:CaTMQrv51WaAlD2eULQ3f03KiahDRO28fleQcKjWrrg= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fjl/memsize v0.0.0-20190710130421-bcb5799ab5e5/go.mod h1:VvhXpOYNQvB+uIk2RvXzuaQtkQJzzIx6lSBe1xv7hi0= +github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww= +github.com/getkin/kin-openapi v0.53.0/go.mod h1:7Yn5whZr5kJi6t+kShccXS8ae1APpYTW6yheSwk8Yi4= +github.com/getkin/kin-openapi v0.61.0/go.mod h1:7Yn5whZr5kJi6t+kShccXS8ae1APpYTW6yheSwk8Yi4= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= +github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs= github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-ole/go-ole v1.2.1/go.mod h1:7FAglXiTm7HKlQRDeOQ6ZNUHidzCWXuZWq/1dTyBNF8= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-sourcemap/sourcemap v2.1.2+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= @@ -169,13 +248,19 @@ github.com/gobuffalo/validate/v3 v3.0.0/go.mod h1:HFpjq+AIiA2RHoQnQVTFKF/ZpUPXwy github.com/gobuffalo/validate/v3 v3.1.0/go.mod h1:HFpjq+AIiA2RHoQnQVTFKF/ZpUPXwyw82LgyDPxQ9r0= github.com/gobuffalo/validate/v3 v3.3.0 h1:j++FFx9gtjTmIQeI9xlaIDZ0nV4x8YQZz4RJAlZNUxg= github.com/gobuffalo/validate/v3 v3.3.0/go.mod h1:HFpjq+AIiA2RHoQnQVTFKF/ZpUPXwyw82LgyDPxQ9r0= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -201,10 +286,16 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/flatbuffers v1.11.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -212,8 +303,10 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.1.1-0.20200604201612-c04b05f3adfa/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= @@ -225,6 +318,7 @@ github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200905233945-acf8798be1f7/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.5/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= @@ -233,18 +327,21 @@ github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8 github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.1.1 h1:YMDmfaK68mUixINzY/XjscuJ47uXFWSSHzFbBQM0PrE= github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/graph-gophers/graphql-go v0.0.0-20201113091052-beb923fada29/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-hclog v0.9.1/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= @@ -261,17 +358,34 @@ github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.5-0.20210104140557-80c98217689d/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/raft v1.1.0/go.mod h1:4Ak7FSPnuvmb0GV6vgIAJ4vYT4bek9bb6Q+7HVbyzqM= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= +github.com/holiman/uint256 v1.2.0/go.mod h1:y4ga/t+u+Xwd7CpDgZESaRcWy0I7XMlTMA25ApIH5Jw= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huin/goupnp v1.0.2/go.mod h1:0dxJBVBHqTMjIUMkESDTNgOOx/Mw5wYIfyFmdzSamkM= +github.com/huin/goutil v0.0.0-20170803182201-1ca381bf3150/go.mod h1:PpLOETDnJ0o3iZrZfqZzyLl6l7F3c6L1oWn7OICBi6o= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imdario/mergo v0.0.0-20160216103600-3e95a51e0639 h1:VMd01CgpBpmLpuERyY4Oibn2PpcVS1fK9sjh5UZG8+o= github.com/imdario/mergo v0.0.0-20160216103600-3e95a51e0639/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/influxdata/flux v0.65.1/go.mod h1:J754/zds0vvpfwuq7Gc2wRdVwEodfpCFM7mYlOw2LqY= +github.com/influxdata/influxdb v1.8.3/go.mod h1:JugdFhsvvI8gadxOI6noqNeeBHvWNTbfYGtiAn+2jhI= +github.com/influxdata/influxdb-client-go/v2 v2.4.0/go.mod h1:vLNHdxTJkIf2mSLvGrpj8TCcISApPoXkaxP8g9uRlW8= +github.com/influxdata/influxql v1.1.1-0.20200828144457-65d3ef77d385/go.mod h1:gHp9y86a/pxhjJ+zMjNXiQAA197Xk9wLxaz+fGG+kWk= +github.com/influxdata/line-protocol v0.0.0-20180522152040-32c6aa80de5e/go.mod h1:4kt73NQhadE3daL3WhR5EJ/J2ocX0PZzwxQ0gXJ7oFE= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= +github.com/influxdata/line-protocol v0.0.0-20210311194329-9aa0e372d097/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= +github.com/influxdata/promql/v2 v2.12.0/go.mod h1:fxOPu+DY0bqCTCECchSRtWfc+0X19ybifQhZoQNF5D8= +github.com/influxdata/roaring v0.4.13-0.20180809181101-fc520f41fab6/go.mod h1:bSgUQ7q5ZLSO+bKBGqJiCBGAl+9DxyW63zLTujjUlOE= +github.com/influxdata/tdigest v0.0.0-20181121200506-bf2b5ad3c0a9/go.mod h1:Js0mqiSBE6Ffsg94weZZ2c+v/ciT8QRHFOap7EKDrR0= +github.com/influxdata/usage-client v0.0.0-20160829180054-6d3895376368/go.mod h1:Wbbw6tYNvwa5dlB6304Sd+82Z3f7PmVZHVKU637d4po= github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= @@ -286,6 +400,8 @@ github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpT github.com/jackc/pgconn v1.6.0/go.mod h1:yeseQo4xhQbgyJs2c87RAXOH2i624N0Fh1KSPJya7qo= github.com/jackc/pgconn v1.8.0 h1:FmjZ0rOyXTr1wfWs45i4a9vjnjWUAGpMuQLD9OSs+lw= github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= +github.com/jackc/pgerrcode v0.0.0-20201024163028-a0d42d470451 h1:WAvSpGf7MsFuzAtK4Vk7R4EVe+liW4x83r4oWu0WHKw= +github.com/jackc/pgerrcode v0.0.0-20201024163028-a0d42d470451/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2 h1:JVX6jT/XfzNqIjye4717ITLaNwV9mWbJx0dLCpcRzdA= @@ -331,6 +447,11 @@ github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0f github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackpal/go-nat-pmp v1.0.2-0.20160603034137-1fa385a6f458/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/jedisct1/go-minisign v0.0.0-20190909160543-45766022959e/go.mod h1:G1CVv03EnqU1wYL2dFwXxW2An0az9JTl/ZsqXQeBlkU= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= github.com/jmoiron/sqlx v1.3.1 h1:aLN7YINNZ7cYOPK3QC83dbM6KT0NMqVMw961TqrejlE= github.com/jmoiron/sqlx v1.3.1/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ= @@ -339,11 +460,16 @@ github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqx github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/jsternberg/zap-logfmt v1.0.0/go.mod h1:uvPs/4X51zdkcm5jXl5SYoN+4RK21K8mysFmDaM/h+o= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jwilder/encoding v0.0.0-20170811194829-b4e1701a28ef/go.mod h1:Ct9fl0F6iIOGgxJ5npU/IUOhOhqlVrGjyIZc8/MagT0= +github.com/karalabe/usb v0.0.0-20190919080040-51dc0efba356/go.mod h1:Od972xHfMJowv7NGVDiWVxk2zxnWgjLlJzE+F4F7AGU= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/karrick/godirwalk v1.15.3/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= github.com/karrick/godirwalk v1.15.8/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= @@ -354,7 +480,13 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:C github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/klauspost/compress v1.4.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/cpuid v0.0.0-20170728055534-ae7887de9fa5/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/klauspost/crc32 v0.0.0-20161016154125-cb6bfca970f6/go.mod h1:+ZoRqAPRLkC4NPOvfYeR5KNOrY6TD+/sAC3HXPZgDYg= +github.com/klauspost/pgzip v1.0.2-0.20170402124221-0bf5dcad4ada/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -368,6 +500,10 @@ github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/labstack/echo/v4 v4.2.1/go.mod h1:AA49e0DZ8kk5jTOOCKNuPR6oTnBS0dYiM4FW1e6jwpg= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/leanovate/gopter v0.2.9/go.mod h1:U2L/78B+KVFIx2VmW6onHJQzXtFb+p5y3y2Sh+Jxxv8= github.com/lestrrat-go/jwx v0.9.0 h1:Fnd0EWzTm0kFrBPzE/PEPp9nzllES5buMkksPMjEKpM= github.com/lestrrat-go/jwx v0.9.0/go.mod h1:iEoxlYfZjvoGpuWwxUz+eR5e6KTJGsaRcy/YNA/UnBk= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= @@ -381,12 +517,15 @@ github.com/luna-duclos/instrumentedsql v1.1.3 h1:t7mvC0z1jUt5A0UQ6I/0H31ryymuQRn github.com/luna-duclos/instrumentedsql v1.1.3/go.mod h1:9J1njvFds+zN7y85EDhN9XNQLANWwZt2ULeIC8yMNYs= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/markbates/errx v1.1.0 h1:QDFeR+UP95dO12JgW+tgi2UVfo0V8YBHiUIOaeBPiEI= github.com/markbates/errx v1.1.0/go.mod h1:PLa46Oex9KNbVDZhKel8v1OT7hD5JZ2eI7AHhA0wswc= github.com/markbates/oncer v1.0.0 h1:E83IaVAHygyndzPimgUYJjbshhDTALZyXxvk9FOlQRY= github.com/markbates/oncer v1.0.0/go.mod h1:Z59JA581E9GP6w96jai+TGqafHPW+cPfRxz2aSZ0mcI= github.com/markbates/safe v1.0.1 h1:yjZkbvRM6IzKj9tlu/zMJLS0n/V351OZWRnF3QfaUxI= github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= +github.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ= github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= @@ -394,9 +533,13 @@ github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcncea github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-ieproxy v0.0.0-20190610004146-91bb50d98149/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc= +github.com/mattn/go-ieproxy v0.0.0-20190702010315-6dee0af9227d/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= @@ -404,10 +547,14 @@ github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2y github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-tty v0.0.0-20180907095812-13ff1204f104/go.mod h1:XPvLUNfbS4fJH25nqRHfWLMa1ONC8Amw+mIA639KxkE= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= github.com/microcosm-cc/bluemonday v1.0.16 h1:kHmAq2t7WPWLjiGvzKa5o3HzSfahUKiOq7fAPUiMNIc= @@ -421,11 +568,17 @@ github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS4 github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 h1:j2kD3MT1z4PXCiUllUJF9mWUESr9TWKS7iEKsQ/IipM= github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM= +github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/naoina/go-stringutil v0.1.0/go.mod h1:XJ2SJL9jCtBh+P9q5btrd/Ylo8XwT/h1USek5+NqSA0= +github.com/naoina/toml v0.1.2-0.20170918210437-9fafd6967416/go.mod h1:NBIhNtsFMo3G2szEBne+bO4gS192HuIYRqfvOWb4i1E= github.com/nats-io/jwt v0.2.6/go.mod h1:mQxQ0uHQ9FhEVPIcTSKwx2lqZEpXWWcCgA7R6NrWvvY= github.com/nats-io/nats-server/v2 v2.0.0/go.mod h1:RyVdsHHvY4B6c9pWG+uRLpZ0h0XsqiuKp2XCTurP5LI= github.com/nats-io/nats-streaming-server v0.15.1/go.mod h1:bJ1+2CS8MqvkGfr/NwnCF+Lw6aLnL3F5kenM8bZmdCw= @@ -438,7 +591,18 @@ github.com/netlify/mailme v1.1.1 h1:S/ANl+Hy/EIoJUgGiLJYYLZJ2QOTG452R73qTQudMns= github.com/netlify/mailme v1.1.1/go.mod h1:8g03BJmU+ps7ma5vcH+t8aMtaicQTMX3ffP7RJ8xY8g= github.com/netlify/netlify-commons v0.32.0 h1:IgpqedBa6aFrc+daRgGZ+SmU9eBXlDXzKSAjevWmshM= github.com/netlify/netlify-commons v0.32.0/go.mod h1:xZH7auZrc/N/ZKS9BRO74yNf8i9LitXq1h6JVFZ2jTc= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/opentracing/opentracing-go v1.0.3-0.20180606204148-bd9c31933947/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= @@ -446,32 +610,42 @@ github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144T github.com/patrickmn/go-cache v0.0.0-20170418232947-7ac151875ffb/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/paulbellamy/ratecounter v0.2.0/go.mod h1:Hfx1hDpSGoqxkVVpBi/IlYD7kChlfo5C6hzIHwPqfFE= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.3.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo= +github.com/peterh/liner v1.0.1-0.20180619022028-8c1271fcf47f/go.mod h1:xIteQHvHuaLYG9IFj6mSxM0fCKrs34IrEQUhOYuGPHc= +github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0= github.com/philhofer/fwd v1.0.0 h1:UbZqGr5Y38ApvM/V/jEljVxwocdweyH+vmYvRPBnbqQ= github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= +github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/term v0.0.0-20180730021639-bffc007b7fd5/go.mod h1:eCbImbZ95eXtAUIbLAuAVnBnwf83mjf6QIVH8SHYwqQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/retailnext/hllpp v1.0.1-0.20180308014038-101a6d2f8b52/go.mod h1:RDpi1RftBQPUCDRw6SmxeaREsAaRKnOclghuzp/WRzc= +github.com/rjeczalik/notify v0.9.1/go.mod h1:rKwnCoCGeuQnwBtTSPL9Dad03Vh2n40ePRrjvIXnJho= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -481,8 +655,9 @@ github.com/rogpeppe/go-internal v1.5.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/rs/cors v1.6.0 h1:G9tHG9lebljV9mfp9SNPDL36nCDxmo3zTlAf1YgvzmI= github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= +github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= @@ -497,11 +672,14 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35 h1:eajwn6K3weW5cd1ZXLu2sJ4pvwlBiCWY4uDejOr73gM= github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35/go.mod h1:wozgYq9WEBQBaIJe4YZ0qTSFAMxmcwBhQH0fO0R34Z0= +github.com/segmentio/kafka-go v0.1.0/go.mod h1:X6itGqS9L4jDletMsxZ7Dz+JFWxM6JHfPOCvTvk+EJo= +github.com/segmentio/kafka-go v0.2.0/go.mod h1:X6itGqS9L4jDletMsxZ7Dz+JFWxM6JHfPOCvTvk+EJo= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sethvargo/go-password v0.2.0 h1:BTDl4CC/gjf/axHMaDQtw507ogrXLci6XRiLc7i/UHI= github.com/sethvargo/go-password v0.2.0/go.mod h1:Ym4Mr9JXLBycr02MFuVQ/0JHidNetSgbzutTr3zsYXE= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:DmcHeT/UuSDXaCVb8IijmL+fHX+FK9TLy98W7mfDXXg= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY= @@ -523,6 +701,7 @@ github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasO github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v0.0.4-0.20190321000552-67fc4837d267/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= github.com/spf13/cobra v0.0.6/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= @@ -536,23 +715,38 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= +github.com/status-im/keycard-go v0.0.0-20190316090335-8537d3370df4/go.mod h1:RZLeN1LMWmRsyYjvAu+I6Dm9QmlDaIIt+Y+4Kd7Tp+Q= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.2.0/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.1.0 h1:9fQd+ICuRIu/ue4vxJZu6/LzxN0HwMds2nq/0cFvxHU= github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= +github.com/tklauser/go-sysconf v0.3.5/go.mod h1:MkWzOF4RMCshBAMXuhXJs64Rte09mITnppBXY/rYEFI= +github.com/tklauser/numcpus v0.2.2/go.mod h1:x3qojaO3uyYt0i56EW/VUYs7uBvdl2fkfZFu0T9wgjM= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/tyler-smith/go-bip39 v1.0.1-0.20181017060643-dbb3b84ba2ef/go.mod h1:sJ5fKU0s6JVwZjjcUEX2zFOnvq0ASQ2K9Zr6cf67kNs= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/willf/bitset v1.1.3/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xlab/treeprint v0.0.0-20180616005107-d6fb6747feb6/go.mod h1:ce1O1j6UtZfjr22oyGxGLbauSBp2YVXpARAosm7dHBg= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -574,6 +768,7 @@ go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKY go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -584,15 +779,21 @@ golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190909091759-094676da4a83/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= @@ -602,6 +803,7 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -622,9 +824,11 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20161007143504-f4b625ec9b21/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -654,12 +858,19 @@ golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200927032502-5d4f70055728/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210220033124-5f55cee0dc0d/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba h1:6u6sik+bn/y7vILcYkK3iwTBWN7WtBvB0+SZswQnbf8= golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -677,11 +888,13 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a h1:DcqTD9SDLc+1P/r1EmRBwnVsrOwW+kk2vWf9n+1sGhs= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -701,10 +914,14 @@ golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200107162124-548cf772de50/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -717,12 +934,23 @@ golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210316164454-77fc1eacc6aa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420205809-ac73e9fd8988/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= @@ -734,6 +962,9 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -741,11 +972,15 @@ golang.org/x/time v0.0.0-20160926182426-711ca1cb8763/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI= -golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba h1:O8mE0/t419eoIwhTFpKVkHiTs/Igowgfkj25AcZrtiE= +golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= @@ -773,6 +1008,7 @@ golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200108203644-89082a384178/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200117220505-0cba7a3a9ee9/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= @@ -795,6 +1031,7 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= golang.org/x/tools v0.0.0-20200929161345-d7fc70abf50f/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -802,6 +1039,12 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= +gonum.org/v1/gonum v0.0.0-20181121035319-3f7ecaa7e8ca/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= +gonum.org/v1/gonum v0.6.0/go.mod h1:9mxDZsDKxgMAuccQkewq682L+0eCu4dCN2yonUJTCLU= +gonum.org/v1/netlib v0.0.0-20181029234149-ec6d1f5cefe6/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= +gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= +gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= @@ -833,6 +1076,7 @@ google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRn google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190716160619-c506a9f90610/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= @@ -840,6 +1084,7 @@ google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvx google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200108215221-bd8f9a0ef82f/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= @@ -898,27 +1143,36 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gomail.v2 v2.0.0-20150902115704-41f357289737/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= +gopkg.in/olebedev/go-duktape.v3 v3.0.0-20200619000410-60c24ae608a6/go.mod h1:uAJfkITjFhyEEuUfm7bsmCZRbW5WRq8s9EY8HZ6hCns= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/urfave/cli.v1 v1.20.0/go.mod h1:vuBzUtMdQeixQj8LVd+/98pzhxNGQoyuPBlsXHOQNO0= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20190924164351-c8b7dadae555/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -926,6 +1180,8 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/hack/migrate.sh b/hack/migrate.sh index 06c55f972..2d1f0e5e8 100755 --- a/hack/migrate.sh +++ b/hack/migrate.sh @@ -9,7 +9,4 @@ export GOTRUE_DB_DRIVER="postgres" export GOTRUE_DB_DATABASE_URL="postgres://supabase_auth_admin:root@localhost:5432/$DB_ENV" export GOTRUE_DB_MIGRATIONS_PATH=$DIR/../migrations -echo soda -v -soda drop -d -e $DB_ENV -c $DATABASE -soda create -d -e $DB_ENV -c $DATABASE go run main.go migrate -c $DIR/test.env diff --git a/hack/migrate_postgres.sh b/hack/migrate_postgres.sh deleted file mode 100755 index 59a8e80c2..000000000 --- a/hack/migrate_postgres.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash - -DB_ENV=$1 - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -DATABASE="$DIR/database.yml" - -export GOTRUE_DB_DRIVER="postgres" -export GOTRUE_DB_DATABASE_URL="postgres://postgres:root@localhost:5432/$DB_ENV?sslmode=disable" -export GOTRUE_DB_MIGRATIONS_PATH=$DIR/../migrations - -go run main.go migrate -c $DIR/test.env diff --git a/hack/test.env b/hack/test.env index 9b5d9b89c..b1dcd884a 100644 --- a/hack/test.env +++ b/hack/test.env @@ -37,6 +37,11 @@ GOTRUE_EXTERNAL_GITHUB_ENABLED=true GOTRUE_EXTERNAL_GITHUB_CLIENT_ID=testclientid GOTRUE_EXTERNAL_GITHUB_SECRET=testsecret GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI=https://identity.services.netlify.com/callback +GOTRUE_EXTERNAL_KEYCLOAK_ENABLED=true +GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID=testclientid +GOTRUE_EXTERNAL_KEYCLOAK_SECRET=testsecret +GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI=https://identity.services.netlify.com/callback +GOTRUE_EXTERNAL_KEYCLOAK_URL=https://keycloak.example.com/auth/realms/myrealm GOTRUE_EXTERNAL_LINKEDIN_ENABLED=true GOTRUE_EXTERNAL_LINKEDIN_CLIENT_ID=testclientid GOTRUE_EXTERNAL_LINKEDIN_SECRET=testsecret @@ -61,6 +66,10 @@ GOTRUE_EXTERNAL_SLACK_ENABLED=true GOTRUE_EXTERNAL_SLACK_CLIENT_ID=testclientid GOTRUE_EXTERNAL_SLACK_SECRET=testsecret GOTRUE_EXTERNAL_SLACK_REDIRECT_URI=https://identity.services.netlify.com/callback +GOTRUE_EXTERNAL_WORKOS_ENABLED=true +GOTRUE_EXTERNAL_WORKOS_CLIENT_ID=testclientid +GOTRUE_EXTERNAL_WORKOS_SECRET=testsecret +GOTRUE_EXTERNAL_WORKOS_REDIRECT_URI=https://identity.services.netlify.com/callback GOTRUE_EXTERNAL_TWITCH_ENABLED=true GOTRUE_EXTERNAL_TWITCH_CLIENT_ID=testclientid GOTRUE_EXTERNAL_TWITCH_SECRET=testsecret @@ -77,6 +86,7 @@ GOTRUE_EXTERNAL_SAML_ENABLED=true GOTRUE_EXTERNAL_SAML_METADATA_URL= GOTRUE_EXTERNAL_SAML_API_BASE=http://localhost GOTRUE_EXTERNAL_SAML_NAME=TestSamlName +GOTRUE_RATE_LIMIT_VERIFY="1000" GOTRUE_TRACING_ENABLED=false GOTRUE_TRACING_HOST=127.0.0.1 GOTRUE_TRACING_PORT=8126 @@ -84,3 +94,4 @@ GOTRUE_TRACING_TAGS="env:test" GOTRUE_SECURITY_CAPTCHA_ENABLED="false" GOTRUE_SECURITY_CAPTCHA_PROVIDER="hcaptcha" GOTRUE_SECURITY_CAPTCHA_SECRET="0x0000000000000000000000000000000000000000" +GOTRUE_SECURITY_CAPTCHA_TIMEOUT="10s" diff --git a/mailer/mailer.go b/mailer/mailer.go index 496572760..9db10bb3e 100644 --- a/mailer/mailer.go +++ b/mailer/mailer.go @@ -14,29 +14,27 @@ import ( // Mailer defines the interface a mailer must implement. type Mailer interface { Send(user *models.User, subject, body string, data map[string]interface{}) error - InviteMail(user *models.User, referrerURL string) error - ConfirmationMail(user *models.User, referrerURL string) error - RecoveryMail(user *models.User, referrerURL string) error - MagicLinkMail(user *models.User, referrerURL string) error - EmailChangeMail(user *models.User, referrerURL string) error + InviteMail(user *models.User, otp, referrerURL string) error + ConfirmationMail(user *models.User, otp, referrerURL string) error + RecoveryMail(user *models.User, otp, referrerURL string) error + MagicLinkMail(user *models.User, otp, referrerURL string) error + EmailChangeMail(user *models.User, otpNew, otpCurrent, referrerURL string) error + ReauthenticateMail(user *models.User, otp string) error ValidateEmail(email string) error GetEmailActionLink(user *models.User, actionType, referrerURL string) (string, error) } // NewMailer returns a new gotrue mailer func NewMailer(instanceConfig *conf.Configuration) Mailer { - if instanceConfig.SMTP.Host == "" { - logrus.Infof("Noop mailer being used for %v", instanceConfig.SiteURL) - return &noopMailer{} - } - mail := gomail.NewMessage() from := mail.FormatAddress(instanceConfig.SMTP.AdminEmail, instanceConfig.SMTP.SenderName) - return &TemplateMailer{ - SiteURL: instanceConfig.SiteURL, - Config: instanceConfig, - Mailer: &mailme.Mailer{ + var mailClient MailClient + if instanceConfig.SMTP.Host == "" { + logrus.Infof("Noop mail client being used for %v", instanceConfig.SiteURL) + mailClient = &noopMailClient{} + } else { + mailClient = &mailme.Mailer{ Host: instanceConfig.SMTP.Host, Port: instanceConfig.SMTP.Port, User: instanceConfig.SMTP.User, @@ -44,7 +42,13 @@ func NewMailer(instanceConfig *conf.Configuration) Mailer { From: from, BaseURL: instanceConfig.SiteURL, Logger: logrus.New(), - }, + } + } + + return &TemplateMailer{ + SiteURL: instanceConfig.SiteURL, + Config: instanceConfig, + Mailer: mailClient, } } diff --git a/mailer/noop.go b/mailer/noop.go index 9c486fc4e..b35a7af1e 100644 --- a/mailer/noop.go +++ b/mailer/noop.go @@ -1,8 +1,13 @@ package mailer -import "github.com/netlify/gotrue/models" +import ( + "errors" + + "github.com/netlify/gotrue/models" +) type noopMailer struct { + Mailer MailClient } func (m noopMailer) ValidateEmail(email string) error { @@ -36,3 +41,12 @@ func (m noopMailer) Send(user *models.User, subject, body string, data map[strin func (m noopMailer) GetEmailActionLink(user *models.User, actionType, referrerURL string) (string, error) { return "", nil } + +type noopMailClient struct{} + +func (m *noopMailClient) Mail(to, subjectTemplate, templateURL, defaultTemplate string, templateData map[string]interface{}) error { + if to == "" { + return errors.New("to field cannot be empty") + } + return nil +} diff --git a/mailer/template.go b/mailer/template.go index 634eb4f8a..e336d9b3e 100644 --- a/mailer/template.go +++ b/mailer/template.go @@ -2,18 +2,22 @@ package mailer import ( "fmt" + "strings" "github.com/badoux/checkmail" "github.com/netlify/gotrue/conf" "github.com/netlify/gotrue/models" - "github.com/netlify/mailme" ) +type MailClient interface { + Mail(string, string, string, string, map[string]interface{}) error +} + // TemplateMailer will send mail and use templates from the site for easy mail styling type TemplateMailer struct { SiteURL string Config *conf.Configuration - Mailer *mailme.Mailer + Mailer MailClient } var configFile = "" @@ -49,6 +53,10 @@ const defaultEmailChangeMail = `

Confirm email address change

Change email address

Alternatively, enter the code: {{ .Token }}

` +const defaultReauthenticateMail = `

Confirm reauthentication

+ +

Enter the code: {{ .Token }}

` + // ValidateEmail returns nil if the email is valid, // otherwise an error indicating the reason it is invalid func (m TemplateMailer) ValidateEmail(email string) error { @@ -56,7 +64,7 @@ func (m TemplateMailer) ValidateEmail(email string) error { } // InviteMail sends a invite mail to a new user -func (m *TemplateMailer) InviteMail(user *models.User, referrerURL string) error { +func (m *TemplateMailer) InviteMail(user *models.User, otp, referrerURL string) error { globalConfig, err := conf.LoadGlobal(configFile) redirectParam := "" @@ -72,7 +80,7 @@ func (m *TemplateMailer) InviteMail(user *models.User, referrerURL string) error "SiteURL": m.Config.SiteURL, "ConfirmationURL": url, "Email": user.Email, - "Token": user.ConfirmationToken, + "Token": otp, "Data": user.UserMetaData, } @@ -86,14 +94,15 @@ func (m *TemplateMailer) InviteMail(user *models.User, referrerURL string) error } // ConfirmationMail sends a signup confirmation mail to a new user -func (m *TemplateMailer) ConfirmationMail(user *models.User, referrerURL string) error { +func (m *TemplateMailer) ConfirmationMail(user *models.User, otp, referrerURL string) error { globalConfig, err := conf.LoadGlobal(configFile) - + if err != nil { + return err + } redirectParam := "" if len(referrerURL) > 0 { redirectParam = "&redirect_to=" + referrerURL } - url, err := getSiteURL(referrerURL, globalConfig.API.ExternalURL, m.Config.Mailer.URLPaths.Confirmation, "token="+user.ConfirmationToken+"&type=signup"+redirectParam) if err != nil { return err @@ -102,7 +111,7 @@ func (m *TemplateMailer) ConfirmationMail(user *models.User, referrerURL string) "SiteURL": m.Config.SiteURL, "ConfirmationURL": url, "Email": user.Email, - "Token": user.ConfirmationToken, + "Token": otp, "Data": user.UserMetaData, } @@ -115,29 +124,51 @@ func (m *TemplateMailer) ConfirmationMail(user *models.User, referrerURL string) ) } +// ReauthenticateMail sends a reauthentication mail to an authenticated user +func (m *TemplateMailer) ReauthenticateMail(user *models.User, otp string) error { + data := map[string]interface{}{ + "SiteURL": m.Config.SiteURL, + "Email": user.Email, + "Token": otp, + "Data": user.UserMetaData, + } + + return m.Mailer.Mail( + user.GetEmail(), + string(withDefault(m.Config.Mailer.Subjects.Reauthentication, "Confirm reauthentication")), + m.Config.Mailer.Templates.Reauthentication, + defaultReauthenticateMail, + data, + ) +} + // EmailChangeMail sends an email change confirmation mail to a user -func (m *TemplateMailer) EmailChangeMail(user *models.User, referrerURL string) error { +func (m *TemplateMailer) EmailChangeMail(user *models.User, otpNew, otpCurrent, referrerURL string) error { type Email struct { - Address string - Token string - Subject string - Template string + Address string + Otp string + TokenHash string + Subject string + Template string } emails := []Email{ { - Address: user.EmailChange, - Token: user.EmailChangeTokenNew, - Subject: string(withDefault(m.Config.Mailer.Subjects.EmailChange, "Confirm Email Change")), - Template: m.Config.Mailer.Templates.Confirmation, + Address: user.EmailChange, + Otp: otpNew, + TokenHash: user.EmailChangeTokenNew, + Subject: string(withDefault(m.Config.Mailer.Subjects.EmailChange, "Confirm Email Change")), + Template: m.Config.Mailer.Templates.EmailChange, }, } - if m.Config.Mailer.SecureEmailChangeEnabled { + currentEmail := user.GetEmail() + if m.Config.Mailer.SecureEmailChangeEnabled && currentEmail != "" { emails = append(emails, Email{ - Address: user.GetEmail(), - Token: user.EmailChangeTokenCurrent, - Subject: string(withDefault(m.Config.Mailer.Subjects.Confirmation, "Confirm Email Address")), - Template: m.Config.Mailer.Templates.EmailChange, + Address: currentEmail, + Otp: otpCurrent, + TokenHash: user.EmailChangeTokenCurrent, + Subject: string(withDefault(m.Config.Mailer.Subjects.Confirmation, "Confirm Email Address")), + Template: m.Config.Mailer.Templates.EmailChange, }) } @@ -156,7 +187,7 @@ func (m *TemplateMailer) EmailChangeMail(user *models.User, referrerURL string) referrerURL, globalConfig.API.ExternalURL, m.Config.Mailer.URLPaths.EmailChange, - "token="+email.Token+"&type=email_change"+redirectParam, + "token="+email.TokenHash+"&type=email_change"+redirectParam, ) if err != nil { return err @@ -177,7 +208,7 @@ func (m *TemplateMailer) EmailChangeMail(user *models.User, referrerURL string) defaultEmailChangeMail, data, ) - }(email.Address, email.Token, email.Template) + }(email.Address, email.Otp, email.Template) } for i := 0; i < len(emails); i++ { @@ -191,8 +222,11 @@ func (m *TemplateMailer) EmailChangeMail(user *models.User, referrerURL string) } // RecoveryMail sends a password recovery mail -func (m *TemplateMailer) RecoveryMail(user *models.User, referrerURL string) error { +func (m *TemplateMailer) RecoveryMail(user *models.User, otp, referrerURL string) error { globalConfig, err := conf.LoadGlobal(configFile) + if err != nil { + return err + } redirectParam := "" if len(referrerURL) > 0 { @@ -207,7 +241,7 @@ func (m *TemplateMailer) RecoveryMail(user *models.User, referrerURL string) err "SiteURL": m.Config.SiteURL, "ConfirmationURL": url, "Email": user.Email, - "Token": user.RecoveryToken, + "Token": otp, "Data": user.UserMetaData, } @@ -221,8 +255,11 @@ func (m *TemplateMailer) RecoveryMail(user *models.User, referrerURL string) err } // MagicLinkMail sends a login link mail -func (m *TemplateMailer) MagicLinkMail(user *models.User, referrerURL string) error { +func (m *TemplateMailer) MagicLinkMail(user *models.User, otp, referrerURL string) error { globalConfig, err := conf.LoadGlobal(configFile) + if err != nil { + return err + } redirectParam := "" if len(referrerURL) > 0 { @@ -237,7 +274,7 @@ func (m *TemplateMailer) MagicLinkMail(user *models.User, referrerURL string) er "SiteURL": m.Config.SiteURL, "ConfirmationURL": url, "Email": user.Email, - "Token": user.RecoveryToken, + "Token": otp, "Data": user.UserMetaData, } @@ -290,3 +327,17 @@ func (m TemplateMailer) GetEmailActionLink(user *models.User, actionType, referr return url, nil } + +// formatEmailOtp separates the otp into chunks of 5 with "-" as the separator +func formatEmailOtp(otp string) string { + chunkSize := 5 + var chunks []string + for i := 0; i < len(otp); i += chunkSize { + if i+chunkSize >= len(otp) { + chunks = append(chunks, otp[i:]) + } else { + chunks = append(chunks, otp[i:i+chunkSize]) + } + } + return strings.Join(chunks, "-") +} diff --git a/migrations/20211006231400_create_keys_table.up.sql b/migrations/20211006231400_create_keys_table.up.sql new file mode 100644 index 000000000..449490754 --- /dev/null +++ b/migrations/20211006231400_create_keys_table.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE auth.asymmetric_keys ( + id bigserial NOT NULL, + user_id uuid NOT NULL, + key VARCHAR ( 150 ) UNIQUE NOT NULL, + algorithm VARCHAR (15) NOT NULL, + main bool DEFAULT false NOT NULL, + challenge_token uuid NOT NULL, + challenge_token_issued_at timestamptz NOT NULL, + challenge_token_expires_at timestamptz NOT NULL, + challenge_passed bool DEFAULT false NOT NULL, + + created_at timestamptz NULL, + updated_at timestamptz NULL, + CONSTRAINT asymmetric_keys_pkey PRIMARY KEY (id), + CONSTRAINT asymmetric_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE +); diff --git a/migrations/20220323170000_add_user_reauthentication.up.sql b/migrations/20220323170000_add_user_reauthentication.up.sql new file mode 100644 index 000000000..3b6e60b7c --- /dev/null +++ b/migrations/20220323170000_add_user_reauthentication.up.sql @@ -0,0 +1,5 @@ +-- adds reauthentication_token and reauthentication_sent_at + +ALTER TABLE auth.users +ADD COLUMN IF NOT EXISTS reauthentication_token varchar(255) null default '', +ADD COLUMN IF NOT EXISTS reauthentication_sent_at timestamptz null default null; diff --git a/migrations/20220429102000_add_unique_idx.up.sql b/migrations/20220429102000_add_unique_idx.up.sql new file mode 100644 index 000000000..b4280e0ce --- /dev/null +++ b/migrations/20220429102000_add_unique_idx.up.sql @@ -0,0 +1,14 @@ +-- add partial unique indices to confirmation_token, recovery_token, email_change_token_current, email_change_token_new, phone_change_token, reauthentication_token +-- ignores partial unique index creation on fields which contain empty strings, whitespaces or purely numeric otps + +DROP INDEX IF EXISTS confirmation_token_idx; +DROP INDEX IF EXISTS recovery_token_idx; +DROP INDEX IF EXISTS email_change_token_current_idx; +DROP INDEX IF EXISTS email_change_token_new_idx; +DROP INDEX IF EXISTS reauthentication_token_idx; + +CREATE UNIQUE INDEX IF NOT EXISTS confirmation_token_idx ON auth.users USING btree (confirmation_token) WHERE confirmation_token !~ '^[0-9 ]*$'; +CREATE UNIQUE INDEX IF NOT EXISTS recovery_token_idx ON auth.users USING btree (recovery_token) WHERE recovery_token !~ '^[0-9 ]*$'; +CREATE UNIQUE INDEX IF NOT EXISTS email_change_token_current_idx ON auth.users USING btree (email_change_token_current) WHERE email_change_token_current !~ '^[0-9 ]*$'; +CREATE UNIQUE INDEX IF NOT EXISTS email_change_token_new_idx ON auth.users USING btree (email_change_token_new) WHERE email_change_token_new !~ '^[0-9 ]*$'; +CREATE UNIQUE INDEX IF NOT EXISTS reauthentication_token_idx ON auth.users USING btree (reauthentication_token) WHERE reauthentication_token !~ '^[0-9 ]*$'; diff --git a/migrations/20220531120530_add_auth_jwt_function.up.sql b/migrations/20220531120530_add_auth_jwt_function.up.sql new file mode 100644 index 000000000..1ddc69a2e --- /dev/null +++ b/migrations/20220531120530_add_auth_jwt_function.up.sql @@ -0,0 +1,16 @@ +-- add auth.jwt function + +comment on function auth.uid() is 'Deprecated. Use auth.jwt() -> ''sub'' instead.'; +comment on function auth.role() is 'Deprecated. Use auth.jwt() -> ''role'' instead.'; +comment on function auth.email() is 'Deprecated. Use auth.jwt() -> ''email'' instead.'; + +create or replace function auth.jwt() +returns jsonb +language sql stable +as $$ + select + coalesce( + nullif(current_setting('request.jwt.claim', true), ''), + nullif(current_setting('request.jwt.claims', true), '') + )::jsonb +$$; diff --git a/migrations/20220614074223_add_ip_address_to_audit_log.postgres.up.sql b/migrations/20220614074223_add_ip_address_to_audit_log.postgres.up.sql new file mode 100644 index 000000000..070e829ad --- /dev/null +++ b/migrations/20220614074223_add_ip_address_to_audit_log.postgres.up.sql @@ -0,0 +1,3 @@ +-- Add IP Address to audit log +ALTER TABLE auth.audit_log_entries +ADD COLUMN IF NOT EXISTS ip_address VARCHAR(64) NOT NULL DEFAULT ''; diff --git a/models/asymmetric_key.go b/models/asymmetric_key.go new file mode 100644 index 000000000..a310bcba4 --- /dev/null +++ b/models/asymmetric_key.go @@ -0,0 +1,181 @@ +package models + +import ( + "database/sql" + "fmt" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" + "github.com/gofrs/uuid" + "github.com/netlify/gotrue/storage" + "github.com/pkg/errors" + "time" +) + +const challengeTokenExpirationDuration = 30 * time.Minute + +var AlgorithmNotSupportedError = errors.New("Provided algorithm is not supported") +var WrongEthAddressFormatError = errors.New("Provided key cannot be ETH address") +var WrongSignatureFormatError = errors.New("Provided signature has wrong format") +var WrongPublicKeyError = errors.New("Provided signature does not match with Key") + +// RefreshToken is the database model for refresh tokens. +type AsymmetricKey struct { + ID int64 `db:"id"` + UserID uuid.UUID `db:"user_id"` + Key string `db:"key"` + Algorithm string `db:"algorithm"` + Main bool `db:"main"` + + ChallengeToken uuid.UUID `db:"challenge_token"` + ChallengeTokenIssuedAt time.Time `db:"challenge_token_issued_at"` + ChallengeTokenExpiresAt time.Time `db:"challenge_token_expires_at"` + ChallengePassed bool `db:"challenge_passed"` + + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func (AsymmetricKey) TableName() string { + tableName := "asymmetric_keys" + return tableName +} + +func NewAsymmetricKey(userId uuid.UUID, pubkey, algorithm string, main bool) (*AsymmetricKey, error) { + err := VerifyKeyAndAlgorithm(pubkey, algorithm) + if err != nil { + return nil, err + } + + k := &AsymmetricKey{ + UserID: userId, + Key: pubkey, + Algorithm: algorithm, + Main: main, + } + + k.generateChallengeToken() + return k, nil +} + +func (a *AsymmetricKey) IsChallengeTokenExpired() bool { + return time.Now().Unix() >= a.ChallengeTokenExpiresAt.Unix() || a.ChallengePassed +} + +func (a *AsymmetricKey) GetChallengeToken(tx *storage.Connection) (uuid.UUID, error) { + if a.IsChallengeTokenExpired() { + err := a.generateChallengeToken() + if err != nil { + return uuid.Nil, err + } + + err = tx.UpdateOnly( + a, + "challenge_token", + "challenge_token_issued_at", + "challenge_token_expires_at", + "challenge_passed") + + if err != nil { + return uuid.Nil, err + } + } + + return a.ChallengeToken, nil +} + +func (a *AsymmetricKey) generateChallengeToken() error { + newToken, err := uuid.NewV4() + if err != nil { + return err + } + + a.ChallengeToken = newToken + a.ChallengeTokenIssuedAt = time.Now() + a.ChallengeTokenExpiresAt = time.Now().Add(challengeTokenExpirationDuration) + a.ChallengePassed = false + + return nil +} + +func (a *AsymmetricKey) VerifySignature(signature string) error { + var err error + switch a.Algorithm { + case "ETH": + err = a.verifyEthKeySignature(signature) + default: + return AlgorithmNotSupportedError + } + + if err == nil { + a.ChallengePassed = true + } + return err +} + +func (a *AsymmetricKey) verifyEthKeySignature(rawSignature string) error { + signature, err := hexutil.Decode(rawSignature) + if err != nil { + return err + } + + // https://github.com/ethereum/go-ethereum/blob/55599ee95d4151a2502465e0afc7c47bd1acba77/internal/ethapi/api.go#L442 + // Note, the signature must conform to the secp256k1 curve R, S and V values, where + // the V value must be be 27 or 28 for legacy reasons. + if signature[64] != 27 && signature[64] != 28 { + return WrongSignatureFormatError + } + signature[64] -= 27 + + signaturePublicKey, err := crypto.SigToPub(SignEthMessageHash([]byte(a.ChallengeToken.String())), signature) + if err != nil { + return err + } + + addr := crypto.PubkeyToAddress(*signaturePublicKey) + if addr.String() != a.Key { + return WrongPublicKeyError + } + + return nil +} + +// verifyKeyAndAlgorithm verifies public key format for specific algorithm. +// If key satisfies conditions, nil is returned +func VerifyKeyAndAlgorithm(pubkey, algorithm string) error { + var err error + switch algorithm { + case "ETH": + err = verifyEthKey(pubkey) + default: + return AlgorithmNotSupportedError + } + return err +} + +func verifyEthKey(key string) error { + if common.IsHexAddress(key) { + return nil + } + return WrongEthAddressFormatError +} + +// SignEthMessageHash is a helper function that calculates a hash for the given message in the Ethereum format +// The hash is calculated as +// keccak256("\x19Ethereum Signed Message:\n"${message length}${message}). +func SignEthMessageHash(data []byte) []byte { + msg := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(data), data) + return crypto.Keccak256([]byte(msg)) +} + +// FindMainAsymmetricKeyByUser is the helper function that finds the main( used for sign up) Asymmetric key for the given User. +func FindMainAsymmetricKeyByUser(tx *storage.Connection, user *User) (*AsymmetricKey, error) { + key := &AsymmetricKey{} + if err := tx.Q().Where("user_id = ? and main = true", user.ID).First(key); err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return &AsymmetricKey{}, nil + } + return &AsymmetricKey{}, errors.Wrap(err, "error finding keys") + } + return key, nil +} diff --git a/models/audit_log_entry.go b/models/audit_log_entry.go index 3aa39b742..7187a694b 100644 --- a/models/audit_log_entry.go +++ b/models/audit_log_entry.go @@ -8,6 +8,7 @@ import ( "github.com/gofrs/uuid" "github.com/netlify/gotrue/storage" "github.com/pkg/errors" + "github.com/sirupsen/logrus" ) type AuditAction string @@ -22,6 +23,7 @@ const ( UserDeletedAction AuditAction = "user_deleted" UserModifiedAction AuditAction = "user_modified" UserRecoveryRequestedAction AuditAction = "user_recovery_requested" + UserReauthenticateAction AuditAction = "user_reauthenticate_requested" UserConfirmationRequestedAction AuditAction = "user_confirmation_requested" UserRepeatedSignUpAction AuditAction = "user_repeated_signup" TokenRevokedAction AuditAction = "token_revoked" @@ -52,10 +54,9 @@ var actionLogTypeMap = map[AuditAction]auditLogType{ type AuditLogEntry struct { InstanceID uuid.UUID `json:"-" db:"instance_id"` ID uuid.UUID `json:"id" db:"id"` - - Payload JSONMap `json:"payload" db:"payload"` - - CreatedAt time.Time `json:"created_at" db:"created_at"` + Payload JSONMap `json:"payload" db:"payload"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + IPAddress string `json:"ip_address" db:"ip_address"` } func (AuditLogEntry) TableName() string { @@ -63,7 +64,7 @@ func (AuditLogEntry) TableName() string { return tableName } -func NewAuditLogEntry(tx *storage.Connection, instanceID uuid.UUID, actor *User, action AuditAction, traits map[string]interface{}) error { +func NewAuditLogEntry(tx *storage.Connection, instanceID uuid.UUID, actor *User, action AuditAction, ipAddress string, traits map[string]interface{}) error { id, err := uuid.NewV4() if err != nil { return errors.Wrap(err, "Error generating unique id") @@ -85,6 +86,7 @@ func NewAuditLogEntry(tx *storage.Connection, instanceID uuid.UUID, actor *User, "action": action, "log_type": actionLogTypeMap[action], }, + IPAddress: ipAddress, } if name, ok := actor.UserMetaData["full_name"]; ok { @@ -95,7 +97,12 @@ func NewAuditLogEntry(tx *storage.Connection, instanceID uuid.UUID, actor *User, l.Payload["traits"] = traits } - return errors.Wrap(tx.Create(&l), "Database error creating audit log entry") + if err := tx.Create(&l); err != nil { + return errors.Wrap(err, "Database error creating audit log entry") + } + + logrus.Infof("{\"actor_id\": %v, \"action\": %v, \"timestamp\": %v, \"log_type\": %v, \"ip_address\": %v}", actor.ID, action, l.Payload["timestamp"], actionLogTypeMap[action], ipAddress) + return nil } func FindAuditLogEntries(tx *storage.Connection, instanceID uuid.UUID, filterColumns []string, filterValue string, pageParams *Pagination) ([]*AuditLogEntry, error) { diff --git a/models/errors.go b/models/errors.go index 6c33c70c2..a72f59147 100644 --- a/models/errors.go +++ b/models/errors.go @@ -9,6 +9,8 @@ func IsNotFoundError(err error) bool { return true case RefreshTokenNotFoundError: return true + case AsymmetricKeyNotFoundError: + return true case InstanceNotFoundError: return true case TotpSecretNotFoundError: @@ -47,6 +49,12 @@ func (e RefreshTokenNotFoundError) Error() string { return "Refresh Token not found" } +type AsymmetricKeyNotFoundError struct{} + +func (e AsymmetricKeyNotFoundError) Error() string { + return "Asymmetric Key not found" +} + // InstanceNotFoundError represents when an instance is not found. type InstanceNotFoundError struct{} diff --git a/models/identity_test.go b/models/identity_test.go index c94b6a567..e34b95242 100644 --- a/models/identity_test.go +++ b/models/identity_test.go @@ -61,7 +61,7 @@ func (ts *IdentityTestSuite) TestFindUserIdentities() { } func (ts *IdentityTestSuite) createUserWithEmail(email string) *User { - user, err := NewUser(uuid.Nil, email, "secret", "test", nil) + user, err := NewUser(uuid.Nil, "", email, "secret", "test", nil) require.NoError(ts.T(), err) err = ts.db.Create(user) @@ -71,7 +71,7 @@ func (ts *IdentityTestSuite) createUserWithEmail(email string) *User { } func (ts *IdentityTestSuite) createUserWithIdentity(email string) *User { - user, err := NewUser(uuid.Nil, email, "secret", "test", nil) + user, err := NewUser(uuid.Nil, "", email, "secret", "test", nil) require.NoError(ts.T(), err) err = ts.db.Create(user) diff --git a/models/instance.go b/models/instance.go index 009c456a6..4cd2ba7f0 100644 --- a/models/instance.go +++ b/models/instance.go @@ -74,8 +74,8 @@ func GetInstanceByUUID(tx *storage.Connection, uuid uuid.UUID) (*Instance, error func DeleteInstance(conn *storage.Connection, instance *Instance) error { return conn.Transaction(func(tx *storage.Connection) error { delModels := map[string]*pop.Model{ - "user": &pop.Model{Value: &User{}}, - "refresh token": &pop.Model{Value: &RefreshToken{}}, + "user": {Value: &User{}}, + "refresh token": {Value: &RefreshToken{}}, } for name, dm := range delModels { diff --git a/models/json_map.go b/models/json_map.go index 6db3c998b..a343a100f 100644 --- a/models/json_map.go +++ b/models/json_map.go @@ -23,6 +23,8 @@ func (j JSONMap) Scan(src interface{}) error { source = []byte(v) case []byte: source = v + case nil: + source = []byte("") default: return errors.New("Invalid data type for JSONMap") } diff --git a/models/refresh_token.go b/models/refresh_token.go index 054806e4e..ea73da36d 100644 --- a/models/refresh_token.go +++ b/models/refresh_token.go @@ -1,6 +1,7 @@ package models import ( + "database/sql" "time" "github.com/gobuffalo/pop/v5" @@ -41,7 +42,7 @@ func GrantRefreshTokenSwap(tx *storage.Connection, user *User, token *RefreshTok var newToken *RefreshToken err := tx.Transaction(func(rtx *storage.Connection) error { var terr error - if terr = NewAuditLogEntry(tx, user.InstanceID, user, TokenRevokedAction, nil); terr != nil { + if terr = NewAuditLogEntry(tx, user.InstanceID, user, TokenRevokedAction, "", nil); terr != nil { return errors.Wrap(terr, "error creating audit log entry") } @@ -57,19 +58,33 @@ func GrantRefreshTokenSwap(tx *storage.Connection, user *User, token *RefreshTok // RevokeTokenFamily revokes all refresh tokens that descended from the provided token. func RevokeTokenFamily(tx *storage.Connection, token *RefreshToken) error { + tablename := (&pop.Model{Value: RefreshToken{}}).TableName() err := tx.RawQuery(` with recursive token_family as ( - select id, user_id, token, revoked, parent from refresh_tokens where parent = ? + select id, user_id, token, revoked, parent from `+tablename+` where parent = ? union - select r.id, r.user_id, r.token, r.revoked, r.parent from `+(&pop.Model{Value: RefreshToken{}}).TableName()+` r inner join token_family t on t.token = r.parent + select r.id, r.user_id, r.token, r.revoked, r.parent from `+tablename+` r inner join token_family t on t.token = r.parent ) - update `+(&pop.Model{Value: RefreshToken{}}).TableName()+` r set revoked = true from token_family where token_family.id = r.id;`, token.Token).Exec() + update `+tablename+` r set revoked = true from token_family where token_family.id = r.id;`, token.Token).Exec() if err != nil { return err } return nil } +// GetValidChildToken returns the child token of the token provided if the child is not revoked. +func GetValidChildToken(tx *storage.Connection, token *RefreshToken) (*RefreshToken, error) { + refreshToken := &RefreshToken{} + err := tx.Q().Where("parent = ? and revoked = false", token.Token).First(refreshToken) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, RefreshTokenNotFoundError{} + } + return nil, err + } + return refreshToken, nil +} + // Logout deletes all refresh tokens for a user. func Logout(tx *storage.Connection, instanceID uuid.UUID, id uuid.UUID) error { return tx.RawQuery("DELETE FROM "+(&pop.Model{Value: RefreshToken{}}).TableName()+" WHERE instance_id = ? AND user_id = ?", instanceID, id).Exec() diff --git a/models/refresh_token_test.go b/models/refresh_token_test.go index 96df7262f..e5e8790d5 100644 --- a/models/refresh_token_test.go +++ b/models/refresh_token_test.go @@ -3,10 +3,10 @@ package models import ( "testing" + "github.com/gofrs/uuid" "github.com/netlify/gotrue/conf" "github.com/netlify/gotrue/storage" "github.com/netlify/gotrue/storage/test" - "github.com/gofrs/uuid" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -79,7 +79,7 @@ func (ts *RefreshTokenTestSuite) createUser() *User { } func (ts *RefreshTokenTestSuite) createUserWithEmail(email string) *User { - user, err := NewUser(uuid.Nil, email, "secret", "test", nil) + user, err := NewUser(uuid.Nil, "", email, "secret", "test", nil) require.NoError(ts.T(), err) err = ts.db.Create(user) diff --git a/models/user.go b/models/user.go index d2f7083e1..f1dd215b7 100644 --- a/models/user.go +++ b/models/user.go @@ -2,6 +2,7 @@ package models import ( "database/sql" + "fmt" "strings" "time" @@ -50,6 +51,9 @@ type User struct { PhoneChange string `json:"new_phone,omitempty" db:"phone_change"` PhoneChangeSentAt *time.Time `json:"phone_change_sent_at,omitempty" db:"phone_change_sent_at"` + ReauthenticationToken string `json:"-" db:"reauthentication_token"` + ReauthenticationSentAt *time.Time `json:"reauthentication_sent_at,omitempty" db:"reauthentication_sent_at"` + LastSignInAt *time.Time `json:"last_sign_in_at,omitempty" db:"last_sign_in_at"` AppMetaData JSONMap `json:"app_metadata" db:"raw_app_meta_data"` @@ -64,8 +68,7 @@ type User struct { } // NewUser initializes a new user from an email, password and user data. -// TODO: Refactor NewUser to take in phone as an arg -func NewUser(instanceID uuid.UUID, email, password, aud string, userData map[string]interface{}) (*User, error) { +func NewUser(instanceID uuid.UUID, phone, email, password, aud string, userData map[string]interface{}) (*User, error) { id, err := uuid.NewV4() if err != nil { return nil, errors.Wrap(err, "Error generating unique id") @@ -82,6 +85,7 @@ func NewUser(instanceID uuid.UUID, email, password, aud string, userData map[str ID: id, Aud: aud, Email: storage.NullString(strings.ToLower(email)), + Phone: storage.NullString(phone), UserMetaData: userData, EncryptedPassword: pw, } @@ -145,6 +149,9 @@ func (u *User) BeforeSave(tx *pop.Connection) error { if u.PhoneChangeSentAt != nil && u.PhoneChangeSentAt.IsZero() { u.PhoneChangeSentAt = nil } + if u.ReauthenticationSentAt != nil && u.ReauthenticationSentAt.IsZero() { + u.ReauthenticationSentAt = nil + } if u.LastSignInAt != nil && u.LastSignInAt.IsZero() { u.LastSignInAt = nil } @@ -172,6 +179,16 @@ func (u *User) SetRole(tx *storage.Connection, roleName string) error { return tx.UpdateOnly(u, "role") } +//SetSuperAdmin sets the user as SuperAdmin +func (u *User) SetSuperAdmin(tx *storage.Connection) error { + u.IsSuperAdmin = true + err := u.SetRole(tx, "superadmin") + if err != nil { + return err + } + return tx.UpdateOnly(u, "is_super_admin") +} + // HasRole returns true when the users role is set to roleName func (u *User) HasRole(roleName string) bool { return u.Role == roleName @@ -275,6 +292,12 @@ func (u *User) Authenticate(password string) bool { return err == nil } +// ConfirmReauthentication resets the reauthentication token +func (u *User) ConfirmReauthentication(tx *storage.Connection) error { + u.ReauthenticationToken = "" + return tx.UpdateOnly(u, "reauthentication_token") +} + // Confirm resets the confimation token and sets the confirm timestamp func (u *User) Confirm(tx *storage.Connection) error { u.ConfirmationToken = "" @@ -347,6 +370,18 @@ func findUser(tx *storage.Connection, query string, args ...interface{}) (*User, return obj, nil } +func AnyUser(tx *storage.Connection) (bool, error) { + obj := &User{} + err := tx.Eager().Q().First(obj) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return false, nil + } + return false, err + } + return true, nil +} + // FindUserByConfirmationToken finds users with the matching confirmation token. func FindUserByConfirmationToken(tx *storage.Connection, token string) (*User, error) { user, err := findUser(tx, "confirmation_token = ?", token) @@ -386,6 +421,12 @@ func FindUserByEmailChangeToken(tx *storage.Connection, token string) (*User, er return findUser(tx, "email_change_token_current = ? or email_change_token_new = ?", token, token) } +// FindUserByTokenAndTokenType finds a user with the matching token and token type. +func FindUserByTokenAndTokenType(tx *storage.Connection, token string, tokenType string) (*User, error) { + query := fmt.Sprintf("%v = ?", tokenType) + return findUser(tx, query, token) +} + // FindUserWithRefreshToken finds a user from the provided refresh token. func FindUserWithRefreshToken(tx *storage.Connection, token string) (*User, *RefreshToken, error) { refreshToken := &RefreshToken{} @@ -404,6 +445,24 @@ func FindUserWithRefreshToken(tx *storage.Connection, token string) (*User, *Ref return user, refreshToken, nil } +// FindUserWithRefreshToken finds a user from the provided refresh token. +func FindUserWithAsymmetrickey(tx *storage.Connection, key string) (*User, *AsymmetricKey, error) { + asymmetricKey := &AsymmetricKey{} + if err := tx.Where("key = ? and main = true", key).First(asymmetricKey); err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, nil, AsymmetricKeyNotFoundError{} + } + return nil, nil, errors.Wrap(err, "error finding asymmetric key") + } + + user, err := findUser(tx, "id = ?", asymmetricKey.UserID) + if err != nil { + return nil, nil, err + } + + return user, asymmetricKey, nil +} + // FindUsersInAudience finds users with the matching audience. func FindUsersInAudience(tx *storage.Connection, instanceID uuid.UUID, aud string, pageParams *Pagination, sortParams *SortParams, filter string) ([]*User, error) { users := []*User{} @@ -432,9 +491,39 @@ func FindUsersInAudience(tx *storage.Connection, instanceID uuid.UUID, aud strin return users, err } -// FindUserWithPhoneAndPhoneChangeToken finds a user with the matching phone and phone change token -func FindUserWithPhoneAndPhoneChangeToken(tx *storage.Connection, phone, token string) (*User, error) { - return findUser(tx, "phone = ? and phone_change_token = ?", phone, token) +// FindUserByEmailChangeCurrentAndAudience finds a user with the matching email change and audience. +func FindUserByEmailChangeCurrentAndAudience(tx *storage.Connection, instanceID uuid.UUID, email, token, aud string) (*User, error) { + return findUser( + tx, + "instance_id = ? and LOWER(email) = ? and email_change_token_current = ? and aud = ?", + instanceID, strings.ToLower(email), token, aud, + ) +} + +// FindUserByEmailChangeNewAndAudience finds a user with the matching email change and audience. +func FindUserByEmailChangeNewAndAudience(tx *storage.Connection, instanceID uuid.UUID, email, token, aud string) (*User, error) { + return findUser( + tx, + "instance_id = ? and LOWER(email_change) = ? and email_change_token_new = ? and aud = ?", + instanceID, strings.ToLower(email), token, aud, + ) +} + +// FindUserForEmailChange finds a user requesting for an email change +func FindUserForEmailChange(tx *storage.Connection, instanceID uuid.UUID, email, token, aud string, secureEmailChangeEnabled bool) (*User, error) { + if secureEmailChangeEnabled { + if user, err := FindUserByEmailChangeCurrentAndAudience(tx, instanceID, email, token, aud); err == nil { + return user, err + } else if !IsNotFoundError(err) { + return nil, err + } + } + return FindUserByEmailChangeNewAndAudience(tx, instanceID, email, token, aud) +} + +// FindUserByPhoneChangeAndAudience finds a user with the matching phone change and audience. +func FindUserByPhoneChangeAndAudience(tx *storage.Connection, instanceID uuid.UUID, phone, aud string) (*User, error) { + return findUser(tx, "instance_id = ? and phone_change = ? and aud = ?", instanceID, phone, aud) } // IsDuplicatedEmail returns whether a user exists with a matching email and audience. diff --git a/models/user_test.go b/models/user_test.go index 752adf44d..c15b40f72 100644 --- a/models/user_test.go +++ b/models/user_test.go @@ -39,7 +39,7 @@ func TestUser(t *testing.T) { } func (ts *UserTestSuite) TestUpdateAppMetadata() { - u, err := NewUser(uuid.Nil, "", "", "", nil) + u, err := NewUser(uuid.Nil, "", "", "", "", nil) require.NoError(ts.T(), err) require.NoError(ts.T(), u.UpdateAppMetaData(ts.db, make(map[string]interface{}))) @@ -58,7 +58,7 @@ func (ts *UserTestSuite) TestUpdateAppMetadata() { } func (ts *UserTestSuite) TestUpdateUserMetadata() { - u, err := NewUser(uuid.Nil, "", "", "", nil) + u, err := NewUser(uuid.Nil, "", "", "", "", nil) require.NoError(ts.T(), err) require.NoError(ts.T(), u.UpdateUserMetaData(ts.db, make(map[string]interface{}))) @@ -113,7 +113,7 @@ func (ts *UserTestSuite) TestFindUsersInAudience() { sp := &SortParams{ Fields: []SortField{ - SortField{Name: "created_at", Dir: Descending}, + {Name: "created_at", Dir: Descending}, }, } n, err = FindUsersInAudience(ts.db, u.InstanceID, u.Aud, nil, sp, "") @@ -186,7 +186,7 @@ func (ts *UserTestSuite) createUser() *User { } func (ts *UserTestSuite) createUserWithEmail(email string) *User { - user, err := NewUser(uuid.Nil, email, "secret", "test", nil) + user, err := NewUser(uuid.Nil, "", email, "secret", "test", nil) require.NoError(ts.T(), err) err = ts.db.Create(user) diff --git a/security/hcaptcha.go b/security/hcaptcha.go index 4017f8571..64da884d7 100644 --- a/security/hcaptcha.go +++ b/security/hcaptcha.go @@ -5,8 +5,10 @@ import ( "encoding/json" "fmt" "io/ioutil" + "log" "net/http" "net/url" + "os" "strconv" "strings" "time" @@ -20,7 +22,7 @@ type GotrueRequest struct { } type GotrueSecurity struct { - Token string `json:"hcaptcha_token"` + Token string `json:"captcha_token"` } type VerificationResponse struct { @@ -40,11 +42,24 @@ const ( var Client *http.Client func init() { - // TODO (darora): make timeout configurable - Client = &http.Client{Timeout: 10 * time.Second} + var defaultTimeout time.Duration = time.Second * 10 + timeoutStr := os.Getenv("GOTRUE_SECURITY_CAPTCHA_TIMEOUT") + if timeoutStr != "" { + if timeout, err := time.ParseDuration(timeoutStr); err != nil { + log.Fatalf("error loading GOTRUE_SECURITY_CAPTCHA_TIMEOUT: %v", err.Error()) + } else if timeout != 0 { + defaultTimeout = timeout + } + } + + Client = &http.Client{Timeout: defaultTimeout} } func VerifyRequest(r *http.Request, secretKey string) (VerificationResult, error) { + if r.FormValue("grant_type") == "refresh_token" { + // captcha shouldn't be enabled on requests to refresh the token + return SuccessfullyVerified, nil + } res := GotrueRequest{} bodyBytes, err := ioutil.ReadAll(r.Body) if err != nil { diff --git a/storage/dial.go b/storage/dial.go index e176851ea..569063fd4 100644 --- a/storage/dial.go +++ b/storage/dial.go @@ -31,6 +31,7 @@ func Dial(config *conf.GlobalConfiguration) (*Connection, error) { db, err := pop.NewConnection(&pop.ConnectionDetails{ Dialect: config.DB.Driver, URL: config.DB.URL, + Pool: config.DB.MaxPoolSize, }) if err != nil { return nil, errors.Wrap(err, "opening database connection") @@ -72,6 +73,10 @@ func getExcludedColumns(model interface{}, includeColumns ...string) ([]string, xcols := make([]string, len(cols.Cols)) for n := range cols.Cols { + // gobuffalo updates the updated_at column automatically + if n == "updated_at" { + continue + } xcols = append(xcols, n) } return xcols, nil diff --git a/utilities/postgres.go b/utilities/postgres.go new file mode 100644 index 000000000..08a57b6ad --- /dev/null +++ b/utilities/postgres.go @@ -0,0 +1,72 @@ +package utilities + +import ( + "errors" + "strconv" + "strings" + + "github.com/jackc/pgconn" + "github.com/jackc/pgerrcode" +) + +// PostgresError is a custom error struct for marshalling Postgres errors to JSON. +type PostgresError struct { + Code string `json:"code"` + HttpStatusCode int `json:"-"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` + Detail string `json:"detail,omitempty"` +} + +// NewPostgresError returns a new PostgresError if the error was from a publicly +// accessible Postgres error. +func NewPostgresError(err error) *PostgresError { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && isPubliclyAccessiblePostgresError(pgErr.Code) { + return &PostgresError{ + Code: pgErr.Code, + HttpStatusCode: getHttpStatusCodeFromPostgresErrorCode(pgErr.Code), + Message: pgErr.Message, + Detail: pgErr.Detail, + Hint: pgErr.Hint, + } + } + + return nil +} + +// isPubliclyAccessiblePostgresError checks if the Postgres error should be +// made accessible. +func isPubliclyAccessiblePostgresError(code string) bool { + if len(code) != 5 { + return false + } + + // default response + return getHttpStatusCodeFromPostgresErrorCode(code) != 0 +} + +// getHttpStatusCodeFromPostgresErrorCode maps a Postgres error code to a HTTP +// status code. Returns 0 if the code doesn't map to a given postgres error code. +func getHttpStatusCodeFromPostgresErrorCode(code string) int { + if code == pgerrcode.RaiseException || + code == pgerrcode.IntegrityConstraintViolation || + code == pgerrcode.RestrictViolation || + code == pgerrcode.NotNullViolation || + code == pgerrcode.ForeignKeyViolation || + code == pgerrcode.UniqueViolation || + code == pgerrcode.CheckViolation || + code == pgerrcode.ExclusionViolation { + return 500 + } + + // Use custom HTTP status code if Postgres error was triggered with `PTXXX` + // code. This is consistent with PostgREST's behaviour as well. + if strings.HasPrefix(code, "PT") { + if httpStatusCode, err := strconv.ParseInt(code[2:], 10, 0); err == nil { + return int(httpStatusCode) + } + } + + return 0 +}